重构与测试:如何安全地重构遗留C代码、测试作为安全网
说实话,干嵌入式这行十几年,我见过最多的场景不是写新代码,而是改老代码。那些跑了七八年甚至十几年的遗留C代码,就像一座老房子——你知道它结构不合理,知道它布线混乱,但你就是不敢动。因为一动,可能整个系统就塌了。
我有个朋友,曾经接手一个工业控制项目。前任工程师离职时留下一句「那个状态机函数别动,动了会死机」。结果他忍不住改了,测试没覆盖到,现场直接炸了。嗯,从那以后,他再也不敢裸改遗留代码了。
今天我们就聊聊,怎么安全地重构遗留C代码。核心就一句话:测试是你的安全网,没有网就别翻跟头。
为什么遗留代码这么难搞?
先说说遗留代码的典型特征。你打开一个.c文件,发现一个函数写了800行。全局变量满天飞,goto语句跳来跳去。注释写着「这里不要改,原因未知」。你问老员工,他说「这代码是十年前一个外包写的,那人早联系不上了」。
说白了,遗留代码最大的问题不是技术差,而是没有测试。没有测试,你就不知道改了A会不会影响B。没有测试,你只能靠「感觉」和「运气」来改代码。这哪是重构?这是赌博。
我在项目中遇到过最夸张的一次:一个串口驱动函数,里面嵌套了6层if-else,还混着两个goto。我花了三天才理清楚它的逻辑。后来加了单元测试,发现里面有三个分支根本走不到——死代码。但没人敢删,因为「万一哪天用到了呢」。
重构前的准备工作:先织网,再翻跟头
重构遗留代码的第一步,不是改代码,而是建立测试保护网。这个网怎么织?我总结了三步走:
- 识别关键模块:哪些函数最容易被改?哪些函数历史bug最多?优先给这些函数加测试。
- 编写特性测试:先别管代码好不好,先写测试把当前行为「固化」下来。哪怕这个行为是错的,也要先记录下来。
- 建立回归测试:每次改完代码,跑一遍所有测试。确保旧功能没被破坏。
核心原则:不改代码,先加测试。测试通过后,再开始重构。重构过程中,每改一小步就跑一次测试。
你可能会问:「代码一团糟,怎么加测试?」嗯,这是个好问题。我常用的方法是接缝测试——找到代码中那些可以「插入」测试的地方。比如一个函数有输入参数和返回值,那就可以直接写单元测试。如果函数依赖全局变量,那就用mock把全局变量替换掉。
安全重构的四个步骤
有了测试保护网,我们就可以开始重构了。我习惯把重构拆成四个小步骤,每一步都跑测试验证:
| 步骤 | 做什么 | 测试验证点 |
|---|---|---|
| 1. 提取函数 | 把长函数拆成小函数 | 输入输出不变 |
| 2. 消除全局变量 | 把全局变量改为参数传递 | 状态一致性 |
| 3. 简化条件逻辑 | 把嵌套if-else改为早期返回或状态机 | 所有分支覆盖 |
| 4. 重命名与整理 | 变量名、函数名规范化 | 编译通过,测试通过 |
举个例子。我曾经重构过一个温度采集函数,原函数大概这样:
// 原代码:一个函数干了所有事
int temp_read(void) {
int raw;
int temp;
// 初始化传感器
sensor_init();
// 读取原始值
raw = sensor_get_raw();
// 转换温度
if (raw > 0) {
temp = raw * 100 / 1024;
} else {
temp = 0;
}
// 校准
if (temp > 85) {
temp = temp - 5;
}
return temp;
}
这个函数虽然不长,但把初始化、读取、转换、校准全混在一起。测试起来很麻烦。我第一步先加测试:
// 测试用例:固化当前行为
void test_temp_read_normal(void) {
// 假设sensor_get_raw返回512
// 那么temp_read应该返回 512*100/1024 = 50
int result = temp_read();
TEST_ASSERT_EQUAL(50, result);
}
测试通过后,我开始提取函数:
// 重构后:职责分离
static int raw_to_temp(int raw) {
if (raw > 0) {
return raw * 100 / 1024;
}
return 0;
}
static int apply_calibration(int temp) {
if (temp > 85) {
return temp - 5;
}
return temp;
}
int temp_read(void) {
sensor_init();
int raw = sensor_get_raw();
int temp = raw_to_temp(raw);
return apply_calibration(temp);
}
每提取一个函数,我就跑一次测试。测试全部通过,说明行为没变。这时候我就可以放心地继续优化了。
小技巧:重构时不要同时做「重构」和「修bug」。重构只改变结构,不改变行为。如果发现bug,先记录下来,等重构完再修。否则你分不清测试失败是因为重构错了,还是因为bug被暴露了。
测试作为安全网:怎么织才结实?
测试不是随便写几个用例就完事了。我见过太多人写了测试,但测试本身就有bug,或者覆盖率太低,根本起不到保护作用。
我个人习惯,给遗留代码加测试时,重点关注三个维度:
- 边界值:最大值、最小值、空值、零值。遗留代码最容易在这些地方出问题。
- 异常路径:错误处理分支、超时分支、资源不足分支。这些分支往往从来没被测试过。
- 状态依赖:如果函数依赖全局状态,要测试不同状态下的行为。比如先调用A再调用B,和先调用B再调用A,结果是否一样?
我曾经遇到过一个案例:一个通信协议解析函数,正常路径跑得飞起,但只要收到一个错误帧,整个系统就死锁。原因是一个全局变量在错误路径下没有被正确复位。后来加了测试,专门构造各种异常帧,才把这个bug抓出来。
警告:不要相信「这段代码很简单,不用测」。我职业生涯中遇到的最严重的线上事故,就是从一个「很简单」的宏定义开始的。那个宏只改了一行,但影响了整个系统的内存布局。没有测试,谁也没发现。
重构遗留代码的常见陷阱
说几个我踩过的坑,你遇到了可以绕开:
- 一次性改太多:我曾经一口气重构了三个模块,结果测试全红,根本定位不到问题。后来学乖了,一次只改一个函数,改完就跑测试。
- 忽略隐式依赖:有些函数看起来只用了参数,实际上偷偷读了全局变量。这种依赖很难发现,但测试能帮你暴露出来。
- 过度设计:遗留代码重构的目标是「让代码可维护」,不是「让代码完美」。别一上来就搞设计模式、分层架构。先把面条代码变成意大利面,下次再优化。
一张图看懂重构与测试的关系
下面这张图展示了重构与测试的协作流程。核心就是:测试先行,小步重构,持续验证。
这张图的核心逻辑很简单:先加测试,再改代码。改完就跑测试,失败就回退,成功就继续。循环往复,直到代码变得干净。
写在最后
重构遗留C代码,说白了就是一场「有准备的冒险」。测试是你的安全绳,没有它,你就是在悬崖边跳舞。有了它,你才能放心地大刀阔斧地改。
我见过太多团队,重构前信誓旦旦,重构后bug满天飞。原因只有一个:没有测试。所以,如果你现在手里有遗留代码要重构,别急着动键盘。先花时间把测试写好。这个时间花得值。
记住一句话:测试不是为了证明代码是对的,而是为了让你敢改代码。
最后一个小建议:如果你实在不知道怎么给一段遗留代码加测试,那就先从「打印日志」开始。把函数的输入和输出打印出来,跑几个典型场景,把结果记录下来。这就是最原始的「测试」。虽然简陋,但比没有强。