1. 先搞明白:嵌入式软件的“架构设计”到底是在解决什么问题
干了这么多年嵌入式开发,我见过太多这样的代码:一个main函数里写了上千行,各种外设初始化、数据处理、逻辑判断全堆在一起,全局变量满天飞,中断回调里直接干重活,改一个功能就要牵动半个工程。最要命的是,这种代码当时写得爽,三个月之后自己都看不懂,更别说让别人接手维护了。
说白了,很多嵌入式开发者不是不想做架构设计,而是总觉得“单片机资源这么紧张,搞那么多层不是浪费吗”“就一个简单的控制逻辑,用得着那么复杂吗”。但我想说的是,软件架构设计在嵌入式领域,解决的根本不是“能不能跑”的问题,而是“好不好改、好不好查、好不好测”的问题。尤其是现在嵌入式项目的复杂度越来越高——带屏幕交互的、带Wi-Fi联网的、带OTA升级的、带传感器融合算法的——如果还停留在堆代码的阶段,项目越往后越痛苦。
那什么才是“真正实用”的嵌入式软件架构?我的理解是:它不一定是多高深的设计模式,也不一定非得用上状态机、事件驱动这些听起来很高级的概念,而是要让你的代码满足三个基本要求:
- 可维护:改一个功能模块,不需要翻遍整个工程,改动影响能被控制住。
- 可测试:核心业务逻辑能脱离硬件跑起来,或者至少能在硬件上快速定位问题。
- 可复用:换个芯片平台、换块驱动板,业务层代码基本不用动。
这篇文章我会结合自己实际做过的项目,讲一讲在资源受限的MCU上,怎么落地一套真正好用的软件架构。不是空谈理论,不讲那些动辄几千行的框架代码,而是给你一套能直接抄作业的思路和代码骨架。
2. 内容整体设计与架构选型思路
2.1 为什么你的代码会越写越乱
在聊架构设计之前,先看看“堆代码”是怎么一步步形成的。很多人写嵌入式程序是这样的路径:先跑通一个外设驱动,然后在main函数里加一个初始化、加一个轮询;再跑通一个传感器,又在主循环里加一段读取和解析;然后要加按键处理,加显示刷新,加通信协议……
每个需求单独看都不复杂,但叠加在一起,main函数就变成了一个大杂烩。我见过最夸张的一个项目,main函数里主循环有七八个while嵌套,中断里直接做浮点运算,全局变量定义了上百个,模块之间的耦合全靠“谁都能动谁的变量”来维持。这种代码初期跑起来好像挺顺,但一旦遇到bug就非常痛苦,因为你根本不知道数据是什么时候被谁改掉的。
这个问题的本质是什么?没有把“变化的东西”和“稳定的东西”分开。底层寄存器操作是稳定的、传感器数据格式是稳定的,但产品的业务逻辑是经常变的——今天要加一个告警功能,明天要改一下显示策略,后天要调整上报周期。如果你把业务逻辑和硬件操作混在一起写,那么业务一变,底层全得跟着动,改动面就失控了。而架构设计的核心工作,就是在“会变的部分”和“不太变的部分”之间,划出清晰的边界。
2.2 嵌入式场景下哪些架构模式最实用
网上聊嵌入式架构,通常绕不开这几种:分层架构、事件驱动架构、状态机、发布订阅。这些模式在互联网后端和大型桌面软件里都是家常便饭,但移植到MCU上,需要做一些本土化改造,因为资源限制摆在那里——RAM可能就是几十KB,Flash也就几百KB,CPU主频还低,跑不了太花哨的东西。
在我实际项目中,最常用也最见效果的是两套组合拳:
第一套是**“三层架构 + 回调函数”**,适合裸机或者轻量级RTOS项目。把代码分成驱动层、服务层和应用层:驱动层只负责操作寄存器、读写引脚、收发数据,向上层暴露标准接口;服务层封装一些通用的业务组件,比如传感器数据解析、通信协议组包解包、设备状态管理;应用层专注于产品逻辑,比如什么时候触发告警、显示什么内容。层与层之间通过接口调用,驱动层不关心上层怎么用数据,应用层也不关心数据到底来自哪个具体型号的传感器。
第二套是**“超级循环 + 事件标志”**,适合裸机项目的任务调度。很多教程教你RTOS怎么用,但对于一个5块钱的MCU,资源不够跑RTOS怎么办?用超级循环配合事件标志、消息队列(可以是极简的实现)来组织业务逻辑,把“轮询”变成“按需处理”,效率会高很多,代码也会更清晰。
再配合上状态机来处理设备的主流程——比如设备的启动、待机、运行、告警、低功耗这些状态切换,你会发现代码的逻辑变得规整很多,因为每个状态下能干什么、不能干什么,都被明确约束住了。状态机的实现也足够轻量,一张二维表或者一个switch就能搞定,在嵌入式里特别实用。
2.3 为什么我不建议在资源紧张的MCU上过度设计
这里也要说句公道话——架构设计不是越复杂越好。嵌入式的核心约束是资源,你为了“优雅”在应用层和驱动层之间又加了一层抽象,代码倒是好看了,但每次外设操作都要多跳几次函数指针,这在某些极端时序场景下可能是致命的。比如PWM脉冲输出、高速ADC采样,这些地方就是要直接操作寄存器,你要是在这种代码上套三层抽象,反而是给自己找麻烦。
所以我在实际落地时有一个原则:热点路径直接干,冷路径再讲架构。所谓热点路径,就是那些执行频率高、对时序敏感的操作,比如中断服务程序里的数据接收、电机控制环路的PWM更新,这些代码保持直接、高效,不要加多余的层次。至于冷路径,就是那些执行频率低、对时序不敏感的操作,比如按键扫描、显示刷新、数据上报、参数配置,这些才是架构设计的主战场,可以放心地分层、做抽象。
这个原则非常实用,它让我既能享受到架构设计带来的可维护性,又不会因为过度设计牺牲实时性。你应该也能看出来,我讲的“实用架构”其实是很有弹性的,不是拿一套模板生搬硬套。
3. 核心细节解析:一个可落地的分层架构长什么样
3.1 一个典型的小型嵌入式项目代码结构
先不急着写代码,我画一下我常用的工程目录结构(用文字描述),这是一个典型的基于STM32的物联网传感器节点的分包方式:
project/ ├── app/ │ ├── main.c // 入口,初始化+超级循环 │ ├── app_task.c // 应用层任务调度,业务逻辑入口 │ └── app_display.c // 显示策略、界面逻辑 ├── service/ │ ├── sensor_mgr.c // 传感器管理:轮询周期、数据缓存、校准 │ ├── protocol.c // 通信协议:组包、解包、校验 │ ├── event_queue.c // 轻量级事件队列 │ └── device_state.c // 设备状态机 ├── driver/ │ ├── bsp_uart.c // 串口驱动 │ ├── bsp_i2c.c // I2C驱动,操作具体传感器 │ ├── bsp_oled.c // OLED显示驱动 │ ├── bsp_gpio.c // GPIO/按键/指示灯驱动 │ └── board.h // 引脚映射、时钟配置等板级定义 └── middleware/ ├── FreeRTOS/ // 如果用到RTOS └── fatfs/ // 如果需要文件系统看到没,其实结构并不复杂,本质上就是**“谁在最底层、谁在中间、谁在上面”的问题**。驱动层不依赖任何上层模块,它只知道自己操作的是什么外设;服务层依赖驱动层,但不知道具体业务是什么;应用层只依赖服务层对外暴露的接口,完全不碰寄存器。这样分层之后,每一层都可以独立测试、独立替换。
3.2 模块间通信:用函数指针和回调代替全局变量
分层架构落地时,最大的拦路虎是模块间数据怎么传递。很多人的第一反应是:定义一个全局结构体,所有模块都能读、都能写。这种做法短期内很爽,但问题在于你完全控制不了数据被谁改过,代码一多就变成“野指针现场”。
我的习惯是用**“回调 + 接口函数”**的方式做模块间通信。什么意思呢?举一个串口接收的例子。驱动层只负责把数据收进来放到缓冲区,然后通过注册的回调函数把“收到一帧完整数据”这个事件通知上层:
// driver/bsp_uart.h typedef void (*uart_rx_callback_t)(uint8_t *data, uint16_t len); void bsp_uart_init(uart_rx_callback_t rx_callback); void bsp_uart_send(uint8_t *data, uint16_t len);// driver/bsp_uart.c static uart_rx_callback_t s_rx_callback = NULL; void bsp_uart_init(uart_rx_callback_t rx_callback) { s_rx_callback = rx_callback; // 配置UART外设、DMA、中断等 } void bsp_uart_rx_isr(uint8_t byte) { // 将字节存入缓冲,判断帧完整后调用上层回调 if (frame_complete && s_rx_callback) { s_rx_callback(rx_buffer, rx_len); } }而服务层的协议模块,初始化时把自己解析数据包的函数注册进去:
// service/protocol.c static void on_uart_frame(uint8_t *data, uint16_t len) { // 解析数据帧、校验、更新设备配置 } void protocol_init(void) { bsp_uart_init(on_uart_frame); }这样做了之后,驱动层完全不知道数据会被拿去做什么——可能是协议解析,可能是日志打印,也可能是Bootloader升级。如果你想换一个通信方式,比如把串口换成蓝牙透传,那只需要把驱动层换成蓝牙驱动的封装,服务层和应用层的代码一行都不用改。
我自己用下来的体会是,这种回调机制虽然简单,但它强迫你在写代码之前先想清楚“谁依赖谁”,比什么高深的设计模式都管用。
3.3 状态机:管理设备主流程的利器
再聊聊状态机。嵌入式设备通常有明显的运行阶段:上电自检、等待配置、正常运行、异常告警、低功耗睡眠。如果用一堆if-else去判断当前该干什么,代码很快就会乱套。
我习惯用一个枚举加一个状态表来实现:
// service/device_state.h typedef enum { STATE_INIT = 0, STATE_IDLE, STATE_RUNNING, STATE_ALARM, STATE_LOWPOWER, STATE_MAX } device_state_t; typedef struct { device_state_t state; void (*on_enter)(void); void (*on_process)(void); void (*on_exit)(void); } state_handler_t;状态表里每一项定义了进入该状态时的初始化动作、该状态下的处理逻辑、以及离开该状态时的清理动作。主循环只需要调用device_state_process(),根据当前状态去执行对应的函数就行:
// service/device_state.c static const state_handler_t s_state_table[STATE_MAX] = { {STATE_INIT, init_enter, init_process, init_exit}, {STATE_IDLE, idle_enter, idle_process, idle_exit}, {STATE_RUNNING, running_enter, running_process, running_exit}, {STATE_ALARM, alarm_enter, alarm_process, alarm_exit}, {STATE_LOWPOWER, lowpower_enter, lowpower_process, lowpower_exit}, }; void device_state_transfer(device_state_t new_state) { if (new_state >= STATE_MAX || new_state == s_current_state) { return; } if (s_state_table[s_current_state].on_exit) { s_state_table[s_current_state].on_exit(); } s_current_state = new_state; if (s_state_table[s_current_state].on_enter) { s_state_table[s_current_state].on_enter(); } }这种写法的好处很多:代码结构一目了然,不用翻很久就能知道设备在什么状态下做什么;状态切换逻辑被收拢到一个函数里,想加新状态,在枚举里加一项、在状态表里加一行就行,不会产生“改一处漏一处”的问题;而且每个状态的处理逻辑是独立的,测试的时候可以单独验证某个状态的行为,非常方便。
当然,状态机也不是万能的。如果你的业务逻辑是“顺序执行几个步骤”而不是“不同状态下行为不同”,那用状态机反而别扭。这时候更合适的是用事件队列配合任务调度来处理。
3.4 轻量级事件队列:裸机也能实现“解耦式”任务调度
我给很多朋友推荐过一种在裸机上很好用的轻量级任务调度方法:你不一定需要RTOS,一个事件队列就能帮你在业务模块之间解耦。核心思路是:任何模块都可以往队列里投递一个事件,然后主循环统一从队列取事件并分发。这样各模块之间不需要知道对方到底怎么处理事件,只要知道“发生了一件事,会有人处理”就行了。
一个极简的实现大概长这样:
// service/event_queue.h #define EVENT_QUEUE_SIZE 16 typedef struct { uint16_t id; uint32_t param; } event_t; int event_queue_post(const event_t *evt); int event_queue_receive(event_t *evt);// service/event_queue.c static event_t s_queue[EVENT_QUEUE_SIZE]; static uint8_t s_head = 0; static uint8_t s_tail = 0; int event_queue_post(const event_t *evt) { uint8_t next = (s_tail + 1) % EVENT_QUEUE_SIZE; if (next == s_head) { return -1; // 队列已满 } s_queue[s_tail] = *evt; s_tail = next; return 0; } int event_queue_receive(event_t *evt) { if (s_head == s_tail) { return -1; // 队列为空 } *evt = s_queue[s_head]; s_head = (s_head + 1) % EVENT_QUEUE_SIZE; return 0; }你可以把它理解为系统的“消息中心”。按键按下是一个事件,定时器到点是一个事件,串口收到完整帧也可以是一个事件。主循环里面不断调用event_queue_receive()去取事件,然后通过一个分发函数(可以是一个大的switch,也可以是一张事件处理函数表)去调用对应的处理函数。
这种写法的优势是:硬件中断里只需要做最轻量的事情——放一个事件到队列,剩下的事情全部在主循环上下文里处理。这样既避免了中断里做复杂处理导致的问题,也让各个业务模块之间形成了“通过事件交流”的默契,模块之间的依赖关系被大幅削弱。
4. 实操过程:拿一个温湿度监测节点项目练手
4.1 项目需求与硬件资源盘点
下面用一个我在智能家居项目里做过的“温湿度监测节点”作为完整例子,带你走一遍从“堆代码”到“分层架构”的重构过程。这个节点的硬件配置如下:
- 主控:STM32F103C8T6,64KB Flash,20KB RAM
- 传感器:SHT30温湿度传感器,I2C接口
- 显示:0.96寸OLED,I2C接口
- 通信:RS485总线,Modbus RTU协议,9600波特率
- 输入:一个按键(用于本地切换显示内容)
需求也不复杂:每隔2秒读一次温湿度,刷新OLED显示;当温度超过设定阈值(比如60度)时,产生告警并通过RS485上报;按键按下时可以在“温度/湿度/告警状态”之间切换显示。
这个项目如果堆代码,估计300行就写完了。但随便一个需求变更,比如把SHT30换成一个国产AHT21传感器,或者改一下上报周期,你就要开始“考古”了。所以这个例子非常适合演示架构设计的价值。
4.2 驱动层:封装底层,屏蔽具体芯片差异
先看驱动层。SHT30和OLED都是I2C设备,所以我先写一个统一的I2C底层驱动,然后基于I2C接口来封装具体的传感器驱动和显示驱动。这样做的直接好处是:以后换传感器或者换显示芯片,应用层完全不知道。
// driver/bsp_sht30.h typedef struct { float temperature; float humidity; } sht30_data_t; int bsp_sht30_init(void); int bsp_sht30_read(sht30_data_t *data);// driver/bsp_sht30.c #include "bsp_sht30.h" #include "bsp_i2c.h" #define SHT30_ADDR 0x44 #define SHT30_CMD_MEASURE 0x2C #define SHT30_SOFT_RESET 0x30A2 static uint8_t s_i2c_handle; int bsp_sht30_init(void) { s_i2c_handle = bsp_i2c_register_device(SHT30_ADDR); if (s_i2c_handle == 0xFF) { return -1; } // 发送软件复位命令 uint8_t cmd[2] = {0x30, 0xA2}; bsp_i2c_write(s_i2c_handle, cmd, 2); return 0; } int bsp_sht30_read(sht30_data_t *data) { uint8_t cmd[2] = {0x2C, 0x06}; uint8_t buf[6] = {0}; if (bsp_i2c_write(s_i2c_handle, cmd, 2) != 0) { return -1; } // 等待测量完成 delay_ms(20); if (bsp_i2c_read(s_i2c_handle, buf, 6) != 0) { return -1; } // 根据SHT30数据手册解析温湿度 uint16_t raw_temp = (buf[0] << 8) | buf[1]; uint16_t raw_humi = (buf[3] << 8) | buf[4]; >// service/sensor_mgr.h typedef void (*sensor_data_callback_t)(sht30_data_t *data); void sensor_mgr_init(sensor_data_callback_t callback); void sensor_mgr_process(void); sht30_data_t sensor_mgr_get_latest(void);注意这个sensor_mgr_process(),它需要被主循环周期性调用。内部实现是:上次读取到现在超过2秒,就重新读取,读取成功后既缓存数据,又通过回调把数据推给上层。上层如果注册了回调,就能第一时间拿到最新传感器数据——比如用来刷新显示,或者做告警判断。
服务层的协议模块更典型,它把Modbus RTU的帧解析、CRC校验、功能码分发都封装在内部:
// service/modbus_slave.h void modbus_slave_init(void); void modbus_slave_poll(void);modbus_slave_poll()会从串口驱动取接收到的数据帧,解析地址码、功能码、数据区和CRC。如果校验通过且功能码是需要处理的,就调用内部处理函数来生成响应帧。这个模块完全不关心传感器数据是怎么读到的——它只调用sensor_mgr_get_latest(),拿到一个sh30_data_t结构体,然后组织成Modbus的响应格式发出去。
4.4 应用层:把业务逻辑变成一块清爽的“拼图”
最后是应用层。应用层要做的事包括:初始化所有模块、创建事件队列、在主循环里把各个服务模块的process函数串起来、根据按键事件切换显示内容、判断温度是否越限并触发告警。
// app/app_task.c #include "bsp_sht30.h" #include "bsp_oled.h" #include "bsp_gpio.h" #include "sensor_mgr.h" #include "modbus_slave.h" #include "event_queue.h" #include "device_state.h" static sht30_data_t s_latest_data; static display_mode_t s_display_mode = DISPLAY_TEMP; static void on_sensor_data(sht30_data_t *data) { s_latest_data = *data; // 数据更新后,检查是否需要告警 if (data->temperature > 60.0f) { device_state_transfer(STATE_ALARM); } else if (device_state_get() == STATE_ALARM) { device_state_transfer(STATE_RUNNING); } } static void on_key_pressed(void) { event_t evt = {0}; evt.id = EVENT_KEY_PRESSED; evt.param = 0; event_queue_post(&evt); } static void process_event(event_t *evt) { switch (evt->id) { case EVENT_KEY_PRESSED: s_display_mode = (s_display_mode + 1) % DISPLAY_MODE_MAX; bsp_oled_clear(); break; case EVENT_SENSOR_READY: if (s_display_mode == DISPLAY_TEMP) { bsp_oled_show_temp(s_latest_data.temperature); } else if (s_display_mode == DISPLAY_HUMI) { bsp_oled_show_humi(s_latest_data.humidity); } break; default: break; } } void app_task_init(void) { bsp_gpio_init(); bsp_i2c_init(); bsp_oled_init(); bsp_sht30_init(); sensor_mgr_init(on_sensor_data); modbus_slave_init(); device_state_transfer(STATE_INIT); } void app_task_loop(void) { event_t evt; sensor_mgr_process(); modbus_slave_poll(); device_state_process(); if (event_queue_receive(&evt) == 0) { process_event(&evt); } }注意几个关键点:数据变化触发告警判断是在on_sensor_data回调里做的,然后通过状态机切换设备状态;按键事件通过事件队列传递,不直接调用显示逻辑;显示刷新策略集中在process_event里处理,根据当前显示模式决定展示温度还是湿度。
这样分层之后,我来一个需求变更演示一下效果:如果产品经理说要改变温度告警阈值,或者把告警策略改成“连续3次超温才告警”,只需要改app_task.c里的判断逻辑就行;如果硬件工程师把SHT30换成了AHT21,只需要重写bsp_sht30.c,只要保证bsp_sht30_init()和bsp_sht30_read()接口不变,上层代码完全不感知;如果产品要加一个Wi-Fi模块上报数据,只需要在服务层加一个wifi_service.c,然后订阅sensor_mgr的回调即可,驱动层、应用层都不用动。
这就是架构设计带来的实实在在的好处——需求在变,但变化的影响被限制在了一个很小的范围内。
5. 常见问题与排查技巧实录
5.1 分层之后性能变差了怎么办
我听到最多的抱怨就是:加了一层抽象,函数调用多了,实时性满足不了了。这个问题的答案要从两个方向去找。
首先,你要自查是不是真的把热点路径给套进去了。我前面强调过,中断里和高速控制环里不要做分层抽象。如果你在定时器中断里通过回调、事件队列去处理PWM输出,那性能变差太正常了,这不是架构的问题,是用错了地方。
其次,单次函数指针跳转的开销远没有你想象的那么大——在72MHz的Cortex-M3上,一次间接调用也就几个时钟周期,对应的实际时间在几十纳秒级别。如果你的控制周期是微秒级的,那确实可能有影响;但如果控制周期是毫秒级的,这部分开销完全可以忽略不计。
我还见过一种情况:架构本身没问题,但是模块边界划分不当,导致底层驱动被频繁地“绕过”,上层直接去读寄存器。这种情况通常说明接口设计不合理,应该把一些底层的批量操作封装好,让上层一次调用拿到完整数据,而不是分好几次小调用。
5.2 模块循环依赖,编译都过不去
分层架构落地时最常见的一个坑就是循环依赖:协议模块要调用传感器模块拿数据,传感器模块又要通过协议模块上报数据,然后你就懵了,这俩模块到底谁在底层谁在上层?
解决办法是**“依赖倒置”**——让两个模块都依赖一个抽象出来的第三方接口。比如我可以在服务层定义一个data_reporter_t结构体,里面放一个函数指针:
// service/data_reporter.h typedef struct { int (*report_temp_humi)(float temp, float humi); } data_reporter_t; void data_reporter_register(data_reporter_t *reporter);传感器管理模块定期拿到数据后,调用data_reporter_report_temp_humi(),而这个函数内部实际上调的是协议模块注册进来的上报函数。这样传感器模块不直接依赖协议模块,协议模块也不直接依赖传感器模块,它们都只依赖一个“上报接口”的抽象定义。这个思路在嵌入式里完全够用,不需要引入复杂的IoC容器。
5.3 回调嵌套太深,代码难以阅读
函数指针和回调用得多了之后,代码会变得跳来跳去的,可读性反而下降。我的做法是:把回调只用在“事件通知”这种场景,不要用回调去做完整的数据处理链路。
比如on_sensor_data回调里就做一个缓存和状态判断,然后把具体的处理交给事件队列去驱动;event_queue把事件推到应用层后,由一个集中的process_event函数做分发。这样整个程序的控制流还是清晰的:事件从哪来、经过谁、最终到哪去,都能通过搜索函数名跟出来。
如果发现回调深到三层以上,那大概率是设计有问题,建议把中间层打散,或者引入状态机来整理逻辑。我踩过几次坑之后,现在有个习惯:每写完一个模块,就随手画一下模块调用关系图,看到有明显的“循环线”或者“跨层调用”,立马修正,趁着代码量小改起来不心疼。
5.4 调试技巧:用日志和状态输出给架构“体检”
好的架构设计有个额外的好处——方便调试。我经常在工程里加一个非常轻量的日志模块,通过串口把关键状态打出来。日志模块本身不依赖任何业务模块,只有一行接口:
// middleware/log.h void log_printf(const char *fmt, ...);在驱动层可以打“I2C读写超时”“UART收到异常帧”,在服务层可以打“传感器读数为xx.x度,xx%RH”“Modbus请求功能码0x03”,在应用层可以打“状态切换到STATE_ALARM”。然后出问题的时候,串口日志一拉,整个系统在哪里出问题、数据是怎么流转的,一目了然。
有一次客户反馈某个设备复位了,我看了日志发现是历史告警状态恢复后没有正确清理缓存,导致下次判断时读到脏数据触发了看门狗喂狗超时。如果代码还堆在一起,这个bug我估计得排查好几天;但分层之后,日志明确指向了应用层状态切换和传感器数据缓存的问题,我直接在device_state_transfer和sensor_mgr_get_latest两个函数里加了检查,不到半小时就解决了。
5.5 现有项目不是从零开始,怎么逐步重构
最后一个很现实的问题:我已经有一个堆了几千行代码的项目了,总不能推倒重来吧?我的建议是**“蛀虫式重构”**——不急着把整个工程翻一遍,而是从痛点最明显的地方开始,一点一点啃。
第一步,先把硬件相关的代码全部挪到driver目录下,main函数里只保留初始化和主循环框架。这个过程是纯搬代码,不改任何逻辑,风险很小。
第二步,挑一个最常改动的模块,比如通信协议或者传感器数据管理,把它从主循环里拆出来,做成一个独立的模块,并定义好接口。让主循环通过调用接口来使用它,其他模块暂时还保留原来的“全局变量直读”方式。
第三步,等新的边界稳定下来之后,再逐步把其他模块也拉进来,最后把全局变量都收拢到一个模块内部,对外只暴露读写接口。
这个过程可能会持续一两个版本迭代,但每一步都是渐进式的,不影响正常发版。我自己接手过的老项目基本都是这么整理过来的,虽然没有“推倒重来”来得彻底,但风险和收益的平衡是最好的。
我个人在实际操作中还发现一个很有意思的现象:分层架构搭好之后,很多之前“玄学”的bug会自动消失。原因也很简单,数据流变得清晰了,边界变强了,很多因为变量被乱改、初始化顺序不对导致的问题,在写代码阶段就被挡掉了。
如果你也正在被“堆代码”式的开发方式折磨,我的建议是别急着学那些花哨的设计模式,先把驱动层、服务层、应用层的边界划出来,把回调、事件队列、状态机这几样工具用好,你的嵌入式软件质量会有一个非常明显的提升。等到这套打法熟练了,之后再看更复杂的架构方案,你会发现理解起来轻松得多。