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

5.3 E2E保护——CRC8+计数器+DataID的消息引擎

场景导入:被篡改的刹车指令

2013年,某汽车品牌的电动助力转向系统在测试中出现了诡异的现象:方向盘转角传感器的CAN报文偶尔携带一个偏移了2.3度的值。这个偏移非常小,在低速行驶时几乎察觉不到,但在高速公路变道场景下,2.3度的累积误差足以让车辆偏离预定轨迹。

事故调查组花了三个月才找到根因:转向电机驱动器的PWM走线距离传感器CAN收发器仅1.2厘米。当电机在特定转速下运行时,PWM电流的di/dt产生的电磁场恰好耦合到CAN信号线上,翻转了传感器报文中特定比特位置的数据。更诡异的是:标准CAN的15-bit CRC完全通过了:因为CRC是在发送端计算的,而比特翻转发生在传输过程中,CRC校验的是“被篡改后的数据“。

这揭示了一个关键事实:底层通信协议的校验(CAN CRC、UART parity、SPI checksum)保护的是链路层,不是端到端(End-to-End)的应用层数据完整性。如果数据在进入发送端的CAN控制器之前就被损坏了,或者安全相关的信号在ECU内部多个SW-C之间经过多次变换后被“隐形修改“,底层的链路纠错机制对此一无所知。

这就是AUTOSAR E2E(端到端通信保护)的诞生背景。今天,你将实现它的教学级简化版。

E2E保护的“三要素“设计

将E2E保护抽象到最精简的形式,它做三件事:

  1. 内容校验(CRC):数据比特是否被意外修改?→ CRC8校验
  2. 身份认证(DataID):这条消息是不是我以为的那条消息?→ 4-bit消息ID
  3. 新鲜度验证(Counter):这条消息是不是一条被重放的旧消息?→ 4-bit递增计数器

这三个要素对应着通信安全的三个基本威胁模型:

威胁攻击者/故障类型E2E应对手段
数据损坏EMI/SEU/电压跌落CRC8
消息伪装SW-C配置错误/路由错误DataID
重放攻击网络中间人/任务卡死后恢复Counter

在教学场景中,我们不区分“安全攻击“和“随机故障“,对于功能安全工程师来说,两者产生的后果相同:安全目标被破坏。E2E保护必须同时抵御两者。

线格式:2字节头部如何编码?

在真实的AUTOSAR E2E Profile 1中,线格式为:

Byte 0: CRC (8 bits)
Byte 1: [Counter(4 bits) | DataID(4 bits)]

safe-lite严格遵循此布局:

/* safe_e2e.h */
typedef struct {
    uint8_t data_id;   /* 0..15,标识消息来源 */
    uint8_t counter;   /* 0..15,每发一条+1,自动回绕 */
} safe_e2e_ctx_t;

为什么是4位计数器? 真实E2E Profile 1使用4位,Profile 5使用32位。对于教学场景:

  • 4位足够展示计数器回绕的逻辑(模16)
  • 4位计数器占用半个字节,与DataID共享一个字节。这是带宽敏感型嵌入式协议的典型做法
  • 对于生产系统,4位显然不足:在100Hz的CAN消息速率下,4位计数器每160ms就回绕一次,接收端必须在这160ms内收到消息才能区分“合法回绕“和“重放“

头部的编解码实现非常直观:

/* 打包:Counter放在高4位,DataID放在低4位 */
uint8_t safe_e2e_build_header_byte(uint8_t counter, uint8_t data_id)
{
    return (uint8_t)(((counter & 0x0FU) << 4) | (data_id & 0x0FU));
}

/* 解包 */
void safe_e2e_parse_header_byte(uint8_t byte, uint8_t *counter, uint8_t *data_id)
{
    *counter = (byte >> 4) & 0x0FU;
    *data_id = byte & 0x0FU;
}

为什么Counter在高位?没有特别的原因,是一个设计惯例。在实际系统中,端序的选择取决于网络字节序约定。这里高位在前纯粹是个人偏好,你有权交换两个nibble的位置,只要收发双方一致即可。

Protect():构建受保护的消息

safe_e2e_protect()是发送端的核心函数。它接收原始数据,输出带有E2E头部的完整帧:

size_t safe_e2e_protect(safe_e2e_ctx_t *ctx,
                        const uint8_t  *data,
                        size_t          len,
                        uint8_t        *out)
{
    /* 1. 递增计数器,模16回绕 */
    ctx->counter = (ctx->counter + 1) & SAFE_E2E_MAX_COUNTER;  /* & 0x0F */

    /* 2. 构建CRC计算缓冲区:[DataID | Counter | payload] */
    uint8_t crc_buf[SAFE_E2E_MAX_DATA_LEN + 2];
    crc_buf[0] = ctx->data_id;
    crc_buf[1] = ctx->counter;
    memcpy(&crc_buf[2], data, len);

    /* 3. 计算CRC8 */
    uint8_t crc = safe_crc8_compute(crc_buf, len + 2);

    /* 4. 组装输出帧 */
    out[0] = crc;
    out[1] = safe_e2e_build_header_byte(ctx->counter, ctx->data_id);
    memcpy(&out[2], data, len);

    return SAFE_E2E_HEADER_SIZE + len;
}

最关键的设计决策:CRC覆盖的是 [DataID | Counter | payload],不是单独的payload。

为什么?

想象一个没有把DataID纳入CRC计算的E2E实现:

  1. 发送方A(DataID=3)发送一条消息到队列
  2. 队列的一次元数据损坏导致DataID字段从3变成7
  3. CRC仍然匹配(因为CRC只覆盖payload,DataID的损坏不影响CRC)
  4. 接收方B(DataID=7)收到这条消息,payload完全正确,它接受了
  5. 灾难:接收方B以为这是来自数据源7(比如刹车踏板位置)的数据,实际上来自数据源3(油门踏板位置)

因此:CRC必须覆盖所有决定消息“含义“的元数据。这是端到端保护与链路层保护的根本区别。

Check():接收端的验证逻辑

接收端的代码更长,因为它需要执行多重校验:

safe_status_t safe_e2e_check(safe_e2e_ctx_t *ctx,
                             const uint8_t  *buf,
                             size_t          total_len,
                             uint8_t        *data_out,
                             size_t         *data_len)
{
    /* 1. 参数校验 */
    if (total_len < SAFE_E2E_HEADER_SIZE) return SAFE_ERROR;

    /* 2. 解包头部 */
    uint8_t rx_crc     = buf[0];
    uint8_t rx_counter = 0, rx_dataid = 0;
    safe_e2e_parse_header_byte(buf[1], &rx_counter, &rx_dataid);

    size_t payload_len = total_len - SAFE_E2E_HEADER_SIZE;

    /* 3. 重新计算CRC */
    uint8_t crc_buf[SAFE_E2E_MAX_DATA_LEN + 2];
    crc_buf[0] = rx_dataid;
    crc_buf[1] = rx_counter;
    memcpy(&crc_buf[2], &buf[2], payload_len);
    uint8_t computed_crc = safe_crc8_compute(crc_buf, payload_len + 2);

    if (computed_crc != rx_crc) {
        return SAFE_CRC_MISMATCH;  /* 比特错误或EMI干扰 */
    }

    /* 4. 验证DataID */
    if (rx_dataid != ctx->data_id) {
        return SAFE_DATAID_MISMATCH;  /* 配置错误或路由错误 */
    }

    /* 5. 验证计数器新鲜度 */
    uint8_t diff = (rx_counter - ctx->counter) & SAFE_E2E_MAX_COUNTER;
    if (diff == 0 || diff > 8) {
        return SAFE_COUNTER_ERROR;  /* 重复或重放 */
    }

    /* 6. 接受消息 */
    ctx->counter = rx_counter;
    memcpy(data_out, &buf[2], payload_len);
    *data_len = payload_len;
    return SAFE_OK;
}

注意检查顺序:CRC → DataID → Counter。这个顺序不是随意的,它遵循“最轻量检查优先“的原则。CRC检查是最先进行的,因为如果CRC不对,后面的检查毫无意义(数据本身就是损坏的)。而Counter检查应该放在最后,因为Counter更新会修改接收方的状态,如果消息因为CRC或DataID原因被拒绝,Counter必须保持不变。

Counter窗口验证:模16环上的“向前看“

这是整个E2E实现中最精妙的部分:

uint8_t diff = (rx_counter - ctx->counter) & SAFE_E2E_MAX_COUNTER;
if (diff == 0 || diff > 8) {
    return SAFE_COUNTER_ERROR;
}

这条语句需要深入的数论视角来理解。考虑4位计数器的取值范围0-15,排列在模16的循环群上:

... → 13 → 14 → 15 → 0 → 1 → 2 → 3 → ...

窗口大小为8(模16的一半)意味着什么呢?让我们用具体数值解释:

情况1:计数器正常递增

  • 上次收到counter=5,这次收到counter=7
  • diff = (7-5) & 0x0F = 2 → 1 ≤ 2 ≤ 8 → 合法

情况2:计数器回绕

  • 上次收到counter=14,这次收到counter=2
  • 发送方发生了:14→15→0→1→2 的回绕(中间丢失了一些消息)
  • diff = (2-14) & 0x0F = (2+2) & 0x0F = 4 → 1 ≤ 4 ≤ 8 → 合法

情况3:重复消息(重放)

  • 上次收到counter=8,这次收到counter=8
  • diff = (8-8) & 0x0F = 0 → diff == 0 → 拒绝

情况4:严重回退(疑似重放)

  • 上次收到counter=10,这次收到counter=3
  • diff = (3-10) & 0x0F = 9 → 9 > 8 → 拒绝

为什么窗口大小选择8(half range)而不是14或2?

这来自于最大可容忍的连续丢包数的假设。如果窗口是8,你允许最多丢掉7条消息(发送了8条新消息只收到1条,这在快速总线上是合理的)。如果窗口是14,你几乎允许任意回绕,丢失15条消息仍被接受,这意味着系统在近两个完整的计数器周期中都没有收到任何有效消息,这通常是网络断开而不是正常丢包。如果窗口是2,任何轻微的突发丢包都会导致计数器错误。

在生产系统中,这个窗口的大小是可配置的,并通过FMEA分析确定。如果你正在设计一个ASIL D系统,你需要回答:在FTTI时间窗内,最坏情况下可能丢失多少条消息?如果网络负载和重传机制保证丢包不超过N条,窗口至少为N+1。

CRC8多项式:0x1D的背后

safe-lite使用CRC-8/AUTOSAR多项式(0x1D,即x^8 + x^4 + x^3 + x^2 + 1)。选择这个多项式不是随意的:它在HD(汉明距离)和计算复杂度之间有经过验证的平衡。

0x1D的Hamming Distance:

  • 对于 ≤ 119位的数据块,HD=4(可检测任意3比特错误)
  • 这涵盖了safe-lite的典型使用场景(≤64字节 payload + 2字节元数据 = ≤530位)

CRC计算通过256条目的查找表实现:

const uint8_t safe_crc8_table[256] = {
    0x00, 0x1D, 0x3A, 0x27, 0x74, 0x69, 0x4E, 0x53,
    /* ... 共256个条目 ... */
    0x97, 0x8A, 0xAD, 0xB0, 0xE3, 0xFE, 0xD9, 0xC4,
};

uint8_t safe_crc8_compute(const uint8_t *data, size_t len)
{
    uint8_t crc = 0x00;
    for (size_t i = 0; i < len; i++) {
        crc = safe_crc8_table[crc ^ data[i]];
    }
    return crc;
}

查找表方法在嵌入式系统中是标准做法,256字节的ROM开销换取每个字节1次查表+1次XOR的计算成本,比逐位计算快约8倍。在Cortex-M/R系列上,这是约6个CPU周期/字节。

测试验证:完整的状态空间

safe-lite的E2E测试覆盖了三个正常场景和两个故障场景:

测试A:正常收发

TX counter: 1 → 发送 "Hello Safety!"
RX counter: 1 → 接收  "Hello Safety!"  ✓

测试B:CRC损坏检测

原始帧: B8 42 AA BB CC DD
翻转bit 3 of payload byte 1 (AA→B3):
损坏帧: B8 42 AA B3 CC DD
验证结果: CRC_MISMATCH ✓

测试C:计数器错误

正常收发后: TX=8, RX=8
注入故障: 复位TX counter → TX=0
发送新消息: counter=1
RX检查: diff=(1-8)&0x0F=9 > 8 → COUNTER_ERROR ✓

测试D:DataID不匹配

TX DataID=4, RX期望DataID=9
→ DATAID_MISMATCH ✓

每个故障场景都验证了一个独立的安全属性。这个分离是刻意的,它使得任何一个测试失败时,你可以立即判断是哪个验证规则出了问题。

E2E保护揭示了一个深层设计原则:通信安全取决于“你以为什么时候、从谁那里、收到了什么“这三个维度的完整性。很多自研通信协议只做了第一步(CRC校验数据),忽略了后两步(验证数据源和新鲜度)。这在单ECU系统中也许可接受,但在由几十个ECU组成的分布式安全关键系统中,一个错误的DataID意味着你的ABS控制器正在根据座椅位置数据做制动决策。

本篇小结

  1. E2E 保护三要素——CRC8(内容完整性)、DataID(消息身份)、Counter(新鲜度)——对应三种基本威胁模型:数据损坏、消息伪装、重放攻击。
  2. CRC 必须覆盖 DataID + Counter + payload 三者,而非仅 payload;否则 DataID 被篡改时 CRC 仍然匹配,接收方可能将错误来源的数据当作有效消息。
  3. Counter 窗口验证使用 (rx_counter - last_counter) & 0x0F 计算模 16 环上的差值,窗口大小为 8(half range),在丢包容忍与防重放之间取得平衡。
  4. Protect() 先递增计数器再计算 CRC;Check() 遵循最轻量优先顺序:CRC → DataID → Counter,Counter 更新放在最后以保持接收方状态一致性。
  5. CRC-8/AUTOSAR(多项式 0x1D)通过 256 条目查找表实现,对 ≤119 位数据 HD=4,可检测任意 3 比特错误。

【下集预告】: E2E 保护了消息在传输中的完整性,但消息到达后的“住所“安全吗?下一节构建带自检能力的安全环形缓冲区——每个条目都携带魔数(0xDEADBEEF)和独立 CRC,入队即签发“护照“,出队先验证再放行。如果队列的 head 指针被野指针破坏,validate 函数会如何察觉?答案藏在一个有趣的设计里:check 的顺序不是随意的,先验身份再验内容,和 E2E 的检查顺序刚好相反。