Chapter 16 · CAN 总线 / 汽车电子

实战:多节点车身网络

全书收官。我们把前十五章——物理层差分信号、bxCAN 收发、仲裁、FreeRTOS 任务、DBC、UDS 诊断、网关——全部串起来,搭建一个真实的三节点车身网络:仪表、车门、网关。从 ID 分配、节点固件、DBC 定义,到 python 上位机监控与故障注入测试,走完一次完整工程闭环。

课程进度100%

关键概念

车身网络 (Body CAN)
整车 CAN 网络按功能分域:动力域(发动机/变速箱,高速 CAN 500k)、底盘域(ABS/ESP)、车身域 (Body)(车灯/车窗/门锁/雨刮,常用 125k~500k)、信息娱乐域。本章的仪表/车门/网关三节点组成一个精简的车身网络,运行在 500 kbps 高速 CAN 上,麻雀虽小五脏俱全。
仪表节点 (Cluster / DASH)
典型的"纯接收+显示"节点:订阅总线上的车速、转速、门状态等报文,解码后驱动屏幕/指针/指示灯。它一般不主动发控制报文,但会周期上报自身状态(如仪表在线心跳)供网关监控。是网络中的信息汇聚点。
车门节点 (Door Module)
车门控制器,负责车窗升降电机、门锁执行器、车门 SW(按键/微动开关)。它既发送——把本地按键、车窗位置、门开合状态周期或事件上报;也接收——响应中控锁总线命令。是典型的"传感器+执行器"混合节点。
网关节点 (Gateway)
网络的"路由器+外交官"。对内做报文转发/路由(如把车门状态转发给仪表所在的另一子网),对外提供统一的 OBD/UDS 诊断入口——诊断仪只连网关,由它代理访问各子节点的 DID 与故障码。它也是网络管理(唤醒/休眠)与信息安全的守门人。
周期报文 vs 事件报文
周期报文:固定间隔发送(如车速 20ms、心跳 100ms),接收方靠超时判断节点是否离线,占用可预测带宽。事件报文:仅在状态变化时触发(如按下车窗键、门被打开),响应快、平时零占用,但需配合"至少每 N ms 发一次"的最小周期防丢失。真实网络两者混用。
ID 分配与总线负载
CAN ID 越小优先级越高,需按报文实时性分配:安全/控制类给小 ID,状态/诊断类给大 ID。总线负载 = Σ(每条报文比特数 × 频率) / 波特率。一帧 8 字节标准帧约 111 位(含填充位),设计时应把峰值负载控制在 50~70% 以下,留出仲裁与突发余量。
读取 DID (UDS 0x22)
ReadDataByIdentifier,UDS 最常用服务之一。用一个 16 位 DID(Data Identifier)标识一项数据(软件版本、序列号、传感器实时值等),请求 22 F1 90 读 DID 0xF190,ECU 返回 62 F1 90 <data>。网关据此把各节点状态暴露给诊断仪,是产线检测与售后诊断的基础手段。
故障注入测试 (Fault Injection)
主动制造异常验证系统鲁棒性的测试方法:拔掉某节点看接收方是否报"超时/丢失"、注入错误 CRC 帧看是否被丢弃、发送非法信号值看是否被范围钳位、拉低总线制造错误帧看错误计数与 Bus-Off 恢复。上车前必做——它验证的是"出错时的行为",而非"正常时的行为"。

网络拓扑与 ID 分配

三节点挂在同一条 500 kbps 高速 CAN 总线上,两端 120Ω 终端电阻。下图是拓扑与报文/ID 规划:

三节点车身网络拓扑(500 kbps,双绞线 + 120Ω 终端) ═══════════════════════════════════════════════════════════════ 120Ω 120Ω ┴ ┴ ├──────────────┬──────────────────┬──────────────────┤ │ CAN_H/CAN_L │ │ │ ┌─┴──────────┐ ┌─┴──────────────┐ ┌─┴──────────────┐ │ DASH 仪表 │ │ DOOR 车门 │ │ GATEWAY 网关 │ │ STM32+bxCAN│ │ STM32+bxCAN │ │ STM32+bxCAN │ │ 只收+显示 │ │ 收发(按键/锁) │ │ 转发+UDS诊断 │ └────────────┘ └────────────────┘ └────────────────┘ 报文 / ID 分配表 ────────────────────────────────────────────────────────────── ID(hex) 报文名 发送者 类型 周期 DLC 0x100 EngineData GATEWAY 周期 20ms 8 车速/转速/水温 0x110 DashHeartbeat DASH 周期 100ms 2 仪表心跳/在线 0x200 DoorStatus DOOR 周期 100ms 3 门锁/车窗/门开合 0x201 DoorButtonEvent DOOR 事件 变化即发 2 按键动作(高优先) 0x300 LockCommand GATEWAY 事件 变化即发 1 中控锁命令 0x7E0 UDS 诊断请求 Tester 事件 — 8 诊断仪→网关 0x7E8 UDS 诊断响应 GATEWAY 事件 — 8 网关→诊断仪
总线负载估算

标准帧 8 字节约 111 位。周期报文合计:EngineData 8B@50Hz + DashHeartbeat 2B@10Hz + DoorStatus 3B@10Hz ≈ (111×50 + 60×10 + 76×10) ≈ 6910 bit/s… 实际按最坏 111 位计约 (50+10+10)×111 = 7770 bit/s。相对 500000 bit/s 的负载仅约 1.6%,加上事件报文与诊断突发仍远低于 50% 安全线——本网络实时性充裕。真实整车几十上百条报文才需要认真核算负载。

车门节点:状态上报 + 事件报文

车门节点在 FreeRTOS 下跑两个任务:一个 100ms 周期上报 DoorStatus,一个扫描按键、变化时立即发 DoorButtonEvent(高优先级 ID 0x201)。

/* door_node.c — 车门节点:周期状态 + 事件按键上报 (STM32 HAL + FreeRTOS) */
#include "stm32f4xx_hal.h"
#include "FreeRTOS.h"
#include "task.h"

extern CAN_HandleTypeDef hcan1;

/* 本地车门状态 */
static volatile uint8_t g_lock_state  = 0;   /* 0=解锁 1=上锁 */
static volatile uint8_t g_window_pos  = 0;   /* 0~100 % */
static volatile uint8_t g_door_open   = 0;   /* 0=关 1=开 */

/* 发送一帧标准数据帧 */
static void can_send(uint32_t id, uint8_t *data, uint8_t dlc) {
    CAN_TxHeaderTypeDef tx = {0};
    uint32_t mailbox;
    tx.StdId = id;  tx.IDE = CAN_ID_STD;
    tx.RTR   = CAN_RTR_DATA;  tx.DLC = dlc;
    HAL_CAN_AddTxMessage(&hcan1, &tx, data, &mailbox);
}

/* 任务1:100ms 周期上报门状态 (ID 0x200) */
void DoorStatusTask(void *arg) {
    TickType_t last = xTaskGetTickCount();
    for (;;) {
        uint8_t d[3];
        d[0] = g_lock_state;          /* byte0: 门锁 */
        d[1] = g_window_pos;          /* byte1: 车窗位置 % */
        d[2] = g_door_open;           /* byte2: 门开合 */
        can_send(0x200, d, 3);
        vTaskDelayUntil(&last, pdMS_TO_TICKS(100));  /* 精确 100ms */
    }
}

/* 任务2:扫描按键,状态变化立即发事件帧 (ID 0x201) */
void DoorButtonTask(void *arg) {
    uint8_t last_btn = 0;
    for (;;) {
        /* 读车窗按键:PA0=上升 PA1=下降 (低有效) */
        uint8_t btn = 0;
        if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) btn = 1; /* 升 */
        if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) == GPIO_PIN_RESET) btn = 2; /* 降 */

        if (btn != last_btn) {          /* 仅变化时发送——事件报文 */
            uint8_t e[2] = { btn, g_window_pos };
            can_send(0x201, e, 2);
            last_btn = btn;
        }
        /* 模拟车窗电机移动 */
        if (btn == 1 && g_window_pos < 100) g_window_pos += 2;
        if (btn == 2 && g_window_pos > 0)   g_window_pos -= 2;

        vTaskDelay(pdMS_TO_TICKS(10));  /* 10ms 扫描去抖 */
    }
}

网关节点:转发 + UDS 诊断入口

网关在接收中断里做两件事:把关心的报文缓存/转发,把 UDS 请求 (0x7E0) 交给诊断处理。这里演示接收回调转发与 0x22 读 DID。

/* gateway_node.c — 网关:报文转发 + UDS 0x22 读 DID */
#include "stm32f4xx_hal.h"

extern CAN_HandleTypeDef hcan1;

/* 缓存各节点最新状态,供诊断读取 */
static uint8_t g_door_status[3] = {0};
static uint8_t g_dash_alive  = 0;

static void can_send(uint32_t id, uint8_t *data, uint8_t dlc) {
    CAN_TxHeaderTypeDef tx = {0};  uint32_t mb;
    tx.StdId = id;  tx.IDE = CAN_ID_STD;
    tx.RTR = CAN_RTR_DATA;  tx.DLC = dlc;
    HAL_CAN_AddTxMessage(&hcan1, &tx, data, &mb);
}

/* 处理 UDS 请求:仅实现 0x22 ReadDataByIdentifier */
static void uds_handle(uint8_t *req, uint8_t len) {
    uint8_t resp[8] = {0};
    if (req[0] == 0x22 && len >= 3) {
        uint16_t did = (req[1] << 8) | req[2];
        switch (did) {
            case 0xF190:  /* DID: 车门状态 */
                resp[0] = 0x62; resp[1] = 0xF1; resp[2] = 0x90;
                resp[3] = g_door_status[0];  /* 锁 */
                resp[4] = g_door_status[1];  /* 窗 */
                resp[5] = g_door_status[2];  /* 门 */
                can_send(0x7E8, resp, 6);
                break;
            case 0xF191:  /* DID: 仪表在线 */
                resp[0] = 0x62; resp[1] = 0xF1; resp[2] = 0x91;
                resp[3] = g_dash_alive;
                can_send(0x7E8, resp, 4);
                break;
            default:  /* 否定响应 NRC 0x31 requestOutOfRange */
                resp[0] = 0x7F; resp[1] = 0x22; resp[2] = 0x31;
                can_send(0x7E8, resp, 3);
        }
    }
}

/* CAN 接收中断回调:分发转发 + 诊断 */
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *h) {
    CAN_RxHeaderTypeDef rx;  uint8_t data[8];
    HAL_CAN_GetRxMessage(h, CAN_RX_FIFO0, &rx, data);

    switch (rx.StdId) {
        case 0x200:  /* 缓存车门状态,供诊断读取 */
            memcpy(g_door_status, data, 3);
            break;
        case 0x110:  g_dash_alive = 1;  break;  /* 仪表心跳 */
        case 0x201:  /* 按键事件:转发给中控做锁逻辑 */
            can_send(0x300, data, 1);
            break;
        case 0x7E0:  uds_handle(data, rx.DLC);  break;  /* UDS 诊断 */
    }
}

DBC 定义该网络

把整个网络固化成 DBC,上位机与其他节点即可共享同一份"字典":

// body_network.dbc — 三节点车身网络(片段)
VERSION ""
BU_: DASH DOOR GATEWAY

BO_ 256 EngineData: 8 GATEWAY
 SG_ VehicleSpeed : 0|16@1+ (0.01,0) [0|655.35] "km/h"  DASH
 SG_ EngineRPM    : 16|16@1+ (0.25,0) [0|16383.75] "rpm"  DASH
 SG_ CoolantTemp  : 32|8@1+ (1,-40) [-40|215] "degC"  DASH

BO_ 512 DoorStatus: 3 DOOR
 SG_ LockState  : 0|1@1+ (1,0) [0|1] ""  GATEWAY,DASH
 SG_ WindowPos  : 8|8@1+ (1,0) [0|100] "%"  GATEWAY
 SG_ DoorOpen   : 16|1@1+ (1,0) [0|1] ""  GATEWAY,DASH

BO_ 513 DoorButtonEvent: 2 DOOR
 SG_ ButtonAction : 0|8@1+ (1,0) [0|2] ""  GATEWAY

VAL_ 512 LockState 0 "解锁" 1 "上锁" ;
VAL_ 512 DoorOpen  0 "关闭" 1 "打开" ;
VAL_ 513 ButtonAction 0 "松开" 1 "上升" 2 "下降" ;

BA_DEF_ BO_ "GenMsgCycleTime" INT 0 10000;
BA_ "GenMsgCycleTime" BO_ 256 20;
BA_ "GenMsgCycleTime" BO_ 512 100;

python 上位机:监控 + 诊断 + 故障检测

最后用 python-can + cantools 写一个监控台:实时解码、UDS 读 DID、并对周期报文做超时检测(故障注入测试的基础)。

# body_monitor.py — 车身网络监控台(含节点超时检测)
import can, cantools, time

db  = cantools.database.load_file("body_network.dbc")
bus = can.Bus(channel="can0", interface="socketcan")

# 记录各周期报文最后收到的时间,用于离线检测
last_seen = {}
TIMEOUT = {0x100: 0.1, 0x200: 0.5}  # 允许的最大间隔(s)

def uds_read_did(did: int):
    """发 UDS 0x22 读 DID,等待网关 0x7E8 响应"""
    req = [0x22, (did >> 8) & 0xFF, did & 0xFF]
    bus.send(can.Message(arbitration_id=0x7E0,
             data=req, is_extended_id=False))
    resp = bus.recv(timeout=1.0)
    if resp and resp.arbitration_id == 0x7E8:
        if resp.data[0] == 0x62:
            print(f"DID {did:#06x} = {resp.data[3:].hex(' ')}")
        elif resp.data[0] == 0x7F:
            print(f"DID {did:#06x} 否定响应 NRC={resp.data[2]:#04x}")

# 上电先诊断读一次车门状态
uds_read_did(0xF190)

# 主循环:解码 + 超时检测
while True:
    msg = bus.recv(timeout=0.05)
    now = time.time()

    if msg:
        last_seen[msg.arbitration_id] = now
        try:
            dec = db.decode_message(msg.arbitration_id, msg.data)
            name = db.get_message_by_frame_id(msg.arbitration_id).name
            print(f"[{name}] {dec}")
        except KeyError:
            pass  # 非 DBC 报文(如 UDS)忽略

    # 故障检测:某周期报文超时未收到 → 节点疑似离线
    for mid, tmax in TIMEOUT.items():
        if mid in last_seen and now - last_seen[mid] > tmax:
            print(f"⚠ 报文 {mid:#05x} 超时 {now-last_seen[mid]:.2f}s,节点可能离线!")
            last_seen[mid] = now  # 避免刷屏

故障注入测试清单

注入手段预期行为验证点
拔掉车门节点网关/上位机报 0x200 超时离线检测生效
cansend 灌非法窗位 200DBC 范围钳位 / 应用丢弃取值范围校验
短接 CAN_H/CAN_L错误计数飙升 → Bus-OffBus-Off 恢复机制
读未定义 DID 0xF1FF网关回 7F 22 31UDS 否定响应正确
高频灌 0x100 满负载低优先级报文延迟但不丢仲裁与优先级
从这里走向真实整车

这个三节点网络虽小,但工程闭环完整:物理层→bxCAN→仲裁→FreeRTOS 调度→周期/事件报文→DBC→网关转发→UDS 诊断→故障注入。真实整车只是把节点数扩到几十上百、加上多子网+CAN FD+网关路由表+网络管理唤醒休眠而已,方法论完全一致。你已经具备读懂并搭建整车网络的能力。

本章小结 · 全书收官

我们从零搭起一个三节点车身网络:仪表(只收+显示+心跳)、车门(周期上报状态 + 事件上报按键)、网关(报文转发 + UDS 诊断入口)。工程要素齐备——按优先级分配 ID(安全/控制小 ID、状态/诊断大 ID)、核算总线负载(本网络约 1.6%,远低于 50% 安全线)、STM32 bxCAN + FreeRTOS 用 vTaskDelayUntil 精确调度周期报文、按键变化触发事件帧、网关在接收中断里做转发与 0x22 ReadDataByIdentifier、用 DBC 固化全网信号定义、python-can + cantools 搭监控台做实时解码与超时离线检测,并用一套故障注入清单(拔节点/灌非法值/短接总线/读非法 DID/满负载)验证鲁棒性。

回望全书:从物理层差分信号与位定时,到数据链路层帧格式、仲裁、错误处理与 Bus-Off,到 STM32 bxCAN 收发与过滤器、CAN FD,到 FreeRTOS 下的报文调度,再到 DBC 数据库、UDS 诊断、AUTOSAR 通信栈与 Bootloader 刷写,最终汇成一张能跑、能诊断、能测试的整车网络——你已走完从一根线到一张网的完整旅程。

后续方向:功能安全 ISO 26262(ASIL 等级、安全机制、E2E 保护)、信息安全 ISO 21434(威胁分析、安全启动、入侵检测)、CAN over Ethernet 与 SOME/IP(车载以太网 + 面向服务架构,下一代 E/E 架构的主干)。愿这门古法手艺,成为你读懂每一辆车的起点。

⚒️ 配套开发者工具箱 · 17 款免费在线工具