?
正文
作者?|?Alicedodo
狀態機是一種思想,事件驅動也是一種思想。
事件驅動的概念
生活中有很多事件驅動的例子,上自習瞞著老師偷睡覺就是很生動的一個。
我們都是從高中時代走過來的,高中的學生苦啊,覺得睡覺是世界上最奢侈的東西, 有時候站著都能睡著??!老師看的嚴,上課睡覺不允許啊,要挨批?。∮心居校∠啾榷裕碜粤暿潜容^寬松的,老師只是不定時來巡視,還是有機會偷偷睡一會兒的。
現在的問題是,怎么睡才能既睡得好又不會讓老師發現呢??晚自習是比較寬松的,老師只是不定時來巡視,還是有機會偷偷睡一會兒的。現在的問題是,怎么睡才能既睡得好又不會讓老師發現呢?
我們現在有三種睡覺方案:
方案 A:倒頭就睡,管你三七二十一,睡夠了再說,要知道有時候老師可能一整晚上都不來的。
方案 B:間歇著睡,先定上鬧鐘, 5 分鐘響一次,響了就醒,看看老師來沒來,沒來的話定上鬧鐘再睡,如此往復。
方案 C:睡之前讓同桌給放哨,然后自己睡覺,什么也不用管,什么時候老師來了,就讓同桌戳醒你。
不管你們選擇的是哪種方案,我高中那會兒用的可是方案 C,安全又舒服。
方案 C 是很有特點的:本來自習課偷睡覺是你自己的事兒, 有沒有被老師抓著也是你自己的事兒,這些和同桌是毫無利害關系的,但是同桌這個環節對方案 C 的重要性是不言而喻的,他肩負著監控老師巡視和叫醒睡覺者兩項重要任務,是事件驅動機制實現的重要組成部分 。
在事件驅動機制中,對象對于外部事件總是處于“休眠” 狀態的,而把對外部事件的檢測和監控交給了第三方組件。
一旦第三方檢測到外部事件發生, 它就會啟動某種機制, 將對象從“休眠” 狀態中喚醒, 并將事件告知對象。對象接到通知后, 做出一系列動作, 完成對本次事件響應,然后再次進入“休眠” 狀態,如此周而復始。
有沒有發現,事件驅動機制和單片機的中斷原理上很相似 。
事件驅動與單片機編程
在我們再回到單片機系統中來,看看事件驅動思想在單片機程序設計中的應用。當我還是一個單片機菜鳥的時候(當然,我至今也沒有成為單片機高手),網絡上的大蝦們就諄諄教導:一個好的單片機程序是要分層的。曾經很長一段時間, 我對分層這個概念完全沒有感覺。
什么是程序分層?
程序為什么要分層?
應該怎么給程序分層?
隨著手里的代碼量越來越多,實現的功能也越來越多,軟件分層這個概念在我腦子里逐漸地清晰起來,我越來越佩服大蝦們的高瞻遠矚。
單片機的軟件確實要分層的,最起碼要分兩層:驅動層和應用層。應用是單片機系統的核心,與應用相關的代碼擔負著系統關鍵的邏輯和運算功能,是單片機程序的靈魂。
硬件是程序感知外界并與外界打交道的物質基礎,硬件的種類是多種多樣的,各類硬件的操作方式也各不相同,這些操作要求嚴格、精確、瑣細、繁雜。
與硬件打交道的代碼只鐘情于時序和寄存器,我們可以稱之為驅動相關代碼;與應用相關的代碼卻只專注于邏輯和運算, 我們可稱之為應用相關代碼。
這種客觀存在的情況是單片機軟件分層最直接的依據,所以說,將軟件劃分為驅動層和應用層是程序功能分工的結果。那么驅動層和應用層之間是如何銜接的呢?
在單片機系統中,信息的流動是雙向的,由內向外是應用層代碼主動發起的,實現信息向外流動很簡單, 應用層代碼只需要調用驅動層代碼提供的 API 接口函數即可, 而由外向內則是外界主動發起的, 這時候應用層代碼對于外界輸入需要被動的接收, 這里就涉及到一個接收機制的問題,事件驅動機制足可勝任這個接收機制。
外界輸入可以理解為發生了事件,在單片機內部直接的表現就是硬件生成了新的數據,這些數據包含了事件的全部信息, 事件驅動機制的任務就是將這些數據初步處理(也可能不處理),然后告知應用層代碼, 應用代碼接到通知后把這些數據取走, 做最終的處理, 這樣一次事件的響應就完成了。
說到這里,可能很多人突然會發現,這種處理方法自己編程的時候早就用過了,只不過沒有使用“事件驅動” 這個文縐縐的名詞罷了。其實事件驅動機制本來就不神秘, 生活中數不勝數的例子足以說明它應用的普遍性。下面的這個小例子是事件驅動機制在單片機程序中最常見的實現方法,假設某單片機系統用到了以下資源:
一個串口外設 Uart0,用來接收串口數據;
一個定時器外設 Tmr0,用來提供周期性定時中斷;
一個外部中斷管腳 Exi0,用來檢測某種外部突發事件;
一個 I/O 端口 Port0,連接獨立式鍵盤,管理方式為定時掃描法,掛載到 Tmr0 的 ISR;
這樣,系統中可以提取出 4 類事件,分別是 UART、 TMR、 EXI、 KEY ,其中 UART 和KEY 事件發生后必須開辟緩存存儲事件相關的數據。所有事件的檢測都在各自的 ISR 中完成,然后 ISR 再通過事件驅動機制通知主函數處理。
為了實現 ISR 和主函數通信, 我們定義一個數據類型為INT8U的全局變量 g_u8EvntFlgGrp,稱為事件標志組,里面的每一個 bit 位代表一類事件,如果該 bit 值為 0,表示此類事件沒有發生,如果該 bit 值為 1,則表示發生了此類事件,主函數必須及時地處理該事件。圖 5 所示為g_u8EvntFlgGrp 各個 bit 位的作用 。
程序清單 List9 所示就是按上面的規劃寫成的示例性代碼 。
程序清單List9:
?
#define?FLG_UART?0x01 #define?FLG_TMR?0x02 #define?FLG_EXI?0x04 #define?FLG_KEY?0x08 volatile?INT8U?g_u8EvntFlgGrp?=?0;?/*事件標志組*/ INT8U?read_envt_flg_grp(void); /*************************************** *FuncName?:?main *Description?:?主函數 *Arguments?:?void *Return?:?void *****************************************/ void?main(void) { ?INT8U?u8FlgTmp?=?0; ?sys_init(); ?while(1) ?{ ??u8FlgTmp?=?read_envt_flg_grp();?/*讀取事件標志組*/ ??if(u8FlgTmp?)?/*是否有事件發生??*/ ??{ ???if(u8FlgTmp?&?FLG_UART) ???{ ????action_uart();?/*處理串口事件*/ ???} ???if(u8FlgTmp?&?FLG_TMR) ???{ ????action_tmr();?/*處理定時中斷事件*/ ???} ???if(u8FlgTmp?&?FLG_EXI) ???{ ????action_exi();?/*處理外部中斷事件*/ ???} ???if(u8FlgTmp?&?FLG_KEY) ???{ ????action_key();?/*處理擊鍵事件*/ ???} ??} ??else ??{ ???;/*idle?code*/ ??} ?} } /********************************************* *FuncName?:?read_envt_flg_grp *Description?:?讀取事件標志組?g_u8EvntFlgGrp?, *?讀取完畢后將其清零。 *Arguments?:?void *Return?:?void *********************************************/ INT8U?read_envt_flg_grp(void) { ?INT8U?u8FlgTmp?=?0; ?gbl_int_disable(); ?u8FlgTmp?=?g_u8EvntFlgGrp;?/*讀取標志組*/ ?g_u8EvntFlgGrp?=?0;?/*清零標志組*/ ?gbl_int_enable(); ?return?u8FlgTmp; } /********************************************* *FuncName?:?uart0_isr *Description?:?uart0?中斷服務函數 *Arguments?:?void *Return?:?void *********************************************/ void?uart0_isr(void) { ?...... ?push_uart_rcv_buf(new_rcvd_byte);?/*新接收的字節存入緩沖區*/ ?gbl_int_disable(); ?g_u8EvntFlgGrp?|=?FLG_UART;?/*設置?UART?事件標志*/ ?gbl_int_enable(); ?...... } /********************************************* *FuncName?:?tmr0_isr *Description?:?timer0?中斷服務函數 *Arguments?:?void *Return?:?void *********************************************/ void?tmr0_isr(void) { ?INT8U?u8KeyCode?=?0; ?...... ?gbl_int_disable(); ?g_u8EvntFlgGrp?|=?FLG_TMR;?/*設置?TMR?事件標志*/ ?gbl_int_enable(); ?...... ?u8KeyCode?=?read_key();?/*讀鍵盤*/ ?if(u8KeyCode)?/*有擊鍵操作??*/ ?{ ??push_key_buf(u8KeyCode);?/*新鍵值存入緩沖區*/ ??gbl_int_disable(); ??g_u8EvntFlgGrp?|=?FLG_KEY;?/*設置?TMR?事件標志*/ ??gbl_int_enable(); ?} ?...... } /********************************************* *FuncName?:?exit0_isr *Description?:?exit0?中斷服務函數 *Arguments?:?void *Return?:?void *********************************************/ void?exit0_isr(void) { ?...... ?gbl_int_disable(); ?g_u8EvntFlgGrp?|=?FLG_EXI;?/*設置?EXI?事件標志*/ ?gbl_int_enable(); ?...... }
?
看一下程序清單 List9 這樣的程序結構,是不是和自己寫過的某些程序相似?對于事件驅動機制的這種實現方式, 我們還可以做得更絕一些, 形成一個標準的代碼模板,做一個包含位段和函數指針數組的結構體,位段里的每一個元素作為圖 5 那樣的事件標志位,然后在函數指針數組中放置各個事件處理函數的函數地址, 每個處理函數對應位段里的每個標志位。
這樣, main()函數中的事件處理代碼就可以做成標準的框架代碼。應該說,這樣的實現方式是很好的,足以輕松地應對實際應用中絕大多數的情況。但是,事件驅動機制用這樣的方式實現真的是完美的么?在我看來,這種實現方式至少存在兩個問題:
不同事件集中爆發時,無法記錄事件發生的前后順序。
同一事件集中爆發時,容易遺漏后面發生的那次事件。
圖 6 所示為某一時段單片機程序的執行情況,某些特殊情況下,會出現上面提到的兩個問題。
圖中, f1 為某事件的處理函數, f2 為另一事件的處理函數, I1、 I2、 I3 為 3 個不同事件觸發的 ISR,假定 I1、 I2、 I3 分別對應事件 E1、 E2、 E3。從圖中可以看出,主函數在調用事件處理函數 f1 的時候,發生了 2 次事件,主函數被 I1和 I2 中斷了 2 次, I1 和 I2 執行的時候各自置位了相應的事件標志位。
函數 f1 返回后, 主函數又調用了另一個事件處理函數 f2, f2 執行期間先后發生了 2 次同樣的事件, f2 被 I3 中斷了 2次,對應的事件標志位被連續置位了 2 次。
在圖 6 中我們當然可以看出 I1 先于 I2 執行,即事件 E1 發生在事件 E2 之前,但是主函數再次讀取事件標志組 g_u8EvntFlgGrp 的時候, 看到的是兩個“同時” 被置位的標志位, 無法判斷出事件 E1 和 E2 發生的先后順序, 也就是說有關事件發生先后順序的信息丟失了, 這就是前面說的第 1 個問題:不同事件集中爆發時,無法記錄事件發生的前后順序。
在程序清單 List9 中, 主函數在處理事件時, 按照程序預先設定好的順序, 一個一個地處理發生的事件, 如果不同事件某時段集中爆發, 可能會出現事件的發生順序和事件的處理順序不一致的情況。倘若系統功能對事件的發生順序敏感,那么程序清單 List9 中的程序就不能滿足要求了。
同樣的道理,如果 I3 對應的事件 E3 是程序清單 List9 中 EXI 那樣的事件(這種事件沒有緩沖機制), 事件 E3 第 2 次的發生就被遺漏了, 這就是前面所說的第 2 個問題:同一事件集中爆發時,容易遺漏后后面發生的事件。
如果系統功能對事件 E3 的發生次數敏感,程序清單 List9 中的程序也是不能滿足要求的。既然事件驅動機制這樣的實現方式存在缺陷, 那么有沒有一種更好的實現方式呢?當然有!把事件轉換成消息存入消息隊列就能完美解決這個問題, 只不過大家不要對我這種自導自演的行文方式產生反感就好 ?。
事件驅動與消息
什么是消息?消息是數據信息的一種存儲形式。從程序的角度看,消息就是一段存儲著特定數據的內存塊, 數據的存儲格式是設計者預先約定好的, 只要按照約定的格式讀取這段內存, 就能獲得消息所承載的有用信息。
消息是有時效性的。任何一個消息實體都是有生命周期的,它從誕生到消亡先后經歷了生成、 存儲、 派發、 消費共 4 個階段:消息實體由生產者生成, 由管理者負責存儲和派發, 最后由消費者消費。
被消費者消費之后, 這個消息就算消亡了, 雖然存儲消息實體的內存中可能還殘留著原來的數據, 但是這些數據對于系統來講已經沒有任何意義了, 這也就是消息的時效性。說到這里,大家有沒有發現,這里的“消息” 和前面一直在說的“事件” 是不是很相似?把“消息” 的這些特點套用在“事件” 身上是非常合適的, 在我看來, 消息無非是事件的一個馬甲而已。
我們在設計單片機程序的時候,都懷著一個夢想,即讓程序對事件的響應盡可能的快,理想的情況下,程序對事件要立即響應,不能有任何延遲。這當然是不可能的,當事件發生時,程序總會因為這樣那樣的原因不能立即響應事件。
為了不至于丟失事件,我們可以先在事件相關的 ISR 中把事件加工成消息并把它存儲在消息緩沖區里, ISR 做完這些后立即退出。主程序忙完了別的事情之后,去查看消息緩沖區,把剛才 ISR 存儲的消息讀出來, 分析出事件的有關信息, 再轉去執行相應的響應代碼, 最終完成對本次事件的響應。
只要整個過程的時間延遲在系統功能容許的范圍之內, 這樣處理就沒有問題。將事件轉化為消息,體現了以空間換時間的思想。再插一句,雖然事件發生后對應的 ISR 立即被觸發,但是這不是嚴格意義上的“響應”, 頂多算是對事件的“記錄”, “記錄” 和“響應” 是不一樣的。事件是一種客觀的存在,而消息則是對這種客觀存在的記錄。
對于系統輸入而言,事件是其在時間維度上的描述;消息是其在空間維度上的描述,所以,在描述系統輸入這一功能上,事件和消息是等價的。對比一下程序清單 List9 中的實現方式, 那里是用全局標志位的方式記錄事件, 對于某些特殊的事件還配備了專門的緩沖區, 用來存儲事件的額外信息, 而這些額外信息單靠全局標志位是無法記錄的。
現在我們用消息+消息緩沖區的方式來記錄事件,消息緩沖區就成了所有事件共用的緩沖區,無論發生的事件有沒有額外的信息,一律以消息的形式存入緩沖區 ?。
為了記錄事件發生的先后順序,消息緩沖區應該做成以“先入先出” 的方式管理的環形緩沖隊列。事件生成的消息總是從隊尾入隊,管理程序讀取消息的時候總是從隊頭讀取,這樣,消息在緩沖區中存儲的順序就是事件在時間上發生的順序,先發生的事件總是能先得到響應。
一條消息被讀取之后, 管理程序回收存儲這個消息的內存, 將其作為空閑節點再插入緩沖隊列的隊尾,用以存儲將來新生成的消息。圖 7 所示為使用了消息功能的事件驅動機制示意圖。不知道有沒有人對圖中的“消費者”有疑問, 這個“消費者” 在程序中指的是什么呢?
既然這個事件/消息驅動機制是為系統應用服務的, 消費者當然就是指應用層的代碼了, 更明確一點兒的話, 消費者就是應用代碼中的狀態機 ?。
用消息的方法來實現事件驅動機制完全解決了前面提到的那兩個問題,即不同事件集中爆發時,無法記錄事件發生的前后順序。同一事件集中爆發時,容易遺漏后面發生的那次事件。對于第一種情況,消息(事件)在緩沖隊列中是以“先入先出” 的方式存儲的,存儲順序就代表了事件發生的先后順序。
對于第二種情況, 任何被 ISR 捕捉到的事件都會以一個獨立的消息實體存入緩沖隊列, 即使前后兩個是同一個事件, 只要 ISR 反應夠快就不會遺漏事件。實際上, ISR 的主要工作就是填寫消息實體, 然后將其存入緩沖隊列, 做這些工作只占用 CPU 很短的時間。
接下來再說一說這個消息機制在程序中如何實現。在程序中,消息機制可以看做是一個獨立的功能模塊,一個功能模塊的實現無非就是數據結構+算法。先來看消息機制的數據結構。這里的數據結構是指和消息機制有關的數據組織形式,包含 2 個部分:
消息節點自身的數據組織形式
消息緩沖區的數據組織形式
程序清單 List10 所示就是消息機制的數據結構 。
「程序清單List10:」
?
typedef?union?msg_arg?/*消息參數共用體*/ { ?INT8U?u8Arg;?/*成員:8 位無符號*/ ?INT8U?s8Arg;?/*成員:8 位有符號*/ ?#if?CFG_MSG_ARG_INT16_EN>0 ?INT16U?u16Arg;?/*可選成員:16 位無符號*/ ?INT16S?s16Arg;?/*可選成員:16 位有符號*/ ?#endif ?#if?CFG_MSG_ARG_INT32_EN>0 ?INT32U?u32Arg;?/*可選成員:32 位無符號*/ ?INT32S?s32Arg;?/*可選成員:32 位有符號*/ ?#endif ?#if?CFG_MSG_ARG_FP32_EN>0 ?FP32?f32Arg;?/*可選成員:32 位單精度浮點*/ ?#endif ?#if?CFG_MSG_ARG_PTR_EN>0 ?void*?pArg;?/*可選成員:void 指針*/ ?#endif }MSG_ARG; typedef?struct?_msg?/*消息結構體*/ { ?INT8U?u8MsgID;?/*消息?ID*/ ?#if?CFG_MSG_USR_SUM?>?1 ?INT8U?u8UsrID;?/*消費者?ID*/ ?#endif ?MSG_ARG?uMsgArg;?/*應用消息參數*/ }?MSG; typedef?struct?msg_box?/*消息緩沖區結構體*/ { ?INT8U?u8MBLock;?/*隊列上鎖標識*/ ?INT8U?u8MsgSum;?/*隊列長度*/ ?INT8U?u8MQHead;?/*隊列頭結點位置*/ ?INT8U?u8MQTail;?/*隊列尾節點位置*/ ?MSG?arMsgBox[CFG_MSG_SUM_MAX];?/*存放隊列的數組*/ }?MB; static?MB?g_stMsgUnit;?/*消息管理單元全局變量*/
?
消息的數據結構包含 2 部分:消息頭和消息參數,在消息結構體 MSG 中, u8MsgID 和u8UsrID 就是消息頭,共用體 MSG_ARG 就是消息參數。
u8MsgID 是消息的類型標志,也就是生成此消息的事件的事件類型標志,程序根據這個成員選擇對應的事件處理函數;u8UsrID 是消息的消費者代號, 如果應用代碼中只有一個消費者,則成員 u8UsrID 可以忽略。MSG_ARG 就是消息附帶的參數,也就是事件的內容信息。
系統中的事件是多種多樣的,有的事件只需要類型標志即可, 有的事件可能還需要整型變量存儲事件內容, 還有的事件可能需要大塊的內存來存儲一些附帶的數據。為了將各種類型的事件生成的消息納入統一管理, 要求 MSG_ARG 必須能存儲各種類型的數據,因此 MSG_ARG 被定義成了共用體。
從程序清單 List10 中可以看出, MSG_ARG 既可以存儲 8 位~32 位有符號無符號整型數據,又可以存儲單精度浮點, 還可以存儲 void* 型的指針變量, 而 void*的指針又可以強制轉換成任意類型的指針,所以 MSG_ARG 可以存儲指向任意類型的指針。
對于MSG_ARG中的某些成員, 還配備了預編譯常量 CFG_MSG_ARG_XXX_EN加以控制,如果實際應用中不需要這些耗費內存較大的數據類型, 可以設置CFG_MSG_ARG_XXX_EN 去掉它們。全開的情況下, 每個消息節點占用 6 個字節的內存, 最精簡的情況下, 每個消息節點只占用 2 個字節。
全局結構體變量 g_stMsgUnit 是消息緩沖區的數據結構。消息緩沖區是一個環形緩沖隊列,這里將環形隊列放在了一個一維數組中,也就是g_stMsgUnit 的成員 arMsgBox[],數組元素的數據類型就是消息結構體 MSG ,數組的大小由預編譯常量 CFG_MSG_SUM_MAX 控制,該常量是環形緩沖隊列的最大容量。
理論上, CFG_MSG_SUM_MAX 值的選取越大越好,但考慮到單片機的 RAM 資源有CFG_MSG_SUM_MAX 值的選取要在資源消耗和實際最大需求之間折中, 只要能保證在最壞情況下環形緩沖隊列仍有裕量即可。用數組實現環形隊列還需要一些輔助變量,也就是 g_stMsgUnit 剩余的成員。
u8MBLock 是隊列的控制變量, u8MBLock>0 表示隊列處于鎖定/保護狀態,不能寫也不能讀, u8MBLock=0 表示隊列處于正常狀態,可寫可讀;u8MsgSum 是隊列長度計數器,記錄著當前隊列中存有多少條消息,存入一條消息u8MsgSum++,讀出一條消息 u8MsgSum--;
u8MQHead 記錄著當前隊頭消息節點在數組 arMsgBox[]中的位置,其值就是數組元素的下標,消息讀取的時候所讀出的就是 u8MQHead 所指向的節點,讀完之后 u8MQHead 向隊尾方向移動一個位置,指向新的隊頭節點;u8MQTail 記錄著當前隊尾消息節點在數組 arMsgBox[]中的位置,其值是數組元素的下標,新消息寫入之前, u8MQTail 向隊尾方向后移一個位置, 然后新寫入的消息存入 u8MQTail 所指向的空閑節點;
圖 8 所示為消息緩沖區結構體變量 g_stMsgUnit 的示意圖 。
有了數據結構,還要有對應的算法實現,消息機制的數據主體就是一個數組化了的環形隊列,環形隊列的算法就是我們所要的算法。消息機制是一個獨立的功能模塊,應該對外屏蔽其內部實現細節,而僅對外界開放一定數量的接口函數,外界通過調用這些接口來使用消息功能,這也就是我在聲明 g_stMsgUnit 變量的時候使用了 static 關鍵詞的原因。
消息模塊的接口函數一共有 9 個:
void mq_init(void)?消息隊列初始化,負責初始化 g_stMsgUnit 。
void mq_clear(void)清空消息隊列,效果同 mq_init(),可有可無。
void mq_lock(void)消息隊列鎖定,鎖定的消息隊列不可讀不可寫。
void mq_unlock(void)消息隊列解鎖,解鎖后消息隊列恢復正常功能。
BOOL mq_is_empty(void)消息隊列判空,返回 TRUE 表示消息隊列當前為空,返回 FALSE 表示有消息存儲。
INT8U mq_get_msg_cur_sum(void)查詢消息隊列中當前存儲的消息總數,函數返回值為查詢結果
INT8U mq_get_msg_sum_max(void)查詢消息隊列的最大容量,函數返回值為查詢結果。
INT8U mq_msg_post_fifo(MSG* pMsg)向消息隊列中寄送消息,方式為先入先出,形參 pMsg 指向消息的備份內存,函數返回操作結果。該函數多被 ISR 調用,所以必須為可重入函數。
INT8U mq_msg_req_fifo(MSG* pMsg)從消息隊列中讀取消息, 方式為先入先出, 函數將讀出的消息存入形參 pMsg 指向的內存,函數返回操作結果。該函數被主程序調用, 可以不是可重入函數, 但要對共享數據進行臨界保護 ?。
事件/消息驅動機制是一個標準的通用的框架,配合 ISR,對任何系統輸入都能應對自如。事件/消息驅動機制屏蔽了應用層程序獲取各種系統輸入的工作細節,將系統輸入抽象整合, 以一種標準統一的格式提交應用代碼處理, 極大地減輕了應用層代碼獲取系統輸入的負擔, 應用層只需要專注于高級功能的實現就可以了。
從軟件分層的角度來看, 事件/消息驅動機制相當于驅動層和應用層之間的中間層, 這樣的層次結構如圖 9 。
圖9 中之所以驅動層和應用層之間還有接觸,是因為系統輸出響應的時候,應用層可能還需要直接調用驅動層提供的函數接口。如果一個單片機的軟件是圖 9 這樣的結構,并且應用層的程序使用狀態機來實現,在消息的驅動下使應用層的狀態機運轉起來, 那么這個軟件的設計思想就是整篇文章的主題:基于事件/消息驅動+狀態機結構的裸奔通用框架 。
程序框架:狀態機+事件/消息驅動
事件/消息驅動和狀態機是天生的搭檔,這對黃金組合是分析問題解決問題的利器 ?。
1、牛刀小試
規則描述:
L1L2 狀態轉換順序 OFF/OFF--->ON/OFF--->ON/ON--->OFF/ON--->OFF/OFF
通過按鍵控制 L1L2 的狀態,每次狀態轉換只需按鍵 1 次
從上一次按鍵的時刻開始計時,如果 10 秒鐘之內沒有按鍵事件,則不管當前 L1L2 狀態如何,一律恢復至初始狀態。
L1L2 的初始狀態 OFF/OFF
現在我們用狀態機+事件/消息驅動的思想來分析問題。系統中可提取出兩個事件:按鍵事件和超時事件,分別用事件標志 KEY 和 TOUT 代替。L1L2 的狀態及轉換關系可做成一個狀態機,稱為主狀態機,共 4 個狀態:LS_OFFOFF、LS_ONOFF、 LS_ONON、 LS_OFFON 。主狀態機會在事件 KEY 或 TOUT 的驅動下發生狀態遷移,各個狀態之間的轉換關系比較簡單,在此略過。
事件/消息驅動機制的任務就是檢測監控事件 KEY 和 TOUT,并提交給主狀態機處理。檢測按鍵需要加入消抖處理,消抖時間定為 20ms, 10S 超時檢測需要一個定時器進行計時。
這里將按鍵檢測程序部分也做成一個狀態機,共有 3 個狀態:
WAIT_DOWN :空閑狀態,等待按鍵按下
SHAKE :初次檢測到按鍵按下,延時消抖
WAIT_UP :消抖結束,確認按鍵已按下,等待按鍵彈起
按鍵狀態機的轉換關系可在圖 10 中找到。按鍵檢測和超時檢測共用一個定時周期為 20ms 的定時中斷,這樣就可以把按鍵檢測和超時檢測的代碼全部放在這個定時中斷的 ISR 中。我把這個中斷事件用 TICK 標記, 按鍵狀態機在 TICK 的驅動下運行, 按鍵按下且消抖完畢后觸發 KEY 事件, 而超時檢測則對 TICK 進行軟時鐘計數,記滿 500 個 TICK 則超時 10S,觸發 TOUT 事件。
有了上面的分析,實現這個功能的程序的結構就十分清晰了, 圖 10 是這個程序的結構示意圖,這張圖表述問題足夠清晰了,具體的代碼就不寫了。仔細瞅瞅,是不是有點兒那個意思了?
如果忽略定時中斷 ISR 中的細節,圖 10 中的整個程序結構就是事件/消息驅動+主狀態機的結構, ISR 是消息的生產者,與消息緩沖、派發相關的程序部分是管理者,而主狀態機則是消息的消費者,應用層代碼中只有這一個狀態機,是消息的唯一消費者。
這個結構就是通用框架 GF1.0 的標準結構:多個 ISR + 一個消息緩沖區 + 一個應用層主狀態機。ISR 生成的消息(事件)全部提交主狀態機處理, 在消息的驅動下主狀態機不斷地遷移。
如果把應用層主狀態機看做是一臺發動機, 那么 ISR 生成的消息則是燃料, 事件不斷的發生, 消息不斷的生成,有了燃料(消息)的供給,發動機(主狀態機)就能永不停息地運轉。
接下來關注一下圖 10 中的 ISR, 這個 ISR 里面的內容是很豐富的, 里面還套著 2 個小狀態機:按鍵狀態機和計時狀態機。按鍵狀態機自不必說, 這個計時部分也可以看做是一個狀態機,不過這個狀態機比較特殊,只有一個狀態 DELAY。
既然是狀態機, 想要跑起來就需要有事件來驅動, 在這個 ISR 里, 定時器的中斷事件 TICK就是按鍵狀態機和計時狀態機的驅動,只不過這兩個事件驅動+狀態機結構沒有消息緩沖,當然也不需要消息緩沖,因為狀態機在 ISR 中,對事件是立即響應的。
從宏觀上看,圖 10 中是事件/消息驅動+狀態機,從微觀上看,圖 10 中的 ISR 也是事件驅動+狀態機。ISR 中的狀態機在遷移過程中生成消息(事件),而這些消息(事件)對于主狀態機來講又是它自己的驅動事件。事件的級別越高, 事件自身也就越抽象, 描述的內容也就越接近人的思維方式。我覺得這種你中有我我中有你的特點正是事件驅動+狀態機的精髓所在 ?。
2、通用框架GF1.0
前面說過, 狀態機總是被動地接受事件, 而 ISR 也只是負責將消息(事件)送入消息緩沖區,這些消息僅僅是數據,自己肯定不會主動地跑去找狀態機。那么存儲在緩沖區中的消息(事件)是怎么被發送到目標狀態機呢?
把消息從緩沖區中取出并送到對應的狀態機處理,這是狀態機調度程序的任務,我把這部分程序稱作狀態機引擎(State Machine Engine , 簡寫作 SME)。圖 11 是 SME 的大致流程圖。
從圖 11 可以看出, SME 的主要工作就是不斷地查詢消息緩沖隊列,如果隊列中有消息,則將消息按先入先出的方式取出, 然后送入狀態機處理。SME 每次只處理一條消息, 反復循環,直到消息隊列中的消息全部處理完畢。
當消息隊列中沒有消息時, CPU 處于空閑狀態, SME 轉去執行“空閑任務”??臻e任務指的是一些對單片機系統關鍵功能的實現無關緊要的工作,比如喂看門狗、算一算 CPU 使用率之類的工作,如果想降低功耗,甚至可以讓 CPU 在空閑任務中進入休眠狀態,只要事件一發生, CPU 就會被 ISR 喚醒,轉去執行消息處理代碼。
實際上, 程序運行的時候 CPU 大部分時間是很“閑” 的, 所以消息隊列查詢和空閑任務這兩部分代碼是程序中執行得最頻繁的部分,也就是圖 11 的流程圖中用粗體框和粗體線標出的部分。
如果應用層的主狀態機用壓縮表格驅動法實現,結合上面給出的消息模塊, 則GF1.0 的狀態機引擎代碼如程序清單 List11 所示。
「程序清單List11:」
?
void?sme_kernel(void); /*************************************** *FuncName?:?main *Description?:?主函數 *Arguments?:?void *Return?:?void *****************************************/ void?main(void) { ?sys_init(); ?sme_kernel();?/*GF1.0?狀態機引擎*/ } /*************************************** *FuncName?:?sme_kernel *Description?:?裸奔框架?GF1.0?的狀態機引擎函數 *Arguments?:?void *Return?:?void *****************************************/ void?sme_kernel(void) { ?extern?struct?fsm_node?g_arFsmDrvTbl[];?/*狀態機壓縮驅動表格*/ ?INT8U?u8Err?=?0;?/**/ ?INT8U?u8CurStat?=?0;?/*狀態暫存*/ ?MSG?stMsgTmp;?/*消息暫存*/ ?struct?fsm_node?stNodeTmp?=?{NULL,?0};?/*狀態機節點暫存*/ ?memset((void*)(&stMsgTmp),?0,?sizeof(MSG));?/*變量初始化*/ ?gbl_int_disable();?/*關全局中斷*/ ?mq_lock();?/*消息隊列鎖定*/ ?mq_init();?/*消息隊列初始化*/ ?mq_unlock();?/*消息隊列解鎖*/ ?fsm_init();?/*狀態機初始化*/ ?gbl_int_enable();?/*開全局中斷*/ ? ?while(1) ?{ ??if(mq_is_empty()?==?FALSE) ??{ ???u8Err?=?mq_msg_req_fifo(&stMsgTmp);?/*讀取消息*/ ???if(u8Err?==?MREQ_NOERR) ???{ ????u8CurStat?=?get_cur_state();?/*讀取當前狀態*/ ????stNodeTmp?=?g_arFsmDrvTbl[u8CurStat];?/*定位狀態機節點*/ ????if(stNodeTmp.u8StatChk?==?u8CurStat) ????{ ?????u8CurStat?=?stNodeTmp.fpAction(&stMsgTmp);?/*消息處理*/ ?????set_cur_state(u8CurStat?);?/*狀態遷移*/ ????} ????else ????{ ?????state_crash(u8CurStat?);?/*非法狀態處理*/ ????} ???} ??} ??else ??{ ???idle_task();?/*空閑任務*/ ??} ?} }
?
3、狀態機與ISR在驅動程序中的應用
在驅動層的程序中使用狀態機和 ISR 能使程序的效率大幅提升。這種優勢在通信接口中最為明顯,以串口程序為例。
單片機和外界使用串口通信時大多以數據幀的形式進行數據交換,一幀完整的數據往往包含幀頭、接收節點地址、幀長、數據正文、校驗和幀尾等內容,圖 12 所示為這種數據幀的常見結構。
圖12 表明的結構只是數據幀的一般通用結構, 使用時可根據實際情況適當簡化, 例如如果是點對點通信, 那么接收節點地址 FRM_USR 可省略;如果通信線路沒有干擾, 可確保數據正確傳輸,那么校驗和 FRM_CHKSUM 也可省略。
假定一幀數據最長不超過 256 個字節且串口網絡中通信節點數量少于 256 個,那么幀頭、接收節點地址、幀長、幀尾都可以用 1 個字節的長度來表示。雖然數據的校驗方式可能不同,但校驗和使用 1~4 個字節的長度來表示足以滿足要求。
先說串口接收, 在裸奔框架 GF1.0 的結構里, 串口接收可以有 2 種實現方式:ISR+消息 orISR+緩沖區+消息。ISR+消息比較簡單, ISR 收到一個字節數據,就把該字節以消息的形式發給應用層程序,由應用層的代碼進行后續處理。這種處理方式使得串口接收 ISR 結構很簡單,負擔也很輕, 但是存在 2 個問題。
數據的接收控制是一個很底層的功能, 按照軟件分層結構, 應用代碼不應該負責這些工作,混淆職責會使得軟件的結構變差;用消息方式傳遞單個的字節效率太低, 占用了太多的消息緩沖資源,如果串口波特率很高并且消息緩沖區開的不夠大,會直接導致消息緩沖區溢出。
相比之下, ISR+緩沖區+消息的處理方式就好多了, ISR 收到一個字節數據之后, 將數據先放入接收緩沖區,等一幀數據全部接收完畢后(假設緩沖區足夠大),再以消息的形式發給應用層,應用層就可以去緩沖區讀取數據。
對于應用層來講,整幀數據只有數據正文才是它想要的內容,數據幀的其余部分僅僅是數據正文的封皮, 沒有意義。從功能劃分的角度來看, 確保數據正確接收是 ISR 的職責, 所以這部分事情應該放在 ISR 中做,給串口接收 ISR 配一個狀態機,就能很容易的解決問題。圖 13為串口接收 ISR 狀態轉換圖。
圖13 中的數據幀使用 16 位校驗和,發送順序高字節在前,低字節在后。接收緩沖區屬于 ISR 和主程序的共享資源,必須實現互斥訪問,所以 ISR 收完一幀數據之后對緩沖區上鎖, 后面再發生的 ISR 發現緩沖區上鎖之后, 不接收新的數據, 也不修改緩沖區中的數據。
應用層程序收到消息, 讀取緩沖區中的數據之后再對緩沖區解鎖, 使能 ISR 接收串口數據和對緩沖區的寫入。數據接收完畢后,應該校驗數據,只有校驗結果和收到的校驗和相符,才能確信數據正確接收。
數據校驗比較耗時,不適合在 ISR 中進行,所以應該放在應用代碼中處理。這樣實現的串口接收 ISR 比較復雜,代碼規模比較大,看似和 ISR 代碼盡量簡短,執行盡量迅速的原則相悖, 但是由于 ISR 里面是一個狀態機, 每次中斷的時候 ISR 僅執行全部代碼的一小部分,之后立刻退出,所以執行時間是很短的,不會比“ISR+消息” 的方式慢多少。
串口發送比串口接收要簡單的多,為提高效率也是用 ISR+緩沖區+消息的方式來實現。程序發送數據時調用串口模塊提供的接口函數, 接口函數通過形參獲取要發送的數據, 將數據打包后送入發送緩沖區, 然后啟動發送過程, 剩下的工作就在硬件和串口發送 ISR 的配合下自動完成,數據全部發送完畢后, ISR 向應用層發送消息,如果有需要,應用層可以由此獲知數據發送完畢的時刻。圖 14 為串口發送 ISR 的狀態轉換圖。
上面只是討論了串口設備的管理方法, 其實這種狀態機+ISR 的處理方式可以應用到很多的硬件設備中,一些適用的場合:
標準的或自制的單總線協議 (狀態機+定時中斷+消息)
用 I/O 模擬 I2C 時序并且通信速率要求不高 (狀態機+定時中斷+消息)
數碼管動態掃描 (狀態機+定時中斷)
鍵盤動態掃描 (狀態機+定時中斷 ?)
小結
裸奔框架 GF1.0 處處體現著事件驅動+狀態機的思想, 大到程序整體的組織結構, 小到某個ISR 的具體實現,都有這對黃金組合的身影。從宏觀上看, 裸奔框架 GF1.0 是一個 ISR+消息管理+主狀態機的結構, 如圖 15 所示。
不管主狀態機使用的是 FSM(有限狀態機)還是 HSM(層次狀態機), GF1.0 中有且只有 1 個主狀態機。主狀態機位于軟件的應用層, 是整個系統絕對的核心, 承擔著邏輯和運算功能, 外界和單片機系統的交互其實就是外界和主狀態機之間的交互, 單片機程序的其他部分都是給主狀態機打雜的。
從微觀上看, 裸奔框架 GF1.0 中的每一個 ISR 也是事件驅動+狀態機的結構。ISR 的主要任務是減輕主狀態機獲取外界輸入的負擔, ISR 負責處理獲取輸入時硬件上繁雜瑣細的操作,將各種輸入抽象化,以一種標準統一的數據格式(消息)提交給主狀態機,好讓主狀態機能專注于高級功能的實現而不必關注具體的細節。
裸奔框架 GF1.0 應用的難點在于主狀態機的具體實現,對于一個實際的應用,不管功能多復雜, 都必須將這些功能整合到一個主狀態機中來實現。這既要求設計者對系統的目標功能有足夠詳細的了解, 還要求設計者對狀態機理論有足夠深的掌握程度, 如果設計出的狀態機不合理,程序的其他部分設計得再好,也不能很好的實現系統的要求。
將實際問題狀態機化,最重要的是要合理地劃分狀態,其次是要正確地提取系統事件,既不能遺漏, 也不能重復。有了狀態和事件, 狀態轉換圖的骨架就形成了, 接下來就是根據事件確定狀態之間的轉換關系,自頂向下,逐步細化,最終實現整個功能。
審核編輯:湯梓紅
評論
查看更多