從零開始漫談?|?多實(shí)例的狀態(tài)機(jī)
來源:裸機(jī)思維作者:GorgonMeducer
【說在前面的話】在前面的講解中,我們介紹了如何使用狀態(tài)圖的方式來設(shè)計(jì)有限狀態(tài)機(jī)、明確了狀態(tài)圖設(shè)計(jì)的“清晰”原則,并結(jié)合最簡單和常用的switch狀態(tài)機(jī)翻譯模式詳細(xì)說明了狀態(tài)圖的“無腦翻譯”方法。
比如下面這個(gè)狀態(tài)圖就是一個(gè)典型:
通過圖示,我們能清晰的看出該狀態(tài)機(jī)實(shí)現(xiàn)的是“通用字符串輸出”的功能。其實(shí),這里我算是埋下了一個(gè)小小的“彩蛋”——當(dāng)然,它的真實(shí)身份是一個(gè)陷阱。如果你已經(jīng)熟悉了我前面介紹的翻譯規(guī)則,很容易就會發(fā)現(xiàn)這里存在的巨大問題:是的,這個(gè)狀態(tài)圖按照switch翻譯法無腦翻譯的后果,將是一個(gè)根本無法正常工作的狀態(tài)機(jī):
#include
#include
typedef enum {
fsm_rt_err = -1,
fsm_rt_on_going = 0,
fsm_rt_cpl = 1,
} fsm_rt_t;
extern bool serial_out(uint8_t chByte);
#define PRINT_STR_RESET_FSM() \
do { s_tState = START; } while(0)
fsm_rt_t print_str(const char *pchStr)
{
static enum {
START = 0,
IS_END_OF_STRING,
SEND_CHAR,
} s_tState = START;
switch (s_tState) {
case START:
s_tState = IS_END_OF_STRING;
break;
case IS_END_OF_STRING:
if (*pchStr == '\0') {
PRINT_STR_RESET_FSM();
return fsm_rt_cpl;
}
s_tState = SEND_CHAR;
break;
case SEND_CHAR:
if (serial_out(*pchStr)) {
pchStr ;
s_tState = IS_END_OF_STRING;
}
break;
}
return fsm_rt_on_going;
}
不仔細(xì)看的小伙伴也許會撓撓后腦勺,說:“代碼很漂亮……但我也沒看出有啥問題啊”?不打緊,我們來看看這個(gè)狀態(tài)機(jī)時(shí)如何使用的:
int main(void)
{
...
while(true) {
static const char c_tDemoStr[] = {"Hello world!\r\n"};
print_str(c_tDemoStr);
????}
}
還沒看出問題么?好了,節(jié)目效果到了,我也不賣關(guān)子了,這一狀態(tài)機(jī)存在的問題如下:
- pchStr是一個(gè)局部變量,它保存了狀態(tài)機(jī)函數(shù) print_str 被調(diào)用時(shí)用戶所傳遞的字符串首地址;
- 該狀態(tài)機(jī)在執(zhí)行的過程中,不可避免的要多次出讓(Yield)處理器時(shí)間,以達(dá)到“非阻塞”的目的;
- 由于pchStr是一個(gè)局部變量,它的生命周期在退出print_str函數(shù)后就結(jié)束了;而每次重新進(jìn)入print_str函數(shù),它的值都會被復(fù)位成“hello world\r\n”的起始地址。
既然問題清楚了,修改方式也迎刃而解,如下圖所示:
也就是說,我們可以通過引入一個(gè)靜態(tài)變量 s_pchStr的方式來保存狀態(tài)機(jī)的關(guān)鍵上下文信息。對比圖片,可以注意到:修改后的圖在復(fù)位后的初始化階段(也就是start的行為部分)對靜態(tài)變量 s_pchStr做了一個(gè)初始化——用pchStr為其賦值。此后,圖中所有針對字符串的操作也都是使用 s_pchStr 來完成了。
重新翻譯后的代碼如下:
fsm_rt_t print_str(const char *pchStr)
{
static enum {
START = 0,
IS_END_OF_STRING,
SEND_CHAR,
} s_tState = START;
static const char *s_pchStr = NULL;
switch (s_tState) {
case START:
s_pchStr = pchStr;
s_tState = IS_END_OF_STRING;
//break; //!< fall-through
case IS_END_OF_STRING:
if (*s_pchStr == '\0') {
PRINT_STR_RESET_FSM();
return fsm_rt_cpl;
}
s_tState = SEND_CHAR;
????????????//break;????//!
case SEND_CHAR:
if (serial_out(*s_pchStr)) {
pchStr ;
s_tState = IS_END_OF_STRING;
}
break;
}
return fsm_rt_on_going;
}
【一系列似是而非的問題……】經(jīng)過上面的一連串操作,我們成功的排除了陷阱,獲得了一個(gè)能正常工作的狀態(tài)機(jī)。然而,眼尖的小伙伴還是能很快的發(fā)現(xiàn)這里的限制:
- 狀態(tài)機(jī)print_str?使用了靜態(tài)變量來保存狀態(tài)(s_tState)和關(guān)鍵的上下文(s_pchStr),因此幾乎肯定是不可重入的;
- 狀態(tài)機(jī)print_str使用了共享函數(shù)serial_out(),即便該函數(shù)本身可以保證原子性,但它仍然是一個(gè)臨界資源——換句話說,即便拋開 print_str 的可重入性問題不談,當(dāng)有該狀態(tài)機(jī)存在多個(gè)實(shí)例時(shí),你能保證每個(gè)字符串的打印都是完整的么?比如:
int main(void)
{
...
????while(true)?{
????????print_str(“I?have?a pen...”);
????????print_str("I?have?an apple...");
}
}
你實(shí)際打印出來的絕對不是你想要的結(jié)果。此時(shí),我們可以說,print_str 也不是線程安全(thread-safe)的。
根據(jù)維基百科的描述:
In?computing,?...?a?reentrant?procedure?can?be?interrupted?in?the?middle?of?its?execution?and?then?safely?be?called?again?("re-entered")?before?its?previous?invocations?complete?execution.https://en.wikipedia.org/wiki/Reentrancy_(computing)大體翻譯成中文就是:
……可重入的程序(函數(shù))允許在執(zhí)行的過程中被打斷,并在打斷所執(zhí)行的代碼中再次安全的調(diào)用……
這里,我們需要注意一個(gè)細(xì)節(jié),就是“可重入”關(guān)注的是,在任意時(shí)刻,無論以什么樣的方式,該函數(shù)被多次調(diào)用時(shí)是否“安全”。換句話說,它并不是“非常在意”可重入本身對功能的影響,它在意的是這樣調(diào)用是否“安全”。
以我們的print_str為例,由于狀態(tài)機(jī)的中使用了靜態(tài)變量,尤其是狀態(tài)變量s_tState——這意味著同時(shí)執(zhí)行的多個(gè)實(shí)例,彼此共享同一個(gè)狀態(tài)變量……換句話說,當(dāng)多個(gè)print_str同時(shí)執(zhí)行時(shí),它們是彼此干擾的。這意味著同時(shí)執(zhí)行多個(gè)print_str是“不安全”的,是會出問題的(比如字符串長度不一致時(shí)很可能會出現(xiàn)buffer-overflow的問題),因此可以說 print_str 是不可重入的。
但換一個(gè)角度,假設(shè)我們已經(jīng)解決了print_str的不可重入問題,比如:妥善的解決了狀態(tài)變量和上下文的存儲問題,那么就滿足了“可重入”關(guān)于“安全”的要求——因?yàn)楫?dāng)存在多個(gè)實(shí)例的時(shí)候,這樣執(zhí)行并不會導(dǎo)致系統(tǒng)崩潰,或是buffer-overflow——只不過打印出來的字符串并不完整而已。這就是為什么人們常說的:
可重入的函數(shù)不一定線程安全;線程安全的函數(shù)也不一定可重入。
本質(zhì)上,我們要解決的并不單純是狀態(tài)機(jī)的“可重入”問題——只把眼光放在可重入上就“格局小了”。
我們要實(shí)現(xiàn)的是“支持多實(shí)例的狀態(tài)機(jī)”。
【多實(shí)例的狀態(tài)機(jī)】所謂多實(shí)例的狀態(tài)機(jī),就是指那些同一時(shí)刻可以安全存在多個(gè)運(yùn)行實(shí)例的狀態(tài)機(jī)——本質(zhì)上每個(gè)實(shí)例都是一個(gè)任務(wù)——以多任務(wù)的眼光去看待狀態(tài)機(jī)的多實(shí)例問題,格局就寬闊了起來。
通過前面的分析,我們已經(jīng)注意到了問題所在,即:以現(xiàn)有的實(shí)現(xiàn)方式,如果存在多個(gè) print_str 調(diào)用(實(shí)例),那么它們其實(shí)是在“競爭”關(guān)鍵的狀態(tài)變量 s_tState和上下文 s_pchStr。
聰明的你一定看出來了,解決狀態(tài)機(jī)多實(shí)例的方式就是“給每個(gè)實(shí)例都發(fā)一個(gè)球”。具體來說,就是:
- 為狀態(tài)機(jī)定義一個(gè)控制塊;
- 在控制塊里存放狀態(tài)變量;
- 在控制塊里存放狀態(tài)機(jī)的上下文;
- 建立狀態(tài)機(jī)實(shí)例時(shí),首先要建立一個(gè)控制塊,并對其進(jìn)行必要的初始化;
- 在隨后調(diào)用狀態(tài)機(jī)時(shí),應(yīng)該首先傳遞狀態(tài)機(jī)的控制塊給狀態(tài)機(jī)函數(shù)。
對應(yīng)到圖例上,我們一般會在狀態(tài)圖的某個(gè)角落(比如左下角或右下角)通過一個(gè)矩形框列舉狀態(tài)機(jī)上下文的所有內(nèi)容。如下圖所示:
觀察修改后的狀態(tài)圖,我們應(yīng)該注意以下的一些變化:
- 在圖的右下角,出現(xiàn)了一個(gè)帶標(biāo)題的矩形框。這里標(biāo)題print_str_t是狀態(tài)機(jī)控制塊的類型名稱;下面的列表中列舉了上下文的內(nèi)容,在本例中就是 pchStr,注意,它已經(jīng)去掉了"s_"前綴。
- 狀態(tài)圖中通過?"this.xxxx" 的方式來訪問狀態(tài)機(jī)上下文中的內(nèi)容。
【基本的翻譯方法】一般來說,無論采用何種狀態(tài)機(jī)翻譯方式,可重入的狀態(tài)機(jī)一定會包含一個(gè)控制塊。在C語言中,我們會為其定義一個(gè)結(jié)構(gòu)體類型:
typedef struct <控制塊類型名稱> {
????uint8_t chState; //!< 狀態(tài)變量
????<上下文列表>
} <控制塊類型名稱>;
以print_str狀態(tài)圖為例:typedef struct print_str_t {
????uint8_t?chState;?????//!< 狀態(tài)變量
const char *pchStr; //!< 上下文
} print_str_t;
這里,我們并不會規(guī)定用戶用何種方式來為 print_str_t?類型分配存儲空間——這個(gè)選擇權(quán)應(yīng)該留個(gè)用戶自己——無論是定義靜態(tài)局部變量、全局變量還是從堆或者池中分配,都可以。無論采用哪種分配方式,我們都需要提供一個(gè)專門的函數(shù)來對狀態(tài)機(jī)進(jìn)行初始化。推薦的格式是:
#undef this
#define?this (*ptThis)
...
int?<狀態(tài)機(jī)名稱>_init(<狀態(tài)機(jī)類型名稱>?*ptThis[,?<形參列表>])
{
...
????this.chState?=?0;????//!
????/*!?\note?這里根據(jù)需要可以初始化那些只需要初始化一次的上下文
?????*/???
????/*!?\note?這里也可以對輸入的參數(shù)進(jìn)行有效性檢測,如果發(fā)現(xiàn)錯(cuò)誤,
?????*!???????就返回負(fù)數(shù)值。這里既可以自定義一套枚舉,也可以簡單
?????*!???????返回?-1 了事。
*/
return 0; //!< 如果一切順利返回0,表示正常
}
以 print_str為例:int?print_str_init(print_str_t *ptThis)
{
????if (NULL == ptThis) {
????????return?-1;?????//!
????}
????
????this.chState = 0;
????//在這個(gè)例子中,this.pchStr 更適合在運(yùn)行時(shí)刻由用戶指定。
????
????return 0;
}
接下來,我們就需要對狀態(tài)機(jī)函數(shù)進(jìn)行小小的改造,其格式為:
#include
fsm_rt_t?<狀態(tài)機(jī)名稱>(<狀態(tài)機(jī)類型名>?*ptThis[, <形參列表>])
{
????//!
????assert(NULL?!=?ptThis);???
????enum {
????????START = 0,
????????<狀態(tài)列表>
????};
...
????switch (this.chState) {
???? ...
????}
????
????return?fsm_rt_on_going;
}
最后,該圖的翻譯為:
#undef this
#define this (*ptThis)
#define PRINT_STR_RESET_FSM() \
do { this.State = START; } while(0)
fsm_rt_t print_str(print_str_t *ptThis, const char *pchStr)
{
enum {
START = 0,
IS_END_OF_STRING,
SEND_CHAR,
};
switch (this.chState) {
case START:
this.pchStr = pchStr;
this.chState = IS_END_OF_STRING;
//break; //!< fall-through
case IS_END_OF_STRING:
if (*(this.pchStr) == '\0') {
PRINT_STR_RESET_FSM();
return fsm_rt_cpl;
}
this.chState = SEND_CHAR;
//break; //!< fall-through
case SEND_CHAR:
if (serial_out(*(this.pchStr))) {
this.pchStr ;
this.chState = IS_END_OF_STRING;
}
break;
}
return fsm_rt_on_going;
}
此時(shí),我們就可以“安全”的進(jìn)行多實(shí)例調(diào)用了:static?print_str_t?s_tPrintTaskA;
static print_str_t s_tPrintTaskB;
int main(void)
{
...
print_str_init(