搞嵌入式最怕听到的一句话是什么?“代码能跑就别动。”我刚接手一个物联网网关项目的时候,同事就是这么嘱咐我的。结果加第一个功能就翻车了——一个按键消抖的小改动,把温度采集搞挂了,查了一下午才发现是GPIO初始化顺序被全局变量牵连。四千多行的main.c,二十几个全局变量,中断里做串口解析,定时器里跑协议栈,主循环还轮询着三路传感器。这种代码,说好听点叫“遗留系统”,说难听点就是定时炸弹。
嵌入式代码重构,这四个字放一起,很多工程师第一反应是“别闹”。单片机不同于互联网服务,没法随时发布随时回滚,代码改坏了板子就成砖了。但不敢重构的代价更大:需求加不动、bug修不完、新人接手想骂娘。所以这篇不聊虚的,就讲重构这事到底该怎么干——什么时候动、边界怎么划、架构往哪走、用什么手段保证不翻车。适合接手了老项目被屎山折磨的开发,也适合想系统提升嵌入式代码组织能力的人。
1. 为什么嵌入式代码重构这么难
1.1 代码腐化是必然,不是偶然
嵌入式代码为什么总是容易烂?干得越久越觉得这几乎是行业规律。原因不外乎几个:
第一,硬件耦合太深。寄存器地址、中断号、GPIO配置散落在业务代码里,想抽都抽不出来。一个产品从原型到量产,硬件改了三版,代码里就留下三套ifdef和临时补丁,到后面已经没人说得清哪段代码是干嘛的。
第二,项目节奏催出来的。嵌入式产品往往是跟着硬件调试走,硬件一出来软件就要立刻点亮屏幕、跑通通信、过认证。这时候根本没时间写“优雅代码”,能用就行。等产品稳定了,又没人敢动它,因为“能用”比“好看”重要。代码就这么稳定地腐烂下去。
第三,缺少测试保护。很多嵌入式项目连基本的单元测试都没有,全靠硬件上点灯、串口打印来验证。没有安全网,谁都不敢碰别人的代码。我见过最极端的项目,三个人维护,每个人都有自己不敢碰的“禁区”。
你可能会说,那嵌入式内核源码为什么看起来好很多?因为Linux内核有严格的代码规范、review机制和持续集成的CI测试。说白了,代码不会自己变好,它只会在持续的维护压力下慢慢变坏,除非你有意识反抗这个趋势。换句话说,重构不是可做可不做的“锦上添花”,而是对抗代码腐化的必要手段。
1.2 重构不等于重写,边界必须先划清
很多工程师一说“重构”,脑子里想的是“推倒重来”。这个想法很危险。重构的定义是:在不改变代码外部行为的前提下,改善内部结构。注意三个关键词:不改变外部行为、改善内部结构。重写则是推翻原有设计,另起炉灶。
打个比方,重构是把你家里的电线重新整理一遍,该换的换、该改的改,但房子还是那套房子,住在里面的人还是那些人;重写是把房子推倒重建,施工周期、成本、风险完全不是一个量级。
在嵌入式领域,重写风险尤其大。因为就算代码写得再漂亮,最终还是要跑在真实硬件上。硬件时序、外设bug、电磁兼容问题,全是代码之外的因素。你以为重写能解决的,很可能是硬件问题,重写完会发现bug还在,只是换了个表现方式。有个朋友就是不信邪,把公司一个量产三年的控制器推倒重写了,结果硬调了半年,最后还是在关键路径上保留了老代码的时序逻辑。
所以我的建议很明确:除非代码已经烂到无法维护,而且你有足够预算和时间做完整的系统集成测试,否则不要选重写。老老实实做渐进式重构,一步一步来,风险可控,效果反而更好。
1.3 重构的收益怎么量化?给自己一个坚持的理由
重构不产生新功能,所以很难向老板交代。但从工程角度,收益是实打实的。我常用的量化方式有三个:
第一,增加功能的平均耗时。重构前加一个传感器要两天,重构后只要半天,这个数据可以直接对标人力成本。
第二,线上bug的数量和定位时间。重构前一个bug平均查两天,重构后基本当天就能定位,效率差别非常明显。
第三,代码评审的时间。重构前评审一个PR要看半天,因为隐含依赖太多;重构后模块职责清晰,评审时间能砍一半。
这些数字如果你能用起来,以后申请重构预算会顺利很多。不能光说“代码很烂”,要用数据说话。我一般是选一个正在进行的迭代周期做对比,重构前后各统计两周,效果一目了然。
2. 重构的起点:从“超级大循环”到事件驱动
2.1 超级大循环架构到底卡在哪
大多数老式嵌入式项目都是“超级大循环”结构:main函数里一个while(1),里面轮询读取外设状态,处理完一个再处理下一个,定时器中断和外部中断再时不时插一脚。整个系统的调度就是“主循环轮流问一遍+中断随时打断”。
这种结构的问题不是“不能用”,而是“难扩展”。举个我自己经历的例子:一个主循环里依次做按键扫描、OLED刷新、传感器读取、协议解析、WiFi状态维护。刚开始一切正常,后来加了一个需要频繁刷新的显示功能,按键响应变慢了;再后来给WiFi断线重连加超时判断,一个delay下去,整个循环都卡住。原因很简单,超级大循环天然就是单线程顺序执行,任何一个任务阻塞,其他任务全部遭殃。
有人说“单片机裸机只能这么写”,这话对但也不全对。裸机编程里其实可以做到非常好的模块化解耦——通过状态机、事件队列、时间片轮询,把“超级大循环”升级成“事件驱动的调度架构”。这正是嵌入式架构升级的一个分水岭,也是重构里面性价比最高的一步。
2.2 事件驱动架构的核心思想
事件驱动的核心说起来很简单:不是让CPU主动去问“你有什么事吗”,而是让外设、中断、定时器主动告诉CPU“我有事发生了”。系统维护一个事件队列,主循环只负责从队列里取事件、分发给对应的处理函数。
这样做的好处很直接:
- 模块之间不再直接调用,而是通过事件通信,耦合大大降低;
- 处理某个事件时不会被其他任务打断逻辑,实时性反而更好;
- 添加新功能只需要新注册一个事件处理器,不必改动主循环结构。
事件驱动在嵌入式里并不玄乎,不用上操作系统。裸机上实现一个简单的环形队列存事件,加一个switch-case分发就够用了。两者对比下来,差异很直观:
| 维度 | 超级大循环 | 事件驱动 |
|---|---|---|
| 模块耦合 | 高,主循环里直接调用各模块函数 | 低,模块间通过事件通信 |
| 实时性 | 被前序任务阻塞 | 事件队列+优先级处理 |
| 可扩展性 | 加功能动主循环,风险大 | 加事件处理器即可 |
| 调试难度 | 依赖全局状态,难以定位 | 事件流清晰,可追踪 |
| 适用场景 | 功能固定、很少变动的简单产品 | 功能迭代频繁的中复杂产品 |
我在项目里从超级大循环切到事件驱动后,最直观的感受是:改一个模块不再牵动全局了,头文件包含关系和依赖关系清爽了很多。这个从架构层面的重构,是最值得先做的。
但有一个坑必须提醒:不是所有任务都适合事件驱动。对时间要求非常硬的任务,比如PWM波形生成、高精度定时,还是应该用定时器或中断直接处理,放进事件队列只会增加延迟。要按实时性分级:硬实时任务用中断,软实时任务用事件队列,非实时任务用主循环兜底。
2.3 跑了RTOS或Linux的项目,重构从哪里下手
不少嵌入式Linux项目其实已经用了RTOS或者Linux,本身有任务调度,不存在“超级大循环”问题。但这类项目的重构重点不太一样,更多是任务划分不合理、模块间全局变量满天飞、驱动层和业务层耦合严重。
举个例子,我一个Linux驱动项目里,业务代码直接操作GPIO的sysfs接口,导致后来换平台时所有业务逻辑都要改。重构时我先抽象了一层硬件访问接口,业务层只调用hw_gpio_set(),底层根据平台不同实现各自的驱动文件。这样一改,业务层和硬件解耦,之后再换平台只改驱动就行。
不管是裸机还是Linux,重构思路其实是一致的:先找边界,再切模块,最后定义接口。差别只在于工具链和调试手段。Linux下工具多,可以用gprof、perf、cppcheck辅助分析,裸机下就靠代码阅读和交叉编译了。
3. 落地实操:C语言项目的模块化重构步骤
3.1 第一步:画现状图,别急着动手
重构的第一步不是写代码,而是搞清楚现状。你需要回答三个问题:代码里有哪些模块?模块之间的依赖关系是什么?哪里耦合最重?
我的做法是先把所有.c和.h文件列出来,用Doxygen或cflow生成调用关系图,然后人工梳理出关键路径。没有工具的话,用grep把include依赖打出来,自己画一张粗糙的依赖图也行。这张图不用很精细,能让你看到“谁依赖了谁”就够了。
做完这步你会发现,很多项目的依赖关系根本不是树形的,而是像一团毛线——A依赖B,B依赖C,C又依赖A。这种循环依赖是重构时要优先解决的目标。我一般会在依赖图里用红色标出循环依赖的模块,这些地方就是重构的第一优先级。另一个要重点看的是“上帝模块”——几乎所有人都依赖它,这种模块改动影响面太大,优先拆。
3.2 第二步:头文件接口设计是重构的命门
在C语言项目里,模块化的本质是“头文件即接口”。重构时我一般遵守几个原则:
第一,每个模块的头文件只暴露必要的函数和类型,内部实现细节全部用static限定。不要一上来把所有函数都声明在头文件里,暴露的接口越多,未来改动受影响的面就越大。
第二,头文件之间的include关系要尽量精简。A模块的头文件不应该include B模块的头文件,如果只是用到B里的某个类型,应该考虑用指针前置声明或者拆出公共类型。
第三,全局变量禁止裸奔。所有跨模块共享的数据,要么封装成函数接口(如temperature_get()),要么集中到一个结构体里统一管理。全局变量是嵌入式项目里最隐蔽的坑,重构一定要顺手清掉。
以重构一个传感器模块为例,原来代码里温度值就是一个全局变量float g_temp,到处被人读。我改成用sensor_get_temperature()接口后,发现调用方有十几处,但真正需要读最新值的其实只有三处,其他都是缓存需求。这个发现直接减少了一堆无谓的耦合。
下面给一个简单的接口示例:
/* sensor.h */ #ifndef SENSOR_H #define SENSOR_H int sensor_init(void); float sensor_read_temperature(void); float sensor_read_humidity(void); #endif/* sensor.c */ #include "sensor.h" static float last_temp; static float last_humi; int sensor_init(void) { /* 初始化I2C、GPIO等 */ return 0; } float sensor_read_temperature(void) { /* 读取传感器寄存器并更新 last_temp */ return last_temp; }这种“static变量+函数接口”的组合,是C语言里实现封装的标准做法。静态变量从外部不可见,只能通过接口修改,这样就算以后换了传感器型号,也只改sensor.c一个文件。
3.3 第三步:状态机是嵌入式重构里最实用的一招
如果你要重构的模块里有大量if-else嵌套、标志位判断、时序依赖,那十有八九应该改成状态机。状态机的核心价值,是把你脑子里乱七八糟的“条件判断”变成一张清晰的“状态转移表”,逻辑不容易出错,还天然适合事件驱动。
举个WiFi断线重连的例子。原来的代码长这样:
void wifi_handle(void) { if (state == 0 && wifi_ready) { /* 连接 */ } else if (state == 1 && timeout) { /* 重连 */ } else if (state == 2 && ip_obtained) { /* 上报 */ } ... }这种写法的问题在于,状态一多,每个if条件里还带着各种标志位,很快就没人能看懂“什么条件下会走到哪里”。改成状态机之后:
typedef enum { WIFI_IDLE, WIFI_CONNECTING, WIFI_CONNECTED, WIFI_RECONNECTING, } wifi_state_t; static wifi_state_t wifi_state = WIFI_IDLE; void wifi_event_handler(wifi_event_t evt) { switch (wifi_state) { case WIFI_IDLE: if (evt == EVT_START_CONNECT) { wifi_start_connect(); wifi_state = WIFI_CONNECTING; } break; case WIFI_CONNECTING: if (evt == EVT_CONNECTED) { wifi_state = WIFI_CONNECTED; } else if (evt == EVT_TIMEOUT) { wifi_schedule_reconnect(); wifi_state = WIFI_RECONNECTING; } break; case WIFI_RECONNECTING: if (evt == EVT_CONNECTED) { wifi_state = WIFI_CONNECTED; } break; default: break; } }这虽然只是个雏形,但思路很清楚:把“状态+事件”作为二维输入,输出是动作和下一状态。重构完之后,WiFi断线重连的逻辑一目了然,加新的状态只需要改枚举和switch,不用动其他分支。
这里强调一点,状态机不用非得引入复杂的状态机库,裸写一个switch就够了。关键不是代码量,而是要有意识地把纠缠在一起的逻辑,拆成“状态+事件”的网格。
3.4 第四步:中断与耗时操作解耦
重构里最容易翻车的就是中断处理函数。很多嵌入式工程师习惯在中断里做很多事情:解析串口数据、更新显示缓冲区、甚至直接调用耗时函数。这会导致中断延迟不可控,而且一旦中断里操作了主循环正在用的数据,就会产生难查的竞态bug。
处理这个问题的标准做法是:中断里只做“标记”和“搬运”。中断里把收到的数据放入环形缓冲区,设置一个标志位,或者通过事件队列通知主循环去解析;主循环检测到标志位后,再从缓冲区取数据做耗时解析。如果在RTOS环境下,中断里可以用信号量或消息队列通知任务,本质一样。
这样重构之后,中断处理变得极其轻量,主循环和中断之间的数据竞争也少了很多。但要注意,一定要用带原子保护的操作,比如在临界区里关中断再操作缓冲区,否则还是会有竞态。
用Keil MDK开发的朋友特别注意,如果中断里调用了printf,调试时会莫名卡死,原因是printf底层用了重定向的fputc,可能在中断和主循环间产生竞争。这种问题排查起来非常折磨人,重构时务必把中断里的所有非必要调用都清出去。
4. 让重构不翻车的测试策略
4.1 没有测试的重构等于高空走钢丝
说到嵌入式代码重构,最让工程师心虚的就是“改坏了没人知道”。所以先别急着重构,把测试铺起来。嵌入式单元测试一直有“很难搞”的印象,其实现在工具链已经很成熟了。
我自己用得比较多的是Unity,一个专门为嵌入式C语言设计的轻量级单元测试框架。它的特点是:用C语言写测试用例,可以编译成主机平台的可执行文件直接跑,不需要目标板;也可以交叉编译到目标板跑,灵活度很高。
举个例子,想测试WiFi状态机,不需要真实的WiFi模块,只需要把状态机代码编译到PC上,用Unity写断言:
#include "unity.h" #include "wifi_module.h" void setUp(void) {} void tearDown(void) {} void test_wifi_idle_to_connecting(void) { wifi_event_handler(EVT_START_CONNECT); TEST_ASSERT_EQUAL(WIFI_CONNECTING, wifi_get_state()); } void test_wifi_connecting_timeout(void) { wifi_event_handler(EVT_START_CONNECT); wifi_event_handler(EVT_TIMEOUT); TEST_ASSERT_EQUAL(WIFI_RECONNECTING, wifi_get_state()); } int main(void) { UNITY_BEGIN(); RUN_TEST(test_wifi_idle_to_connecting); RUN_TEST(test_wifi_connecting_timeout); return UNITY_END(); }这里的关键点是:测试的目标是把不依赖硬件的纯逻辑代码跑在PC上,速度快、反馈及时,不烧板子。等逻辑测好了,再放到板子上做硬件相关验证。
你可能会问,代码和硬件耦合那么深,怎么编译到PC?这就引出下一节的内容——硬件依赖剥离。
4.2 硬件依赖剥离:打桩和抽象
嵌入式代码难以测试,最大的障碍是“跑起来就要操作寄存器”。解决办法有两个:打桩(stub/mock)和硬件抽象层(HAL)。
打桩的意思很直白:测试时把真实硬件函数替换成假的。比如代码里调用了i2c_read(),测试时可以给它一个桩实现,返回预设数据:
/* test_stubs.c */ static uint8_t mock_reg; int i2c_read(uint8_t addr, uint8_t reg, uint8_t *buf, uint8_t len) { /* 模拟一个传感器寄存器 */ if (reg == TEMP_REG) { buf[0] = 0x1A; /* 预设温度 */ } return 0; }链接时把这个桩文件和被测代码一起编译,就能测到纯逻辑部分了。这是最简单也最高效的方式,也是我日常重构中用的最多的方法。
另一方面,如果项目是从零开始或者允许较大改动,我更建议先抽象一层HAL。HAL层隔离了“业务逻辑”和“硬件操作”,业务代码只调用HAL接口,HAL接口再调用具体平台驱动。这样业务逻辑可以在PC上测试,硬件问题也能更精准地定位到HAL层。
4.3 手动回归的“黄金用例集”
不是所有嵌入式项目都有条件在重构前建立完整的自动化测试,特别是老项目。这时候别灰心,还有一个折中的办法:手动回归的“黄金用例集”。
所谓黄金用例集,就是从现有功能里提取出一组最核心、最能代表系统行为的使用场景。比如:设备上电能否正确初始化?串口收到一帧合法指令能否正确响应?收到非法数据会不会死机?WiFi断线后能否在10秒内重连?按键短按能否触发对应操作?
每次重构完一个模块,把这组用例全部跑一遍。不要求自动化,哪怕拿个checklist手工点一遍也行。重要的是:在重构前先跑出基线结果,重构后对比结果。如果某个用例表现不一致,先别急着继续重构,停下来排查——这通常意味着你改变了外部行为,违反了重构的基本原则。
这一招在资源有限、自动化测试推不动的项目里非常实用,我至今还在用。它未必能覆盖所有边界,但至少能给你一张“安全网”,让你重构的时候心里有底。
5. 常见问题与排查技巧实录
5.1 重构后功能异常,先查这三件事
每次重构完,开机上电,最常见的现象是:功能不对了。这时候别慌,按顺序排查:
第一,时序变化。重构后你把某些操作从延时循环改成事件驱动,执行顺序变了,可能导致初始化顺序、外设时序不匹配。特别是I2C、SPI这类总线设备,对时序非常敏感。先检查初始化顺序是否和之前完全一致。
第二,内存覆盖。重构中改动了缓冲区大小、数组类型,尤其把全局变量改成局部变量后,要留意栈是否够用。裸机工程里栈溢出往往没有任何提示,表现出来就是莫名其妙的跑飞、死机。建议在重构期间打开编译器的栈检查功能,或者用调试器监视栈指针。
第三,缓冲区竞争。如果重构了中断逻辑但没有保护共享缓冲区,可能出现主循环读到一半数据被中断改写的情况。典型表现是“偶尔解析出错误数据”。查这种问题,可以先关掉中断测试,如果关掉就正常,那就是竞争问题。
我自己遇到过最经典的案例:一个串口协议解析模块重构后,平时跑着都正常,但一旦外部设备以高频率发数据,就偶发校验错误。排查了一圈,最后发现是环形缓冲区的读写指针没有做原子保护,在中断和主循环之间竞争了。
5.2 重构后性能退步?看看这几处
代码重构后系统整体变慢,可能的原因有几种:
一是过度使用函数调用。模块化后会新增很多接口层调用,如果这些函数在中断或毫秒级循环里频繁执行,会增加调用开销。嵌入式MCU主频有限,建议把高频调用设计成宏或者inline函数。注意,这跟前面说的“模块化接口”不矛盾,接口是给模块边界用的,内部的局部高频函数可以保持内联。
二是数据拷贝变多。如果原代码直接操作一个全局数组,重构后改成函数返回结构体,可能在拷贝上多花时间。正确做法是返回指针或引用,而不是整个结构体copy。
三是事件队列积压。事件驱动架构里,如果事件产生速度大于处理速度,队列会越来越满,系统响应越来越慢。这个问题要在设计事件队列大小时评估峰值速率,必要时做流控或丢弃策略。
可以用下表做一个快速诊断:
| 现象 | 可能原因 | 检查方式 |
|---|---|---|
| 系统响应变慢 | 事件队列积压、任务阻塞 | 打印队列深度、任务执行时间 |
| 中断响应变长 | 中断里残留耗时操作 | 示波器测GPIO翻转时间 |
| 偶发死机 | 栈溢出、内存越界 | 开启编译器栈检查、看map文件 |
| 数据频繁出错 | 缓冲区竞争、时序不对 | 关中断测试、对比前后波形 |
5.3 编译体积增大怎么控制
重构后代码体积通常会增大,因为多了接口层和抽象。如果你用的是Keil MDK,可以这样优化:编译时生成ELF文件,通过map文件看各部分占比,定位体积增大的重点。开启编译器优化等级,比如-Os,如果对实时性影响不大可以优先使用。
我在一个项目里通过把调试打印日志统一改成条件编译宏,编译体积直接减了15%。类似这样的小优化在重构最后阶段值得做一轮。但记住,优化编译体积的前提是功能正确,别本末倒置。
5.4 内存泄漏怎么查
在嵌入式Linux平台上重构QT或C++代码时,内存泄漏是个常见问题。我的经验是:先用valgrind在PC上分析,比如valgrind --leak-check=full ./app,看输出的definitely lost块;不行再用目标板上的AddressSanitizer或mtrace。实际项目中,很多泄漏并不是分配后忘记释放,而是回调、信号槽、事件循环中持有的引用没有释放。把每个“持有”关系列清楚再动手。
以上这些坑,我基本都踩过。写出来的目的就是帮大家少走弯路。重构本身是一种技能,跟写业务代码一样,需要练习,也需要胆量。只要按着“先测试、再小步改、随时回归”的节奏来,嵌入式代码重构的翻车率其实可以压得很低。我曾经也是那个看着老代码不敢动的新人,后来发现,真正可怕的不是代码烂,而是你被烂代码吓住了,不敢改变它。敢不敢重构,问的不是别人,问的是你自己。