2.5 开闭原则——扩建不用拆墙
国贸三期的加层设想
北京国贸三期,330米,2010年竣工时是北京第一高楼。你在74层。如果开发商决定扩建到80层,会发生什么?
什么都不会发生。不需要。
因为国贸三期在设计时,核心筒(电梯井、楼梯间、设备竖井)就预留了向上延伸的结构条件。核心筒的混凝土强度、钢筋配比、基础桩的承载力,全部按比实际建造层数更高的标准设计。扩建六层,施工队只需要在屋顶搭好脚手架,继续往上浇筑核心筒、搭设钢结构、安装幕墙。74层以下的人照常上班,连电梯都不用停。
这就是建筑学里的开闭原则的最直观体现:对扩展开放,对修改关闭。 你可以加建(扩展),但不用拆掉已有的楼层(修改)。
现在想象另一栋楼,一栋没有预留核心筒的老式砖混建筑。它要加建一层。施工方案是:先拆掉屋顶,在五楼的承重墙上凿出新的楼梯开口,加固原有地基(因为增加了一层楼的荷载),然后你发现自己花的钱比新建一栋小楼还多。大部分时候,业主直接放弃了:“算了,不建了。”
你觉得这很可笑。但在嵌入式软件里,“不预留核心筒就直接封顶“的设计每天都在发生。
核心洞察:对扩展开放、对修改关闭,靠的是预先留出结构条件。 国贸三期建74层却按更高标准设计核心筒与基础,扩建80层不用打扰楼下;而没留核心筒的老楼加一层要先拆屋顶、凿承重墙、加固地基,代价比新建还高——嵌入式软件里“不预留就封顶“每天都在发生。
一个CAN报文分发的“老式砖混结构“
看看你项目中处理CAN报文的代码:
/* can_dispatcher.c */
void CanDispatcher_Dispatch(uint32_t can_id, const uint8_t *data, uint8_t len) {
switch (can_id) {
case 0x100: /* 电机控制报文 */
MotorControl_Handle(data, len);
break;
case 0x200: /* 传感器状态请求 */
SensorStatus_Respond(data, len);
break;
case 0x300: /* 诊断会话控制 */
DiagSession_Handle(data, len);
break;
default:
/* 未知报文,丢弃 */
break;
}
}
这是最常见的设计模式:枚举ID加switch-case。简洁、高效、零开销:编译器把它编译成跳转表,不额外消耗代码空间。
现在产品经理推门进了你的工位:“V2.1增加了两个新功能:胎压监测(CAN ID: 0x400)和电池管理(CAN ID: 0x500)。这两个报文的处理逻辑你负责加进去。”
你的第一反应是:在switch-case里加两个case。
case 0x400: /* 胎压监测 — V2.1新增 */
Tpms_Handle(data, len);
break;
case 0x500: /* 电池管理 — V2.1新增 */
Bms_Handle(data, len);
break;
完成。提交。下个sprint上线。
但请你停下来想一想:你真的只是在“加建六层“吗?还是在凿开屋顶重砌砖墙?
你刚才的动作是:
- 打开了
can_dispatcher.c。这是一个已经在生产环境稳定运行了六个月的模块。它经过了完整的集成测试,它的每个case都被验证过。 - 在这个模块里插入了两段新代码。你在一个已经被证明正确的函数体里,增加了两个全新的执行路径。
- 你需要重新测试整个模块。不是只测试0x400和0x500,你需要重新验证0x100、0x200、0x300没有被你这次的改动影响。因为你在 修改 一个已有模块,它的所有已有功能都在“可能被破坏“的范围内。
这就是“凿开屋顶加盖楼层“。你增加了功能,但你破坏了已有系统的封闭性。你的勇气可嘉,但你的结构工程师(如果你有的话)会失眠。
核心洞察:在已验证的模块里插入新case,就是凿开屋顶加盖楼层。 加0x400、0x500两个case看起来轻巧,但你打开了一个稳定运行六个月的模块、在其中插入两条全新执行路径,还被迫重测全部旧case——你增加了功能,却破坏了已有系统的封闭性。
注册制:为扩展预留核心筒
同样的需求,用“对修改关闭“的方式实现:
/* can_dispatcher.h — 对外只暴露注册接口 */
typedef void (*CanMsgHandler_t)(const uint8_t *data, uint8_t len);
void CanDispatcher_Init(void);
void CanDispatcher_Register(uint32_t can_id, CanMsgHandler_t handler);
/* can_dispatcher.c — 核心分发逻辑,从此不再修改 */
#define MAX_HANDLERS 32
static struct {
uint32_t can_id;
CanMsgHandler_t handler;
} handler_table[MAX_HANDLERS];
static uint8_t handler_count = 0;
void CanDispatcher_Init(void) {
handler_count = 0;
}
void CanDispatcher_Register(uint32_t can_id, CanMsgHandler_t handler) {
if (handler_count < MAX_HANDLERS) {
handler_table[handler_count].can_id = can_id;
handler_table[handler_count].handler = handler;
handler_count++;
}
}
void CanDispatcher_Dispatch(uint32_t can_id, const uint8_t *data, uint8_t len) {
for (uint8_t i = 0; i < handler_count; i++) {
if (handler_table[i].can_id == can_id) {
handler_table[i].handler(data, len);
return;
}
}
}
/* main.c — 组装层:各类处理器在这里"登记入住" */
int main(void) {
CanDispatcher_Init();
CanDispatcher_Register(0x100, MotorControl_Handle);
CanDispatcher_Register(0x200, SensorStatus_Respond);
CanDispatcher_Register(0x300, DiagSession_Handle);
/* V2.1新增:只在这里加两行 */
CanDispatcher_Register(0x400, Tpms_Handle);
CanDispatcher_Register(0x500, Bms_Handle);
while (1) {
uint32_t id;
uint8_t data[8];
uint8_t len;
if (Can_Receive(&id, data, &len) == CAN_OK) {
CanDispatcher_Dispatch(id, data, len);
}
}
}
现在V2.1的升级操作是什么?
你不需要碰 can_dispatcher.c。 你只在 main.c 的初始化段里加两行 CanDispatcher_Register。can_dispatcher.c,这个被验证过、被测试过、在生产环境稳定运行了六个月的模块,原封不动。你不需要重新测试它的已有逻辑,因为它没有被修改。
这就是扩建80层,74层以下照常上班。
核心洞察:注册制把“改分发器“变成“加注册语句“,让已验证的模块原封不动。 分发逻辑收敛进
can_dispatcher.c后从此不再改,V2.1只在main.c加两行CanDispatcher_Register;被验证、被测试、运行六个月的模块不用重测,因为没人碰过它——这就是扩建80层而74层照常上班。
开闭原则的真正含义
你可能会觉得:“这不就是把switch-case换成了循环查找吗?还多了函数指针的开销,效率反而更低了。”
你说得对一半。switch-case编译成跳转表,O(1)分发。注册表是遍历查找,O(n)。如果注册了50个handler,每次报文分发都要遍历50次,在CAN总线上每秒可能收到上千条报文,这个开销不能忽略。
但开闭原则不是在教你“注册表取代switch-case“。开闭原则是在告诉你:变化的方向决定了抽象的方向。
如果你的CAN报文种类在项目生命周期内是固定的(比如只有3种控制报文,从SOP到EOL都不会变),那switch-case完全合理。它简单、高效、满足需求。你没有“对修改关闭“的必要,因为没有修改。
但如果你的系统需要支持“功能可裁剪“:同样的ECU在不同的车型上用不同的功能组合,高配车型多几个CAN报文类型,低配车型少几个,那注册制就很有价值。你不用维护多个版本的 can_dispatcher.c,只需要在不同的 main.c 或配置模块里注册不同的handler。
如果系统支持OTA(Over-The-Air)升级:新增一个功能意味着新增对一种CAN报文类型的支持,那注册制几乎是必须的。你不能在每次OTA升级时重写整个 can_dispatcher.c,你只能增加新的handler模块,然后在配置区增加一条注册语句。
开闭原则不是说“所有switch-case都是坏的“。它是在问你:“这个模块,哪个维度会扩展?在什么方向上可能增加?” 你基于这个维度和方向来做抽象。如果根本不会扩展,就不需要抽象。如果你在永远不需要“注册新handler“的场景下写了一整套注册框架,那是过度设计。
核心洞察:开闭原则不是教注册表取代switch-case,而是“变化的方向决定抽象的方向“。 报文种类固定就无需抽象,switch-case简单高效;要功能可裁剪、支持OTA时注册制才值钱。不是所有switch-case都坏,过度设计同样坏——先问这个模块哪个维度会扩展。
AUTOSAR的callback注册模式
AUTOSAR里的BswM(基础软件模式管理器)是开闭原则的教科书范例。
BswM本身是一个“规则引擎“,它监控系统状态(通信模式、ECU状态、总线唤醒事件),并根据预定义的规则触发动作。但它不预设“有哪些动作“,模块通过callback注册的方式把自己需要的动作挂到BswM上。
/* 模式切换时,ComM注册自己的callback */
BswM_RegisterCallback(BSWM_COMM_MODE_CHANGE, ComM_HandleModeChange);
/* ECU休眠时,NvM注册自己的callback */
BswM_RegisterCallback(BSWM_ECU_SLEEP, NvM_WriteAll);
当OEM要求新增一种系统状态(比如“快速启动模式“)时,你不需要修改BswM的代码。你只需要让相关的模块在BswM上注册它们对新状态的响应callback。BswM的核心规则引擎,“状态变化→查找注册表→执行callback”,完全不被修改。
这就像国贸三期的核心筒:电梯井、楼梯间、设备竖井的截面尺寸和位置是确定的,但每一层具体做什么用途,办公室、餐厅、健身房,是灵活的。核心筒不需要知道74层是律所还是投行,它只管把人运上去。
核心洞察:AUTOSAR的BswM把“扩展“做成注册callback,规则引擎永不修改。 模块把自己对新状态的响应挂到BswM上,OEM新增“快速启动模式“时,核心的“状态变化→查注册表→执行callback“不被改动;就像核心筒截面固定,但每层做什么用途都灵活。
开闭原则的代价与边界
开闭原则不是免费的。
注册表实现比switch-case多了查找循环、多了handler_table的RAM占用(32个条目 × 8字节 = 256字节)、多了间接函数调用的开销。在Cortex-R5这种平台上,这不是可以忽略的小数目,如果你的ECU有20个这样的注册表,RAM就会吃紧。
所以你需要做出判断:
如果扩展发生前的稳定期足够长,开闭原则的投入就值得。 比如一个平台的CAN报文分发器,它会用在5个车型、10年生命周期里。在这10年里,每个月都有新的功能模块需要接入CAN总线。前期多花256字节RAM和几个CPU周期建立一个注册表,换来的是10年里所有功能团队在扩展时不需要碰已有代码。这笔账,算得过。
如果扩展几乎不会发生,就不要抽象。 比如一个ECU内部的私有协议,只有你和另一个模块在用,而且协议版本和功能范围在产品定义阶段就锁死了。这时候switch-case就是最正确的答案。你写注册表就是浪费。
一个简单的判断标准:问问你自己,这个模块在过去三个月里因为“新需求“被改过几次?如果超过两次,它就值得做开闭处理。如果零次,就先保持简单。
别在第一次写代码时就妄想预判所有扩展方向。你猜不准的。你会猜错。你会在一个永远不会扩展的方向上做了漂亮的抽象,却在另一个你没想到的方向上被迫凿墙。这不是你的错,这是软件工程的本质。所以更务实的策略是:先写简单版本,当修改发生第二次时,重构为开闭版本。这就是著名的“三次法则“(“Rule of Three”)。
核心洞察:开闭原则不是免费的,何时值得取决于变化频率。 注册表多花256字节RAM和几个CPU周期;稳定期够长(如5车型10年、每月有新模块接入)就值,扩展几乎不发生的私有协议用switch-case更正确——判断标准是过去三个月因新需求被改过几次,超两次值得开闭处理。
本篇小结
- 开闭原则要求模块对扩展开放、对修改关闭。
- 实现方式:在嵌入式C中,可以通过handler注册表、函数指针数组等“构建时的插件机制“来实现。
- 不是所有switch-case都是坏的:只有在确实存在扩展需求的维度上才需要做开闭设计。
- 系统级实践:AUTOSAR的BswM callback注册是这一原则的系统级实践。
- 判断标准:看“这个模块在过去三个月里因为新需求改过几次“,超过两次就该考虑重构。
- 三次法则:第一次写简单版,第二次出现修改时注意,第三次直接重构。
【下集预告】:开闭原则告诉你如何在扩展时保护已有的墙。但墙砌得再高,也还有一个更基本的问题没回答:这栋楼从上到下应该怎么组织?哪些墙是承重墙,哪些只是隔断?分层架构告诉你:地基、框架、装修各司其职。下一节,我们看AUTOSAR的四层大厦,以及没有AUTOSAR时你仍然需要的分层纪律。