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.7 防御性编程——邻居不许烧到我家

邻居的明火

2017年6月14日凌晨,伦敦格伦费尔塔公寓大楼发生火灾,72人遇难。事故调查的结论之一是:大楼外墙使用了可燃铝塑板,层与层之间的防火隔离措施失效,火势在几分钟内从4层的一户厨房蹿升到了24层的顶部。

一栋高层建筑在设计的时候,每一层的楼板和建筑防火墙是法律强制要求的。不是因为设计师预测到了格伦费尔塔的火灾,没有人能预测哪一天、哪一户、因为什么原因着火。设计师能控制的是:火灾发生后不会蔓延到整栋楼。楼板防火极限两小时、防火门自动关闭、楼梯间正压送风,这些措施平时看不见、住户感受不到、增加了建造成本,但它们存在是因为“不知道火灾什么时候发生“,而不是因为“火灾会发生“。

嵌入式软件的世界有一条几乎相同的法则:你能控制的是错误发生后不会扩散成系统崩溃,而非错误不发生。

在我职业生涯的早期,有一个让我至今不忘的教训。那时我在一个动力总成项目中负责一个网关模块,一个接收CAN报文、转发到另一路CAN总线的小ECU。网关的核心逻辑不到三百行C代码。我觉得这么简单的逻辑不会出问题,没有加任何输入校验。

第四个月的冬季道路试验,测试车在瑞典Arjeplog的冰湖上做对开路面制动测试。气温零下三十五度。网关突然开始周期性地把一条车速信号从一个错误的值翻转到另一个错误的值。这不是随机噪声,它非常规律,像一个固定的错误模式被锁死在了某个缓存里。不是硬件坏了,不是CAN总线断了,是我的代码在接收缓冲区的某个边界条件下,把一个本来是12位的车速信号错误拼接到了一个相邻的8位信号上,形成了一个完全没有意义的合成值。

我在冰湖边上站了六个小时,顶着寒风,用CANoe一帧一帧地回溯总线上几万帧的历史,找出了那个错误拼接的精确触发条件。触发条件是:发送节点的序列计数器在特定值翻转时,恰好撞上了我的DLC校验逻辑的一个off-by-one错误。

就一行代码。少写了一行范围检查。六个小时。

防御性编程不是为了防黑客,汽车嵌入式软件的大部分输入错误不是恶意的。它们是总线噪声、EMC干扰、老化器件的漂移、配置工具链的错误导出、不同供应商软件在集成时的版本不匹配。这些错误的源头无法消灭。你不可能让所有MCU永远跑在理想的温湿度环境中,你不可能让所有CAN收发器的晶振永远不老化,但你可以让错误在进入你的模块时,就停在门口。

核心洞察:你能控制的不是错误不发生,而是错误不扩散成系统崩溃。 格伦费尔塔的防火楼板存在,是因为“不知道火灾何时发生“;作者为一行少写的范围检查在冰湖上站了六小时——大部分输入错误不是恶意而是噪声、老化、工具误差,你无法消灭源头,但可以让错误在进入模块时就停在门口。


【明线:一个CAN消息解析器的防御之旅】

假设你正在写一个接收CAN总线上车速信号的函数。车速报文CAN ID是0x180,包含一个12位车速值,数据格式是Intel格式(小端),两个字节,低位在前。

不防御的时候,代码是这样的:

void parse_vehicle_speed(const uint8* data, uint16* speed) {
    *speed = ((uint16)data[1] << 8) | data[0];
    *speed &= 0x0FFFu;  /* 12位有效 */
}

这个函数在开发板上运行正常。在HIL台架上运行正常。在校准实验室25度的环境下运行正常。第一批量产车的产线EOL测试通过。

然后这辆车交付给用户。第一个冬天,用户把车开到零下三十度的黑龙江。CAN收发器在低温下的比特误码率(BER)上升了一个数量级。一个原本八字节的报文的DLC字段在传输过程中有一个比特翻转了,从8(1000b)翻成了0(0000b)。接收端的CAN控制器正确地把0字节数据存入了接收缓冲区。你的parse_vehicle_speed函数从data[1]读到的是上一次接收遗留的垃圾值,它落在没有初始化的栈上,这个值可能是任何东西。

车速信号变成了某种随机值,可能是一个看似合理的75km/h,也可能是一个荒谬的65535。如果是前者,静默错误:ABS系统基于一个错误的车速做了决策,没有任何告警,没有任何日志。如果是后者,下游的除法运算可能触发除零异常,如果MCU没有硬件除法异常处理,看门狗复位:整台ECU在高速公路上热重启。

这两种情况都不是“代码写错了“,代码在逻辑上正确。但逻辑正确在汽车上不是充分条件。在汽车上,充分条件是逻辑在所有可能的输入下都正确。

防御性编程的改造始于对每一个输入做详尽校验:

Std_ReturnType parse_vehicle_speed(const Can_PduType* pdu, uint16* speed) {
    uint8 dlc;
    uint8 seq_counter;
    uint8 expected_seq;
    uint16 raw_value;

    /* 校验1:DLC必须足够容纳信号定义的最小字节数 */
    dlc = pdu->dlc;
    if (dlc < 2u) {
        return E_NOT_OK;
    }

    /* 校验2:序列计数器必须单调递增(模16)——丢失帧或重复帧立即检测 */
    seq_counter = (pdu->sdu[2] >> 4u) & 0x0Fu;
    expected_seq = (last_seq_counter + 1u) & 0x0Fu;
    if (seq_counter != expected_seq) {
        last_seq_counter = seq_counter;
        return E_NOT_OK;
    }
    last_seq_counter = seq_counter;

    /* 校验3:可选——如果报文带有CRC字节,做CRC校验 */
    if (calculate_crc(pdu->sdu, dlc) != pdu->sdu[dlc - 1u]) {
        return E_NOT_OK;
    }

    /* 现在可以安全地提取数据 */
    raw_value = ((uint16)pdu->sdu[1] << 8) | pdu->sdu[0];
    raw_value &= 0x0FFFu;

    /* 校验4:物理范围检查——车速的物理界限 */
    if (raw_value > 500u) {
        return E_NOT_OK;
    }

    *speed = raw_value;
    return E_OK;
}

多了大约二十行代码。这二十行代码在99.99%的执行时间里做的事情是:验证全部通过,返回E_OK。但在那0.01%的时间里(EMC测试的暗室里、极寒试验的冰湖上、老化试验箱内的持续高温中),这二十行代码就是软件里的防火隔离板。平时感觉不到它的存在,火灾来的那一刻它救你的命。

防御策略的分层

从这段代码可以提炼出防御性编程在嵌入式系统中的四个经典层次:

第一层:格式校验。数据长度对吗?DLC字段指示的字节数够不够覆盖你要解析的信号?一个信号定义在Byte4到Byte5,而接收到的报文的DLC只有3,这就是一个不可能合法的输入,直接丢弃,不解析任何一个比特。

第二层:时序校验。序列计数器递增吗?时间戳在合理范围内吗?CAN报文本身的丢失并不罕见,总线负载过高时低优先级的报文可能被连续仲裁丢失。但如果你在处理车速这样的关键安全信号,你必须知道哪一帧丢了,并且用上一帧的有效数据或默认安全值顶替,而不是静默地跳过。

第三层:完整性校验。CRC对吗?Checksum对吗?如果协议层有E2E保护(End-to-End Protection,AUTOSAR的E2E库提供了Profile 1到Profile 5),你有没有在接收端做E2E校验?E2E做的不是“这个报文能不能读“,CAN控制器已经替你验证了物理层的CRC。E2E做的是“这个报文在应用层的数据路径中有没有被篡改“,包括COM栈的缓冲拷贝错误、RTE的信号映射错位、甚至ECU内部内存的软错误(Soft Error)。

第四层:物理范围校验。这个值的物理意义成立吗?车速不可能大于500km/h,任何量产车的物理极限都在这个值以下。发动机冷却液温度在-40度到+150度之间,超出这个范围的传感器值必然意味着传感器本身故障或信号传输错误。方向盘转角不可能同时是+540度和-540度。物理范围校验是做在软件里的最后一堵墙,它的论据不来自协议规范,不来自编码标准,它来自你对“这个物理世界如何运作“的基本认知。

核心洞察:逻辑正确不是充分条件,充分条件是逻辑在所有可能的输入下都正确。 零下三十度一比特翻转把DLC从8变成0,读到的栈垃圾值可能是看似合理的75km/h(静默错误),也可能触发看门狗复位;多写的二十行校验(格式、时序、完整性、物理范围)在99.99%时间里只是放行,在0.01%的极端时刻就是救命的防火板。


【隐线:防御性代码是为下一个开发者写的】

防御性编程所有的技术手段(输入校验、序列计数、CRC、范围检查、超时保护、看门狗心跳)表面上都是在防御外部世界的恶意和混乱。但实际上,它们防御的还有另一种更微妙的东西:人类认知的局限性。

你写这段CAN解析代码的时候,脑子里装着所有的系统约束。你知道车速信号走CAN ID 0x180,你知道DLC=8,你知道序列计数器在数据域的第3字节的高4位。六个月后,你被调去做另一个项目了,接手这个模块的是刚入职的小张。

小张对着这份代码看了三个小时。他注意到车速信号只用了两个字节(data[0]和data[1]),他觉得其他六个字节是保留的、不重要的。于是在一次新增硬件配置时(OEM要求新增一个“变速器油温“信号),他很自然地把data[3]分配给了这个新信号。他不知道data[2]的高4位已经被序列计数器占用了。

如果你的原始代码里已经有了对序列计数器的显式解析和注释,如果你用防御性的写法把每一个字节的含义都明确声明出来,小张在修改之前至少会看到那段代码。他至少会停下来问自己一个问题:data[2]是不是已经有用了?

防御性代码的外溢效应是:它让代码自身的意图变得透明,让下一个开发者不会踩进你留的空隙里。

这是防御性编程最深层的本质:它是写代码的人,为六个月后、三年后、可能完全不认识的另一个人,在代码里留下的一根安全绳。你写的这个校验不是为你自己。你不需要它,因为你记得所有的约束。它是为那个凌晨三点被叫醒、因为产线上报“车速信号偶发异常“而被要求紧急排查的工程师。那个人打开你的代码的时候,你留下的每一条范围检查、每一个DLC判断、每一个错误返回码,都是他在黑暗中摸索时摸到的扶手。

在汽车嵌入式这个领域,一份代码的生命周期可能长达十年以上。一款2023年量产的ECU,可能在2033年仍然在一款换代车型上运行,因为OEM的模具线还在、BOM成本核算还能过、功能安全认证还在有效期。你写下的每一行校验逻辑,都可能在十年中的某个深夜,面对某个难以复现的现场故障,帮一个你从未见过的工程师免于一场崩溃。

核心洞察:防御性代码是写给未来开发者的安全绳。 六个月后的小张不会记得车速信号占了哪两个字节,你的显式校验和注释让他至少停下来问“data[2]是不是已经有用了“;ECU代码生命周期可超十年,你留下的每一条范围检查,都是十年后某个深夜排查现场故障的工程师摸到的扶手。


【assert()在嵌入式里的生死抉择】

防御性编程中有一个经久不衰的争论:assert()在嵌入式产品代码中应该留下还是去掉?

标准C库assert()的典型行为是:如果条件为假,打印一条错误消息到标准错误输出,然后调用abort()终止程序。这在桌面开发中很有用:单元测试跑了、assert触发了、开发者看到日志、修bug。但在嵌入式环境(尤其是已经装车、行驶中的ECU),abort()意味着什么?意味着看门狗没有喂、系统复位、ECU宕机。如果你控制的是电动助力转向,意味着驾驶员的手上突然失去了助力,方向盘瞬间变重。

所以很多项目选择在产品构建时用编译选项-DNDEBUG把所有assert()全部编译掉。这个做法的逻辑是:assert是用来在开发阶段抓bug的,产品代码里不应该有“故意崩溃“的逻辑。但这里有一个容易被忽略的危险:如果assert被编译掉了,那么assert括号里的条件判断也跟着消失了。如果这个条件在真实产品环境下真的触发了,谁来接住它?

考虑这段代码:

uint16 speed;
parse_vehicle_speed(&pdu, &speed);
assert(speed <= 500u);

在开发阶段,如果speed的解析出了问题导致值大于500,assert触发,debugger停住,你看到了问题。但产品构建中,-DNDEBUG把assert(speed <= 500u)变成了空气,speed未经校验直接流入下游。那个在开发阶段被assert接住的非法车速值,在产品阶段穿透了所有防线,进入了控制算法。

AUTOSAR对这个问题的答案是Det(Development Error Tracer)。Det的思路是:不崩溃,只报告。API发现参数非法时,调用Det_ReportError()把错误信息记入诊断日志,然后返回错误码(通常是E_NOT_OK),让调用者决定如何处理。调用者可以选择降级运行、可以用默认值替代、可以进入安全状态,不管怎么做,都比整个ECU复位好。

这是一个成熟的权衡:开发阶段Det的日志帮工程师快速定位问题(“你在调用Com_SendSignal的时候传了一个null指针”);产品阶段Det只记录不崩溃,系统继续运行。但代价是:必须有人看日志。如果装了Det但是没有定期提取诊断日志、没有在4S店的定期保养时读出事件存储器中的故障码,那就和没装一样。

还有一种介于两者之间的实用策略:运行时合理性检查(Runtime Sanity Check)。只在关键的信号入口处做检查(CAN消息解析、传感器数据读取、NVM标定参数加载),并且做优雅降级而不是崩溃。不是编译时全局砍掉所有断言,也不是每个断言都打日志。比如上一章车速解析的例子:如果一帧数据校验失败,不使用它,用上一帧的有效数据顶替;连续N帧失败,才上报故障,让系统进入降级安全状态。

核心洞察:断言是删是留,答案是“不崩溃、只报告“的成熟权衡。 产品构建用 -DNDEBUG 会把assert括号里的条件也删掉,非法值穿透防线进入控制算法;AUTOSAR的Det只记录错误、返回 E_NOT_OK 让调用者降级,但代价是必须有人看日志;务实做法是对关键入口做运行时合理性检查并优雅降级。


建筑的防火墙——每间公寓一间防火墙

回到本章开头的格伦费尔塔火灾。火灾之后,英国的建筑法规进行了彻底修订:高层建筑的外墙必须使用不可燃材料,层与层之间的防火隔离必须能抵抗两小时以上的火焰穿透。这些规定的本质,和防御性编程完全一样:确保灾难不蔓延,而不是期望灾难不发生。每一户人家都可能着火,但整栋楼不会因为一户的火灾而倒塌。

软件的防火隔离也有同样严格的结构设计。AUTOSAR OS的内存保护(Memory Protection)功能允许不同的OS-Application运行在独立的内存保护域中。一个SW-C的野指针不能踩到另一个SW-C的数据段。一个栈溢出不会写穿到另一个OS-Application管理的DMA缓冲区。这是操作系统级别的防火隔离:用MMU/MPU的硬件支持把进程级的错误封锁在进程内。

同样的,AUTOSAR的End-to-End(E2E)保护做的不是物理层的校验(信号传输的CRC有CAN控制器保证),E2E做的是应用层数据路径的“穿越校验“。一个信号从发送端的SW-C出发,穿过RTE、COM、PduR、CanTp、CanIf、CAN Driver,通过物理总线,经由接收端的对称路径,最终到达接收端的SW-C。在这条漫长的路径上,任何一个环节的缓存错误、RTE映射表的配置错误、COM信号布局的偏移,都可能让最终的值产生错误。E2E在发送端计算一个额外的CRC和计数器,封装在报文中;接收端重新计算并比较。如果不对,这个报文就是被“火灾“污染了,直接丢弃。不蔓延到接收SW-C的控制逻辑中。

核心洞察:防御的最终形态是“失而不溃“:单层可能被突破,但纵深让单点失效极难引发连锁崩塌。 OS内存保护把野指针锁在进程内,E2E在应用层数据路径上做穿越校验、对污染报文直接丢弃;每家都可能着火,但整栋楼不会因一户的火灾倒塌。

给入口加一道防线

打开你手上那个最近调试通过的模块。找到它接收外部输入的那几个函数入口,不管是CAN消息、SPI传感器数据、串口AT指令、还是从Flash里读出的标定参数。数一数,在开始使用这些数据之前,你做了几步校验?

如果答案是“零“,不要在代码里加注释说“这个数据应该是合法的“。你比谁都清楚,“应该“两个字不曾在任何一条CAN总线上产生过任何一个比特。物理世界不认“应该”,只认“是什么“。

今天,就给那个入口函数加一个检查。只加一个。范围检查就行。输入速度值,检查它是否在0到500之间。就这一个改动,你就可以对自己说:我在这个模块的门口铺了一块防火板。

现在是晚上十一点半。你刚加完那个检查。代码编译通过了。你可以关机下班了。今晚你不会被电话惊醒。至少不是因为那个函数。


本篇小结

  • 防御性编程是为“未来某个不确定的异常时刻“提前缴纳的保险:它不创造新功能,不提升性能指标,不缩短上市时间,但它保护现有功能在异常条件下不被破坏。
  • 防御的层次:在汽车嵌入式软件中,防御从最底层的硬件(EMC防护、独立看门狗、内存保护单元)延伸到软件(输入校验、E2E保护、序列计数器、超时检测),再延伸到系统架构(错误隔离域、降级运行策略、安全状态定义)。
  • 每一层都是一道防火墙:单独一层都可能被突破,但防御的深度让单点失效极难引发连锁崩塌。
  • 防御性编程在汽车上的最终形态:失而不溃,而不求万无一失。

【下集预告】:防御性编程告诉你“每个函数的入口都要设防“,但谁来定义“防“的规则?一行代码怎么写在汽车行业里是不能由工程师个人决定的:一个叫MISRA C的编码规范划了一条红线,告诉你C语言里有哪几十种用法是“绝对禁止“的。同时,一个叫ASPICE的过程标准划了另一条红线:你的开发过程必须有证据。编写代码的门禁和开发流程的门禁,两把锁扣住了汽车软件安全性的大半风险。