可编程故障注入:让“异常输入”成为测试资产
正常路况跑一万公里、AEB 一次误刹都没有,并不能证明什么——功能安全真正关心的问题全藏在异常里:雷达帧丢了 300ms、车速信号冻结成旧值、CRC 突然对不上……这些输入偶发、危险,又几乎不可能在测试时自然等到。故障注入(fault injection)是主动制造它们的手段,而“可编程”决定它是一次性的手艺活,还是能进 git、进回归套件的测试资产。这篇讲两件事:为什么故障注入值得做成可编程的,以及一个可编程的故障注入引擎长什么样。
一、“异常输入”是什么
异常输入 = SUT 在真实世界中可能收到、但测试时很难自然复现的那些坏输入。按层分类:
| 层 | 典型异常 | 真实世界来源 |
|---|---|---|
| 数据层 | 野值、超量程值、NaN、负数车速 | 传感器故障、标定错误 |
| 通信层 | 丢帧、重复帧、乱序、bit 翻转、CRC 错误 | 总线干扰、网关故障 |
| 时间层 | 延迟、抖动、超时、周期漂移 | 发送方 ECU 负载过高、重启 |
| 协议层 | 滚动计数器(rolling counter)不递增、消息长度错误 | 对端软件 bug |
| 状态层 | 诊断会话乱跳、SecurityAccess 暴力尝试 | 异常诊断仪、攻击 |
这类输入的共同特点:偶发、危险、正是功能安全最关心的东西。AEB 在正常输入下表现好不算本事,收到“距离 = 500m 但 3 帧后变成 5m”时会不会误刹,才是 ISO 26262 要验证的。
二、“可编程”是什么
对比两种做法。
不可编程(硬编码)的做法——故障逻辑写死在 C++ 代码里:
// 想测丢帧?改代码、重新编译、跑完再改回来
void onCanFrame(const CanFrame& f) {
static int cnt = 0;
if (f.id == 0x123 && ++cnt >= 100 && cnt <= 102) return; // 丢 3 帧
forward(f);
}
问题很明显:每换一个故障场景就要改平台代码、重新编译,像老式电话接线员一样趴回代码里重新插拔一次;故障逻辑和业务逻辑搅在一起;测试资产无法沉淀——下次项目想用,得翻 git 历史找那段 if。
可编程的做法——故障是声明出来的规则,平台提供引擎去解释执行:
# 测试用例里声明,不写一行 C++
faults:
- name: radar_track_dropout
at: 1000ms # 何时注入
duration: 300ms # 持续多久
channel: can0
match: { id: "0x2A0..0x2AF" } # 对哪些帧生效
action: drop # 动作:丢弃
- name: speed_freeze
at: 2000ms
duration: 500ms
channel: can0
match: { id: "0x123" }
action: freeze # 冻结 vehicle_speed 字段,制造“陈旧数据”
平台侧只有一个通用的故障执行引擎:规则匹配 → 按时刻触发 → 对数据流施加动作。测试工程师加新场景 = 写 YAML,不碰平台代码。这就是“可编程”——故障行为成了数据,而不是代码。(示例从简:字段级冻结这类带参数动作的完整写法见第七节。)
三、为什么值得做成可编程
- 故障场景变成测试资产:可以进 git、可以评审、可以复用、可以进夜间回归套件——和正常激励一样,都是 YAML 编排;
- 和功能安全证据链对齐:ISO 26262 Part 6 的软件测试方法表把故障注入测试列为 ASIL C/D 的推荐方法——验证安全机制是否真的有效,比如 E2E 保护发现 CRC 错误后 SUT 是否进入安全态。每条 YAML 故障规则 + verdict 结果,就是一条可追溯的安全验证证据;
- 可以和正常激励叠加出复杂场景:正常激励驱动 SUT 走向危险工况(目标逼近),同时在关键帧注入异常(恰好此刻丢 3 帧)——“SUT 在即将决策的瞬间瞎了 300ms 会怎样”。这种组合场景只有可编程才做得出。
一句话:异常输入是 SUT 的“压力测试题”,可编程是让这些题可以像普通用例一样被声明、存储、复用和回放——故障注入从“一次性的代码 hack”变成平台的一等公民能力。 下面看一个可编程故障注入引擎的落地设计。
四、放在哪里:适配器装饰器
核心思路一句话:故障注入不是一个新模块,而是给总线适配器(IBusAdapter)的数据通路加一层“规则管线”(FaultPipe)——规则从 YAML DSL 加载,由 Time Master 的 tick 驱动,全程遵守实时路径的 C++ 纪律。
不要让故障逻辑长进 SocketCAN 实现里。用装饰器包住现有适配器,注入点对业务透明:
┌──────────────┐ ┌────────────┐ ┌────────────────┐ ┌─────┐
│ YAML 规则 │──▶│ FaultPipe │──▶│ IBusAdapter │──▶│ SUT │
│ (加载期解析) │ │ (规则管线) │ │ (SocketCAN 等) │ │ │
└──────────────┘ └────────────┘ └────────────────┘ └─────┘
▲
Time Master tick
- RX 方向(总线 → SUT):在这里注入,就是给 SUT 喂异常输入(丢帧/野值/超时);
- TX 方向(SUT → 总线):在这里注入,是篡改 SUT 的输出,用来验证下游的防护机制(比如接收端 E2E 校验能不能抓住 CRC 错)。
五、核心数据结构:variant + 预分配
动作集合是封闭的,用 std::variant;规则容器定长,加载期一次性填满——RT 路径零堆分配:
// fault_rule.h —— 只放 POD-ish 结构,不含任何会分配的成员
struct Drop {};
struct Delay { uint32_t delay_ticks; };
struct CorruptField { uint8_t byte_offset; uint8_t mask; uint8_t value; }; // 简版
struct Freeze {}; // 冻结字段为上一次值
struct CorruptCrc {};
using FaultAction = std::variant<Drop, Delay, CorruptField, Freeze, CorruptCrc>;
struct FaultRule {
uint32_t id;
const char* name; // 指向加载期分配的字符串池,生命周期覆盖整个 run
uint8_t channel_mask; // bit0 = can0, bit1 = can1...
uint32_t id_min, id_max;// 帧 ID 过滤区间
Tick start_tick; // Time Master 的 tick 计数
Tick end_tick;
FaultAction action;
// 可变运行状态(Freeze 的上次值、Delay 的待发队列指针等)
// 每个 run 开始时 reset,保证可复现
bool active(Tick now) const { return now >= start_tick && now < end_tick; }
};
class FaultPipe {
std::array<FaultRule, kMaxRules> rules_{}; // kMaxRules = 64 之类
size_t rule_count_ = 0;
RingBuffer<PendingFrame, 256> delayed_; // Delay 动作的预分配环形缓冲
public:
std::optional<CanFrame> process(const CanFrame& f, Tick now);
void releaseDelayed(Tick now, FrameSink& out); // 每 tick 释放到期的延迟帧
void resetAll(); // run 之间清状态
};
关键点:YAML 解析发生在加载期(那时堆分配无所谓),解析完把规则拷进定长数组,字符串进字符串池。进入仿真循环后,这条路线上没有任何 new。
动作库先实现四五个最常用的就够:drop(丢)、delay(延迟)、corrupt_field(改值)、freeze(冻结成旧值)、corrupt_crc(制造 CRC 错)——这五个能覆盖功能安全验证里 80% 的故障注入需求。
六、运行时:挂在 tick 上的三段式
单线程 1ms 调度器让每个 tick 的处理顺序严格确定:
// 每个 tick 内,调度器按固定顺序执行:
void SimLoop::tick(Tick now) {
faultPipe_.releaseDelayed(now, busSink_); // 1. 先释放到期的延迟帧
replayEngine_.emit(now, busSink_); // 2. CSV 正常激励(经过 FaultPipe)
adapters_.pollAll(); // 3. 收真实总线帧(经过 FaultPipe)
verdict_.evaluate(now); // 4. 判定
}
// FaultPipe::process 是纯匹配管线,按规则声明顺序逐条匹配:
std::optional<CanFrame> FaultPipe::process(const CanFrame& f, Tick now) {
for (size_t i = 0; i < rule_count_; ++i) {
auto& r = rules_[i];
if (!r.active(now) || !matches(r, f)) continue;
logFaultEvent(r, f, now); // 关键:每次命中都进证据链
return apply(r, f, now); // Drop → nullopt;Delay → 入环形缓冲
}
return f; // 无命中,原样透传
}
“三段式”指故障管线在每个 tick 里的三个挂点(第 1–3 步:释放延迟帧、回放激励、收真实总线帧,都经过 FaultPipe);第 4 步判定在管线之后单独执行,不参与数据通路。
为什么顺序固定重要:同一帧可能同时命中“丢”和“改值”两条规则,谁先谁后必须有铁的规定(按 YAML 声明顺序)——否则两次运行行为分叉,bitwise 可复现就破了。
七、YAML DSL:故障和正常激励住在同一个用例文件里
test_case: aeb_radar_dropout
duration: 5000ms
stimulus: # 正常激励
- replay: scenarios/aeb_approach.csv
channel: can0
faults: # 异常输入,可编程
- name: radar_dropout
at: 1000ms
duration: 300ms
channel: can0
match: { id: "0x2A0..0x2AF" }
action: drop
- name: speed_freeze
at: 2000ms
duration: 500ms
channel: can0
match: { id: "0x123" }
action: freeze
verdict: # 判定照常
- expect: brake_request == true
by: 2500ms
- expect: dtc_reported == true # 丢帧 300ms 应触发超时诊断
by: 2000ms
测试工程师加场景 = 加几行 YAML,平台代码零改动。这就是“可编程”的最终形态。
八、证据链:注入事件本身必须可观测
这点很多人会漏——故障注入没发生,verdict 过了也不算数(比如规则的 ID 区间写错了,帧根本没被丢,测试白跑)。所以每次规则命中都发一条 FaultEvent 上总线日志(FlatBuffers):
// 记录:哪个 tick、哪条规则、对哪帧、做了什么动作
FaultEvent{ now, r.id, f.id, actionTag(r.action) }
然后 verdict 引擎支持一类新断言:
verdict:
- expect: fault_injected(radar_dropout) # 确认故障真的打进去了
- expect: fault_hit_count(radar_dropout) >= 240 # 300ms 内应丢约 240 帧
JUnit XML 报告里把注入统计写进 <properties>,审阅报告的人一眼能看到“这个用例的故障确实生效了”。这正是 ISO 26262 故障注入测试要的证据形态。
九、故障注入器本身也要被测
注入器是安全验证工具的组成部分,将来可能被评定为 TCL3 级工具——它的错误输出会直接污染安全证据,还不容易被发现。所以要给它配单元测试:
TEST_F(FaultPipeTest, DropRuleSuppressesMatchingFramesOnly) {
// 夹具:固定假时钟 + 3 条规则 + 20 帧黄金输入序列
// 断言:输出序列与黄金文件逐字节一致
}
每个动作类型一个夹具,输入输出都是黄金帧序列——注入器是确定性纯逻辑,天然好测。
十、落地顺序建议
| 阶段 | 内容 | 理由 |
|---|---|---|
| P0 | drop + delay 两个动作 + FaultEvent 日志 |
结构跑通,覆盖最常用的丢帧/超时场景 |
| P1 | corrupt_field + freeze + corrupt_crc |
数据层异常,配 E2E 验证场景 |
| P2 | RX/TX 双向注入点 + verdict 的 fault_injected 断言 |
证据链闭环 |
| P3 | 序列化故障(“丢 3 帧 → 正常 5 帧 → 再丢 2 帧”这种故障列车) | 覆盖间歇性故障场景 |
P0 两三天就能跑起来:tick、适配器、YAML、日志总线这些基础设施在成熟的 XiL 平台里都是现成的,FaultPipe 只是把它们串起来的一层纯逻辑。
回看这条链:“异常输入可编程” = 定长规则数组 + variant 动作 + tick 驱动的匹配管线 + YAML 声明 + FaultEvent 证据。 平台代码写一次,故障场景从此是数据——进 git、进评审、进夜间回归,和正常激励平起平坐。
附:名词解释(按出场顺序)
| 名词 | 通俗解释 |
|---|---|
| AEB | 自动紧急制动(Autonomous Emergency Braking),本文反复使用的被测功能例子 |
| 功能安全 | 防止电子电气系统故障造成人身伤害的工程学科;汽车行业的标准是 ISO 26262 |
| 故障注入(fault injection) | 主动向被测系统制造异常输入的测试手段 |
| SUT(被测件) | System Under Test,本次测试瞄准的对象 |
| NaN | Not a Number,浮点数中的“非数”,常来自非法运算或无效传感器数据 |
| 标定(calibration) | 给 ECU 参数(阈值、增益等)赋值的工作;标定错误是数据层异常的典型来源 |
| CRC | 循环冗余校验:报文里用于发现数据被改坏的校验字段 |
| 滚动计数器(rolling counter) | 报文里逐帧递增的防重放计数器;收不到正确递增序列就判数据不可信 |
| SecurityAccess | UDS 诊断里的安全解锁服务(0x27);“暴力尝试”指反复猜密钥的攻击 |
| ISO 26262 | 汽车功能安全标准;ASIL C/D 对故障注入测试有明确推荐 |
| YAML | 人类可读的数据描述格式;本文的用例与故障规则都用它声明 |
| ASIL | 汽车安全完整性等级(QM/A/B/C/D,D 最严) |
| E2E 保护 | AUTOSAR 的端到端通信保护:滚动计数器 + CRC 等机制,保证报文可信 |
| 证据链 | 让判定结论可被第三方复算的一整套记录 |
| verdict(判定) | 测试平台对用例给出的通过/失败结论 |
| 装饰器(decorator) | 不改变原接口、在外面包一层来增加功能的设计模式 |
| SocketCAN | Linux 内核的 CAN 协议栈与驱动框架 |
| Time Master | 平台里统一推进仿真时间的模块 |
| tick | 仿真调度器的最小时间步;本文的调度器 1ms 一拍 |
| std::variant | C++17 的类型安全联合体:封闭的“几种类型取其一” |
| RT 路径(实时路径) | 对延迟和内存分配有硬约束的代码路径 |
| 堆分配 | 运行时向堆申请内存;RT 路径上通常禁止 |
| DSL(领域专用语言) | 为特定领域设计的小型描述语言;这套 YAML 故障规则就是故障注入的 DSL |
| bitwise 可复现 | 两次运行的输出逐比特一致 |
| FlatBuffers | 零拷贝序列化库,这里用作总线日志的编码格式 |
| JUnit XML | 测试结果的通用上报格式,CI 与测试管理工具都认 |
| TCL3 | ISO 26262 的工具置信度等级之一:工具的错误输出可能污染安全证据且不易被发现 |
| 黄金文件(golden file) | 预先固化的标准答案文件,输出与它逐字节比对 |
| 故障列车 | 按剧本交替“故障/正常”的序列化故障模式,用来模拟间歇性故障 |
下一步:
查看全部文章