1. 程式人生 > >基於stm32 Systick 的簡單定時器(裸機)-- 陣列實現

基於stm32 Systick 的簡單定時器(裸機)-- 陣列實現

前言

  在嵌入式的開發中,經常需要執行定時的操作。 聰明的同學肯定會想到, 我可以配置硬體定時器, 然後利用定時器中斷來執行需要定時執行的程式碼。然而硬體定時器的數量總是有限,不一定可以滿足我們定時的需求。因此我們常常需要用到軟體定時的方法。
  事實上,關於使用軟體定時,如果有用到作業系統的核心(例如uCOS、FreeRTOS等)的話是非常爽的,因為核心已經幫你做了很多工作。你只需要呼叫定時器的API建立定時器結構體並且編寫定時器回撥函式,就可以完成定時工作的程式碼。
  有時我們的專案很簡單,不需要使用到作業系統。或者我的SOC資源極其有限,ROM和RAM小到連作業系統的程式碼都跑不起來,那麼就可能需要我們自己來寫實現定時的程式碼。

一些假設

本文假設讀者:

  • 掌握C語言
  • 具有類似STM32等微控制器/SoC的程式設計經驗

一些說明

  • 文中使用...來代替省略掉的程式碼,然後將注意力集中在我們需要討論的內容。

工具/材料

  • 一個stm32開發板(筆者使用的開發板是秉火指南者,SOC是STM32F103VET6)
  • keil

一個不通用的定時實現

我們先來討論一個不太通用的軟體定時的實現。
- 假如我讓一個LED燈定時閃爍,亮1s滅1s,要怎麼實現呢?

  當筆者還是菜雞的時候(現在也是),就會想,這很簡單,我直接

...
int main(void)
{
    led_init();
    while
(1){ led_on(); delay_ms(1000); led_off(); delay_ms(1000); } return 0; } ...

不就可以了嘛。
  好,可以,沒問題。那麼我要增加難度了。
我現在不僅要讓led亮1秒暗一秒地閃,還要處理來自各個感測器(外設)的資料,然後實時顯示到LCD螢幕上。

  我們沒有使用到類似uCOS這種搶佔式可剝奪型的作業系統核心,沒有任務切換,所有的程式碼都要放在一個死迴圈中,CPU會消耗大量的時間執行delay_ms,然後再執行其他程式碼。用上述延時的方式實現定時,必然會很不實時


  當然,如果有一款SOC,配置無限箇中斷,那麼請忽略本文所討論的內容。
  我見過一種實現是這樣的。利用了STM32中的嘀嗒定時器實現軟體定時。
在led.c中宣告2個變數,這兩個變數必然是全域性變數。

unsigned char led_state; // 用於記錄led的狀態,假設 0 表示led暗, 1表示led亮
unsigned int led_count; // 用於定時計數

在Main函式中,對led_state 和 led_count判斷,執行不同的程式碼:

...
int main(void)
{
    SystemInit(); // 系統初始化。如果你有留意到STM32的啟動檔案中的彙編程式碼的話,你會發現在進入main函式前,會先執行這個函式。
    SysTick_Config(SystemCoreClock/1000); // 1ms 進入一次嘀嗒中斷服務函式
    led_init();
    ...
    while(1){
        ...
        if((led_count == 0) && (led_state == 0)){
            led_on();
            led_count = 1000;
        }
        if((led_count == 0) && (led_state == 1)){
            led_off();
            led_count = 1000;
        }
        ...
    }
}

然後在嘀嗒中斷服務函SysTick_Handler中,讓 led_count > 0 時自減。這個函式通常放在 system_stm32f10x.c 中。

void SysTick_Handler(void)
{
    if(led_count != 0x00)
    {
        led_count--;
    }
}

  這樣的程式碼是可以滿足我們的定時和實時性的要求的。但是,由於使用了2個全域性變數led_stateled_count,並且這兩個變數很可能還是跨檔案的全域性變數(通常而言, main.c led.c system_stm32f10x.c),就導致了這份程式碼的可讀性和可維護性是非常糟糕的。在閱讀這份程式碼的時候,不得不去找這兩個變數首先在哪裡宣告,然後有那些地方修改了這兩個變數。另外一方面,假如有關led的需求變了,我需要修改相關的程式碼,那麼很可能我不得不修改main.cled.csystem_stm32f10x.c的程式碼。

一個基於stm32 Systick 的簡單定時器(裸機)

抽象

  我們前面討論了一個不通用的程式碼,不管閱讀還是維護都需要耗費極大的精力。
  為了讓程式碼變得更加通用,我們需要引入ADT(抽象資料型別,Abstract Data Type)的概念。即想辦法將待求解的問題抽象成一個數據型別,然後思考並且實現這個資料型別支援的操作。比如說,int 型別的資料是C語言內建的資料型別,它可以進行+-×÷的操作。我們可以盡情地使用(加減乘除)的操作處理整型資料而不用考慮加減乘除是怎麼實現的。同樣道理,對於新的資料型別,在我們實現它的操作集合之後,就可以使用它的操作集合處理這種新型別的資料而不需要考慮這個操作集合是怎麼實現的。同時,當我們要修改操作集合的實現的時候,只要介面不變,那麼應用的程式碼就不需要做任何更改
  換成“面向物件程式設計”的說法就是,首先思考這個東西有什麼屬性,然後這個東西有什麼行為。
  大部分介紹資料結構的書籍,都會介紹ADT的概念。ADT及其操作集合的定義與程式語言無關,而要實現它們則需要考慮具體的程式語言的語法細節。本文無意討論資料結構和麵向物件程式設計。有興趣的讀者可以去閱讀以下材料:
1. 《資料結構與演算法分析 —— C語言描述》(《Data Structures and Algorithm Analysis in C》)
2. 宋寶華的直播視訊《C語言大型軟體設計的面向物件》
3. 師弟的一篇練習作微控制器也能 OO?—— 串列埠命令解析器的實現

接下來讓我們開始一步一步對軟體定時器抽象的過程。
1. 首先思考這個定時器應該怎麼表示(它有什麼屬性)

  • 需要一個變量表示定時器的狀態(state)
  • 需要一個變量表示定時器的初始計數值(reload)
  • 需要一個變量表示定時器的當前計數值(count)
  • 當這個定時器到期之後需要執行什麼操作(回撥函式,callback)

根據C語言的語法,這個軟體定時器可以表示成這樣:

typedef void (*timerCallBack_t)(void *arg)
typedef struct {
    int state;               // 記錄定時器的狀態
    int reload;              // 記錄定時器的重灌載值
    int count;               // 記錄定時器的當前計數
    timerCallBack_t callBack;  // 定時器到期後的回撥函式
}timer_t;
  1. 可以對這個定時器進行怎麼樣的操作?
    • 建立/增加(create)
    • 刪除(delete)
    • 開始計時(start)
    • 停止計時(stop)
    • 計數值復位(reset)

  現在,我們可以定義可以對這個定時器的合法操作了(介面)

timer_t *timer_create(void);
void timer_delete(timer_t * T);
void timer_start(timer_t *T);
void timer_stop(timer_t *T);
void timer_reset(timer_t *T);

陣列實現

  通常,我們可能會有不止一個定時任務(這裡的任務指需要定時執行的程式碼),根據以上我們定義的定時器結構體,我們當然可以這樣宣告:

timer_t timer1;
timer_t timer2;
...

但是這樣子做會造成定時器管理困難。比如說,我要去輪詢定時器是否到期,如果到期則呼叫回撥函式,那麼很可能程式碼要這樣寫:

...
if(timer1.count == 0){
    timer1.callBack(arg);
}
if(timer2.count == 0){
    timer2.callBack(arg);
}
...

  一個明智一點的做法是,把這些定時器變數放到一個數組儲存,比如:

#define N 10
timer_t timer[N];

  那麼,輪詢定時器的程式碼就可以寫成

int i;
for(i=0; i<N; i++){
    if(timer[i].count == 0){
        timer[i].callBack(arg);
    }
}

  現在,我們決定使用陣列來儲存定時器,然後思考實現“建立/新增定時器”的操作。“建立/新增定時器”即將定時器的各個成員的值填入到定時器陣列的一個元素中。那麼新的問題就出現了。
1. 怎麼樣保證往定時器陣列填資料的時候,不會填到陣列以外的地址?
2. 以上例子聲明瞭一個含有10元素的定時器陣列。事實上,我使用到的定時器可能只有2個。那麼有沒有辦法不要每次輪詢定時器都要迴圈10次呢?我希望實際使用多少個定時器輪詢時就迴圈多少次。

  為了解決上述2個問題,需要增加2個變數作為控制。

int current_num; // 當前定時器的數量
int max_num; // 允許的定時器的最大數量

  我們把這2個變數和定時器的結構體封裝在一起。

struct {
    int current_num;
    int max_num;
    timer_t *timer;
}Timer;

事實上,這是一個線性表的資料結構。為了好看,我們把它寫成:

typedef struct {
    int length;     // 當前定時器的個數(當前線性表的長度)
    int listsize;   // 當前允許的定時器的最大個數(即陣列的長度) 
    timer_t *timer; // 指向陣列的基地址
}timerList_t;

那麼對定時器的操作就變成對定時器連結串列的操作。
* 增加/刪除定時器 相當於 向線性表中新增結點(node)
* 啟動/停止/復位定時器相當於查詢並且訪問線性表中的定時器

進一步完善定時器結構體和介面

以上我們已經得到了用於表述定時器的結構體, 對定時器結構體操作的介面, 用於管理定時器的線性表. 但是還不完整,
- 我們需要在定時器結構體中新增一個變數unsigned int allocated用於記錄定時器是否被分配到線性表的記憶體中.
- 我們需要在定時器結構體中新增一個變數void *arg用來向定時器傳遞使用者資料. 當然也可以將使用者資料定義為全域性變數, 然後在回撥函式中處理. 不過這樣是不安全的, 因為, 很可能還有除了定時器以外的程式碼修改這些變數.
- 要讓定時器執行起來, 還需要增加對定時器輪詢的函式timer_poll, 並且在main函式中的while迴圈或者SysTick_Handler函式呼叫.

  下面, 我們先給出筆者在寫這篇文章時最終版本, 然後再討論具體的實現過程

標頭檔案 timer.h
#ifndef __TIMER_LIST_H
#define __TIMER_LIST_H

#define config_TIMER_MAX_NUM  10

enum {
    timer_disable = 0,
    timer_enable  = 1,
};

typedef void (*timerCallBack_t)(void *arg);
typedef struct {
    unsigned int allocated;             // 記錄線性表中是否已經分配這個定時器
    unsigned int state;                 // 記錄定時器的狀態
    unsigned int reload;                // 記錄定時器的重灌載值
    unsigned int count;                 // 記錄定時器的當前計數
    void *arg;
    timerCallBack_t callBack;           // 定時器到期後的回撥函式
}timer_t;

timer_t *timer_create(unsigned int reload, void *arg, timerCallBack_t callBack);
void timer_delete(timer_t * T);
void timer_start(timer_t *T);
void timer_stop(timer_t *T);
void timer_reset(timer_t *T);
void timer_poll(void);

void test_print_timerList(void);

#endif // __LIST_H
定時器線性表在原始檔中宣告
typedef struct {
    unsigned int length;     // 當前定時器的個數(當前線性表的長度)
    unsigned int listsize;   // 當前允許的定時器的最大個數(即陣列的長度)
    timer_t *timer; // 指向陣列的基地址
}timerList_t;

timer_create()函式實現

  建立定時器前, 需要先建立管理定時器的線性表. 我們以靜態全域性變數的方式宣告這個線性表並初始化.

static timer_t timer[config_TIMER_MAX_NUM] = { 0 };
static timerList_t L = {0, config_TIMER_MAX_NUM, timer};

在標頭檔案中, 定義了巨集#define config_TIMER_MAX_NUM 10, 因此這個線性表最多隻能容納10定時器.

timer_t *timer_create(unsigned int reload, void *arg, timerCallBack_t callBack)
{
    timer_t *new_timer;
    if(L.length >= L.listsize){
        LOG_E(("timer list is full"));
        return NULL;
    }
    new_timer = find_first_not_alloc_timer(&L);
    new_timer->allocated = 1;
    new_timer->reload = reload;
    new_timer->count = reload;
    new_timer->state = timer_disable;
    new_timer->arg = arg;
    new_timer->callBack = callBack;
    L.length++;
    return new_timer;
}

  在分配空間之前, 首先判斷線性表是否已經滿了, 如果已滿, 則返回NULL
  線上性表中找到第一個沒有被分配的空間, 返回它的首地址. 然後使用傳入的引數reload, arg, callBack初始化定時器.
  new_timer->allocated = 1; 指示定時器已經被分配到線性表中.
  new_timer->count = reload;表示reload值已經被裝入到count中.
  L.length計數加1, 表示當前定時器的數量.

find_first_not_alloc_timer函式實現

遍歷線性表, 如果allocated == 0, 則表示這個空間沒有被分配, 返回這個空間的首地址. 否則返回NULL

static timer_t * find_first_not_alloc_timer( timerList_t *L)
{
    int i = 0;
    for(i=0; i<L->listsize; i++){
        if(L->timer[i].allocated == 0){
            return (&(L->timer[i]));
        }
    }
    LOG_E(("timer list is full"));
    return NULL;
}

timer_delete()函式實現

void timer_delete(timer_t *T)
{
    int i;

    // 對線性表遍歷, 確保T指向的地址線上性表中
    for(i=0; i<L.listsize; i++){
        if(&(L.timer[i]) == T){
            memset(&L.timer[i], 0, sizeof(timer_t));
            T = NULL;
            L.length--;
        }
    }
    if(i == L.listsize)
        LOG_E(("the is not in timer list"));
}

  傳入要從線性表中刪除的定時器指標給timer_delete函式, 然後對線性表進行遍歷, 通過地址匹配的方式找到待刪除的定時器, 把定時器的所有內容設定為0.
  筆者曾經考慮過另外的實現:
1. 按照常規的線性表刪除結點的做法, 在刪除結點的時候, 被刪除結點後面的結點應該要往前移動. 筆者在定時器結構體timer_t中新增一個變數id, 一方面用來記錄定時器的id, 同時也代表了定時器線上性表中的位置. 通過id直接找到要刪除的結點並且將後面的結點往前移. 很明顯, 這種方法是不行的, 因為一旦移動了結點, 那麼應用部分的程式碼很可能就會訪問到錯誤的定時器結點.

//如果刪除結點的時候, 同時移動結點, 那麼可能會導致對其他定時器的訪問錯誤. 因為可能我們需要訪問的定時器地址已經發生變化.
void timer_delete(timer_t * T)
{
    int i;
    timer_t temp;

    // 如果刪除的定時器線上性表的最後一個結點
    if(T->id == L.length-1){
        memset(T, 0, sizeof(timer_t));
        T = NULL;
        L.length--;
        return;
    }
    memcpy(&temp, T, sizeof(timer_t));
    for(i=temp.id; i<L.length - 1 ; i++){
        memcpy(&(L.timer[i]), &(L.timer[i+1]), sizeof(timer_t));
        memset(&(L.timer[i+1]), 0, sizeof(timer_t));
    }
    T = NULL;
    L.length--;

}
  1. 在上述第1點的基礎上修改, 在定時器結構體timer_t中新增一個變數id, 並且在刪除定時器結點的時候不移動結點. 將timer_t中的allocated設定為0, timer_t中的其他內容也設定為0. 這似乎是一個非常高效的方法, 通過id直接找到待刪除的結點, 同時也不會影響應用程式碼對定時器結點的訪問. 但是我們並知道會傳入什麼樣的 timer_t * T. 如果傳入的指標的值不線上性表的地址範圍內, 但是剛好滿足(T->id >= 0) && (T->id < L.listsize), 那麼我們就會破壞了timer_t * T指向的記憶體. 甚至在執行刪除操作前加入一個判斷條件(T >=&L.timer[0]) && (T < &L.timer[L.listsize-1])都是不安全的. 因為我們不能夠保證在正確的地址上修改內容.
void timer_delete(timer_t * T)
{
    int i;

    if((T->id >= 0) && (T->id < L.listsize) ){
        memset(&(L.timer[i]), 0, sizeof(timer_t));
        T = NULL;
        L.length--;
    }

}

timer_start()、timer_stop()、timer_reset()函式實現

  這3個函式的實現會比較簡單, 因為我們已經取得了定時器的地址, 直接通過定時器的地址訪問timer_t的成員變數即可.

void timer_start(timer_t *T)
{
    T->state = timer_enable;
}

void timer_stop(timer_t *T)
{
    T->state = timer_disable;
}

void timer_reset(timer_t *T)
{
    T->count = T->reload;
    T->state = timer_enable;
}

timer_poll() 函式實現

  timer_poll函式需要放到main函式中的while迴圈或者SysTick_Handler函式中執行. 如果將timer_poll函式需要放到 main函式中執行, 那麼則需要在SysTick_Handler函式中設定標誌變數, 示例程式碼如下:

void SysTick_Handler(void)
{   
    if(SysTick_Handler_Flag == 0)
        SysTick_Handler_Flag = 1;

}
void timer_poll(void)
{
    int i;

    if(SysTick_Handler_Flag == 1){
        for(i=0; i<L.listsize; i++){
            if(L.timer[i].allocated && L.timer[i].state){
                if(L.timer[i].count > 0){
                    L.timer[i].count--;
                }
                if(L.timer[i].count == 0){
                    L.timer[i].state = timer_disable;
                    L.timer[i].callBack(&L.timer[i]);
                }
            }
        }
        SysTick_Handler_Flag = 0;
    }
}

  在timer_poll()中, 遍歷線性表, 對滿足L.timer[i].allocated && L.timer[i].state的定時器L.timer[i].count--, 當L.timer[i].count == 0, 則停止定時器並且執行回撥函式, 並且將定時器自身的指標傳入到回撥函式.

缺點

  到此, 我們已經實現了軟體定時器的核心程式碼. 這種實現是有缺點的.
1. 只能允許少量的定時器, 否則僅對定時器線性表的遍歷就會浪費大量的時間.
2. 在回撥函式中不能夠執行阻塞的程式碼或者需要等待太長時間的程式碼, 否者會導致其他定時器同樣阻塞.

測試程式碼

更多

  在對定時器線性表執行操作的時候, 我們只保證了不會訪問到不對的地址. 在增加和刪除定時器結點的時候, 還是不得不遍歷定時器連結串列.
  我們知道, 線性表可以有兩種實現, 一種是陣列, 一種是連結串列. 而連結串列實現, 則會實現我們希望有多少定時器就訪問多少定時器的想法. 我們會在後面的內容中討論連結串列實現.
  這個軟體定時器是非常簡單的。但“麻雀雖小,五臟俱全”。如果仔細閱讀FreeRTOS的定時器實現,你會發現原理是類似的。FreeRTOS的定時器實現更加複雜。使用了一個Daemon任務執行定時器,使用2個連結串列和1個佇列管理定時器。