实战:多节点车身网络
全书收官。我们把前十五章——物理层差分信号、bxCAN 收发、仲裁、FreeRTOS 任务、DBC、UDS 诊断、网关——全部串起来,搭建一个真实的三节点车身网络:仪表、车门、网关。从 ID 分配、节点固件、DBC 定义,到 python 上位机监控与故障注入测试,走完一次完整工程闭环。
全书收官。我们把前十五章——物理层差分信号、bxCAN 收发、仲裁、FreeRTOS 任务、DBC、UDS 诊断、网关——全部串起来,搭建一个真实的三节点车身网络:仪表、车门、网关。从 ID 分配、节点固件、DBC 定义,到 python 上位机监控与故障注入测试,走完一次完整工程闭环。
ReadDataByIdentifier,UDS 最常用服务之一。用一个 16 位 DID(Data Identifier)标识一项数据(软件版本、序列号、传感器实时值等),请求 22 F1 90 读 DID 0xF190,ECU 返回 62 F1 90 <data>。网关据此把各节点状态暴露给诊断仪,是产线检测与售后诊断的基础手段。三节点挂在同一条 500 kbps 高速 CAN 总线上,两端 120Ω 终端电阻。下图是拓扑与报文/ID 规划:
标准帧 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 请求 (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,上位机与其他节点即可共享同一份"字典":
// 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-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 灌非法窗位 200 | DBC 范围钳位 / 应用丢弃 | 取值范围校验 |
| 短接 CAN_H/CAN_L | 错误计数飙升 → Bus-Off | Bus-Off 恢复机制 |
| 读未定义 DID 0xF1FF | 网关回 7F 22 31 | UDS 否定响应正确 |
| 高频灌 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 架构的主干)。愿这门古法手艺,成为你读懂每一辆车的起点。