嵌入式软件架构漫谈
软件架构的意义在于提高开发效率和代码可维护性、可扩展性。
刚好最近需要用到裸机开发,在此自我总结一下经验和见解。如有错误,欢迎评论区指出。
架构需要做到两个维度的解耦:
纵向的分层;
横向的模块化;
分层好理解,可以看一下一个基于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.共享内存
多个模块访问同一个数据空间,往往需要配合互斥锁、临界区保护。优点是简单高效,但是并发控制复杂,会增加出错概率。是裸机最常用的同步方式了。
总结一下:
架构 = 模块化 + 分层
分层 = 清晰职责 + 接口抽象
模块化 = 清晰职责 + 解耦通信 + 合理调度模型