嵌入式軟件架構漫談
軟件架構的意義在于提高開發效率和代碼可維護性、可擴展性。
剛好最近需要用到裸機開發,在此自我總結一下經驗和見解。如有錯誤,歡迎評論區指出。
架構需要做到兩個維度的解耦:
縱向的分層;
橫向的模塊化;
分層好理解,可以看一下一個基于RTOS的軟件架構:

其作用在于后期的移植和排查只需要關注某一層級即可,比如更換芯片,那只需要修改驅動層即可;更換RTOS,那只需修改OS抽象層即可;按鍵任務控制LED任務功能失效,那就按照層級逐層排查(OS層的消息同步是否有問題?LED的亮滅驅動層是否有問題?)即可。
軟件上的實現其實就是把函數盡量封裝成抽象的接口。以存儲功能為例子:
- 對于上層應用調用者來說,需求很簡單,能存能讀即可;
- 不同存儲類型有不同的存儲步驟,如flash需要按頁擦寫,EEPROM可以按字節寫入還不需要擦操作;
- 如果存儲數據量小、存儲頻繁,那還要考慮擦寫均衡;
- 不同存儲芯片有不同廠商不同型號,那寄存器的定義自然不同;
- 芯片的通信方式也有不同,SPI、IIC等等;
一個小小的存儲需求,在實現細節上卻五花八門,我們可以簡單分出三個層級:

這樣,后續的項目甚至可以像搭積木一樣,開發者專心適配積木接口,可以極大提高開發效率,且由于其復用得到反復驗證,穩定性也會不斷提高。
難點在于如何做好橫向的模塊化。因為本身模塊之間就需要交互,層級也不是都只在應用層,那天然就會和追求的模塊獨立性相矛盾。
設計模塊化會涉及到三個問題:
1.怎么分模塊?
模塊的劃分可以參考“功能”單一性、通用性和分層來劃分。
以AT指令AT+LED控制LED亮滅功能為例,整個程序的實現鏈路如下:uart串口接收指令->指令解析->控制LED。那就可以分成三個模塊:
1)驅動模塊:負責接收指令;
2)協議模塊:負責解析AT指令;
3)LED模塊:負責控制LED燈亮滅;
每個模塊只負責單一功能,這樣任一模塊的變動都不會影響到其它模塊。同時可以復用模塊,協議模塊不僅可以解析串口收到的指令,也能同時解析如usb收到的指令。
2.模塊怎么運行?
a.時間片線性輪詢
這是裸機最常用的架構,固定時間片對所有程序走一遍。注意這個時間片的長度設計,太長會導致系統響應慢,太短會導致輪詢一遍的時間大于時間片。
優點是結構簡單明了。
缺點也很明顯,實時性會比較查。
int g_10ms_flag = 0;
void systick_irq_handle(void)
{
g_10ms_flag = 1;
}
int main(void)
{
sysytick_init(); // 10ms
while(1) {
if (g_10ms_flag == 1) {
g_10ms_flag = 0;
key_process();
led_process();
}
}
}
b.調度表驅動
這種方式是時間片線性輪詢的進階版,上面提到其缺點是實時性較差且時間片不能設置太短。因為所有任務不是都必須在同一周期進行輪詢,那我們可以把任務劃分,比如10ms輪詢、200ms輪詢等。而同一周期的還可以進一步作起點偏移,比如A偏移0ms,B偏移3ms,那就會在0ms執行A,3ms執行B,10ms執行A,13ms執行B......如此盡管AB任務周期都是10ms,但避免了同一時刻觸發有效分散系統負載。
缺點是所有模塊都是在調度表中靜態配置,嚴格周期執行的,靈活性稍有欠缺。
#define TASK_NUM 2
typedef struct {
int offset; // 相對于周期起點的偏移
int period; // 周期
void (*task_func)(void); // 任務函數指針
} schedule_entry_t;
int g_system_tick = 0;
void key_process(void);
int led_process(void);
schedule_entry_t schedule_table[TASK_NUM] = {
{ .offset = 0, .period = 20, .task_func = key_process },
{ .offset = 3, .period = 10, .task_func = led_process },
};
void systick_irq_handle(void)
{
g_system_tick++;
}
// 調度器函數
void scheduler_tick(void) {
for (int i = 0; i < TASK_NUM; i++) {
schedule_entry_t *task = &schedule_table[i];
if (((g_system_tick - task->offset) % task->period) == 0) {
task->task_func();
}
}
}
int main(void)
{
sysytick_init(); // 1ms
while (1) {
scheduler_tick();
}
return 0;
}
c.事件驅動
有的模塊并不需要去周期輪詢,而只有當某個事件觸發后才去執行。比如,只有按鍵按下才去亮燈。
好處是響應會更快,不需要等下一個時間片才執行;解耦性也好,每個模塊只要定義好什么事件觸發,需要執行其它模塊執行也只要發出事件即可。
缺點事件如果一多,那管理和邏輯也會變得復雜,而且解耦性越好,那時序的控制力會越弱。
為方便演示,先寫一個簡單的示例:
#define PIN_KEY_1 P1_0
#define EVENT_TICK (1 << 0)
#define EVENT_KEY (1 << 1)
int g_event = 0;
void systick_irq_handle(void)
{
g_event |= EVENT_TICK;
}
void key_process(void)
{
if (PIN_KEY_1 == 0) {
g_event |= EVENT_KEY;
}
}
int main(void)
{
sysytick_init(); // 10ms
while(1) {
if (g_event & EVENT_TICK) {
g_event ^= EVENT_TICK;
key_process();
}
if (g_event & EVENT_KEY) {
g_event ^= EVENT_KEY;
led_process();
}
}
}
大致如上例,通過事件來觸發執行,現在我們再進一步封裝一下,寫一個事件調度器:
#define PIN_KEY_1 P1_0
#define MAX_EVENTS 10
#define MAX_HANDLERS 5
// 事件類型
typedef enum {
EVENT_NONE = 0,
EVENT_TICK,
EVENT_KEY,
EVENT_MAX
} event_type_e;
// 定義事件回調函數指針類型
typedef void (*event_handler)(void);
// 事件注冊表
event_handler handlers[EVENT_MAX][MAX_HANDLERS];
// 簡單環形隊列,用來存儲觸發過的事件
typedef struct {
event_type_e queue[MAX_EVENTS];
int head;
int tail;
} event_queue_t;
event_queue_t g_event = {0};
// 注冊事件處理器
void register_event_handler(event_type_e type, event_handler handler)
{
for (int i = 0; i < MAX_HANDLERS; ++i) {
if (handlers[type][i] == NULL) {
handlers[type][i] = handler;
break;
}
}
}
// 觸發事件
void trigger_event(event_type_e type)
{
g_event.queue[g_event.tail] = type;
g_event.tail = (g_event.tail + 1) % MAX_EVENTS;
}
// 事件調度器
void process_events(void)
{
while (g_event.head != g_event.tail) {
event_type_e type = g_event.queue[g_event.head];
g_event.head = (g_event.head + 1) % MAX_EVENTS;
for (int i = 0; i < MAX_HANDLERS; ++i) {
if (handlers[type][i] != NULL) {
handlers[type][i]();
}
}
}
}
void systick_irq_handle(void)
{
trigger_event(EVENT_TICK);
}
void key_process(void)
{
if (PIN_KEY_1 == 0) {
trigger_event(EVENT_KEY);
}
}
void led_process(void)
{
}
int main(void)
{
sysytick_init(); // 10ms
// 注冊事件回調
register_event_handler(EVENT_TICK, key_process);
register_event_handler(EVENT_KEY, led_process);
while (1) {
process_events();
}
return 0;
}
事件調度器封裝起來后,使用就更加簡單了。開發者只需三步:1)增加事件類型;2)注冊事件回調函數;3)在需要的地方調用觸發事件接口函數。
還有消息驅動模型、發布-訂閱模型,都是和事件驅動類似,改造一下調度器就可以實現了。原理一致就不展開了,請自行查閱。
d.狀態機
狀態機是嵌入式非常常見的設計模式,軟件架構也是能參考使用的。
優點是狀態流程明確,易于維護。
缺點是狀態一多復雜度就會暴漲,特別是狀態中還有子狀態時。所以一般不會只用狀態機,會搭配其它架構一起使用,狀態機只用來管理子模塊。
#define PIN_KEY_1 P1_0
// 定義狀態枚舉
typedef enum {
STATE_IDLE,
STATE_RUNNING,
STATE_WORKING,
STATE_ERROR
} state_e;
// 當前狀態變量
state_e g_current_state = STATE_IDLE;
void systick_irq_handle(void)
{
if (g_current_state == STATE_IDLE) {
g_current_state = STATE_RUNNING;
}
}
void key_process(void)
{
if (PIN_KEY_1 == 0) {
g_current_state = STATE_WORKING;
} else {
g_current_state = STATE_IDLE;
}
}
int led_process(void)
{
}
void error_process(void)
{
}
// 狀態機處理函數
void state_handle(void)
{
switch (g_current_state)
{
case STATE_IDLE:
break;
case STATE_RUNNING:
key_process();
break;
case STATE_WORKING:
key_process();
if (led_process() == 0) {
g_current_state = STATE_ERROR;
}
break;
case STATE_ERROR:
error_process();
g_current_state = STATE_IDLE;
break;
default:
break;
}
}
int main(void)
{
sysytick_init(); // 10ms
while (1) {
state_handle();
}
return 0;
}
e.基于任務RTOS
這里就是直接上系統調度器了,缺點是開銷大,復雜度高,需要注意死鎖、溢出等問題,一旦出了問題排查難度較大。這里主要討論裸機情形,請自行學習,不過多描述了。RTOS也是嵌入式軟件必學技能,等學習實踐后再回頭看本文會有不同體會的。
實際項目中,是很少使用單一模型的,一般會視情況組合使用,比如事件驅動+狀態機、事件驅動+消息驅動、時間片周期輪詢+狀態機等等,取長補短。
3.模塊間怎么交互?
模塊雖然解耦,當本質運行仍存在著時序依賴、數據依賴、狀態依賴等依賴關系,所以就需要有同步機制,來輔助我們實現“異步”編程。
a.函數調用
這是最簡單直觀的方式,開銷最小,缺點就是強耦合,復用性低。上文AT指令控制LED的例子,AT協議解析完如果直接調用LED控制函數,便是該方法。
b.接口回調
模塊A提供一個函數指針,在模塊B中注冊,由模塊 B 在需要時去調用。上文中事件驅動框架中的事件調度器就是該方法。
c.信號/事件/消息/廣播機制
模塊間通過隊列、郵箱、事件等各種異步通信方式來傳遞信息,這是RTOS都會提供同步機制,會帶來一定的開銷,不同機制的開銷不同,局限性也不同,需要根據需求場景選用夠用的機制即可。例如,systick中斷,需要同步到key按鍵去檢測,那使用信號即可,就不需要使用消息的方式,節省資源開銷。(嵌入式軟件其實就是性能和資源的平衡藝術)
d.共享內存
多個模塊訪問同一個數據空間,往往需要配合互斥鎖、臨界區保護。優點是簡單高效,但是并發控制復雜,會增加出錯概率。是裸機最常用的同步方式了。
總結一下:
架構 = 模塊化 + 分層
分層 = 清晰職責 + 接口抽象
模塊化 = 清晰職責 + 解耦通信 + 合理調度模型

浙公網安備 33010602011771號