重构与测试:如何安全地重构遗留C代码、测试作为安全网

说实话,干嵌入式这行十几年,我见过最多的场景不是写新代码,而是改老代码。那些跑了七八年甚至十几年的遗留C代码,就像一座老房子——你知道它结构不合理,知道它布线混乱,但你就是不敢动。因为一动,可能整个系统就塌了。

我有个朋友,曾经接手一个工业控制项目。前任工程师离职时留下一句「那个状态机函数别动,动了会死机」。结果他忍不住改了,测试没覆盖到,现场直接炸了。嗯,从那以后,他再也不敢裸改遗留代码了。

今天我们就聊聊,怎么安全地重构遗留C代码。核心就一句话:测试是你的安全网,没有网就别翻跟头

为什么遗留代码这么难搞?

先说说遗留代码的典型特征。你打开一个.c文件,发现一个函数写了800行。全局变量满天飞,goto语句跳来跳去。注释写着「这里不要改,原因未知」。你问老员工,他说「这代码是十年前一个外包写的,那人早联系不上了」。

说白了,遗留代码最大的问题不是技术差,而是没有测试。没有测试,你就不知道改了A会不会影响B。没有测试,你只能靠「感觉」和「运气」来改代码。这哪是重构?这是赌博。

我在项目中遇到过最夸张的一次:一个串口驱动函数,里面嵌套了6层if-else,还混着两个goto。我花了三天才理清楚它的逻辑。后来加了单元测试,发现里面有三个分支根本走不到——死代码。但没人敢删,因为「万一哪天用到了呢」。

重构前的准备工作:先织网,再翻跟头

重构遗留代码的第一步,不是改代码,而是建立测试保护网。这个网怎么织?我总结了三步走:

  1. 识别关键模块:哪些函数最容易被改?哪些函数历史bug最多?优先给这些函数加测试。
  2. 编写特性测试:先别管代码好不好,先写测试把当前行为「固化」下来。哪怕这个行为是错的,也要先记录下来。
  3. 建立回归测试:每次改完代码,跑一遍所有测试。确保旧功能没被破坏。

核心原则:不改代码,先加测试。测试通过后,再开始重构。重构过程中,每改一小步就跑一次测试。

你可能会问:「代码一团糟,怎么加测试?」嗯,这是个好问题。我常用的方法是接缝测试——找到代码中那些可以「插入」测试的地方。比如一个函数有输入参数和返回值,那就可以直接写单元测试。如果函数依赖全局变量,那就用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满天飞。原因只有一个:没有测试。所以,如果你现在手里有遗留代码要重构,别急着动键盘。先花时间把测试写好。这个时间花得值。

记住一句话:测试不是为了证明代码是对的,而是为了让你敢改代码

最后一个小建议:如果你实在不知道怎么给一段遗留代码加测试,那就先从「打印日志」开始。把函数的输入和输出打印出来,跑几个典型场景,把结果记录下来。这就是最原始的「测试」。虽然简陋,但比没有强。