可编程故障注入:让“异常输入”成为测试资产

正常路况跑一万公里、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,不碰平台代码。这就是“可编程”——故障行为成了数据,而不是代码。(示例从简:字段级冻结这类带参数动作的完整写法见第七节。)

三、为什么值得做成可编程

  1. 故障场景变成测试资产:可以进 git、可以评审、可以复用、可以进夜间回归套件——和正常激励一样,都是 YAML 编排;
  2. 和功能安全证据链对齐:ISO 26262 Part 6 的软件测试方法表把故障注入测试列为 ASIL C/D 的推荐方法——验证安全机制是否真的有效,比如 E2E 保护发现 CRC 错误后 SUT 是否进入安全态。每条 YAML 故障规则 + verdict 结果,就是一条可追溯的安全验证证据;
  3. 可以和正常激励叠加出复杂场景:正常激励驱动 SUT 走向危险工况(目标逼近),同时在关键帧注入异常(恰好此刻丢 3 帧)——“SUT 在即将决策的瞬间瞎了 300ms 会怎样”。这种组合场景只有可编程才做得出。

一句话:异常输入是 SUT 的“压力测试题”,可编程是让这些题可以像普通用例一样被声明、存储、复用和回放——故障注入从“一次性的代码 hack”变成平台的一等公民能力。 下面看一个可编程故障注入引擎的落地设计。

四、放在哪里:适配器装饰器

核心思路一句话:故障注入不是一个新模块,而是给总线适配器(IBusAdapter)的数据通路加一层“规则管线”(FaultPipe)——规则从 YAML DSL 加载,由 Time Master 的 tick 驱动,全程遵守实时路径的 C++ 纪律。

不要让故障逻辑长进 SocketCAN 实现里。用装饰器包住现有适配器,注入点对业务透明:

┌──────────────┐   ┌────────────┐   ┌────────────────┐   ┌─────┐
│ YAML 规则    │──▶│ FaultPipe  │──▶│ IBusAdapter    │──▶│ SUT │
│ (加载期解析) │   │ (规则管线) │   │ (SocketCAN 等) │   │     │
└──────────────┘   └────────────┘   └────────────────┘   └─────┘
                          ▲
                  Time Master tick

五、核心数据结构: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) 预先固化的标准答案文件,输出与它逐字节比对
故障列车 按剧本交替“故障/正常”的序列化故障模式,用来模拟间歇性故障

下一步:

查看全部文章