1. 嵌入式系统中的设计模式:从裸机思维到架构思维的跃迁
做嵌入式开发的朋友大多有过这样的经历:项目初期代码写得飞快,一个main函数里塞进所有逻辑,中断服务函数里直接操作寄存器,全局变量满天飞。功能跑通了,老板满意了,代码提交了。三个月后需求变更,要加一个新传感器、换一款显示屏、或者把裸机程序移植到RTOS上,回头再看自己写的代码,发现牵一发动全身,改一个地方崩三个地方。这不是能力问题,而是缺少架构层面的思考方式。
设计模式这个词在嵌入式圈子里一直有些尴尬。做应用层开发的同行把设计模式玩得炉火纯青,但嵌入式工程师往往觉得那是“上层建筑”,跟寄存器、中断、DMA打交道的自己用不上。实际情况恰恰相反——嵌入式系统资源受限、硬件耦合度高、实时性要求严格,这些特点决定了嵌入式软件对模块解耦和可维护性的需求比通用软件更迫切。一个设计得当的嵌入式架构,能让产品迭代速度提升数倍,也能让接手你代码的同事少掉几根头发。
这篇文章面向的是有一定嵌入式C/C++开发基础、正在从“功能实现”向“架构设计”过渡的开发者。我会结合自己这些年做过的几个项目,把嵌入式场景下最常用、最实用的设计模式拆开来讲,包括它们解决什么问题、在嵌入式中怎么落地、有哪些坑需要避开。不会照搬GoF那套经典教材的写法,而是从嵌入式的实际约束出发,讲清楚每个模式的适用边界和取舍逻辑。
2. 嵌入式为什么需要设计模式
2.1 裸机开发的典型困境
先看一段很多人刚入行时都写过的代码:
// 典型的裸机主循环 int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_ADC1_Init(); while (1) { // 读取传感器 uint16_t adc_val = HAL_ADC_GetValue(&hadc1); float temp = adc_val * 3.3f / 4096.0f * 100.0f; // 处理数据 if (temp > 50.0f) { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); char buf[32]; sprintf(buf, "TEMP:%.1f\r\n", temp); HAL_UART_Transmit(&huart1, (uint8_t*)buf, strlen(buf), 100); } // 按键检测 if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) { HAL_Delay(20); if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) { // 切换模式 g_mode = !g_mode; } } HAL_Delay(10); } }这段代码能跑,但问题很明显:传感器读取、数据处理、通信输出、按键交互全部耦合在一个循环里。想换一个传感器?改主循环。想增加一种通信协议?改主循环。想把这个温度采集逻辑复用到另一个项目?只能复制粘贴再改。这就是典型的**“面条式代码”**——逻辑像面条一样缠绕在一起,牵一发而动全身。
2.2 设计模式带来的三个核心价值
设计模式在嵌入式系统中的价值,我总结为三个层面。
第一是解耦。把“做什么”和“怎么做”分开。传感器读取的接口定义好,上层只管调用read()拿数据,底层是I2C还是SPI、是哪个型号的芯片,上层不关心。这样换硬件时只需要替换底层驱动,业务逻辑一行不用改。
第二是可测试。解耦之后,每个模块可以独立测试。你可以在PC上模拟一个传感器数据源,验证数据处理逻辑是否正确,而不需要把真实硬件接上。这在项目后期排查问题时特别有用——能快速定位是驱动层的问题还是业务层的问题。
第三是团队协作。接口定义清晰之后,A同事写驱动、B同事写业务逻辑、C同事写通信协议,大家并行开发,最后通过接口对接。没有设计模式的话,三个人改同一个文件,合并代码时冲突不断。
2.3 嵌入式场景下的特殊约束
当然,嵌入式不是通用软件,设计模式不能照搬。有几个约束必须考虑:
- RAM和Flash限制:虚函数表、动态内存分配这些在PC上无所谓的东西,在只有几KB RAM的单片机上可能是致命的。能用静态分配就不用动态分配,能用编译期多态就不用运行期多态。
- 实时性要求:某些模式引入的间接层会增加调用开销,在硬实时场景下需要评估。比如一个每100微秒触发一次的中断服务函数,里面套三层虚函数调用就不太合适。
- 工具链限制:很多嵌入式项目还在用C89/C99,没有C++的类、模板、虚函数。这时候需要用C语言模拟面向对象的特性,比如用函数指针结构体模拟虚函数表。
- 调试困难:嵌入式调试不像PC上那么方便,间接层太多会导致调用栈很深,出问题时定位困难。所以模式的使用要适度,不能为了模式而模式。
理解了这些约束,我们才能在实际项目中做出合理的取舍。接下来的章节,我会逐个拆解嵌入式场景下最实用的几种设计模式。
3. 嵌入式场景下最实用的五种设计模式
3.1 状态机模式:告别switch-case地狱
状态机是嵌入式开发中最常见、最实用的模式,没有之一。按键消抖、协议解析、设备工作流程控制,本质上都是状态机。
先看一个反例——用switch-case写状态机:
typedef enum { IDLE, RUNNING, PAUSED, ERROR } state_t; state_t g_state = IDLE; void state_machine(void) { switch (g_state) { case IDLE: if (start_btn_pressed()) { g_state = RUNNING; start_motor(); } break; case RUNNING: if (pause_btn_pressed()) { g_state = PAUSED; stop_motor(); } else if (error_detected()) { g_state = ERROR; stop_motor(); trigger_alarm(); } break; case PAUSED: if (start_btn_pressed()) { g_state = RUNNING; start_motor(); } else if (stop_btn_pressed()) { g_state = IDLE; } break; case ERROR: if (reset_btn_pressed()) { g_state = IDLE; clear_alarm(); } break; } }这种写法在状态少、逻辑简单时还能接受。但状态一多、跳转关系一复杂,switch-case就会变成几百行的庞然大物,而且状态跳转逻辑分散在各个case里,想画一张完整的状态转移图都很困难。
表驱动状态机是更优雅的方案。核心思路是把状态转移关系抽象成一张表:
typedef void (*action_fn)(void); typedef struct { state_t current_state; event_t event; state_t next_state; action_fn action; } state_transition_t; const state_transition_t g_transitions[] = { {IDLE, EV_START, RUNNING, start_motor}, {RUNNING, EV_PAUSE, PAUSED, stop_motor}, {RUNNING, EV_ERROR, ERROR, trigger_alarm}, {PAUSED, EV_START, RUNNING, start_motor}, {PAUSED, EV_STOP, IDLE, NULL}, {ERROR, EV_RESET, IDLE, clear_alarm}, }; void state_machine(event_t ev) { for (int i = 0; i < ARRAY_SIZE(g_transitions); i++) { if (g_transitions[i].current_state == g_current_state && g_transitions[i].event == ev) { if (g_transitions[i].action) { g_transitions[i].action(); } g_current_state = g_transitions[i].next_state; return; } } // 未定义的状态转移,可记录日志或忽略 }表驱动的好处很明显:状态转移关系一目了然,新增状态只需要加一行表项,不需要改动任何逻辑代码。而且这张表可以放在Flash里,不占RAM。
注意:表驱动状态机适合状态数量在几十个以内的场景。如果状态超过一百个,查找效率会成为问题,这时候可以考虑用哈希表或者按状态分组的多张子表。
实操心得:我在一个工业控制项目里用表驱动状态机管理设备的工作流程,总共12个状态、30多条转移规则。后来客户要求增加一个“维护模式”,我只在表里加了三行,测试通过,前后不到半小时。如果用switch-case写,至少得改半天,还容易漏掉边界条件。
3.2 观察者模式:中断与事件驱动的解耦利器
嵌入式系统本质上是事件驱动的——按键按下是事件、串口收到数据是事件、定时器溢出是事件。观察者模式(也叫发布-订阅模式)解决的是“事件产生者”和“事件消费者”之间的解耦问题。
假设你有一个按键模块,按键按下后需要做三件事:点亮LED、发送串口消息、更新状态机。最直接的做法是在按键中断里依次调用这三个函数:
void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) != RESET) { led_toggle(); uart_send("KEY_PRESSED\r\n"); state_machine_post(EV_KEY); EXTI_ClearITPendingBit(EXTI_Line0); } }问题在于,按键模块现在依赖了LED模块、串口模块和状态机模块。如果以后要增加一个蜂鸣器响应,还得改中断函数。模块之间的耦合度太高了。
观察者模式的做法是:按键模块只负责“发布事件”,谁关心这个事件谁自己去“订阅”。
#define MAX_OBSERVERS 8 typedef void (*observer_cb)(void *arg); typedef struct { observer_cb callbacks[MAX_OBSERVERS]; int count; } event_t; event_t g_key_event; void event_subscribe(event_t *evt, observer_cb cb) { if (evt->count < MAX_OBSERVERS) { evt->callbacks[evt->count++] = cb; } } void event_notify(event_t *evt, void *arg) { for (int i = 0; i < evt->count; i++) { evt->callbacks[i](arg); } } // 按键中断里只需要发布事件 void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) != RESET) { event_notify(&g_key_event, NULL); EXTI_ClearITPendingBit(EXTI_Line0); } } // 各模块在初始化时订阅 void led_init(void) { event_subscribe(&g_key_event, led_toggle_cb); } void uart_init(void) { event_subscribe(&g_key_event, uart_send_cb); }这样按键模块完全不知道LED和串口的存在,新增响应者只需要订阅事件即可。
注意:在中断服务函数里调用
event_notify时,回调函数的执行时间必须尽可能短。如果某个回调需要做耗时操作(比如写Flash),应该只设置一个标志位,在主循环里处理。另外,中断里调用的回调函数必须是可重入的,不能有静态变量。
实操心得:观察者模式在RTOS环境下更好用。可以把事件通知放到一个独立的任务里,回调函数通过消息队列异步执行,这样中断服务函数可以做到极短,实时性更好。我在一个多传感器采集项目里用这种方式,中断响应时间从原来的50微秒降到了5微秒以内。
3.3 策略模式:运行时切换算法
策略模式解决的是“同一个功能有多种实现方式,需要在运行时动态选择”的问题。在嵌入式里,典型的应用场景包括:多种滤波算法切换、多种通信协议切换、多种控制算法切换。
假设你有一个温度采集系统,支持三种滤波方式:无滤波、滑动平均滤波、中值滤波。用户可以通过串口命令切换。用策略模式的写法:
typedef float (*filter_fn)(float new_sample); float no_filter(float sample) { return sample; } float moving_average(float sample) { static float buf[8] = {0}; static int idx = 0; static float sum = 0; sum -= buf[idx]; buf[idx] = sample; sum += sample; idx = (idx + 1) % 8; return sum / 8.0f; } float median_filter(float sample) { static float buf[5] = {0}; static int idx = 0; buf[idx] = sample; idx = (idx + 1) % 5; // 简单冒泡排序取中值 float tmp[5]; memcpy(tmp, buf, sizeof(tmp)); for (int i = 0; i < 4; i++) for (int j = 0; j < 4 - i; j++) if (tmp[j] > tmp[j+1]) { float t = tmp[j]; tmp[j] = tmp[j+1]; tmp[j+1] = t; } return tmp[2]; } typedef struct { filter_fn filter; const char *name; } filter_strategy_t; const filter_strategy_t g_filters[] = { {no_filter, "none"}, {moving_average, "average"}, {median_filter, "median"}, }; filter_fn g_current_filter = no_filter; void set_filter(const char *name) { for (int i = 0; i < ARRAY_SIZE(g_filters); i++) { if (strcmp(g_filters[i].name, name) == 0) { g_current_filter = g_filters[i].filter; return; } } } // 使用 float temp = read_sensor(); float filtered = g_current_filter(temp);策略模式的关键在于:把算法封装成统一的函数签名,通过函数指针在运行时切换。新增一种滤波算法只需要写一个函数、加一行表项,不需要改动调用方。
注意:函数指针调用比直接调用多一次间接寻址,在Cortex-M内核上大约多2-3个时钟周期。对于大多数应用来说可以忽略,但在高频中断里要谨慎使用。
实操心得:策略模式配合命令模式(通过串口接收命令)非常好用。我在一个电机控制项目里用这种方式实现了PID参数在线整定和多种控制策略切换,调试效率提升了很多。以前改一个参数要重新编译烧录,现在串口发一条命令就行。
3.4 适配器模式:统一不同硬件的接口
嵌入式项目经常遇到这种情况:产品有多个硬件版本,不同版本用的传感器型号不同,但上层业务逻辑是一样的。适配器模式就是用来屏蔽这种硬件差异的。
假设你有两款温度传感器:一款是I2C接口的数字传感器,直接返回摄氏度;另一款是ADC接口的热敏电阻,需要查表转换。上层业务逻辑只关心“获取当前温度”这个操作。
// 统一的传感器接口 typedef struct { float (*get_temperature)(void); int (*init)(void); const char *name; } temp_sensor_t; // I2C数字传感器适配 static float i2c_sensor_get_temp(void) { uint8_t raw[2]; i2c_read(0x48, raw, 2); int16_t val = (raw[0] << 8) | raw[1]; return val * 0.0625f; } static int i2c_sensor_init(void) { return i2c_init(400000); } // ADC热敏电阻适配 static float adc_sensor_get_temp(void) { uint16_t adc = adc_read(ADC_CH0); float resistance = (adc * 10000.0f) / (4096.0f - adc); // 查表或公式转换 return resistance_to_temp(resistance); } static int adc_sensor_init(void) { return adc_init(); } // 适配器实例 const temp_sensor_t g_i2c_sensor = {i2c_sensor_get_temp, i2c_sensor_init, "I2C-Digital"}; const temp_sensor_t g_adc_sensor = {adc_sensor_get_temp, adc_sensor_init, "ADC-Thermistor"}; // 上层业务逻辑 const temp_sensor_t *g_sensor; void app_init(void) { #ifdef HW_VERSION_A g_sensor = &g_i2c_sensor; #else g_sensor = &g_adc_sensor; #endif g_sensor->init(); } void app_task(void) { float temp = g_sensor->get_temperature(); // 后续处理... }适配器模式的核心价值在于:硬件差异被隔离在适配层,上层业务逻辑完全不需要知道底层用的是哪种传感器。换硬件时只需要写一个新的适配器,业务代码一行不改。
注意:适配器层不要做得太厚。如果适配器里塞了太多业务逻辑,就失去了隔离的意义。适配器只做“接口转换”和“数据格式转换”,不做业务判断。
实操心得:我在一个环境监测项目里用适配器模式同时支持了三种温湿度传感器和两种气压传感器。客户后来要求换一款更便宜的传感器,我只花了半天写新适配器,测试通过,业务代码零改动。如果没有适配器层,这个改动至少需要一周。
3.5 单例模式:全局资源的受控访问
单例模式在嵌入式里用得很多,但也是最容易被滥用的。先说什么场景适合用单例:系统中确实只有一个实例的资源,比如看门狗、日志系统、配置管理器、RTOS的消息队列句柄。
以日志系统为例,整个系统只有一个日志输出通道,所有模块都往同一个串口或Flash写日志。用单例模式保证全局只有一个日志实例:
typedef struct { uart_handle_t *uart; log_level_t level; bool initialized; } logger_t; static logger_t g_logger = {0}; int logger_init(uart_handle_t *uart, log_level_t level) { if (g_logger.initialized) { return -1; // 已经初始化过了 } g_logger.uart = uart; g_logger.level = level; g_logger.initialized = true; return 0; } void logger_write(log_level_t level, const char *tag, const char *fmt, ...) { if (!g_logger.initialized || level < g_logger.level) { return; } char buf[128]; va_list args; va_start(args, fmt); int len = vsnprintf(buf, sizeof(buf), fmt, args); va_end(args); uart_send(g_logger.uart, (uint8_t*)buf, len); }单例模式在C语言里的实现很简单——用static变量加初始化标志位。关键是要控制好访问入口,不要让外部代码直接操作g_logger。
注意:单例模式最大的坑是“隐式依赖”。如果代码里到处都在调
logger_write,那日志系统就成了一个隐形的全局依赖,单元测试时很难替换。更好的做法是通过接口注入,而不是直接调用单例。但在资源紧张的嵌入式系统里,适度的单例是可以接受的,关键是团队要达成共识,知道哪些东西是单例、为什么是单例。
实操心得:我一般只对“真正全局唯一且生命周期贯穿整个系统”的资源使用单例,比如看门狗、日志、配置管理。像传感器、通信协议这些可能有多实例的,坚决不用单例。另外,单例的初始化顺序要特别注意——如果日志系统还没初始化,其他模块就调用了logger_write,要有保护机制,不能直接崩掉。
4. 设计模式在嵌入式中的落地实践
4.1 用C语言模拟面向对象
很多嵌入式项目还在用纯C开发,没有C++的类和虚函数。但设计模式的核心思想是通用的,用C语言的结构体加函数指针完全可以模拟面向对象。
核心技巧是把函数指针放在结构体里,模拟虚函数表:
// 模拟基类 typedef struct shape { void (*draw)(struct shape *self); float (*area)(struct shape *self); } shape_t; // 模拟派生类 typedef struct { shape_t base; // 继承 float radius; } circle_t; static void circle_draw(shape_t *self) { circle_t *c = (circle_t*)self; // 画圆 } static float circle_area(shape_t *self) { circle_t *c = (circle_t*)self; return 3.14159f * c->radius * c->radius; } void circle_init(circle_t *c, float r) { c->base.draw = circle_draw; c->base.area = circle_area; c->radius = r; }这种写法在Linux内核里大量使用,比如file_operations结构体就是典型的例子。好处是保持了C语言的简洁和高效,同时获得了面向对象的扩展性。
注意:用C模拟面向对象时,结构体的第一个成员必须是基类结构体,这样才能安全地进行指针转换。另外,函数指针的初始化要在使用之前完成,不能依赖编译器的默认初始化。
4.2 静态分配优先于动态分配
嵌入式系统里,malloc和free要慎用。原因有三:一是内存碎片问题,长时间运行后可能分配不出连续内存;二是分配时间不确定,不满足实时性要求;三是很多安全标准(如MISRA C)直接禁止动态内存分配。
设计模式里的很多“经典实现”依赖动态内存,比如观察者模式里用链表存储订阅者、策略模式里用new创建策略对象。在嵌入式里需要改成静态分配:
// 用静态数组代替链表 #define MAX_SUBSCRIBERS 16 typedef struct { observer_cb callbacks[MAX_SUBSCRIBERS]; int count; } event_t; // 用静态对象池代替动态创建 #define MAX_STRATEGIES 8 static strategy_t g_strategy_pool[MAX_STRATEGIES]; static int g_strategy_count = 0; strategy_t *strategy_create(strategy_fn fn) { if (g_strategy_count >= MAX_STRATEGIES) { return NULL; } strategy_t *s = &g_strategy_pool[g_strategy_count++]; s->fn = fn; return s; }静态分配的好处是内存使用在编译期就确定了,运行时不会出现分配失败,也不会有碎片问题。代价是灵活性降低——最大数量是固定的。但在嵌入式场景下,这个代价通常是值得的。
4.3 中断服务函数里的模式使用禁忌
中断服务函数(ISR)是嵌入式系统里最特殊的环境,对设计模式的使用有严格限制。
第一,ISR里不能调用可能阻塞的函数。比如观察者模式里,如果某个回调函数里调用了HAL_Delay或者等待信号量,整个系统就卡死了。ISR里的回调必须是“设置标志位”这种极短的操作。
第二,ISR里不能使用动态内存分配。malloc的执行时间不确定,可能几十微秒也可能几毫秒,放在ISR里会严重影响实时性。
第三,ISR里调用的函数必须可重入。如果策略模式里的某个算法用了静态变量,在ISR和主循环里同时调用就会出问题。解决办法是给ISR单独准备一套静态变量,或者用原子操作保护。
第四,ISR的优先级要仔细设计。如果高优先级ISR里调用了观察者模式的notify,而某个回调又触发了低优先级中断,可能导致优先级反转。RTOS环境下可以用FromISR版本的API来规避。
注意:一个实用的原则是——ISR里只做三件事:清中断标志、读/写硬件寄存器、设置软件标志位。所有业务逻辑都放到主循环或RTOS任务里处理。这样设计模式的使用就不会受ISR的特殊性限制。
4.4 模式组合使用的实际案例
单独使用一种模式的情况其实不多,实际项目里往往是多种模式组合。举一个我做过的数据采集系统的例子:
系统需求:采集4路模拟量、2路数字量,通过串口和无线两种方式上传,支持本地存储,支持远程配置采集参数。
架构设计:
- 适配器模式:统一模拟量采集和数字量采集的接口,上层用同一个
channel_read()函数读取所有通道。 - 策略模式:支持多种数据上传策略(实时上传、定时上传、变化上传),用户可配置。
- 观察者模式:采集完成事件通知上传模块和存储模块,两个模块独立响应。
- 状态机模式:管理设备的工作状态(空闲、采集、上传、配置、低功耗)。
- 单例模式:配置管理器全局唯一,所有模块通过它读取配置。
这个系统最终代码量大约8000行,但每个模块的平均圈复杂度控制在5以内,单元测试覆盖率超过80%。后来客户要求增加一路CAN采集,我只写了一个CAN适配器,在通道表里加了一行,其他代码零改动。
这就是设计模式组合使用的威力——每个模式解决一个维度的问题,组合起来形成一个灵活的架构。
5. 常见问题与避坑指南
5.1 过度设计:什么时候不该用设计模式
设计模式是好东西,但滥用比不用更糟糕。我见过一个项目,总共就两个状态(开和关),非要用状态机模式,写了一百多行代码。这就是典型的过度设计。
判断标准很简单:如果一段代码只在一个地方使用,且未来可预见的范围内不会变化,就不需要为它设计模式。直接写死反而更清晰。
另一个判断维度是变化频率。如果某个逻辑每个月都要改,那值得为它设计一个模式来隔离变化。如果某个逻辑从项目开始到结束都没改过,那为它设计模式就是浪费时间。
我的经验法则是:第一次写某个功能时,用最直接的方式实现。第二次需要修改或扩展时,再考虑重构为设计模式。不要一开始就追求“完美的架构”,那是迭代出来的,不是设计出来的。
5.2 性能开销的量化评估
设计模式引入的间接层确实有性能开销,但具体是多少需要量化。以下是我在Cortex-M4(72MHz)上实测的一些数据:
| 操作 | 大约耗时 | 说明 |
|---|---|---|
| 直接函数调用 | 3-5个时钟周期 | 编译器可能内联 |
| 函数指针调用 | 6-10个时钟周期 | 多一次间接寻址 |
| 结构体指针转换 | 1-2个时钟周期 | 编译期完成,无运行时开销 |
| 表驱动查找(10项) | 20-50个时钟周期 | 取决于查找算法 |
| 观察者通知(5个订阅者) | 30-80个时钟周期 | 取决于回调复杂度 |
这些开销在大多数应用里可以忽略。但在以下场景需要谨慎:高频中断(>10kHz)、硬实时控制环路(PID周期<100微秒)、电池供电的低功耗设备(每个时钟周期都影响续航)。
5.3 调试技巧:如何追踪模式调用链
设计模式引入的间接层会让调试变复杂。一个事件通知可能触发五个回调,每个回调又调用其他模块,出问题时很难定位是哪一环出了错。
几个实用的调试技巧:
第一,给每个模块加一个唯一的ID,在日志里输出模块ID和函数名。这样从日志就能看出调用链。
第二,用条件断点。在观察者模式的notify函数里设置条件断点,只在特定事件触发时停下来。
第三,画状态转移图。状态机模式出问题时,把实际的状态转移序列打印出来,和设计的状态图对比,很快就能发现异常跳转。
第四,单元测试覆盖。每个模式单独写单元测试,在PC上跑。比如策略模式,把每种策略都测一遍,确保输入输出符合预期。这样集成到嵌入式系统里时,问题范围就缩小了很多。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 系统运行一段时间后死机 | 动态内存碎片 | 检查是否有malloc/free | 改为静态分配 |
| 中断响应变慢 | ISR里回调太多 | 测量ISR执行时间 | 回调只设标志位 |
| 状态机卡死 | 缺少默认转移 | 打印当前状态和事件 | 增加未处理事件的日志 |
| 观察者通知丢失 | 订阅者数量超限 | 检查订阅者计数 | 增大MAX_SUBSCRIBERS |
| 策略切换不生效 | 函数指针未初始化 | 检查初始化顺序 | 确保init在use之前 |
| 单例重复初始化 | 缺少初始化检查 | 加日志确认调用次数 | 增加initialized标志 |
| 编译后Flash占用过大 | 模式引入过多代码 | 分析map文件 | 裁剪不必要的模式 |
| 实时性不达标 | 间接层太多 | 用示波器测GPIO翻转 | 关键路径直接调用 |
6. 从模式到架构的思维转变
写了这么多模式,最后想聊聊思维层面的东西。设计模式不是目的,而是手段。真正的目标是让代码更好地服务于产品需求的变化。
我刚开始做嵌入式时,觉得能把功能实现出来就很厉害了。后来做的项目多了,才发现“能跑”和“好维护”之间隔着巨大的鸿沟。一个产品从立项到停产,代码可能要经历几十次修改、好几拨人接手。如果一开始不考虑架构,后期维护成本会高到离谱。
设计模式教给我的最重要的一课是:面向接口编程,而不是面向实现编程。上层逻辑依赖抽象接口,底层实现可以随意替换。这个原则在嵌入式里同样适用,而且因为硬件更新换代快,这个原则的价值更加突出。
另一个体会是:模式要适度。我见过太多项目,架构设计得极其“优雅”,但代码量膨胀了三倍,运行效率下降了一半,最后产品延期。也见过完全不用模式的项目,前期开发飞快,后期改一个需求要一周。平衡点在哪里?我的经验是:核心业务逻辑用模式隔离变化,边缘功能直接实现。不要追求全盘模式化,那是学院派的做法,不是工程派的。
最后分享一个我常用的判断方法:当你准备写一段代码时,问自己三个问题——这段代码未来可能变化吗?变化时会影响多少其他代码?如果影响范围很大,那就值得为它设计一个模式。如果答案是否定的,那就怎么简单怎么来。毕竟,代码是写给人看的,顺便让机器执行。