嵌入式代码重构,你敢吗?
前两天有个哥们在群里吐槽,说刚入职接手了一坨"祖传代码",满屏的全局变量、goto 语句、魔法数字,看得他血压飙升。
他问:能不能重构?
群里瞬间炸了。几个老哥齐刷刷回了四个字:
你 别 动 它。
我当时差点把手机扔到饭锅里。
但说实话,他们说得对——至少在嵌入式这个圈子里,"能跑的代码别瞎动"真不是一句段子,是血泪教训。
今天就聊聊这事儿,我自己踩过的坑、看别人踩过的坑。
一、那行"废代码"可能在救你的板子
我刚工作那会儿,接手过一个直流电机控制的项目。代码里有这么一段:
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(linevoid motor_start(void){ gpio_set(MOTOR_EN, HIGH); delay_ms(500); // <-- 当时我心想:这500ms在干嘛?等着看手机吗? pwm_set_duty(MOTOR_PWM, 80);}
我寻思,使能之后直接给 PWM 不就完了?中间搁个半秒延时,纯纯浪费时间啊。
删了。编译,烧录,上电——
电机"嗡"了一下,驱动芯片冒了一缕青烟。
后来老工才告诉我:电机上电瞬间有浪涌电流,能到额定值的 5~8 倍。那 500ms 不是在摸鱼,是给浪涌电流一个衰减的窗口。你把这个窗口一删,H 桥直接过流锁死,严重的就烧了。
教训:
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line// 如果你实在觉得 delay_ms 精度不够,换硬件定时器可以// 但这个等待时间本身,打死也不能删void motor_start(void){ gpio_set(MOTOR_EN, HIGH); timer_delay_us(500000); // 精度更高,但保护逻辑不变 pwm_set_duty(MOTOR_PWM, 80);}
从那以后我养成了一个习惯——看到 delay、nop、空循环,第一反应不是"这是什么垃圾代码",而是翻原理图,查芯片手册。十次里有八次,它都是有用的。
二、跑了八年没翻车的"屎山",你一碰就塌
去年帮朋友公司看过一个项目,一个工业控制器,代码风格大概是这样的:
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line/* 2016年写的,一直在产线上跑,从没出过事 */volatile uint16_t g_adc_val;volatile uint8_t g_ctrl_flag;volatile int32_t g_motor_pos;
void ADC_IRQHandler(void){ g_adc_val = ADC->DR; if (g_adc_val > THRESHOLD) g_ctrl_flag = 1;}
void TIM3_IRQHandler(void){ if (g_ctrl_flag) { g_motor_pos += calculate_step(g_adc_val); g_ctrl_flag = 0; }}
你看完什么感受?全局变量、没有封装、中断里直接操作数据——简直是教科书上"不要这么写"的反面典型。
朋友公司新来了一位科班出身的小伙子,看不下去了,花了两天"优化"成这样:
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line/* 科班生的"优雅"重构 */typedef struct { uint16_t adc_val; // volatile? 那是什么?不需要吧 uint8_t ctrl_flag; int32_t motor_pos;} SystemState;
static SystemState state = {0};static osMutexId_t state_mutex;
void ADC_IRQHandler(void){ osMutexAcquire(state_mutex, 0); // 中断里加互斥锁... state.adc_val = ADC->DR; if (state.adc_val > THRESHOLD) state.ctrl_flag = 1; osMutexRelease(state_mutex);}
编译没报错,他还挺开心。结果一上电——控制器直接 HardFault,连日志都来不及打。
问题出在哪?三个坑,个个致命:
|
他改了什么 |
为什么炸了 |
后果 |
|---|---|---|
|
去掉了 |
编译器觉得"这个变量中断里改了但主循环没读到的,优化掉吧" |
主循环永远看到旧值,控制失灵 |
|
中断里加了 Mutex |
RTOS 的互斥锁在 ISR 里根本不能用,会触发非法调用 |
系统直接 HardFault |
|
结构体改变了内存布局 |
原来全局变量的地址是固定的,DMA 配置直接用的地址 |
DMA 传输写到了错误的位置 |
后来怎么解决的?回滚了。用回了那份"屎山"代码,控制器又稳稳当当地跑了。
如果真想让代码好看一点,可以这样——只动主循环的逻辑,别碰中断:
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line/* 中断保持原样不动,只优化主循环里的读取方式 */volatile uint16_t g_adc_val; // volatile 必须留着!volatile uint8_t g_ctrl_flag;volatile int32_t g_motor_pos;
void control_loop(void){ if (g_ctrl_flag) { uint16_t val = g_adc_val; // 拿一个快照出来用 g_ctrl_flag = 0; g_motor_pos += calculate_step(val); }}
我跟那小伙子说了句话,他后来告诉我印象很深:
ISR 里的代码,就是祖坟里的土,你别去刨。
三、实验室测了一百遍没事,到了现场就炸
这个故事是我一个前同事讲的,真事。
他们项目里有个传感器累加器:
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineint32_t sensor_accumulator = 0; // 32位,够用到天荒地老
void sensor_read_isr(void){ sensor_accumulator += adc_read(); // 每次加个 0~4095}
后来有人觉得"这个值最大也就 4095,用 32 位太奢侈了,省点内存",改成了 16 位:
ounter(lineint16_t sensor_accumulator = 0; // 最大 32767,"够用了"
实验室里跑了一个月,一切正常。设备发到客户那边——第一天就黑屏了。
为什么?实验室里采样频率是 10Hz,累加慢慢的,压根溢不出来。但客户现场的工况下,采样频率飙到了 1000Hz:
ounter(lineounter(lineounter(lineounter(lineounter(line每秒累加: 1000次 × 平均值2000 = 2,000,000/秒int16_t 最大值: 32767溢出时间: 32767 / 2000 ≈ 16秒
也就是说——设备跑16秒就炸一次
这 bug 排查了整整一周。因为在实验室根本复现不出来,最后还是老工程师问了一句:"你是不是改了变量类型?"——一语惊醒梦中人。
所以改变量类型之前,先算算账:
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line// 安全写法:保持 int32_t,加个饱和保护int32_t sensor_accumulator = 0;
void sensor_read_isr(void){ int32_t val = adc_read(); if (sensor_accumulator < INT32_MAX - val) sensor_accumulator += val; else sensor_accumulator = INT32_MAX; // 到顶了就不加了,别翻车}
// 或者定期清零,只看增量void control_task(void){ __disable_irq(); int32_t snapshot = sensor_accumulator; sensor_accumulator = 0; __enable_irq();
process(snapshot);}
公式记住:最大累积量 = 采样频率 × 单次最大值 × 最长不清零时间。算完再决定用多宽的类型。
四、测试周期这笔账,很多人没算过
搞 Web 的朋友跟我说,他们重构完跑个 CI,十分钟就知道有没有问题,出了 bug 热修复推一下就行。
我跟他说了一下嵌入式的测试流程,他以为我在开玩笑:
|
测试阶段 |
干什么 |
要多久 |
|---|---|---|
|
单元测试 |
跑单个模块 |
1~3天 |
|
集成测试 |
模块联调 |
1~2周 |
|
系统测试 |
整机功能走一遍 |
1~2周 |
|
环境测试 |
扔进高低温箱(-40°C~85°C),电磁兼容测试 |
2~4周 |
|
老化测试 |
7×24小时不间断跑 |
2~8周 |
一轮下来 2~3个月。你重构引入了一个 bug?好,全部推倒重来,再来三个月。
更要命的是——很多嵌入式产品已经装在客户的产线上了,甚至装在桥洞里、矿井下、海上平台上。不支持 OTA,出了问题就得派人带着 J-Link 和笔记本去现场刷机。光差旅费就够你心疼的了,更别提客户的停产损失。
所以很多公司的态度就四个字:没事别碰。
五、真要重构,照这个来
说了这么多"别动",不是说永远不能重构。如果旧代码真的维护不下去了,bug 频出,或者架构根本撑不住新需求——该重构还是得重构。
但得讲方法。
第一步:搞清楚哪些代码跟硬件绑着
ounter(lineounter(line# 先来一把 grep,把所有跟硬件沾边的操作都揪出来grep -rn "gpio_\|spi_\|i2c_\|uart_\|delay_\|__NOP\|volatile" src/
搜出来的每一行,都标注清楚:它在干嘛?能不能动?不确定就问老工或者查手册,宁可多问一句,不要多删一行。
第二步:备份,备份,还是备份
ounter(lineounter(linegit tag -a v1.0-before-refactor -m "重构前的稳定版本,产线上跑着没事的"git push origin v1.0-before-refactor
万一改炸了,至少能秒回滚。
第三步:一次只动一个模块
ounter(lineounter(line❌ 周一:一口气改了 20 个文件 3000 行,周五整个系统起不来了,不知道是哪一行的锅✅ 周一:只改 uart_driver.c,80 行,测完没问题。下周再搞 spi_driver.c
第四步:加点防护网
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line// 在开发阶段给关键函数加断言,上线再关掉#ifdef DEBUG#define ASSERT(expr) do { if (!(expr)) { \ printf("ASSERT FAIL: %s:%d\n", __FILE__, __LINE__); \ while(1); \}} while(0)#else#define ASSERT(expr) ((void)0)#endif
void motor_control(int32_t target_pos){ ASSERT(target_pos >= POS_MIN && target_pos <= POS_MAX); ASSERT(g_motor_state == MOTOR_READY); // ... 后面的控制逻辑}
第五步:把"为什么不能删"写在代码里
这一步太重要了。你今天搞清楚了这行代码为什么不能删,三年后下一个接手的人可不知道。写清楚,别让别人重复踩你踩过的坑:
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line/** * @brief 电机启动序列 * @warning delay_ms(500) 不可删除!!! * 电机上电浪涌电流需要 300~500ms 才能衰减到安全范围 * 参考: DRV8871 数据手册第12页 "Power-On Sequencing" * 2024-03: 张工实测确认,低于 300ms 偶发过流保护 */void motor_start(void){ gpio_set(MOTOR_EN, HIGH); delay_ms(500); pwm_set_duty(MOTOR_PWM, 80);}
写在最后
说到底,嵌入式代码重构这事儿,就像给一辆正在高速上跑着的车换轮胎——能换,但你得一个螺丝一个螺丝地来,换一个紧一个,全程盯着别掉轮子。
|
Web/App |
嵌入式 |
|
|---|---|---|
|
最重要的是 |
用户体验、迭代快 |
稳定,稳定,还是稳定 |
|
出了 bug |
推个热修复 |
派人坐飞机去现场 |
|
测试周期 |
分钟到小时 |
周到月 |
|
丑代码能忍不 |
忍不了 |
能跑就是好代码 |
最后送三句话,都是用板子和芯片换来的:
-
能跑十年不出事的"屎山",比你花一周写的"优雅代码"值钱一万倍。
-
每一行看着像废物的代码,删之前先想想——它是不是在伺候某个硬件的臭脾气?
-
重构不是革命,是改良。一口吃不成胖子,一刀也切不好屎山。慢慢来。

共勉。
更多推荐


所有评论(0)