结论:原生 pandad 的 Panda C++ 封装把「CAN 编解码」「健康回执」「命令确认」拆成三层,但三层的校验并不对称——发送路径用 assert 而非显式长度校验,接收路径存在短读下溢,健康结构接受零字节/短读。因此「sendcan 收到」「SPI 交易成功」「MCU 已入队」「安全钩子放行」是四个不同观测点,任何一个都不能替代另一个。
位置与调用链:从 sendcan 到 6 字节帧头
Panda 是 pandad.cc 与 PandaSpiHandle 之间的 C++ 设备封装,而 can_header 把总线、DLC、拒绝/回环位、29 位地址和 XOR 校验压成 6 字节。构造函数创建 SPI 句柄、读取板型,再用 0xc0 清通信状态。
发送路径从 sendcan 取消息并做一秒年龄门,再调用 can_send();接收路径从 can_receive() 得到帧,包装为 can 事件,并把 bool 结果写入事件 valid。send_panda_states() 消费健康结构,按 ignition/onroad 调整安全模式和省电,心跳由后续的 process_panda_state() 发送。封装本身不是独立的 MCU 安全策略,它只是主机侧的一段设备协议。
| 函数/组件 | 作用与边界 |
|---|---|
Panda::Panda()、get_hw_type() |
构造 SPI 句柄、查板型、复位通信;板型读未核对返回长度。 |
get_state()、get_can_state() |
EP0 读取 health_t/can_health_t,负值才返回空值。 |
get_firmware_version()、up_to_date() |
两次 64 字节读拼签名,与本地签名文件尾部比较;这不是运行中固件完整性远程证明。 |
len_to_dlc()、pack_can_buffer() |
过滤非本 Panda 总线,编码 DLC/头/XOR;批次达到 256 字节后调用写回调。 |
can_send() |
把批次写向 bulk 端点 3,未向调用方返回实际写入量或错误。 |
can_receive()、unpack_can_buffer() |
从端点 0x81 读最多 16 KiB,保留未完整帧;XOR 失败清接收缓存并请求 MCU 复位通信。 |
CAN 字节流边界:512 字节缓冲与 256 字节软阈值
发送缓冲为 2 * USB_TX_SOFT_LIMIT(512 字节),每个合法 CAN FD 帧至多 6 + 64 字节,写后超过 256 字节就清零位置。因此在长度被限制为 64 的前提下,单次追加不超过缓冲。
但当前的长度检查只有 assert:代码先把 can_data.size() 传给接受 uint8_t 参数的 len_to_dlc(),再断言 <=64 及长度与 DLC 表相符。在关闭断言的构建中,超长或不能由 CAN FD DLC 表表示的负载不被拒绝,随后的 memcpy 仍按原始 can_data.size() 写进 512 字节栈缓冲。即使保留断言,这也是进程终止而非可恢复的输入错误,必须先做显式长度/DLC 校验再写缓冲。
另一点常被误读:src 在 cereal CanData 中已经是 UInt8,因此不能把 C++ 局部 uint8_t 赋值本身当作独立的源总线截断漏洞;bus < 4 才是此封装对可发送总线的实际门。
assert 不是输入校验:短读下溢与半包
接收循环的条件是 pos <= size - sizeof(can_header),其中 size 为 uint32_t 而 sizeof 为无符号 size_t。当本次读取只得到 1–5 字节且之前没有半包时,减法下溢,循环仍可能进入并从不足 6 字节的数据中拷贝头,之后按头部 DLC/长度判断和移动缓冲,属于条件性越界/错误解析风险。SPI bulk 读允许返回实际收到的字节数,不能假设总是帧头以上。改法是先判断 size - pos >= sizeof(can_header),且只在 pos <= size 不变量成立时做减法。
头中的 DLC 为 4 位,查 16 项表本身有边界;完整帧的 XOR 校验失败会清缓存,但已在同一轮压入 out_vec 的先前帧不会回滚,上层会发布 valid=false 的事件。接收缓存大小为 RECV_SIZE + sizeof(can_header) + 64,can_receive() 只用 assert 检查剩余容量再读 16 KiB;正常解析最多保留一个未完成帧,但不应依靠断言证明这个不变量。
健康回执与命令确认的分离
get_state()、get_can_state() 把结构体零初始化,但只以 err >= 0 接受控制读。SPI 层返回实际字节数,因此零字节或短读也可能被包装成「存在的健康结构」;send_panda_states() 只检查 optional 是否存在,随后据这些字段判断点火、模式及省电。get_serial() 同样接受非负短读;get_firmware_version() 则要求两次恰好 64 字节;风扇 RPM 与板型读没有长度门。这里属于主机读取契约风险,不能推断正常 MCU 必然会产生短响应。
安全模式、心跳、波特率、IR、电源等控制写方法为 void,未检查 SPI 交易返回值;can_send() 也丢弃 bulk_write 的字节数/错误。这正是必须区分四类观测点的原因:
| 观测点 | 它到底证明了什么 |
|---|---|
sendcan 消息收到 |
主机侧已提交发送请求,仅此而已。 |
| SPI 交易完成 | 字节写到了端点;返回值可能被调用方丢弃。 |
| MCU 入队 | 需固件侧回执,封装层不提供确认。 |
| 安全钩子放行 | 由 panda 固件独立许可,主机侧无从得知。 |
自制硬件与插件重构的最低契约
设备插件可以输出带设备身份、采样时间、实际字节数及逐字段有效位的只读健康数据;可替换的 CAN 观测解码器须显式声明帧头、DLC、校验、总线编号和分块恢复契约。发送则由单一受信任网关持有能力:插件只提交候选帧,网关在序列化前校验载荷长度/总线/地址,实施年龄门、资源上限和安全策略,并分别报告「接收请求、SPI 完成、MCU 入队、被安全拒绝」等可观测结果。安全模式、心跳和复位绝不开放给普通插件直接调用。
离线单测应优先覆盖 0–5 字节短头、分块恰在头中间结束、CAN FD 合法长度与非法 9/65/超大长度、非本 Panda 总线过滤、坏 XOR 前已有好帧、健康结构短读,以及 bulk 部分写/负返回。随后在无车台架以 ASan/UBSan、故障注入和 MCU 回执核验;真实车辆执行控制还需车型安全钩子与接管测试。
常见问题
为什么「sendcan 收到」不能当作 MCU 已发送?
因为 can_send() 丢弃 bulk_write 的字节数与错误,控制写方法为 void;sendcan 只是主机侧入队请求。SPI 交易成功、MCU 入队、安全钩子放行是另外三个独立观测点。
发送超过 64 字节的 CAN FD 负载会被拒绝吗?
当前长度检查只有 assert。在关闭断言的构建中不被拒绝,memcpy 仍按原始长度写进 512 字节栈缓冲;即使保留断言,这也是进程终止而非可恢复的输入错误。
为什么 1–5 字节的 SPI 读取会导致帧头解析错误?
因为接收循环用无符号的 size 减去 sizeof(can_header),短读时减法下溢,循环仍可能进入并从不足 6 字节的数据中拷贝头。改法是先判断 size - pos >= sizeof(can_header)。
健康结构读到零字节意味着什么?
get_state()、get_can_state() 只以 err >= 0 接受控制读,零字节或短读也可能被包装成「存在的健康结构」,send_panda_states() 只检查 optional 是否存在,因此不能把结构存在当作字段有效。
延伸阅读
- CAN 报文解析的「健康位」在说什么:校验、计数器与总线空窗 —— 与本文的健康回执/有效位语义直接衔接。
- pandad 原生主循环的存活与遥测 —— 说明封装之外,健康字段如何被上层消费。
- opendbc 校验算法族与收发契约 —— 对照 6 字节帧头 XOR 之外的校验家族边界。
- pandad 的 Python CAN 与 capnp 适配有效性 —— 承接
can事件valid位的来源与传播。 - 同一个「转向灯」在三家车厂是三种实现:车辆状态归一化的坑 —— 说明解码成功之后仍需按品牌归一化。
参考资料
- 项目 02 ——
openpilot-research/PANDAD_NATIVE_PANDA_CAN_CODEC_HEALTH_CONTRACT_DEEP_DIVE.md(取用了 CAN 字节流边界、短读下溢、健康回执与命令确认分离的静态结论)。 - 项目 02 ——
openpilot-research/PANDAD_NATIVE_SPI_RESPONSE_BOUNDS_RETRY_CONTRACT_DEEP_DIVE.md(仅用于确认底层 SPI 响应长度与重试契约的边界)。
说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。
更新记录
首次发布 2026-10-11 最近核对:2026-10-11
发表回复