Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

2.9 技术债务与重构——不翻新也是一种选择

中银胶囊塔的黄昏

东京银座有一座传奇建筑:中银胶囊塔(Nakagin Capsule Tower)。1972年由建筑师黑川纪章设计,是“新陈代谢派“建筑运动的标志性作品。整栋楼由140个预制的混凝土“胶囊“堆叠在核心筒上,每个胶囊是一个独立的居住单元。按照黑川的设计理念,每个胶囊都是可拆卸、可替换的。建筑像生物细胞一样具有新陈代谢的能力。

1972年到2022年,整整半个世纪,这栋楼被拆除。没有一个胶囊被替换过。没有住户真正需要替换它们。住户住得挺好,楼也一直站着。替换的理论可能从未兑现为实际需求。替换的成本远大于维持现状的收益。

技术债务就是这个道理。

不是每一笔债都要还。不是每一段“丑陋的代码“都值得重构。

软件行业,特别是敏捷运动,在过去二十年里给“重构“这个词披上了一层道德光芒。重构等于勤勉,不重构等于懒惰。这种二分法在Web开发的世界里也许大部分时候成立:你的垃圾代码这周就要维护,你不动手的话下周的Sprint Review的Demo就要翻车。但在汽车嵌入式软件的世界里,情况更微妙也更危险,因为重构不完全是一个技术活。重构高安全等级的模块是一项牵涉到功能安全认证的决策。

核心洞察:不是每一笔债都要还,不是每段丑代码都值得重构。 中银胶囊塔的可替换性设计五十年从未兑现为需求,替换成本远大于维持现状的收益;重构在汽车嵌入式领域尤其微妙,因为它牵涉功能安全认证,而不只是一个技术活。


【明线:技术债务不是道德缺陷】

“债务“这个词天然带着负面的道德暗示:欠债不好,有债是羞耻。但Ward Cunningham在1992年创造“技术债务“这个比喻时,他说的其实是一种蓄意的、有代价的策略性选择,就像企业用银行贷款购买设备一样:你借了一笔钱来换取当下的能力提升,代价是未来需要还利息。

考虑一个真实场景:你的模块三个半月后要上量产线的EOL(End-of-Line)测试台架。校准工程师的调试计划已经排好了,在你代码交付的第二天就要开始。你在写助力转向的控制参数查表逻辑时发现:如果能用一个更好的数据结构(比如把线性的if-else链改成二分查找加插值),代码会优雅得多。但是这个“优雅版“加上设计审查、编写、单元测试,需要两周时间。而OEM的SOP日期是死死的不动。你选择先把查表逻辑用最直白的方式写出来(if-else链加硬编码系数),功能跑通了,校准工程师能开始工作了,量产节点保住了。

你欠了一笔技术上的债。但这笔债的利息是理性的、可控的:你知道那些系数的if-else链在下一次功能升级时可能会让你多花半天时间。利息可承受,本金是你用合理的代价换取了一个不可妥协的交付时间节点。这笔债是工程上的取舍,不是技术上的失败。

真正的技术债务灾难发生在两种完全不同的情景下:

情景一:无意的债务。 你不是主动选择了“先跑通,后重构“。你是写完之后过了很久(或者换了一个资深同事来看你的代码之后),才意识到写得有问题。你不知道自己在借债。债务悄然发生的原因是设计能力不足或编码经验不足。这和策略性借债正好相反,你根本不知道自己在生产次品。这类债务最危险的地方在于它的“利息“是完全不可预见的,因为你连债的数额都不清楚,更不要说利息。

情景二:利息失控的债务。 当年你选择用一组全局变量和一个800行的函数来管理一个诊断会话的状态。你知道不够好,但你说:“反正诊断会话的逻辑已经定下来了,不会再改。“第二年的车型facelift,OEM要求:诊断会话的响应时间必须从50ms缩短到20ms以满足新的OTA刷写超时要求;同时,要在扩展诊断会话下新增一个可选的“标定会话“子状态。你打开代码,那个800行函数里有47个全局状态变量,每个状态转换的逻辑散落在14个if-else层级中。新需求涉及其中22个状态变量的状态组合,需要改动函数中的37处判断。当年你为了省两周开发时间欠下的债,现在需要八周来还。利息翻了四倍。

这是利息计算错了。

核心洞察:技术债务不是道德缺陷,是一种有代价的策略性选择。 借债换量产节点,利息理性可控就是工程取舍;真正的灾难是两种:无意的债务(你不知道自己在借债,利息完全不可预见)和利息失控的债务(当年省两周,facelift时要八周来还)——借债不是问题,算错利息才是问题。


【明线:三个判断标准——什么时候重构】

我在多个项目中反复见到团队争论“这个模块该不该重构“,争论到最后往往变成两个阵营:“完美主义派“觉得所有代码都应该像教科书里的示例代码一样优雅,“实用主义派“觉得能跑就不要碰,碰了就可能引入新bug。两边都在自己的经历中有充分的论据。完美主义派经历过被一团混乱代码折磨得三个月加班的项目,实用主义派经历过一次“小幅重构“引发连锁故障的噩梦。

问题的根源在于两派使用的判断标准不同:一派用的是审美标准(代码好不好看),一派用的是消极标准(代码有没有炸)。我们需要的是第三个、更好的标准:基于后果的决策框架。

标准一:这个模块被修改的频率。

打开git log -- path/to/the/module.c,看这个文件过去一年的提交次数。如果超过十五次,而且每次提交都是在同一个函数身上“打补丁“:加一个if分支、特判一个额外条件、再多声明一个静态全局变量。那么你的利息已经积攒到了不可忽略的程度。每一次修改花费的时间都在递增,因为代码的结构在每一次打补丁后变得更加脆弱。你对这个文件的“心理模型“(你脑中对它的状态和行为的理解),需要花费越来越长的时间来重建。这种模块值得重构,因为它的维护成本已经明确超过了重构成本。

反之,如果一个模块从SOP到现在三年没改过一行,它再丑也别碰。一个函数2000行、不要命地使用全局变量、函数名是Func1、Func2、Func3,但它从未出过bug,而且它所服务的功能域也不再演化。它已经进入了“地质层“。没有活人在维护它、理解它,它自己安静地运行着。碰它等于在一座稳定了十年的地基上凿开一个洞,你为重构付出的精力完全浪费了,而且引入了本来不存在的改动风险。

标准二:这个模块的安全等级。

功能安全(ISO 26262)不是一套建议,是一套需要提交给认证机构的完整证据链。如果一个模块的ASIL等级是B级以上,它的每个条件分支都被MCDC(Modified Condition/Decision Coverage,修正条件判定覆盖)测试用例覆盖到了。它的每个安全相关参数都在需求追溯矩阵中有映射。它的单元测试报告、集成测试报告、软件资格测试报告是互锁验证的。

现在你重构它。旧的测试用例还能不能用?如果函数签名变了,你不能直接用,需要更新测试代码。如果内部逻辑结构变了,那些MCDC测试用例覆盖的条件分支已经不是原先那些分支了,你需要重新分析覆盖准则。如果新的实现更精简,安全性论证可能需要重新编写,因为旧版的安全性论证是基于旧版的控制流结构和数据依赖关系的。

重构的代价不只是代码重写的时间。它是整个V模型右侧(SWE.4单元验证、SWE.5集成测试、SWE.6资格测试)全部重新执行的时间。如果认证机构已经签发了该版本的软件发布许可,任何代码改动都需要重新提交变更审查。这种情况下,高ASIL等级的模块,除非不改就会导致安全问题,否则不要重构。

标准三:重构会明显帮到下一个接手的人吗?

这一条判断需要你从当前的工程师身份中短暂跳出来,切换到“一年半后的另一个维护者“的视角。下一个人打开这个文件的时候,他会在哪里困惑?他会误解哪些变量的含义?他会不小心在哪个if-else分支上引入新的bug?哪些变量命名让他以为这是车速而实际上是车轮转速?哪一个函数的前置条件没有被写下来,以至于他在错误的时间调用了这个函数?

如果通过重构你能消除这些困惑点(哪怕只是做到这四件事:拆分一个超过500行的函数、给四个命名有歧义的全局变量重新命名、把一个没有结构注释的状态枚举改成带中文注释的版本、在模块头文件里写清楚调用顺序),那就值得做。因为下一个维护者很可能就是你自己,一年半之后,你的记忆已经清空了。那个清晰的重构成果是你欠自己的一条安全绳。

核心洞察:判断重构的标准不是美丑或炸没炸,而是基于后果的决策框架。 三个标准:被修改频率(git log一年超十五次就值)、安全等级(高ASIL模块除非不改就会导致安全问题否则别动)、是否明显帮到下一个接手的人——重构的代价不只是重写代码,还有V模型右侧全部重跑与重新认证。


【明线:什么绝对不值得重构】

第一类:只丑不恶的代码。 一个函数用十四个层层嵌套的if-else写完,没有模块化、没有设计模式,但它被调用了四十万公里等效寿命试验,从未在任何工况下产生过错误的输出,而且它的功能域不在任何可预见的车型改款范围内。这等同于一座工具仓库。仓库不需要玻璃幕墙和大理石地砖。仓库的唯一职责是:东西放进去,别塌。只要它不塌,你的精力和时间不配投资一寸钢筋混凝土进去。

第二类:别人家的代码。 你接手了一个从供应商手里并入公司代码库的项目。里面有大量你看不惯的编码风格和命名习惯:用的是供应商内部的命名规范、没有按你公司的格式做缩进、函数注释用的是德语。你想把它们全部改写为“你团队的风格“。停。

供应商的代码有一套它自己的测试基线。它的QA团队和开发团队共享着一套对这个代码体的“体感理解“:他们知道哪里容易出问题、哪些变量之间存在隐含的不变量。你一重构,就切断了这条经验链。将来如果有bug,唯一有能力通过代码形态快速定位问题根源的那群供应商工程师,看到重构后的代码形态会完全认不出来。你不仅没有降低风险,还把风险从“已知的丑“转移到了“未知的、可能更危险的、没人理解的干净“。

第三类:没有测试保护的老代码。 一个模块在车上跑了两个量产年,没有单元测试。你想重构它?先别动手。先写测试。测试是对现状的行为快照。只有当你有了一个自动化的、可重复的、对现有行为进行精确刻画的测试套件之后,你才有权利去改代码。否则你的重构就是在黑房间里跑酷,你不知道墙在哪,撞上去才能知道。

核心洞察:三类代码绝对不值得重构:只丑不恶的、别人家的、没有测试保护的老代码。 仓库只需“别塌“;重构供应商代码会切断他们独有的体感理解,把风险从“已知的丑“变成“没人理解的干净“;没测试的代码先写测试做行为快照,否则重构就是在黑房间里跑酷。


【明线:一个值得重构的案例——转向助力计算模块】

假设你手上的EPS(电动助力转向)基本助力计算模块长成这样:

sint16 calc_assist_torque(sint16 driver_torque, uint16 vehicle_speed) {
    sint16 assist;
    if (vehicle_speed < 200) {
        assist = driver_torque * 30 / 10;
    } else if (vehicle_speed < 400) {
        assist = driver_torque * 20 / 10;
    } else if (vehicle_speed < 600) {
        assist = driver_torque * 15 / 10;
    } else if (vehicle_speed < 800) {
        assist = driver_torque * 10 / 10;
    } else {
        assist = driver_torque * 5 / 10;
    }
    if (assist > 50) assist = 50;
    if (assist < -50) assist = -50;
    return assist;
}

这段代码有什么问题?不是逻辑问题,逻辑在给定的参数下是正确的。问题在于它在工程上是脆弱的:

200、400、600、800、30、20、15、10、5,这些数字全部是“魔法数字“。它们的物理含义是什么?200是车速值,但它的单位是什么?0.1km/h?1/64 km/h?还是CAN信号的原始值?没有人能通过阅读这个函数获得这些数字的物理含义。两年后需要重新调整这些标定值,你打开这段代码面对12个裸数字,唯一的选择是翻出三年前的标定报告,逐一对应“第三个数“代表哪个工况的参数。

这些参数硬编码在了源码中。如果标定工程师需要调校系数(这是EPS领域最常见的日常),他必须给你发邮件:帮我把车速分段的第三段的增益从15改成13。你放下手里的活,改源码、重新编译、在HIL上跑一遍回归测试确定没有引入新bug、发hex文件。一来一回,两个工作日。如果这个过程能通过XCP协议做到不重新编译,标定工程师自己就能完成,你省下的是每年几十次中断的开销。

这个模块正中“值得重构“的三条判断标准:它被修改频率极高(不同车型的标定值不同,同一车型不同底盘配置的标定值也不同);它有ASIL安全等级(转向力矩直接影响车辆的横摆响应和驾驶员的操控感受,ASIL C是标准的EPS分配等级);下一个维护者(标定工程师和下一位软件开发人员)都明显受益于重构。

重构后:

CONST(uint16, ASSIST_CFG_TYPE)
assist_speed_thresholds[ASSIST_SPEED_SEGMENTS] = {
    200u, 400u, 600u, 800u
};

CONST(sint16, ASSIST_CFG_TYPE)
assist_gains[ASSIST_SPEED_SEGMENTS + 1u] = {
    30, 20, 15, 10, 5
};

CONST(sint16, ASSIST_CFG_TYPE) assist_gain_denom = 10;
CONST(sint16, ASSIST_CFG_TYPE) assist_torque_limit = 50;

sint16 calc_assist_torque(sint16 driver_torque, uint16 vehicle_speed) {
    uint8 idx;
    sint16 assist;
    for (idx = 0u; idx < ASSIST_SPEED_SEGMENTS; idx++) {
        if (vehicle_speed < assist_speed_thresholds[idx]) {
            break;
        }
    }
    assist = (driver_torque * assist_gains[idx]) / assist_gain_denom;
    if (assist > assist_torque_limit) {
        assist = assist_torque_limit;
    } else if (assist < -assist_torque_limit) {
        assist = -assist_torque_limit;
    }
    return assist;
}

所有的魔法数字变成了命名的常量。常量声明用了CONST宏(AUTOSAR内存类宏),它们被放在标定区(calibration memory segment),可以通过XCP协议在线改写,不需要重新编译、不需要重新刷写固件、不需要在HIL上跑完整的回归测试。标定工程师自己就能做迭代优化。

新的算法接口工程师打开这个文件,20秒就看懂了:这是一组分段常数增益的助力曲线,有N个速度分段阈值和N+1个增益值。数据结构本身就描述了算法的形状。下一个项目用不同的标定数据,代码一行不用改,只改配置常量所在的标定数据段。

这个重构的投入是一个下午的工作量。回报是未来每一个车型年、每一个标定迭代周期中省掉的沟通成本和重新编译的时间。回报远大于投入,它是一个教科书式的“值得重构“。

核心洞察:魔法数字硬编码让标定变成跨部门邮件往来,重构把它变成纯数据配置。 12个裸数字没人懂物理含义,调参得改源码、重新编译、跑回归;重构为命名常量+CONST宏放入标定区后,XCP在线改写即可,一个下午的投入换回每个车型年的沟通与编译成本——教科书式的“值得重构“。

一个你不敢重构的函数

下班之前,打开你项目的git log,找一个你“一直觉得该重构但始终没动“的文件:

git log --oneline -- path/to/that/file.c | wc -l

看看它的提交次数。如果超过十五次,今晚花二十分钟列一个重构清单。只列三个条目。只列你最痛的那三个,那三个每次改这个文件时你都要在心里骂脏话的东西。

如果提交次数不到五次,关掉终端。安心下班。那段代码不需要你来拯救。它活得挺好的。你今晚的休息时间比拯救一个不需要拯救的文件更有价值。


本篇小结

  • 技术债务是有代价的策略性选择:它是你在有限时间、有限资源、确定交付节点的工程现实中,做的一次有代价的策略性选择;它不是道德污点,也不是必须清偿的罪。
  • 关键要知道四件事:你欠了多少、利息是多少、什么事件触发你还债、以及(更重要也更难接受的一点),选择不还永远是一个合法的选项。
  • 有些债不需要还:那个模块会在你的团队意识到它的老化之前,随这个ECU的成功退役一起从代码仓库中归档。
  • 更多的情况是假装不知道欠债:直到利息在某个截稿日滚到了本金的四倍,才被迫在最狼狈的时间点去偿还;你在那个时间点没有选择,因为OEM的报价单已经发出去了。

【下集预告】:转向助力计算的参数标定好了,代码也重构干净了。但恭喜你,还有一个比代码审查和流程认证更硬的约束正悬在你的头顶:那个计算模块必须在一个严格的截止时间之内跑完:“必须在这个时间之前完成,否则看门狗会复位你的系统”,没有“最好快点“这个选项。这是物理世界的刚性截止时间,和软件项目经理写在你JIRA里的软性deadline不是一回事。你的MCU在跑你的转向算法的时候,汽车在公路上以120km/h的速度前进了,每一毫秒的延迟都会实实在在地体现在车轮的轨迹上。