嵌入式软件架构漫谈

软件架构的意义在于提高开发效率和代码可维护性、可扩展性。

刚好最近需要用到裸机开发,在此自我总结一下经验和见解。如有错误,欢迎评论区指出。

架构需要做到两个维度的解耦:
纵向的分层;
横向的模块化

分层好理解,可以看一下一个基于RTOS的软件架构:
image

其作用在于后期的移植和排查只需要关注某一层级即可,比如更换芯片,那只需要修改驱动层即可;更换RTOS,那只需修改OS抽象层即可;按键任务控制LED任务功能失效,那就按照层级逐层排查(OS层的消息同步是否有问题?LED的亮灭驱动层是否有问题?)即可。
软件上的实现其实就是把函数尽量封装成抽象的接口。以存储功能为例子:

  • 对于上层应用调用者来说,需求很简单,能存能读即可;
  • 不同存储类型有不同的存储步骤,如flash需要按页擦写,EEPROM可以按字节写入还不需要擦操作;
  • 如果存储数据量小、存储频繁,那还要考虑擦写均衡;
  • 不同存储芯片有不同厂商不同型号,那寄存器的定义自然不同;
  • 芯片的通信方式也有不同,SPI、IIC等等;

一个小小的存储需求,在实现细节上却五花八门,我们可以简单分出三个层级:
image

这样,后续的项目甚至可以像搭积木一样,开发者专心适配积木接口,可以极大提高开发效率,且由于其复用得到反复验证,稳定性也会不断提高。

难点在于如何做好横向的模块化。因为本身模块之间就需要交互,层级也不是都只在应用层,那天然就会和追求的模块独立性相矛盾。
设计模块化会涉及到三个问题:

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.共享内存

多个模块访问同一个数据空间,往往需要配合互斥锁、临界区保护。优点是简单高效,但是并发控制复杂,会增加出错概率。是裸机最常用的同步方式了。

总结一下:

架构 = 模块化 + 分层
分层 = 清晰职责 + 接口抽象
模块化 = 清晰职责 + 解耦通信 + 合理调度模型

posted @ 2025-08-06 19:27  HughWu  阅读(76)  评论(0)    收藏  举报