标签: pandad

  • 风扇控制:30% 点火下限直接夹积分,15 秒永久告警的边界

    结论:风扇链是一条 命令加故障告警,而不是以 RPM 调速的闭环:hardwared 按温区与点火状态算目标百分比,经 deviceState → pandad → 0xb1 → panda 固件 执行,再由 peripheralState 回报约 2 Hz 转速,selfdrived 只做告警。两个反直觉细节必须记住:点火的 30% 下限同时被用作积分项下界,因此首次点火可能直接把积分项夹到 30,而不是缓慢爬升;selfdrived 的代码注释写「5 seconds」,实际判断是严格超过 15.0 秒,且事件只含 ET.PERMANENT,不等于自动降额、软禁用或关机。

    命令链:从复合温度到 8 Hz PWM

    下表把整条链的关键环节与职责列出。要注意主机侧目标与固件侧执行是两段判断,deviceState 里的百分比并不等于最终 PWM 占空。

    环节 源码 实现职责
    FanController.update() fan_controller.py 输入当前复合温度和点火状态,更新 PID 上下限;点火状态变化时重置积分;返回整数百分比。
    PIDController.update() pid.py k_p=0、k_i=0.004、无微分,积分时间步长由传入 rate 固定;输出等于积分项与前馈之和再限幅。
    hardware_thread() hardwared.py 用内存/CPU/GPU/PMIC 的滤波温度生成目标;过热告警时在 PID 结果之后覆盖为 100%,随后发布 deviceState。
    process_peripheral_state() pandad.cc 在 deviceState 更新且未设置 NO_FAN_CONTROL 时下发改变的目标;还在满足 frame 模条件时重发同一目标。
    Panda::set_fan_speed() / get_fan_speed() panda.cc 通过控制请求 0xb1 写目标、0xb2 读 RPM。
    fan_set_power() / fan_tick() fan.h 非零目标先夹到 20–100%,8 Hz tick 更新 PWM、测速滤波及共享供电门。
    send_peripheral_state() pandad.cc 读取 panda 状态与转速,约 2 Hz 发布 peripheralState;外层 valid 来自通信健康。
    selfdrived 风扇检查 selfdrived.py、events.py 已知 panda、RPM 小于 500 且期望大于 50 时,自最后健康处理帧起严格超过 15 秒对应帧数后添加永久 fanMalfunction;本分支不校验反馈新鲜度。

    主机控制不是 RPM 闭环:30% 下限直接夹积分

    风扇控制器在导入模块时按设备类型设定 OFFSET:mici 为 0,其他为 5 °C。因此设定点分别为 75 / 80 °C,前馈从 60 / 65 °C 的 0% 线性增加到 100 / 105 °C 的 100%。FanController 只读温度和点火,不读实际 RPM;点火时输出限在 30–100%,驻车限在 0–30%;点火边沿重置 PID 状态。积分步长固定为 1/rate,hardwared 传 int(1/DT_HW),调度不均匀时仍按名义步长积分。

    探针揭示了上下界实现的一个关键组合:PIDController.update() 的限幅不是单纯「最终输出至少 30%」——点火下限 neg_limit=30 同时被用作积分项的下界。mici 在 80 °C 时前馈为 50%、误差为 5 °C;首次点火更新的名义积分增量仅 0.004 × 0.5 × 5 = 0.01,但被直接夹到 i=30,最终输出为 80%。即使把 rate 从 2 改为 10,此样例首步仍为 i=30;只有把 PID 下限单独改成 0 后,才显出 1/rate 的 0.01 / 0.002 增量。不应把它误读为缓慢积分爬升。

    另一个容易误判的地方是高温覆盖:hardwared 对正常目标计算之后把 deviceState 里的期望值改为 100%,PID 内部积分并不知道这次覆盖。因此告警解除时目标可能回到此前积分/前馈决定的值,而不是从 100% 受控斜坡下降。此外 panda 固件有非零最小 20% 的夹限,主机驻车期望 1–19% 与实际 PWM 目标并不线性一致,期望 0% 才是真正的停机请求。

    量 取值 / 边界 含义
    平台偏移 OFFSET mici 0 °C,其他 5 °C 设定点 75 / 80 °C,前馈 60–100 / 65–105 °C
    点火输出限 30–100% 下限同时作为积分项下界
    驻车输出限 0–30% 期望 0% 才是真停机请求
    固件非零最小 20% 主机期望 1–19% 与实际 PWM 不线性一致
    告警阈值 期望 > 50 且 RPM < 500 配合 15 秒条件触发永久告警

    故障语义:15 秒永久告警不等于停机

    固件 tach 每个 8 Hz tick 根据四个边沿/转计算瞬时 RPM,再以新值 1/4、旧值 3/4 滤波。send_peripheral_state() 即使从 panda 读到 0 RPM,也由通信健康而非风扇健康设置外层 valid;selfdrived 的事件条件检查 pandaType、目标和 RPM,没有直接对 RPM 字段的采样年龄或质量做独立门控。代码注释写「5 seconds」,实际判断是 > 15.0 秒,事件定义只含 ET.PERMANENT——这不等于自动降额、软禁用或关机。整条链是命令加故障告警,不是以 RPM 调速的闭环。

    指令新鲜度:frame 模条件不是看门狗

    pandad 以 100 Hz 主循环运行、约 20 Hz 调用 process_peripheral_state();只有 sm.updated("deviceState") 为真才进入风扇下发分支。目标变化会立即发送,未变化时的 sm.frame % 100 == 0 只是周期性重发尝试,但仍受新 deviceState 到达及其与 frame 余数的相位约束,不是独立、可靠的五秒看门狗。NO_FAN_CONTROL 环境变量只要存在就跳过主机风扇命令;源码也没有把 deviceState 过期、0xb1 写失败或固件实际占空确认转换为主机可观察的执行状态。

    新硬件验收

    自制硬件应把 has_fan、PWM 可用、tach 可用、电源门共享关系和转速量程作为能力声明;指令含序号、期望值与有效期,设备返回接受/执行状态。RPM 观测需带采样时间与有效位,堵转或断线要由可信热安全策略处理,不能靠 UI 永久告警替代降额。台架需测 0→20% 门槛、点火边沿、温度源缺失、100% 强制覆盖、主机或 USB 失联、tach 开短路、供电门与 IR 干扰,以及最坏情况下的 SoC 温升和恢复时间。探针不覆盖 panda 指令丢失、RPM 零值、无风扇板型、NO_FAN_CONTROL、状态消息中断、15 秒事件时序或高温覆盖后的恢复。

    常见问题

    为什么点火后风扇输出会直接从 30% 起跳?

    因为点火下限 neg_limit=30 同时被用作积分项的下界。探针显示,mici 在 80 °C 时名义积分增量只有 0.01,但被直接夹到 i=30,最终输出 80%;即使把 rate 从 2 改为 10,首步仍是 i=30。这不是缓慢积分爬升。

    高温告警触发后,温度下降会平滑降速吗?

    不一定。hardwared 是在正常目标计算之后把 deviceState 期望值覆盖为 100%,PID 内部积分并不知道这次覆盖,因此告警解除时目标可能直接回到此前积分/前馈决定的值,而不是从 100% 做受控斜坡下降。

    停机时把期望设成 1% 到 19% 有用吗?

    不等于停机。panda 固件对非零目标有最小 20% 的夹限,所以主机驻车期望 1–19% 与实际 PWM 目标并不线性一致;期望 0% 才是真正的停机请求。

    出现 fanMalfunction 就代表风扇停了吗?

    不代表。触发条件是已知 panda、期望大于 50 且 RPM 小于 500,并自最后健康处理帧起严格超过 15 秒后添加永久事件;事件只含 ET.PERMANENT,不等于自动降额、软禁用或关机。而且 peripheralState 的外层 valid 来自通信健康而非风扇健康。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/FAN_HOST_PANDA_FEEDBACK_CONTRACT_DEEP_DIVE.md(取用了风扇命令链的组件索引、主机 PID 与 30% 下限夹积分、15 秒永久告警边界、指令新鲜度与新硬件验收要求)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • pandad 主机复位循环:reset/BOOT0 交替与无退避重试

    结论:pandad 的 Python 包装器是一台「复位—恢复—刷写—启动」的循环机:偶数轮常规复位、奇数轮带 BOOT0 的 ROM 恢复,恢复所有 DFU 设备后要求运行态 panda 恰好一块。它的失效模式不是崩溃而是「沉默继续」——GPIO 写异常被吞掉、recover() 的布尔返回值未检查、无设备时无退避地快速重试,而包装器存活并不等于 CAN 已就绪。

    包装器时序:心跳丢失与每轮枚举

    启动时,包装器遍历已发现的 panda,读取内部板 heartbeat_lost;若为真,写持久键并记录日志。此处构造的 Panda() 默认 disable_checks=True,连接会禁用心跳检查、退出省电并重置部分 CAN 配置,因此这一步也不是纯只读探测;异常被记录后仍进入恢复循环。

    每轮 count 偶数执行正常复位,奇数执行带 BOOT0 的 ROM 恢复。之后 PandaDFU.list() 合并 USB/SPI DFU 发现结果,包装器对所有 DFU 序列号顺序执行 recover();只有运行态 Panda.list() 最终恰好一块时才进入应用签名验收和原生进程。设备数大于一会抛 AssertionError,被宽泛异常分支记录后下一轮重试;无设备也直接下一轮。

    入口 实现语义
    reset_internal_panda() 初始化 reset 与 BOOT0;高电平保持 reset、BOOT0 低,10 ms 后释放 reset。
    recover_internal_panda() reset 高、BOOT0 高,10 ms 后释放 reset,再过 10 ms 拉低 BOOT0,使 MCU 进入 ROM 路径。
    gpio_init()、gpio_set() sysfs 写失败仅打印错误,不向上抛异常,也无强制读回。
    get_expected_signature()、flash_panda() 从 H7 应用镜像提预期签名,条件性刷应用固件、恢复 bootstub,最后比对运行态签名。
    pandad.main() 内部板 reset/recover 交替、恢复 DFU、限制运行态 panda 数量、启动并等待 C++ pandad。
    PandaDFU.list()、recover()、Panda.recover() USB/SPI DFU 枚举后逐个恢复;恢复属于有固件写入副作用的维护操作,等待 DFU 超时返回 False。

    复位极性:STM_RST_N 是高电平复位

    源码引脚注释特别说明 STM_RST_N 实际为高电平复位,不能按后缀 _N 推断极性。这一点直接决定了 reset_internal_panda() 与 recover_internal_panda() 的差异:前者保持 BOOT0 低,后者在释放 reset 后再拉低 BOOT0,从而把 MCU 送进 ROM 路径。

    更隐蔽的是 GPIO helper 会吞掉写异常并返回 None,因此包装器不会因为 reset/BOOT0 写失败而直接知道硬件没有进入预期模式。仅仅「写 sysfs 成功」不能证明 MCU 已启动。

    固件验收:bootstub 与签名门

    flash_panda() 在 bootstub 或运行签名不等于本地 H7 镜像签名时调用 flash()。若刷后仍在 bootstub,内部板先走主机 GPIO ROM 恢复,外部板则用 panda 自身 reset 进入 ROM,再调用 panda.recover();最后仍在 bootstub 或签名不匹配就抛错。Panda.recover() 的 False 返回值在此处没有单独检查,最终由 bootstub/签名门决定是否继续。

    清理层面也值得注意:panda.close() 只在正常完成的末尾显式调用;异常路径未见同一函数内的清理块。假依赖探针验证了控制流:运行签名匹配时跳过刷写并显式关闭;bootstub 内部板按 flash()、主机 GPIO 恢复、recover(reset=False) 的顺序调用,外部板则调用 recover(reset=True);recover() 返回 False 但假板卡最终状态和签名合格时仍可完成;最终签名不符时抛错且本函数未显式 close()。这些结果只说明包装器如何使用依赖,不说明真实恢复失败后仍可安全继续。

    无退避循环与 SIGINT 竞态

    签名验收通过后,包装器启动 ./pandad 并以 process.wait() 阻塞。原生子进程结束后回到外层恢复循环,没有固定退避;无设备、多个设备或持续协议错误同样可能快速反复切换内部板模式,具体速率取决于枚举和日志成本。

    包装器接收 SIGINT 时设置退出标记,并在子进程存在时转发 SIGINT;但退出标记只在下一轮循环入口检查。假依赖探针在运行态 Panda.list() 返回前注入 SIGINT,确认当前轮仍会调用 flash_panda() 并启动 ./pandad,且信号到达时子进程尚不存在,因此没有转发给新进程。这是需在真实进程/设备台架上处理和验收的退出竞态。

    失效点 静态表现 为何不能静默
    GPIO 写失败 helper 吞异常返回 None,无强制读回 包装器不知道 MCU 未进入预期模式。
    recover() 返回 False 调用点未检查布尔值 假 DFU 的返回不阻断后续流程。
    无设备/多设备 直接进入下一轮,无固定退避 可能快速反复切换内部板模式。
    SIGINT 退出标记只在下一轮入口检查 新子进程可能未收到转发信号。

    维护权限与插件边界

    主机平台插件可以提供板卡发现与复位请求,但 ROM 切换、固件写入和安全模式配置应由受信任维护服务独占,限制在驻车、供电稳定且目标设备身份已绑定的事务。主机 GPIO 应回报操作是否成功、复位线和 BOOT0 实测电平、设备重枚举代号以及截止时间。外置看门狗和 CAN 收发器默认无输出仍需在自制板上独立实现并验证。台架需分别注入 GPIO 拒写、BOOT0 卡高/卡低、无 panda/多 panda、USB/SPI DFU 同时出现、错误固件签名、刷写中断、原生子进程崩溃和 wrapper SIGINT;不能以包装器 PID 存活作为车辆控制授权。

    常见问题

    为什么 STM_RST_N 这个命名容易误导?

    因为源码引脚注释特别说明 STM_RST_N 实际为高电平复位,不能按后缀 _N 推断极性。复位极性直接决定 reset_internal_panda() 与带 BOOT0 的 recover_internal_panda() 行为差异。

    GPIO 写失败后包装器会知道吗?

    不会。GPIO helper 在 sysfs 写失败时仅打印错误、返回 None,不向上抛异常也无强制读回,因此包装器不会因为 reset/BOOT0 写失败而直接知道硬件没有进入预期模式。

    recover() 返回 False 会阻断流程吗?

    在当前调用点不会。Panda.recover() 的 False 返回值未被单独检查,最终由 bootstub/签名门决定是否继续;假依赖探针也确认返回 False 但假板卡最终状态和签名合格时仍可完成。

    为什么包装器存活不代表 CAN 就绪?

    manager 跟踪的是 Python 包装器的存活,而非独立证明固件签名、安全模式或 CAN 流已就绪。签名通过后包装器只是启动并等待 C++ pandad,因此不能以包装器 PID 存活作为车辆控制授权。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/PANDAD_HOST_RESET_DFU_RECOVERY_LOOP_DEEP_DIVE.md(取用了包装器时序、复位极性、固件验收、无退避循环与 SIGINT 竞态)。
    • 项目 02 —— openpilot-research/PANDA_BOOTSTUB_SIGNATURE_FLASH_BOUNDARY_DEEP_DIVE.md(仅用于确认固件签名与刷写门的边界)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • pandad 硬件测试:DFU 恢复、SPI 注错与 loopback 边界

    结论:pandad 的三个设备测试给出三种不同强度的证据:恢复测试只证明设备可见性,CAN loopback 证明期望集合被逐条消耗,SPI 注错证明服务存活与粗时序。它们都会复位/刷写 panda、切换 allOutput safety 并发送随机 CAN,绝不可在连接车辆的环境运行。源文档明确,三者都不能证明永久 NACK、非法长度、短健康包、热插拔、多 Panda、心跳失效后的 MCU 静默或实际车型 RX/TX safety 钩子。本文为静态核对,未启动测试、未刷写、未发送 CAN、未连接任何设备。

    恢复测试:只证明设备可被看到

    TestPandad 要求 comma 硬件;每个测试的 setUp() 先执行测试基类的硬件初始化,再停止已有 pandad,如果当前枚举不到 Panda 就尝试启动包装器恢复。核心 _run_test() 启动 pandad,在 30 秒内订阅 pandaStates,看到至少一块且首块 pandaType != unknown 即停止并返回耗时。

    源文档强调它没有断言 CAN 能收发、固件签名值、心跳健康或目标安全模式。因此「看到 pandaStates」不等于整条安全链通过。

    场景 实际操作 静态覆盖范围
    test_in_dfu() 主机 GPIO 进入 ROM DFU,再让包装器恢复 验证可恢复到非 unknown 状态,不测刷写中断
    test_in_bootstub() 经 Panda API 进入 bootstub,确认标志,再让包装器恢复 验证 bootstub 起点的正常恢复
    test_in_reset() 操作 STM_RST_N,确认暂时枚举不到 Panda,再启动包装器 属于物理复位线测试,不是纯软件 mock
    test_release_to_devel_bootstub() _flash_bootstub() 选择 MCU 配套 bootstub 文件并通过 PandaDFU.program_bootstub() 写入 有真实固件修改;名称里的 release/devel 不能替代证书/签名核验
    test_recover_from_bad_bootstub() 进入 DFU 后向 bootstub 起始地址写入 100 个零字节,复位并确认普通/DFU 枚举为空,再靠包装器恢复 主动制造启动损坏,失败时可能需要外部救援

    公共台架夹具与 allOutput 的放开范围

    setup_pandad() 有三次重试:清除当前测试 Prefix 下的 Params,反复发布 deviceState.started=False 等待非 unknown pandaStates,再写 FirmwareQueryDone、ControlsReady 和仅含 allOutput 的 CarParams.safetyConfigs,发布 started=True。

    两处边界需要注意:测试基类的 OpenpilotPrefix 隔离的是软件路径,不隔离接在 panda 上的物理 CAN;allOutput 的 tx_hook 无帧内容限制,通用发送门也对该模式放开白名单,这不是车型量产 safety 策略。台架必须与车辆 CAN 物理断开,不能仅依靠软件设置「loopback」。

    loopback 与 SPI 注错的断言边界

    send_random_can_messages() 对 0~2 号总线生成随机 29 位以内地址和 1~8 字节载荷,用 sendcan 发布;没有 CAN FD 长帧。TestBoarddLoopback.test_loopback() 运行 200 轮,每轮除原总线集合外还期待 src+128 的发送回执集合;收到消息就从集合移除,最后要求所有期望集合清空。源文档指出这比只看「有 CAN 流量」强,但不测负载极限、重复帧保序、超时恢复或车辆控制的安全限幅;每轮接收循环最多 500 次 wait_for_one,没有外层总时间上限。

    TestBoarddSpi.setup_class() 设置 STARTED=1、SPI_ERR_PROB=0.001,默认还开启 BOARDD_LOOPBACK;SPI 层在配置概率下扰动传输长度、TX/RX 缓冲,然后继续真实 ioctl,故它是传输层随机注错而非确定性故障脚本。test_spi_corruption() 在默认约 20 秒循环中发送随机 CAN,检查状态消息有效与设备类型/点火/电压/风扇范围,并统计三种主题的平均/最大消息间隔和数量。

    测试 断言边界 不能证明
    test_loopback() 200 轮,期望集合(含 src+128 回执)全部清空 负载极限、重复帧保序、超时恢复、安全限幅
    test_spi_corruption() CAN Event 即使 valid=False 仍进入测试;只断言收到且 src<=4 的(地址,载荷)曾在对应发送集合中;最后只要求 total_recv_count > 20(集合移除被注释) 接收数等于发送数、每条只到达一次或全部发送帧均回传
    JUNGLE_SPAM 模式 校验地址、源总线和 0xff 载荷模式 不产生本测试自己的发送集合

    三种证据各自不证明什么

    源文档收束得很清楚:恢复测试的设备可见性、loopback 的期望集合消耗、SPI 注错时的服务存活/粗时序,三者都不能证明永久 NACK、非法长度、短健康包、热插拔、多 Panda、心跳失效后的 MCU 静默或实际车型 RX/TX safety 钩子。

    硬件插件验证的推荐顺序是:先用离线消息/协议测试,再在断开车辆的台架做可重复错误矩阵与固件恢复,最后另由独立 safety 层和受监督实车流程验收。已静态核对三个测试文件、公共 helper、SPI 注错点与 allOutput 安全钩子;未运行设备测试或执行任何刷写/CAN 操作。

    源文档也把恢复包装器列为待验证对象:真实包装器的复位、签名和重试边界需要另做专题核对,本节列出的是测试证据。分支和计数结论是源码可见的断言范围,目标硬件时序和异常恢复结果仍需专用台架验证;全项目其他组件仍待逐项审查。

    常见问题

    pandad 恢复测试通过是否代表固件与安全链正常?

    不代表。源文档明确 _run_test() 只要求在 30 秒内看到至少一块非 unknown 的 pandaStates,没有断言 CAN 能收发、固件签名值、心跳健康或目标安全模式。

    为什么 SPI 注错测试通过不能说明接收完整?

    因为它的断言很窄。源文档指出,test_spi_corruption() 只断言收到且 src<=4 的帧曾在对应发送集合中,集合移除被注释,最后只要求 total_recv_count > 20,并不要求接收数等于发送数或全部发送帧均回传。

    台架上把 safety 设成 allOutput 就安全吗?

    不安全。allOutput 的 tx_hook 无帧内容限制,通用发送门也放开白名单,它不是车型量产 safety 策略;且 OpenpilotPrefix 只隔离软件路径,不隔离物理 CAN,台架必须与车辆 CAN 物理断开。

    这些测试能在连接车辆的环境下跑吗?

    绝对不能。源文档在开头即警示:它们会复位/刷写 panda、切换 allOutput 并发送随机 CAN,绝不可在连接车辆的环境运行。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/PANDAD_HARDWARE_TEST_DFU_SPI_LOOPBACK_SAFETY_BOUNDARY_DEEP_DIVE.md(取用了恢复测试场景表、公共台架夹具、loopback 与 SPI 注错的断言范围、三类证据边界)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • pandad 原生 CAN 编解码与健康回执:短读、下溢与 512 字节缓冲

    结论:原生 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 是否存在,因此不能把结构存在当作字段有效。

    延伸阅读

    参考资料

    • 项目 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