【說在前面的話】
2022年了,想必已經不會有人對嵌入式開發中“數據結構(Data )”的作用產生疑問了吧?無論你是否心存疑惑,本文都將給你一個完全不同的視角。
每每說起數據結構,很多人腦海里復現的一定是以下的內容:
數據結構其實不是一個高大上的名詞,它意外的非常樸實——你也許每天都在用。作為一個新坑,我將在【非常C結構】系列文章中為大家分享很多嵌入式開發中很多“非常”而又“好用”的數據結構。
【人人都可以學會的“表格”】
你不必學過所謂的“關系數據庫”也可以理解“表格(Table)”這種數據結構的本質含義。
在C語言環境中,表格的本質就是結構體數組,即:由結構體組成的數組。這里:
在嵌入式系統中,表格具有以下特點:
如果一個需求能夠1)接受上述的特點;或者2)本身就具有上述特點;或者3)部分內容經過改造后可以接受上述特點——那么,就可以使用表格來保存數據了。
一個典型的例子就是:交互菜單。
很容易看到,每一級菜單本質上都“可以”是一個表格。
雖然在很多UI設計工具中(比如LVGL),菜單的內容是在運行時刻動態生成的(用鏈表來實現),但在嵌入式系統中,動態生成表格本身并不是一個“必須使用”的特性,相反,由于產品很多時候功能固定——菜單的內容也是固定的,因此完全沒有必要在運行時刻進行動態生成——這就滿足了表格的“在編譯時刻初始化”的要求。
采用表格的形式來保存菜單,就獲得了在ROM中保存數據、減少RAM消耗的的優勢。同時,數組的訪問形式又進一步簡化了用戶代碼。
另外一個常見用到表格的例子是消息地圖( Map),它在通信協議棧解析類的應用中非常常見,在很多結構緊湊功能復雜的中也充當著重要的角色。
如果你較真起來,菜單也不過消息地圖的一種。表格不是實現消息地圖的唯一方式,但卻是最簡單、最常用、數據存儲密度最高的形式。在后續的例子中,我們就以“消息地圖”為例,深入聊聊表格的使用和優化。
【表格的定義】一般來說,表格由兩部分構成:
因此,表格的定義也分為兩個部分:
記錄的定義一般格式如下:
typedef?struct?<表格名稱>_item_t <表格名稱>_item_t;
struct?<表格名稱>_item_t {
????//?每條記錄中的內容
};
這里,第一行的所在行的作用是“前置聲明”;所在行的作用是定義結構體的實際內容。雖然我們完全可以將“前置聲明”和“結構體定義”合二為一,寫作:
typedef?struct <表格名稱>_item_t {
// 每條記錄中的內容
} <表格名稱>_item_t;
但基于以下原因,我們還是推薦大家堅持第一種寫法:
以消息地圖為例,一個常見的記錄結構體定義如下:
typedef?struct?msg_item_t msg_item_t;
struct?msg_item_t {
uint8_t chID; //!< 指令
????uint8_t?chAccess;?????????????//!
????uint16_t hwValidDataSize; //!< 數據長度要求
????bool?(*fnHandler)(msg_item_t?*ptMSG,???
???? void?*pData,?
??????????????????????uint_fast16_t?hwSize);
};
在這個例子中,我們腦補了一個通信指令系統,當我們通過通信前端進行數據幀解析后,獲得了以下的內容:
為了方便指令解析,我們也需要有針對性的來設計每一條指令的內容,因此,我們加入了 chID 來存儲指令碼;并加入了函數指針來為當前指令綁定一個處理函數;考慮到每條指令所需的最小有效數據長度是已知的,因此,我們通過來記錄這一信息,以便進行信息檢索時快速的做出判斷。具體如何使用,我們后面再說。
對表格來說,容器是所有記錄的容身之所,可以簡單,但不可以缺席。最簡單的容器就是數組,例如:
const?msg_item_t?c_tMSGTable[20];
這里, 類型的數組就是表格的容器,而且我們手動規定了數組中元素的個數。實踐中,我們通常不會像這樣手動的“限定”表格中元素的個數,而是直接“偷懶”——埋頭初始化數組,然后讓編譯器替我們去數數——根據我們初始化元素的個數來確定數組的元素數量,例如:
const msg_item_t c_tMSGTable[] = {
[0] = {
????????.chID?=?0,
????????.fnHandler = NULL,
},
[1] = {
...
},
...
};
上述寫法是C99語法,不熟悉的小伙伴可以再去翻翻語法書哦。說句題外話,2022年了,連頑固不化的Linux都擁抱C11了,不要再抱著C89規范不放了,起碼用個C99沒問題的。
上面寫法的好處主要是方便我們偷懶,減少不必要的“數數”過程。那么,我們要如何知道一個表格中數組究竟有多少個元素呢?別慌,我們有 ():
這個語法糖 dimof() 可不是我發明的,不信你問Linux。它的原理很簡單,當我們把數組名稱傳給 dimof() 時,它會:
通過 () 來獲取整個目標數組的字節尺寸;
通過 ([0]) 來獲取數組第一個元素的字節尺寸——也就是數組元素的尺寸;
通過除法獲取數組中元素的個數。
【表格的訪問(遍歷)】
由于表格的本質是結構體數組,因此,針對表格最常見的操作就是遍歷(搜索)了。還以前面消息地圖為例子:
static?volatile?uint8_t?s_chCurrentAccessPermission;
/*!?\brief?搜索消息地圖,并執行對應的處理程序
?*!?\retval?false??消息不存在或者消息處理函數覺得內容無效
?*! \retval true 消息得到了正確的處理
?*/
bool?search_msgmap(uint_fast8_t?chID,
?????????????????? void *pData,
?????????????????? uint_fast16_t hwSize)
{
????for?(int?n?=?0;?n?
????????msg_item_t *ptItem = &c_tMSGTable[n];
????????if?(chID?!=?ptItem->chID) {
???????? continue;
????????}
????????if?(!(ptItem->chAccess & s_chCurrentAccessPermission)) {
????????????continue;??//!
????????}
????????if?(hwSize < ptItem->hwSize) {
????????????continue; //!< 數據太小了
????????}
????????if (NULL == ptItem->fnHandler) {
????????????continue;??//!
????????}
????????
????????//! 調用消息處理函數
????????return?ptItem->fnHandler(ptItem,?pData,?hwSize);
????}
????
????return?false;???//!
}
別看這個函數“很有料”的樣子,其本質其實特別簡單:
其實上述代碼隱藏了一個特性:就是這個例子中的消息地圖中允許出現chID相同的消息的——這里的技巧是:對同一個chID值的消息,我們可以針對不同的訪問權限(值)來提供不同的處理函數。比如,通信系統中,我們可以設計多種權限和模式,比如:只讀模式、只寫模式、安全模式等等。不同模式對應不同的值。這樣,對哪怕同樣的指令,我們也可以根據當前模式的不同提供不同的處理函數——這只是一種思路,供大家參考。
【由多實例引入的問題】前面的例子為我們展示表格使用的大體細節,對很多嵌入式應用場景來說,已經完全夠用了。但愛思考的小伙伴一定已經發現了問題:
如果我的系統中有多個消息地圖(每個消息地圖中消息數量是不同的),我改怎么復用代碼呢?
為了照顧還一臉懵逼的小伙伴,我把這個問題給大家翻譯翻譯:
簡而言之,()現在跟某一個消息地圖(數組)綁定死了,如果要讓它支持其它的消息地圖(其它數組),就必須想辦法將其與特定的數組解耦,換句話說,在使用 () 的時候,要提供目標的消息地圖的指針,以及消息地圖中元素的個數。
一個頭疼醫頭腳疼醫腳的修改方案呼之欲出:
bool?search_msgmap(msg_item_t?*ptMSGTable,
uint_fast16_t hwCount,
uint_fast8_t chID,
void *pData,
uint_fast16_t hwSize)
{
for (int n = 0; n < hwCount; n++) {
msg_item_t *ptItem = &ptMSGTable[n];
if (chID != ptItem->chID) {
continue;
}
...
//! 調用消息處理函數
return ptItem->fnHandler(ptItem, pData, hwSize);
}
return false; //!< 沒找到對應的消息
}
假設我們有多個消息地圖,對應不同的工作模式:
const?msg_item_t?c_tMSGTableUserMode[] = {
...
};
const msg_item_t c_tMSGTableSetupMode[] = {
...
};
const msg_item_t c_tMSGTableDebugMode[] = {
...
};
const msg_item_t c_tMSGTableFactoryMode[] = {
...
};
在使用的時候,可以這樣:
typedef enum?{
????USER_MODE?=?0,????//!
????SETUP_MODE,???????//!
????DEBUG_MODE,???????//!
????FACTORY_MODE,?????//!
}?comm_mode_t;
bool?frame_process_backend(comm_mode_t tWorkMode,
?????????????????????????? uint_fast8_t chID,
?????????????????????????? void *pData,
?????????????????????????? uint_fast16_t hwSize)
{
????bool?bHandled = false;
switch (tWorkMode) {
case USER_MODE:
????????????bHandled = search_msgmap(
???????????? c_tMSGTableUserMode,
???????????? dimof(c_tMSGTableUserMode),
???????????? chID,
???????????? pData,
??????????????????????????hwSize);
????????????break;
???????? case SETUP_MODE:
bHandled = search_msgmap(
c_tMSGTableSetupMode,
dimof(c_tMSGTableUserMode),
chID,
pData,
hwSize);
break;
?????????...
}
return bHandled;
}
看起來很不錯,對吧?非也非也!早得很呢。
【表格定義的完全體】前面我們說過,表格的定義分兩個部分:
其中,關于容器的定義c語言修改結構體中數據,我們說過,數組是容器的最簡單形式。那么容器定義的完全體是怎樣的呢?
“還是結構體”!
是的,表格條目的本質是結構體,表格容器的本質也是一個結構體:
typedef struct <表格名稱>_item_t <表格名稱>_item_t;
struct <表格名稱>_item_t {
// 每條記錄中的內容
};
typedef struct <表格名稱>_t <表格名稱>_t;
struct <表格名稱>_t {
????uint16_t?hwItemSize;
????uint16_t?hwCount;
????<表格名稱>_item_t *ptItems;
};
容易發現c語言修改結構體中數據,這里表格容器被定義成了一個叫做 _t 的結構體,其中包含了三個至關重要的元素:
這個其實是來湊數的,因為32位系統中指針4字節對齊的緣故,2字節的橫豎會產生2字節的氣泡。不理解這一點的小伙伴,可以參考文章《》
還是以前面消息地圖為例,我們來看看新的容器應該如何定義和使用:
typedef?struct?msg_item_t?msg_item_t;
struct msg_item_t {
uint8_t chID; //!< 指令
uint8_t chAccess; //!< 訪問權限檢測
uint16_t hwValidDataSize; //!< 數據長度要求
bool (*fnHandler)(msg_item_t *ptMSG,
void *pData,
uint_fast16_t hwSize);
};
typedef?struct?msgmap_t?msgmap_t;
struct msgmap_t {
uint16_t hwItemSize;
uint16_t hwCount;
????msg_item_t *ptItems;
};
const msg_item_t c_tMSGTableUserMode[] = {
...
};
const?msgmap_t?c_tMSGMapUserMode = {
????.hwItemSize?=?sizeof(msg_item_t),
????.hwCount?=?dimof(c_tMSGTableUserMode),
????.ptItems = c_tMSGTableUserMode,
};
既然有了定義,() 也要做相應的更新:
bool?search_msgmap(msgmap_t?*ptMSGMap,
uint_fast8_t chID,
void *pData,
uint_fast16_t hwSize)
{
for (int n = 0; n < ptMSGMap->hwCount; n++) {
msg_item_t *ptItem = &(ptMSGMap->ptItems[n]);
if (chID != ptItem->chID) {
continue;
}
...
//! 調用消息處理函數
return ptItem->fnHandler(ptItem, pData, hwSize);
}
return false; //!< 沒找到對應的消息
}
看到這里,相信很多小伙伴內心是毫無波瀾的……
“是的……是稍微優雅一點……然后呢?”
“就這!?就這?!”
別急,下面才是見證奇跡的時刻。
【要優雅……】
在前面的例子中,我們注意到表格的初始化是分兩部分進行的:
const msg_item_t c_tMSGTableUserMode[] = {
[0] = {
.chID = 0,
.fnHandler = NULL,
},
[1] = {
...
},
...
};
const?msgmap_t?c_tMSGMapUserMode = {
????.hwItemSize?=?sizeof(msg_item_t),
????.hwCount?=?dimof(c_tMSGTableUserMode),
????.ptItems = c_tMSGTableUserMode,
};
那么,我們可不可以把它們合二為一呢?這樣:
要做到這一點,我們可以使用一個類似“匿名數組”的功能:
我們想象中的樣子:
const msgmap_t c_tMSGMapUserMode = {
.hwItemSize = sizeof(msg_item_t),
.hwCount = dimof(c_tMSGTableUserMode),
.ptItems = const msg_item_t c_tMSGTableUserMode[] = {
[0] = {
.chID = 0,
.fnHandler = NULL,
},
[1] = {
...
},
...
},
};
使用“匿名數組”后的樣子(也就是刪除數組名稱后的樣子):
const msgmap_t c_tMSGMapUserMode = {
.hwItemSize = sizeof(msg_item_t),
.hwCount = dimof(c_tMSGTableUserMode),
????.ptItems?=?(msg_item_t?[]){
[0] = {
.chID = 0,
.fnHandler = NULL,
},
[1] = {
...
},
...
},
};
其實,這不是什么“黑魔法”,而是一個廣為使用的GNU擴展,被稱為“復合式描述( )”,本質上就是一種以“省略”數組或結構體名稱的方式來初始化數組或結構體的語法結構。具體語法介紹,小伙伴們可以參考這篇文章《》。
眼尖的小伙伴也許已經發現了問題:既然我們省略了變量名,那么如何通過 dimof() 來獲取數組元素的個數呢?
少俠好眼力!
解決方法不僅有,而且簡單粗暴:
const msgmap_t c_tMSGMapUserMode = {
.hwItemSize = sizeof(msg_item_t),
.hwCount = dimof((msg_item_t []){
[0] = {
.chID = 0,
.fnHandler = NULL,
},
[1] = {
...
},
...
??????}),
????.ptItems?=?(msg_item_t?[]){
[0] = {
.chID = 0,
.fnHandler = NULL,
},
[1] = {
...
},
...
},
};
所以說?……
為了優雅的初始化……
我們要把同樣的內容寫兩次?!!
手寫的確挺愚蠢,但宏可以啊!
????.ptItems?=?(__item_type?[])?{??????????????????????? \
__VA_ARGS__ \
}, \
????.hwCount?=?sizeof((__item_type?[])?{?__VA_ARGS__?})??\
/ sizeof(__item_type), \
.hwItemSize = sizeof(__item_type)
__impl_table(__item_type,?__VA_ARGS__)
借助上面的語法糖,我們可以輕松的將整個表格的初始化變得簡單優雅:
const msgmap_t c_tMSGMapUserMode = {
impl_table(msg_item_t,
[0] = {
.chID = 0,
.fnHandler = NULL,
},
[1] = {
...
},
...
),
};
這下舒服了吧?
【禁止套娃……】還記得前面多實例的例子吧?
const msg_item_t c_tMSGTableUserMode[] = {
...
};
const msg_item_t c_tMSGTableSetupMode[] = {
...
};
const msg_item_t c_tMSGTableDebugMode[] = {
...
};
const msg_item_t c_tMSGTableFactoryMode[] = {
...
};
現在當然就要改為如下的形式了:
const msgmap_t c_tMSGMapUserMode = {
impl_table(msg_item_t,
...
????),
};
const msgmap_t c_tMSGMapSetupMode = {
impl_table(msg_item_t,
...
),
};
const msgmap_t c_tMSGMapDebugMode = {
impl_table(msg_item_t,
...
),
};
const msgmap_t c_tMSGMapFactoryMode = {
impl_table(msg_item_t,
...
),
};
但……它們不都是 類型的么?為啥不做一個數組呢?
typedef enum {
USER_MODE = 0, //!< 普通的用戶模式
SETUP_MODE, //!< 出廠后的安裝模式
DEBUG_MODE, //!< 工程師專用的調試模式
FACTORY_MODE, //!< 最高權限的工廠模式
} comm_mode_t;
const?msgmap_t?c_tMSGMap[] = {
[USER_MODE] = {
impl_table(msg_item_t,
...
),
},
[SETUP_MODE] = {
impl_table(msg_item_t,
...
),
},
[DEBUG_MODE] = {
impl_table(msg_item_t,
...
),
},
[FACTORY_MODE] = {
impl_table(msg_item_t,
...
),
},
};
是不是有點意思了?再進一步,我們完全可以做一個新的表格,表格的元素就是 呀?
typedef?struct?cmd_modes_t?cmd_modes_t;
struct cmd_modes_t {
uint16_t hwItemSize;
uint16_t hwCount;
msgmap_t *ptItems;
};
然后就可以開始套娃咯:
const?cmd_modes_t?c_tCMDModes?=?{
????impl_table(msgmap_t,
???? [USER_MODE] = {
impl_table(msg_item_t,
[0] = {
.chID = 0,
.fnHandler = NULL,
},
[1] = {
...
},
...
),
},
[SETUP_MODE] = {
impl_table(msg_item_t,
...
),
},
[DEBUG_MODE] = {
impl_table(msg_item_t,
...
),
},
[FACTORY_MODE] = {
impl_table(msg_item_t,
...
),
},
????),
};
【差異化……】
在前面的例子中,我們可以根據新的定義方式更新函數 d() 函數:
extern const cmd_modes_t c_tCMDModes;
bool frame_process_backend(comm_mode_t tWorkMode,
uint_fast8_t chID,
void *pData,
uint_fast16_t hwSize)
{
bool bHandled = false;
????if?(tWorkMode?> FACTORY_MODE) {
???? return false;
????}
????
????return search_msgmap( &(c_tCMDModes.ptItems[tWorkMode]),?
chID,
pData,
??????????????????????????hwSize);
}
是不是特別優雅?
把容器定義成結構體還有一個好處,就是可以給表格更多的差異化,這意味著,除了條目數組相關的內容外,我們還可以放入其它東西,比如:現有的 d() 為每一個消息地圖()都使用相同的處理函數 () ,這顯然缺乏差異化的可能性。如果每個消息地圖都有可能有自己的特殊處理函數怎么辦呢?
為了實現這一功能,我們可以對 進行擴展:
typedef struct msgmap_t msgmap_t;
struct msgmap_t {
uint16_t hwItemSize;
uint16_t hwCount;
msg_item_t *ptItems;
????bool?(*fnHandler)(msgmap_t?*ptMSGMap,
uint_fast8_t chID,
void *pData,
??????????????????????uint_fast16_t?hwSize);
};
則初始化的時候,我們就可以給每個消息地圖指定一個不同的處理函數:
extern
bool?msgmap_user_mode_handler(msgmap_t?*ptMSGMap,
uint_fast8_t chID,
void *pData,
uint_fast16_t hwSize);
extern
bool msgmap_debug_mode_handler(msgmap_t *ptMSGMap,
uint_fast8_t chID,
void *pData,
uint_fast16_t hwSize);
const cmd_modes_t c_tCMDModes = {
impl_table(msgmap_t,
[USER_MODE] = {
impl_table(msg_item_t,
????????????????...
),
.fnHandler = &msgmap_user_mode_handler,
},
[SETUP_MODE] = {
impl_table(msg_item_t,
...
),
????????????.fnHandler?=?NULL;?//!
},
[DEBUG_MODE] = {
impl_table(msg_item_t,
...
),
.fnHandler = &msgmap_debug_mode_handler,
},
[FACTORY_MODE] = {
impl_table(msg_item_t,
...
),
????????????//.fnHandler = NULL 什么都不寫,就是NULL(0)
},
),
};
此時,我們再更新d()函數,讓上述差異化功能成為可能:
bool frame_process_backend(comm_mode_t tWorkMode,
uint_fast8_t chID,
void *pData,
uint_fast16_t hwSize)
{
bool bHandled = false;
????msgmap_t?*ptMSGMap?= c_tCMDModes.ptItems[tWorkMode];
????if?(tWorkMode?> FACTORY_MODE) {
???? return false;
????}
????
????//!?調用每個消息地圖自己的處理程序
????if (NULL != ptMSGMap->fnHandler) {
?????????return?ptMSGMap->fnHandler(ptMSGMap,?
chID,
pData,
hwSize);
????}
????//!?默認的消息地圖處理程序
????return search_msgmap( ptMSGMap,
chID,
pData,
hwSize);
}
【說在后面的話】啥都不說了……你們看著辦吧。我們下期再見。
—— The End——
往期推薦
點擊上方名片關注我
你點的每個好看,我都認真當成了喜歡