标签: openpilot

  • selfdrived 告警仲裁:99 个 EventName 里只留 1 条上屏

    结论:同一帧里可以同时成立多个故障事件,但最终屏幕文字、声音与 HUD 图像只会呈现一条告警。selfdrived 用「事件容器(有序但不去重)→ 状态机(按类型优先级)→ AlertManager(按 alert_type 每帧留一条)」这三段流水线完成仲裁,任何一段的边界都会让结果和直觉不同。本文为固定源码快照 c8fb906 的静态分析,不等于独立硬件安全保证。

    从车辆状态到消息输出

    SelfdriveD.__init__() 等待或接收 CarParams,创建车辆事件适配、消息订阅、告警管理器和状态机。carState 用独立 socket 接收,以它作为步进输入;data_sample() 再非阻塞更新其余主题。初始化须满足 CS.canValid && sm.all_checks(),或约 6 秒超时进入初始化完成路径;这不是「超时后所有输入都变有效」,后续仍会创建故障事件。

    step() 每次先采样、聚合事件;只有 CP.passive=False 且完成初始化才调用状态机;随后更新告警并发布消息。params_thread() 约每 100 毫秒刷新单位、车道偏离、油门踏板退出、实验模式和驾驶风格。异步参数刷新与控制步进并行,不能假设 UI 写完 Params 后下一条 selfdriveState 已同步使用全部设置。

    publish_selfdriveState() 将 enabled、active、状态、engageable、告警文案/声音/HUD 图像写入 selfdriveState;engageable 的条件是当前没有 NO_ENTRY 事件,并不等于当前已使能。onroadEvents 每约 1 秒或事件名单变化时发布。controlsd 用 enabled/active 再结合车辆故障、静止门和纵向覆盖决定 latActive/longActive;pandad 还要求 selfdriveState 消息 alive/valid 才把 engaged 告知 panda。软件状态机、控制器活动门和 panda 固件安全门是三个不同层次。

    事件容器为什么不去重

    Events 保存当前帧有序事件、静态事件以及每个 EventName 的连续出现计数。clear() 先更新计数再仅保留静态事件,随后 SelfdriveD.update_events() 按来源追加动态事件。contains(ET) 查询事件的类型映射;create_alerts() 只为本状态允许的类型创建 Alert,并用 creation_delay 抑制短暂出现的告警。

    add() / add_from_msg() 用 bisect.insort 排序,但不去重;重复事件会产生重复的 create_alerts() 结果,计数器仍按「该事件名是否出现」每帧只加一次。static=True 会同时加入静态列表和当前列表,连续 clear() 会保留静态项并继续累计它的计数。创建延迟按 DT_CTRL * (之前连续帧数 + 1) 判断当前帧,等号也允许产生告警;回调在延迟判断前调用,异常直接向调用方传播。

    生产路径并非完全没有重复机会:bigModelFailed 可由失败边沿与活动模型故障段同帧添加,canError 可由雷达错误与车辆 CAN 无效同帧添加;这四项分支由隔离探针确认,但不证明实车同帧共现。同一 EventName 还可以有多个类型,例如 controlsMismatch 同时映射 IMMEDIATE_DISABLE 与 NO_ENTRY,cameraMalfunction 有 PERMANENT/SOFT_DISABLE/NO_ENTRY;哪个 Alert 真正参与显示取决于状态机当前的 current_alert_types。

    事件类型 状态意义 典型例子
    ENABLE / NO_ENTRY 启用请求 / 禁止进入 按键或 PCM 启用;系统初始化、车型能力、故障。
    PRE_ENABLE 进入预使能等待 需要条件继续满足才进入 enabled。
    OVERRIDE_LATERAL / OVERRIDE_LONGITUDINAL 驾驶员覆盖某执行维度 overriding 状态。
    SOFT_DISABLE 给驾驶员接管窗口 摄像头故障、进程失联、通信问题。
    IMMEDIATE_DISABLE / USER_DISABLE 立即退出 / 用户主动退出 安全模式不匹配、CAN 总线丢失、踏板/取消。
    WARNING / PERMANENT 活动状态展示 / 始终候选 转向临时不可用、FCW/LDW、启动与硬件提示。

    状态机的优先级

    StateMachine.update() 每步先递减软退出计时器,并始终加入 PERMANENT 告警类型。非 disabled 状态下,IMMEDIATE_DISABLE 优先于 USER_DISABLE,两者优先于软退出或覆盖。disabled 只有在出现 ENABLE 且没有 NO_ENTRY 时才能进入 preEnabled、overriding 或 enabled。ENABLED_STATES 包含 preEnabled、enabled、softDisabling、overriding;ACTIVE_STATES 不含 preEnabled,所以「enabled」与「执行器正在活动」并非同义词。

    当前状态 条件 结果
    enabled SOFT_DISABLE 进入 softDisabling,计时约 3 秒。
    enabled 横/纵覆盖 进入 overriding,继续 enabled=True, active=True。
    softDisabling 故障消失 回 enabled;若持续至计时结束则转 disabled。
    preEnabled PRE_ENABLE 消失 转 enabled;否则停留预使能。
    overriding 覆盖消失 回 enabled;若出现软退出则转 softDisabling。
    任意非 disabled IMMEDIATE_DISABLE 或 USER_DISABLE 直接变成 disabled。

    SOFT_DISABLE_TIME 是按 DT_CTRL 换算的步数,取决于循环是否按预期执行;最坏墙上时间和驾驶员实际听到提醒的延迟需要实测。源码还有两个易误判的边界:处于 disabled 且同帧有 ENABLE + IMMEDIATE_DISABLE、却没有 NO_ENTRY 时,代码只走 disabled 分支、结果进入 enabled;处于 preEnabled 时若 PRE_ENABLE 消失但 SOFT_DISABLE 仍在,会先进入 enabled 且返回 active=True,下一次调用才进入 softDisabling。

    告警仲裁:一帧只留一条

    AlertEntry.active() / just_added() 用包含端点的 frame <= end_frame 判定有效;只有当前帧紧挨上次加入帧且原条目仍有效才保留旧 start_frame。AlertManager.add_many() 以 alert_type 为键保留告警:每次覆盖该槽的负载和 added_frame,结束帧取 max(frame + 1, start_frame + duration),duration 单位是控制帧而非墙上秒。间隔一帧以上的重加即使旧告警还有效,也会重置 start_frame。

    process_alerts() 先把匹配 clear_event_types 的条目 end_frame 设为 -1,再从仍有效的条目按 (priority, start_frame) 最大值选择。完全同分时使用严格 >,字典中先插入的 alert_type 胜出;同一键的不同负载则由最后一次 add_many() 覆盖,并不产生第二个候选。SelfdriveD.update_alerts() 在不活动时清理 WARNING,已使能时清理 NO_ENTRY。因此同一帧可有多个事件,但只有一个当前显示/声音告警。set_offroad_alert() 另把驻车提示写入 Params,属于另一呈现通道,不参加本次 onroad AlertManager 排序。

    常见问题

    同一帧有多个故障,屏幕上为什么只显示一条告警?

    因为 process_alerts() 只从有效条目里按 (priority, start_frame) 最大值选一条;完全同分时用严格 >,字典中先插入的 alert_type 胜出。事件列表可以有多条,最终呈现的告警只有一条。

    enabled=True 是不是就代表执行器已经动了?

    不是。ENABLED_STATES 含 preEnabled,而 ACTIVE_STATES 不含它;enabled 只表示状态机处于使能族。controlsd 还要结合车辆故障、静止门和纵向覆盖决定 latActive/longActive,pandad 另要求 selfdriveState 消息 alive/valid。

    重复添加的事件会被自动去重吗?

    不会。add() / add_from_msg() 用 bisect.insort 排序但不去重,重复事件会产生重复的 create_alerts() 结果,计数仍按「该事件名是否出现」每帧只加一次。插件接入不应依赖容器自动去重。

    软退出(soft disable)给的 3 秒是按墙钟算的吗?

    不是。SOFT_DISABLE_TIME 是按 DT_CTRL 换算的步数,取决于循环是否按预期执行;最坏墙上时间和驾驶员实际听到提醒的延迟需要实测。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/SELFDRIVE_EVENTS_STATE_ALERTS_DEEP_DIVE.md(事件容器、状态机与 AlertManager 的三段仲裁机制与故障边界)。
    • 项目 02 —— 同目录探针记录:五项 Events 容器、五项状态机转移、五项 AlertManager 仲裁隔离探针结论。

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

    更新记录

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

  • AGNOS A/B 升级:sparse 解包与 64 字节尾标记

    结论:AGNOS 的 A/B 系统更新是一条「拉取 staging → 标非活动槽不可启动 → 流式解压并解 sparse → 写分区与哈希校验 → abctl 切槽 → 重启后标记成功」的链。校验分两层:system 等非全量校验分区只比较尾部 64 字节 marker,其余分区重算 SHA-256。因此 marker 是性能优化,不是加密签名或全量介质自检。本文为固定源码快照的静态分析,不是可在车辆上照做的刷机手册。

    平台辅助函数:设备路径、GPIO 与功放

    Paths 按 PC/AGNOS 选择日志、持久化、下载缓存和共享内存路径;LOG_ROOT、COMMA_CACHE、OPENPILOT_PREFIX 可影响部分路径。GPIO 统一列出 panda、GNSS、蜂窝、相机、蜂鸣器和 IMU 中断 GPIO 编号;注释明确某些 _RST_N 名称与实际高有效复位相反,不能按名字推电平。功放侧 Amplifier._set_configs() 对 MAX98089 执行 I2C 读改写,初始化时先全局关闭、配置 DAC/I2S/扬声器、再开启;Amplifier.set_configs() 最多 15 次重试并递增等待,仅捕获 OSError,全部失败返回 False。

    省电策略 HardwareComma.set_power_save() 在驻车时关闭功放、大核下线并调整 CPU governor/IRQ,恢复时重新初始化功放;它紧耦合 SoC sysfs 与 IRQ 名称。台架功率工具 read_power()/sample_power() 从固定 I2C hwmon 节点读取瓦特,按 123 Hz 定时采样,这是测量工具,不是 hardwared 中的 PowerMonitoring 驻车电量估计器。

    组件 源码入口 行为与移植边界
    路径选择 Paths 按 PC/AGNOS 选择日志、持久化、下载缓存与共享内存路径。
    引脚表 GPIO 列出各外设 GPIO 编号;_RST_N 名称与实际复位极性可能相反。
    功放配置 Amplifier._set_configs() MAX98089 I2C 读改写;失败返回 False,调用方未检查布尔值。
    省电策略 HardwareComma.set_power_save() 驻车关功放、大核下线并调整 governor/IRQ。
    台架功率工具 read_power()/sample_power() 按 123 Hz 读取 I2C hwmon 瓦特,非驻车电量估计器。

    A/B 更新调用链

    updated.handle_agnos_update() 只在 AGNOS 版本变化时触发,清除 staging 的一致标记后刷目标 slot;updated 由 manager 作为驻车进程运行。launch_chffrplus.sh 则在启动时先验证/切换已下载的目标系统,或循环调用设备更新器。清单在 common/hardware/comma/agnos.json,system/hardware/comma/agnos.json 是指向它的符号链接,故两处路径并非两份独立配置。

    选择槽时 get_target_slot_number() 读取 abctl --boot_slot,选择另一槽;get_partition_path() 按清单 has_ab 拼分区标签。下载解压由 StreamingDecompressor.read() 流式处理 HTTP/XZ,同时计算解压后字节的 SHA-256;unsparsify() 解析 Android sparse 的 raw/fill/don’t-care 三类 chunk。分区写入 flash_partition() 先快检;非全量分区先清尾部 hash 标记,再由 extract_compressed_image() 写设备、核对 raw/hash/大小,成功后回写标记。重试与切槽上,flash_agnos_update() 先 --set_unbootable,仅下载类异常最多重试 10 次;swap() 清 marker 后调用 abctl --set_active。

    阶段 核心函数与契约
    选择槽与路径 get_target_slot_number() 读 abctl --boot_slot 选另一槽;get_partition_path() 按 has_ab 拼标签。
    下载解压 StreamingDecompressor.read() 流式 HTTP/XZ 解压并计算解压后 SHA-256。
    稀疏镜像 unsparsify() 解析 Android sparse 的 raw/fill/don’t-care chunk。
    分区写入 flash_partition() 快检、清尾标记、写设备并核对 raw/hash/大小、回写标记。
    校验策略 verify_partition() 全量校验分区重算 SHA-256;其他分区只比较末尾 64 字节 marker。
    重试与切槽 flash_agnos_update() 先 --set_unbootable,下载类异常最多重试 10 次;swap() 清 marker 后 abctl --set_active。

    清单中的 hash 指解压后的稀疏输入流,hash_raw 指写入的原始分区数据;对于非稀疏分区两者相同。system 是 sparse 且采用尾部 hash 标记,其余本快照清单分区采用全量哈希。

    静态发现的故障边界

    unsparsify() 遇到 don’t-care chunk 只 yield b"",extract_compressed_image() 对空字节不推进输出文件位置;用 1 个 4096 字节 don’t-care 块的合成 sparse 输入验证过该函数只输出 0 字节。若发布镜像实际包含此类 chunk,写入布局/大小不会保留空洞,并可能因大小或哈希校验失败——但当前未下载真实镜像,不能断言发布镜像触发了该路径。

    swap() 在 abctl 返回不含成功标记的输出时无限循环,源码中没有退避或截止时间;若命令直接抛异常则退出。隔离探针还显示:前两次刷写抛 RequestException、第三次成功时,同一分区被调用三次、前两次各睡 10 秒;刷写抛非下载类 ValueError 时第一次异常直接传播,不进入十次重试;abctl --set_unbootable 假返回码为 1 时仍继续调用刷写,因为源码调用 subprocess.run() 后未检查 returncode。

    verify_partition() 对非全量校验分区只信任尾部 marker,无法检测 marker 写成之后发生的实体损坏;隔离探针验证了「载荷已变而尾部仍放预期哈希字符串」时非全量 verify_partition() 返回真、强制全量检查返回假。test_manifest() 只检查 URL 的 HEAD 可达性、内容类型和非稀疏分区的两个 hash 字段相等,不覆盖 sparse 解包、分区写入、切槽或电源中断。

    常见问题

    AGNOS 更新时断电会怎样?

    源码在写入前先 abctl --set_unbootable 标记目标槽不可启动,写入后由 swap() 用 abctl --set_active 切槽,新系统启动后再以 abctl --set_success 标记成功。本文只做静态分析,未做断电注入实板验证;插件化更新服务应把「镜像校验通过」「目标槽可启动」「切槽完成」作为分离状态回执。

    为什么有些分区只比较 64 字节尾部标记?

    因为 verify_partition() 对 full_check 分区重算指定大小的 SHA-256,其他分区只比较末尾 64 字节 marker,并不每次重读分区实体。这个 marker 是性能优化,无法检测写标记之后的实体损坏。

    sparse 镜像里的 don’t-care 块会被保留吗?

    源码里 unsparsify() 遇到 don’t-care chunk 只 yield b"",而 extract_compressed_image() 对空字节不推进输出位置;隔离探针用合成输入验证了它只输出 0 字节,所以空洞不会按原布局保留。

    A/B 切槽失败会重试吗?

    源码中 swap() 在 abctl 输出不含成功标记时无限循环,没有退避或截止时间;隔离探针连续尝试三次、中间无 sleep()。新平台应对切槽失败设置有界重试、告警和恢复策略。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/AGNOS_PLATFORM_UPDATE_DEEP_DIVE.md(平台辅助组件、A/B 安装与校验策略、sparse/XZ 边界)。
    • 项目 02 —— 同目录探针记录:四项更新控制流、五项 sparse/校验、四项 XZ 流隔离探针结论。

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

    更新记录

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

  • 1024 位 RSA 加 SHA-1:panda 的签名、刷写与扇区边界

    结论:panda 的启动真实性由 bootstub 用「长度门 + SHA-1 + 版本门 + 1024 位 RSA 校验」完成,这是一道启动镜像真实性检查,而不是给运行期 USB/SPI 控制请求做逐请求鉴权。更关键的是:主机端「不许擦 sector 7」只是高层约束,设备端 flash_erase_sector() 实际上接受 sector 7;长度门允许接近 16 MiB,而该 H7 链接脚本声明的 flash 只有 1 MiB。以下基于源码快照 c8fb906 的静态阅读与宿主隔离探针,未运行刷写工具、未做实板测试。

    构建制品与签名字节契约

    SConscript 分别构建 bootstub 与 app,app 入口放在 0x08020000,随后以 SETLEN=1 调用 sign.py。签名脚本在原始 app 尾部追加 VERS 与版本 2,把首 4 字节重写为「签名覆盖区长度」,再以 SHA-1 对首 4 字节之后的内容计算摘要,最后附加 128 字节 RSA 签名。普通构建使用 debug 私钥并启用 ALLOW_DEBUG;RELEASE 构建需提供证书/密钥路径。生成的 cert.h 含 debug/release 公钥,但构建期未见核对 CERT 私钥是否与 release.pub 配对。

    环节 字节/常量 说明
    签名长度 RSANUMBYTES=128 公钥模数在构建时须为 1024 位
    填充前缀 00 01 + 105 字节 FF + 00 + 20 字节摘要 PKCS#1 v1.5 风格,但无标准 SHA-1 DigestInfo/ASN.1 编码
    十六进制补齐 rjust(0x100,'0') = 256 字符 写入镜像时转换为 128 字节
    摘要长度 20 字节 验签只接受 128 字节签名与 20 字节摘要

    验签函数在做完公钥运算后,把末尾摘要与预期值异或归零,并逐字节比较整个 128 字节固定模板。因此它只处理这一种签名格式,不应直接当作通用 RSA-SHA1 校验;源码注释里出现的「2048-bit」和 SHA-256 与当前启用的常量及检查不符。

    上电到跳转:bootstub 做哪几道检查

    early_initialization() 先初始化寄存器、浮置早期 GPIO、识别板型;遇到 ROM 模式标志或芯片 ID 不符会走 jump_to_bootloader()。此后 bootstub.main() 先处理 softloader 标志,再读 app 长度、计算 SHA-1、检查 VERS/最小版本,最后用发布公钥验签;仅 ALLOW_DEBUG 构建额外接受调试公钥。失败进入 soft_flasher_start(),成功则调用 app 向量。

    摘要实现是标准 SHA-1:SHA_hash() 依次调用 SHA_init()、SHA_update()、SHA_final(),64 字节分组经 SHA1_Transform() 处理,末尾补 0x80、零填充及 64 位比特长度,输出 20 字节大端摘要。bootstub 只哈希首字长度之后的 len-4 字节,签名本身与长度字都不在哈希区;长度字虽不参与摘要,却控制被读取的范围和签名位置。SHA-1 原实现通过空串、abc、分块 abc、56 字节填充边界与百万个 a 五类宿主标准向量。

    扇区与刷写边界:主机保护不等于设备保护

    存储布局上,sector0 放 bootstub,sector1 至 6 放 app,sector7 放设备序列与制造数据。主机端的 Panda.flash_static() 会用 last_sector < 7 阻止正常客户端擦除 sector 7,但设备端 flash_erase_sector() 只拒绝 sector 0,允许在解锁状态下擦除 sector 1 至 7。也就是说,直接发 EP0/EP2 的主机可以绕过这一层。EP0 控制请求 0xb1 解锁、0xb2 擦除、0xd1 进 ROM/softloader、0xd8 刷新并复位;comms_endpoint2_write() 从 app 起点顺序写 32 位字,源码未见对写指针、镜像长度或 provisioning 区域做边界检查。

    边界 源码事实 需要台架确认的点
    扇区保护 主机禁擦 sector 7;设备端仅拒绝 sector 0 经原始维护接口能否破坏制造数据
    长度门 bootstub 允许镜像长度接近 16 MiB 超出 1 MiB flash 后读/故障表现
    版本门 只要求 vers[1] >= 2 是否另设单调计数器防降级

    关键函数与地址契约

    下表整理了引导路径上与签名、刷写、身份相关的实现及其边界。它们是理解「哪一层保证什么」的最小清单:

    函数/定义 实际作用 边界
    bootstub.main() 用首字长确定签名位置,哈希 app 主体,校验版本与 RSA-SHA1 签名 长度上界接近 16 MiB,超出 1 MiB flash 声明
    RSA_verify() 检查公钥结构、128 字节签名、20 字节摘要及固定填充模板 只处理该格式,不宜泛化为新硬件的完整安全启动方案
    SHA_hash() 对镜像被签区做 SHA-1 分组计算并输出 20 字节摘要 只覆盖调用者传入的 len-4 字节;memcpy 宿主声明有 ABI 警告
    get_key_header() 从固定公钥文件生成固件公钥常量 公钥与 CERT 私钥的配对关系未见构建期检查
    flash_erase_sector() 拒绝 sector 0,已解锁时擦除 sector 1 至 7 sector 7 含制造数据,设备端并未排除

    五项隔离探针把原版 rsa.c、sha.c 编译为临时共享库,按 debug 私钥构造无 DigestInfo 固定填充签名后经 ctypes 调用原版验签:有效签名返回 1,摘要/签名字节被改、标准 SHA-1 DigestInfo 填充、错误签名或摘要长度、错误公钥字数、不支持的指数均返回 0。合成镜像另核对首字长度、末尾 VERS/版本 2 与 128 字节签名,改动被签主体后验签失败。

    常见问题

    bootstub 验签通过,是不是就代表设备运行期安全?

    不是。它校验的是启动镜像的真实性与完整性,并没有给运行期的 USB/SPI 控制请求做逐请求鉴权。EP0 解锁、擦除、EP2 写入等维护命令仍会被接受,因此需要把签名密钥与刷写权隔离到受控维护进程。

    为什么主机端禁止擦 sector 7 还不够?

    因为那是主机库的约束。flash_erase_sector() 只拒绝 sector 0,允许已解锁时擦除 sector 1 至 7,而 sector 7 含设备序列与制造数据。直接发 EP0/EP2 的主机不受「last_sector < 7」限制。

    版本号能防止固件降级吗?

    不能作为单调防回滚证明。启动检查只要求 vers[1] >= 2,源码中未见与设备单调计数器或持久最小版本比较。硬件改版若需防降级,应另行设计并保留救援路径。

    这套 1024 位 RSA 加 SHA-1 能直接搬到新硬件吗?

    不宜照搬。该验签只处理当前这种非标准固定填充格式,源码注释里的「2048-bit」与 SHA-256 也与实际常量不符。新硬件应先定义清晰格式与测试向量,再决定算法与密钥长度。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/PANDA_BOOTSTUB_SIGNATURE_FLASH_BOUNDARY_DEEP_DIVE.md(bootstub 签名、刷写与制造数据边界,含源码行号索引)
    • 项目 02 —— openpilot-research/probes/test_panda_rsa_signature_contract.py(RSA 与签名脚本五项宿主隔离探针)
    • 项目 02 —— openpilot-research/probes/panda_sha1_vectors_contract.c(SHA-1 标准向量宿主探针)

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

    更新记录

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

  • selfdrived 事件生产者:53 个直接事件与两道提前返回

    结论:SelfdriveD.update_events() 在同一帧内直接引用 53 个 EventName,但它们只是在「生产者」一侧被加入;加入事件之后仍要经过状态门与告警仲裁,才能变成驾驶员听到的告警或执行器退出。两道提前返回(未初始化、CP.passive)会让后续平台健康检查整体跳过,而 cruiseMismatch/noGps 在事件类型映射中为空,不能凭名字推断会触发禁入或退出。以下基于固定源码快照 c8fb906 的静态分析,未运行端到端时序或实车验证。

    每帧入口与两道提前返回

    update_events() 每帧先执行 clear():它保留静态事件并更新计数,随后处理调试、大模型与一次性启动提示。若 initialized=False,函数添加 selfdriveInitializing 后立即返回,此时不运行后续的驾驶员监测、车况、硬件与通信故障检查。也就是说「初始化中」这一种状态会掩盖其他所有平台健康判定。

    初始化本身在 data_sample() 中等待 canValid && sm.all_checks(),或约 6 秒超时;超时只结束等待,不保证订阅数据有效。初始化完成后可以处理书签,但 CP.passive 又会使函数提前返回。carUnrecognized/dashcamMode 是构造时添加的静态事件,startupNoCar/startupNoControl 等则通过 startup_event 变量在首轮间接加入,所以不能从 53 个直接名称的计数推断它们缺席。

    53 个直接事件按来源分组

    下表按各来源首次直接引用的顺序,列出相应的 EventName 与生产条件的中文摘要。同一帧可能追加多个事件,Events 的有序插入并不等于去重。

    来源 直接 EventName(摘要) 条件及边界
    调试与大模型 joystickDebug、bigModelLoading、bigModelFailed、lateralManeuver、longitudinalManeuver、selfdriveInitializing 大模型失败是有条件的软退出(失败/失联/外设消失边沿),非每帧无条件触发;机动实验按接收帧选择,横向优先
    书签与巡航恢复 userBookmark、userBookmarkNotPaired、resumeBlocked 非 PCM 巡航、设定速度 vCruise > 250 且加速/恢复按键出现时阻止恢复
    驾驶员监测 tooDistracted、driverDistracted1~3、driverUnresponsive1~3 非 notCar 才检查;lockout 禁止使能;alertLevel 1/2/3 与 activePolicy 是否 vision 决定两套提示
    CAN 与踏板 pcmEnable、pedalPressed CS.canValid 后才转发 CarEvents;油门上升沿需开启 DisengageOnAccelerator
    设备健康 overheat、outOfSpace、lowMemory、fanMalfunction 热状态达 overheated;非仿真空闲空间 <7% 或内存 >90%;风扇见下文数值门
    标定与车道偏离 calibrationIncomplete、calibrationRecalibrating、calibrationInvalid、ldw 标定状态非 calibrated 时三选一;IsLdwEnabled 且左右偏离任一成立才产生 ldw
    panda 安全 controlsMismatch、relayMalfunction 与 safetyConfigs 逐项比较;额外 panda 必须 silent/noOutput;mismatch_counter >= 200 可直接触发
    系统/总线/通信 processNotRunning、cameraMalfunction、canBusMissing、canError、commIssue 等 进程故障优先并跳过相机检查;总通信问题仅在 all_checks() 失败且大模型不在缓冲期时产生
    定位/参数/传感器 posenetInvalid、locationdTemporaryError、paramsdTemporaryError、sensorDataInvalid 主题曾被收到才看有效位;加速度计或陀螺仪任一主题距最近接收帧超过约 10 秒
    提示与副作用 fcw、steerSaturated、noGps、modeldLagging、personalityChanged FCW 只生产提醒不下发制动;转向饱和看目标/实际横向加速度比值;驾驶风格在按键释放时循环

    车辆侧的更多事件由 CarEvents.update() 转发,它还会产生车门、安全带、挡位、定速、转向和品牌专属事件,并由 CS.canValid 门控;车辆接口上报的 CarState 信号真假、车型安全钩子和线束电气行为不由这一层证明。

    几个容易被名字误导的事件值得单列。excessiveActuation 只在非 notCar 且有校准姿态时调用 ExcessiveActuationCheck.update(),首次触发会写驻车告警并把内存标志置真,之后才持续添加,构造时还会从已有的 Offroad_ExcessiveActuation 恢复该标志。变道提示同理:模型处于预变道且对应方向盲区被占用时阻止提示,进入开始或结束阶段时才发 laneChange——这些是模型状态消费,不代表真实盲区已被验证。

    数值门与告警边界

    这些条件的触发都绑定具体数值门,且多数在「严格超过某段时间对应的帧数」之后才成立。下表汇总关键阈值。

    项目 阈值 / 时间门
    空闲空间 / 内存 非仿真下空闲空间 <7%,或内存使用 >90%
    风扇故障 已知 panda、风扇目标 >50% 且缓存转速 <500 rpm,严格超过 15 秒对应帧数;注释称「5 秒」,数值实现为 15 秒
    未初始化超时 约 6 秒(只结束等待,不保证数据有效)
    panda 配置不匹配 严格超过 10 秒对应帧数;或 safetyRxChecksInvalid / mismatch_counter >= 200 直接触发
    传感器新鲜度 加速度计或陀螺仪任一主题距最近接收帧超过约 10 秒
    转向饱和 横控活动且最近约 2 秒未检测到驾驶员转向,并满足比值与控制器 saturated
    原车巡航一致性 / GPS cruiseMismatch 约 6 秒;noGps 约 2 秒且累计无 GPS 行驶距离 >1500 m(映射为空)
    模型频率 modelV2.frameDropPerc > 1 即添加 modeldLagging

    需要特别记住两处:风扇反馈故障「注释 5 秒、实现 15 秒」,并且不检查反馈新鲜度;cruiseMismatch 与 noGps 在本快照的 EVENTS 映射中为空,而 Events.contains() 只看类型映射,所以它们不会据此生成禁入或退出类型。

    生产条件不等于告警或退出

    update_events() 对某些系统故障有意设置了优先级与缓冲:总通信问题仅在 all_checks() 失败、无其他新增系统禁入/退出故障、且大模型不在加载或就绪后 5 秒缓冲期时才产生;进程故障优先,alive 优先于平均频率。这些都是「生产条件」层面的取舍,不是最终告警时刻。

    系统与通信故障内部也排了优先级:manager 指出应运行而未运行的进程时先报进程故障并跳过相机检查,recv_frame=0 的首次消息不通过该门,缓存故障可以持续触发事件;车况的 canTimeout 优先于 !canValid,且只有 openpilot 纵控才检查雷达错误,雷达 CAN 错误归入 canError。

    因此插件化时,传感器、模型与车型插件必须提供时间戳、数据有效位和失联证据,核心才保留事件归一化、状态机与 panda 安全约束。下一步应逐事件追到上游生产消息及回调告警,配合进程回放、SIL/HIL 和封闭场地验证;本文没有运行测试或控制车辆。

    常见问题

    未初始化时为什么很多故障检查根本不运行?

    因为 clear() 后若 initialized=False,函数会添加 selfdriveInitializing 并立即返回,后续的驾驶员监测、车况、硬件与通信故障检查全部被跳过。

    这 53 个直接事件就是全部事件吗?

    不是。这只是 update_events() 中直接引用的名称;启动事件通过变量间接加入、车辆事件由 CarEvents 转发,全部 99 个名称与类型组合另见静态事件索引。

    风扇故障到底是 5 秒还是 15 秒?

    注释称「5 秒」,但数值实现为 15 秒对应帧数;并且该检查不核对反馈新鲜度,只按「从最后一次健康处理帧起严格超过 15 秒」成立。

    cruiseMismatch 或 noGps 触发后会退出吗?

    不能这样推断。这两个 EventName 在本快照的 EVENTS 映射中为空,而 Events.contains() 只看类型映射,所以不会据此生成告警/退出类型。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/SELFDRIVE_EVENT_PRODUCERS_DEEP_DIVE.md(53 个直接事件的生产条件、两道提前返回与数值门)
    • 项目 02 —— openpilot-research/SELFDRIVE_EVENT_CATALOG.md(全部 99 个名称与类型组合的静态事件索引)

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

    更新记录

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

  • CarEvents 判定分两级:1 组通用条件加 5 个品牌分支

    结论:CarEvents 是「车况 → 使能/退出事件」的适配层:它只把 CarState、上一帧状态与最近一次控制请求翻译成事件名,本身不发 CAN,也不替代 panda 固件安全钩子。它的判定分两级——先跑一组通用条件,再进入 5 个显式品牌分支;并且只在 CS.canValid 为真时被调用,CAN 无效时走的是另一条独立故障路径。以下为固定源码快照 c8fb906 的静态追踪。

    CarEvents 在链路里的位置

    card.state_update() 经车型 CarInterface.update() 形成 carState;selfdrived.update_events() 只在 CS.canValid 时调用 self.car_events.update(CS, CS_prev, sm['carControl']) 并合并返回的事件。若 CAN 无效,selfdrived 走 canError/canBusMissing 等独立故障路径,因此不能把「CarEvents 忽略无效报文」误解为此时仍可使能。

    CarEvents 的输入有三层时间语义:当前 CS 是车辆观察,上一帧 CS_prev 用于巡航与转向故障等边沿判断,CC 是最近被消费的控制请求,并不代表车端执行器已按该请求动作。update() 对 body/mock 直接返回空容器,其余品牌先过通用条件,再进入本文件显式实现的品牌分支;未被显式实现的品牌并非不支持,而是使用通用条件与车型自身的 CarState/CarParams/接口规则。

    通用条件 create_common_events() 覆盖什么

    create_common_events() 创建一个新的 Events 容器,从车型接口的 DRIVABLE_GEARS、CP.pcmCruise 与品牌判断中决定可行驶挡位与 PCM 使能方式。它覆盖四类信息:乘员与挡位、原车辅助与模式、速度/制动/准备状态、以及用户覆盖与请求。倒挡是一个典型边界——当倒挡不属于车型 DRIVABLE_GEARS 时,源码会同时产生 wrongGear 与 reverseGear 两个事件。

    速度与准备类条件里,vEgo > MAX_CTRL_SPEED 产生超速事件,brakeHoldActive、parkingBrake、accFaulted、carNotReady 各有对应事件;其中制动保持只在 openpilot 纵向控制时才加入。传感器与转向类则把 vehicleSensorsInvalid、invalidLkasSetting、lowSpeedAlert 与 steerFaultTemporary/Permanent 写成事件。

    条件组 代表字段与 EventName 状态机含义
    乘员与挡位 doorOpen、seatbeltUnlatched、wrongGear、reverseGear 门/带/挡位常映射 NO_ENTRY 与软退出;倒挡另有用户退出。
    原车辅助/模式 cruiseState.available、nonAdaptive、espDisabled/espActive、stockFcw/stockAeb/stockLkas 原车巡航/稳定控制状态与原车碰撞/车道功能被映射为提示或禁止使能。
    速度/制动/准备 vEgo > MAX_CTRL_SPEED、brakeHoldActive、parkingBrake、accFaulted、carNotReady 分别产生超速、制动保持、驻车制动、ACC 故障与未就绪事件。
    用户覆盖/请求 steeringPressed、steeringDisengage 上升沿、gasPressed、buttonEnable、cancel 按钮 转向/油门覆盖、用户退出与启用请求分别走不同类型。
    传感器/转向 vehicleSensorsInvalid、invalidLkasSetting、lowSpeedAlert、steerFaultTemporary/Permanent 加入传感器与转向故障事件,暂时故障另有提示强度选择。

    PCM 启用门与边沿语义

    通用 PCM 启用门的条件是 CP.pcmCruise and brand != 'honda'。原车巡航 enabled 出现上升沿且 blockPcmEnable=False 时产生 pcmEnable,未启用时产生 pcmDisable;非 PCM 车型通常由 CarState.buttonEnable 产生 buttonEnable。这些事件分别被映射到 ENABLE 或 USER_DISABLE,最终由下游状态机裁决。

    这里有一个容易误判的细节:Hyundai 的 cancel 按钮只有在非 PCM 巡航时才会被本层转为 buttonCancel,因为源码明确避免把某些车的暂停/恢复复用键误作取消。同理,steeringDisengage 只在上升沿加入,不会在持续按下的每一帧重复产生。

    临时转向故障为什么不是布尔映射

    steerFaultTemporary 不是简单地把某个位映射成事件。steering_unpressed 记录未按方向盘的帧数,no_steer_warning 与 silent_steer_warning 决定究竟发静默的 steerTempUnavailableSilent 还是更强的 steerTempUnavailable。近 1.5 秒内有手动覆盖、静止或仍处静默阶段时偏向静默提醒;永久转向故障另发 steerUnavailable。

    隔离探针给出一个可复现的数量关系:在本快照 DT_CTRL=0.01 下,静默判定对应 150 帧,即 int(1.5/DT_CTRL)。这些计数取决于控制循环节奏与上游 CarState 更新节奏,不能从车型 CAN 原始位直接推出屏幕告警强度,这个 150 也不是所有车型的告警延迟保证。

    5 个品牌补充规则

    品牌分支处理的是通用条件表达不了的低速与边沿特例。Chrysler 用 0.5 m/s 迟滞区间避免速度边界抖动;Honda 在品牌分支内单独处理 PCM 低速与巡航退出;Toyota 的低速判断只在 OP 纵向启用时评估;GM 在「静止且踩刹车并且网络位置为前视摄像头」时跳过低速使能事件;Volkswagen 则在控制请求使能后才额外加入 speedTooLow。

    品牌 源码条件 产生的事件/目的
    Chrysler vEgo < minSteerSpeed + 0.5 置提醒,vEgo > minSteerSpeed + 1.0 清除 belowSteerSpeed;0.5 m/s 区间避免边界抖动。
    Honda PCM 巡航时低于 minEnableSpeed;巡航启用上升沿;巡航丢失且控制请求不在继续制动 belowEngageSpeed、pcmEnable、speedTooLow/cruiseDisabled;保留低速继续制动特例。
    Toyota 仅 OP 纵向;standstill 且未踩刹车、控制请求 resume 或混动标志;低于最低使能速度 resumeRequired、belowEngageSpeed;请求加速度大于 0.3 时 speedTooLow。
    GM 低于 minEnableSpeed,但 standstill && brakePressed && networkLocation=fwdCamera 例外 通常 belowEngageSpeed;前述条件允许驻车踩刹车时不产生该事件。
    Volkswagen 仅 OP 纵向;vEgo < minEnableSpeed + 0.5,且控制使能时 vEgo < minEnableSpeed belowEngageSpeed 和 speedTooLow。

    要强调:以上只是 CarEvents 中的品牌分支,不是这些品牌完整的 CAN/DBC/安全策略。事件容器本身不负责禁用执行器,最终状态由 StateMachine.update() 决定,执行活动由 controlsd 决定,panda 固件仍负责独立发送限制。

    常见问题

    CarEvents 会直接下发 CAN 或让车退出吗?

    不会。它只产生事件名交给下游:gasPressedOverride、steerOverride、buttonCancel、wrongGear 等分别被 events.py 映射到覆盖、用户退出或软退出,然后由状态机、controlsd 与 panda 固件分层决定最终行为。

    为什么倒挡会同时出现两个事件?

    因为当倒挡不属于车型的 DRIVABLE_GEARS 时,代码会同时产生 wrongGear 与 reverseGear。隔离探针确认了这个组合,具体结果还要看车型的 DRIVABLE_GEARS 定义。

    PCM 车和非 PCM 车的启用事件一样吗?

    不一样。通用 PCM 启用门是 CP.pcmCruise and brand != 'honda',PCM 车靠巡航 enabled 上升沿产生 pcmEnable;Honda 在品牌分支单独处理;非 PCM 车通常由 CarState.buttonEnable 产生 buttonEnable。

    1.5 秒的静默提醒延时对每辆车都成立吗?

    不成立。150 帧来自本快照 DT_CTRL=0.01 与 int(1.5/DT_CTRL),计数还取决于控制循环与上游 CarState 的更新节奏,不是所有车型实际告警延迟的保证。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/CAR_EVENTS_GATEWAY_DEEP_DIVE.md(CarEvents 的通用条件、PCM 门、临时转向故障与 5 个品牌分支)。
    • 项目 02 —— openpilot-research/ 同目录探针记录:五项通用条件与六项品牌契约隔离探针结论。

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

    更新记录

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

  • calibrationd 的 5 块门槛:500 个合格样本与名义 10 次平滑

    结论:calibrationd 的「已标定」不是一次测量结果,而是要凑满 5 个有效分块,即至少 500 次被接受的样本。样本门只认严格的高速直行与低不确定度:车速与视觉前向速度都要高于 15 mph 对应值、偏航率低于 2°/s;前 5 块会放宽不确定度门,但不放宽速度门。持久快照能让进程重启快速回到名义状态,但那不是新行程重新测得 500 个独立样本。

    主循环与输入有效性

    main() 读取 CarParams,以 cameraOdometry 作为轮询主题,接收 carState,并创建 Calibrator(param_put=True)。每当收到更新的相机里程计,就先把当前 carState.vEgo 交给 handle_v_ego(),再把视觉平移、旋转、广角外参、标准差和路面高度输入 handle_cam_odom()。服务表给 cameraOdometry 配置名义 20 Hz,extrinsicsCalibration 为 4 Hz。

    循环每 5 次 SubMaster.update() 调用一次 send_data(),消息顶层 valid 取 sm.all_checks()。但样本更新段仅判断 sm.updated['cameraOdometry'],没有在进入 handle_cam_odom() 前显式判断该消息的 valid;输出有效位和内部估计更新是两条不同的门。主循环相位探针还确认:仅 carState 更新不触发估计;相机更新时若没有新的车况,就复用上次 carState.vEgo。因此样本门使用的车速没有独立的新鲜度检查。

    还有一个节拍细节:SubMaster.update_msgs() 每次调用都会推进 frame,包括 poll 超时后无新消息;因此源码注释中的「4 Hz driven by cameraOdometry」是正常输入下的名义速率,不是无消息时严格不发布。隔离探针确认:首次更新后 frame=0 即发布,此后 frame=5 再发布,且无相机更新仍可发布 valid=False 的输出。

    样本接受门

    handle_cam_odom() 先将旧姿态平滑权重按每次调用 1/SMOOTH_CYCLES 递减,再执行下列判定。任一条不满足即丢弃本次样本。

    条件 源码阈值 结果
    直行高速 carState.vEgo > MIN_SPEED_FILTER 且 trans[0] > MIN_SPEED_FILTER、abs(rot[2]) < MAX_YAW_RATE_FILTER 车速与视觉前向速度都严格高于 15 mph 对应的 m/s,偏航率严格低于 2°/s;任一不满足即丢本次样本。
    方向估计 atan2(trans_std[1], trans[0]) < MAX_VEL_ANGLE_STD 横向平移不确定度角严格低于 0.25°;源码用 trans_std[1],不是三轴方差的综合门。
    高度估计 若 roadTransformTransStd 长度为 3,则第 3 项 < exp(-3.5) 约 0.030 的长度单位;若列表长度不是 3,代码把此项视为通过,而非拒绝。
    初始放宽 (rpy_certain && height_certain) || valid_blocks < INPUTS_NEEDED 前 5 个有效分块可绕过后两项不确定度门,但仍需满足直行高速。

    sanity_clip() 将 NaN 姿态回退初值,并把 pitch/yaw 限在有效范围外扩 0.005 弧度的区间;它是数值夹取,不是「校准有效」的判定。PITCH_LIMITS 在 mici 上与其他设备不同,YAW_LIMITS 共用。

    统计窗口、状态转换与平滑

    moving_avg_with_linear_decay() 在当前 100 样本块内更新姿态、广角角度与高度;只在接受样本后前进索引,满块时递增 valid_blocks 并让 block_idx 在 50 块窗口内环绕。因此「完成 5 块」表示至少 500 次被接受的样本,并非固定 25 秒或任意 500 帧。get_valid_idxs() 会从统计中排除当前正在写的块。

    状态 判定 边界
    uncalibrated 已完成块不足 5 块 原先处于 recalibrating 则保留该状态。
    calibrated 达到 5 块且平均 pitch/yaw 在严格限制内 否则为 invalid。
    recalibrating 当前为 calibrated 且 pitch 跨度 > 4° 或 yaw 跨度 > 2° 以最近块重置为 1 块、保存旧姿态,按 SMOOTH_CYCLES=10 次调用递减权重平滑;高度跨度目前只留 TODO。

    平滑权重在样本拒绝之前递减。从 1.0 开始以二进制浮点每次减 0.1,第 10 次后仍有约 1.39e-16 的正残值,第 11 次才由 max(0, ...) 精确归零。所以「10 次平滑」是名义次数,不代表 10 个被接受的样本或严格固定时间。

    持久状态与消费者

    Calibrator.__init__() 读取持久的 CalibrationParams,但仅在 param_put=True 时解析旧 rpyCalib、validBlocks、广角角度和高度;解析失败则保留默认值。update_status() 在 idx==0 且 block_idx % 10 == 5 时写入 get_msg(True).to_bytes()——这是分块节拍上的 Params 写入,使用 True 构造持久消息,并非当次 sm.all_checks() 的快照。此外,CP.notCar 时输出被覆盖为已标定、100%、零姿态,不能以仿真/非汽车输出证明真实传感器已标定。

    下游有三个消费者,且有效性门并不一致:modeld 在外参主题更新时更新模型 warp;AugmentedRoadView 还要求消息有效及状态已标定才更新叠加矩阵;selfdrived 根据标定状态产生禁止进入/软退出事件。接入新相机时,不能用其中一个界面的「显示正常」替代端到端验证。

    测试范围与硬件插件要求

    test_calibrationd.py 覆盖持久状态读取、低速/大偏航拒样、高不确定度在前 5 块后拒样、pitch/yaw 分散度触发重标定;原版测试本次因缺少 libparams_c.dylib 在收集阶段未执行用例,而三组隔离探针(快照边界五项、筛样五项、主循环相位四项)通过。自制相机/IMU 插件要明确输出坐标、量纲、时间戳、标准差与安装几何;插件不得直接伪造 calStatus=calibrated 或持久 CalibrationParams 来绕过测量链。

    常见问题

    calibrationd 的「完成 5 块」到底代表什么?

    代表至少 500 次被接受的样本(每块 100 个合格样本)。它不是固定 25 秒,也不是任意 500 帧;只有通过样本门、被真正接受的样本才会让块内索引前进。

    为什么说车速没有独立的新鲜度检查?

    因为样本更新段只判断 sm.updated['cameraOdometry'];相机更新到达时若没有新的车况,就复用上次的 carState.vEgo。能否接受仍由 handle_cam_odom() 的速度与不确定度门决定,而不是车速本身是否新鲜。

    高度标准差为空的列表为什么不会拒样?

    源码只在 roadTransformTransStd 长度等于 3 时检查第 3 项 < exp(-3.5);长度不为 3 时该分支直接把高度判为「确定」,空列表亦可接受。这是实现细节,不代表数据缺失被主动识别。

    重启后立即「已标定」是重新测出来的吗?

    不是。持久快照(CalibrationParams)能让进程重启时迅速恢复名义标定状态,但那不是新行程重新测得 500 个独立样本;对持久快照的异常形状也应单独核查。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/CALIBRATIOND_STATE_PERSISTENCE_DEEP_DIVE.md(取用了样本接受门、统计窗口与状态转换、持久状态与三个消费者,以及测试范围)。
    • 项目 02 —— openpilot-research/GEOMETRY_TRANSFORMS_HARDWARE_DEEP_DIVE.md(仅用于确认相机/IMU 坐标定义与外参几何前提)。

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

    更新记录

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

  • 回放输出相等≠实车安全:process_replay 的 14 个配置与三道门

    结论:process_replay 用 14 个 ProcessConfig 把「输入日志 → 单进程输出 → 多进程合流」重新跑一遍,再用容差比较参考日志;它产出的是回归证据,不是实车安全证明。决定性细节在于 get_output_msgs() 把输出 logMonoTime 设为输入最大时间加配置里的 processing_time,这是合成时间戳而非真实执行耗时;同时 model_replay 在 CI 环境中即使比较发现差异也把 failed 置假,test_fuzzy 明确排除含安全/控制/模型的 9 个进程。以下是对源码快照 c8fb906 的静态阅读,本地没有 Git 检出、参考日志或模型 LFS 实体,未运行回放。

    回放主链路:14 个配置与合成时间戳

    ProcessConfig 与 CONFIGS 声明各进程的发布输入、订阅输出、忽略字段、触发主题、容差、超时和 VisionIPC 依赖,含 selfdrived、card、controlsd 与模型等共 14 个。这是回放配置,不是受管进程全集;新增插件若要回放,必须补上配置与输入/输出契约。

    replay_process() 先调用 migrate_all() 统一旧日志 schema,再进入多进程调度;return_all_logs 会用新输出替换同主题原日志。ProcessContainer.start() 建立独立 OpenpilotPrefix、写入 Params/环境、建立 Pub/Sub 与可选 VisionIPC,再启动对应受管进程;ReplayContext 用假事件让输入发送与进程的 recv 对齐。

    run_step() 按触发回调积累消息,收集上一周期输出后送入本周期输入;相机主题从 FrameReader 重建 NV12 并经 VisionIPC 发送。到了 get_output_msgs(),输出 logMonoTime 被写成「输入最大时间 + 配置 processing_time」,这一步决定了整个比较链的时间语义。

    组件/函数 输入与行为 输出或限制
    ProcessConfig / CONFIGS 14 个配置声明发布输入、订阅输出、忽略字段、触发主题、容差、超时与 VisionIPC 依赖 回放配置,不是受管进程全集
    replay_process() 先 migrate_all() 统一旧 schema,再调多进程调度;return_all_logs 替换同主题原日志 返回模拟进程消息,不自动验证安全或性能
    ProcessContainer.start() 建隔离前缀、写 Params/环境、建立 Pub/Sub 与可选 VisionIPC 初始化依赖来自 generate_params_config() 与 generate_environ_config()
    ReplayContext 假事件令输入发送与进程 recv 对齐 只控制调度顺序,不等于真实总线抖动或丢帧
    get_output_msgs() 输出 logMonoTime = 输入最大时间 + 配置 processing_time 这是合成时间戳,不是实测执行耗时
    _replay_multi_process() 外部日志消息与进程新产出消息按 logMonoTime 合流,结束时冲刷残余输出 受配置主题图与合成时间戳约束,不能代替实车闭环时序

    还有两处回调值得单独记:ModeldCameraSyncRcvCallback 按单/双摄像头凑齐触发帧,MessageBasedRcvCallback 用指定主题触发周期。ProcessOutputCapture 可选地把被测进程 stdout/stderr 重定向到 /tmp,结束后读回,但仅辅助定位崩溃或告警,不参与消息差异判定。

    初始状态也会显著左右结果。get_custom_params_from_lr() 可从前一段日志取 CarParamsPrevRoute、标定、车辆参数与扭矩学习状态,因此初始状态不一致会造成差异,但这并非必然的算法回归;相机主题到 VisionIPC 流的对应关系另由 vision_meta.py 描述。

    输入迁移、比较与三类测试门

    migrate_all() 组织传感器、车辆状态、定位、驾驶模型等迁移,并按选项补 manager、panda 和相机状态;声明 product 的规则在该主题已存在时跳过。因此比较对象是迁移后的日志,不是原始字节流,新增 schema 必须定义可解释的迁移策略。

    compare_logs() 先过滤忽略主题、要求消息数量及顺序/类型对齐,再清零忽略字段后递归比较 Cap’n Proto,浮点按绝对/相对容差判断;长度或顺序不一致会直接报错。忽略字段不等于相等证明,时间字段通常被忽略。

    test_processes.py 从远端取片段与参考输出,检查输出主题与有效消息占比,再按配置比较并生成字段报告。它的默认覆盖并不全:默认排除 modeld、dmonitoringmodeld,其余 12 个配置中仅 card、controlsd、lagd 跑全部选定车型,其余只取 HYUNDAI/TOYOTA 片段。

    层次 实现与判定 易误读处
    历史 schema migrate_all() / migrate() 汇总替换、插入与删除后按时间排序 比较的是迁移后日志;声明 product 的规则在主题已存在时跳过
    字段比较 compare_logs() 要求数量及顺序/类型对齐,清零忽略字段后递归比较 忽略字段不是相等证明;不同长度或顺序直接报错
    通用回归 test_processes.py 检查输出主题与有效消息占比,再生成字段报告 默认排除两个模型进程,仅三个配置覆盖全部选定车型
    差异报告 diff_process() / diff_report() 按输出主题收集逐字段差异 报告只是解释差异,不能把预期差异自动视为安全可接受
    模型专项 model_replay() 用相机帧跑道路与座舱模型,检查最大/平均执行耗时 PC 路径使用 0.3 容差;CI 下发现差异仍 failed=False
    模糊测试 test_fuzzy.py 生成随机 Cap’n Proto 输入并回放 明确排除 9 个进程(含安全/控制/模型),只覆盖配置子集

    三类门里最容易被高估的是模型专项。PC 路径忽略多组模型字段且使用 0.3 容差;在 CI 环境中即使比较发现差异,主程序也会把 failed=False 用于报告,不能当作严格数值门。

    框架自身的静态风险

    run_step() 把周期操作置于 Timeout 内,但 _replay_multi_process() 最后冲刷残余输出所调用的 get_output_msgs() 并不在这个超时上下文里;它等待的 wait_for_one_event() 默认超时为 -1。若被测进程在尾部不再接收,回放可能卡住。

    另一条待测路径是容器加入 containers 后 start() 中途失败:finally 仍会调用 stop(),而此刻 rc 可能尚未初始化。这两处都是源码推导出的故障路径,尚未复现,不能用一次正常回放证明框架对崩溃也可靠。

    对插件与硬件重构的验证含义

    1. 保留每个插件的输入/输出主题、schema 版本、触发条件、初始 Params 与模拟环境,才能复用 ProcessConfig 与多进程回放;重构前先保存原版参考输出及模型/车型/固件哈希。
    2. 把「消息值回归」「最坏时延」「故障退出」「panda 安全策略」拆成独立门。processing_time 只是合成排序参数,忽略执行时间字段的回放结果不能证明新硬件满足控制周期。
    3. 模型插件需单独补齐 LFS 权重、NV12 帧、目标设备与更严格数值阈值;新车型还须增加能覆盖转向/制动路径的片段与安全测试。
    4. 本地源码归档没有 .git、ref_commit 或 fakedata;test_processes.py 还要求获取当前 Git commit、远端路段与参考日志。本次不运行这些测试,也不把静态阅读标记为「回归通过」。

    常见问题

    回放输出和参考日志一致,就说明改动是安全的吗?

    不一致是回归信号,但一致只是回归证据。get_output_msgs() 用的是「输入最大时间 + processing_time」的合成时间戳,model_replay 在 CI 下即使发现差异也 failed=False,test_fuzzy 又排除安全/控制/模型进程,因此相等不能证明实车安全、最坏时延或新硬件兼容。

    process_replay 覆盖了多少进程?

    ProcessConfig / CONFIGS 声明 14 个配置,含 selfdrived、card、controlsd 与模型等,这是回放配置而非受管进程全集。默认测试还排除 modeld、dmonitoringmodeld,其余 12 个配置中只有 card、controlsd、lagd 跑全部选定车型。

    比较时哪些差异会被忽略?

    compare_logs() 先过滤忽略主题,清零忽略字段后再递归比较 Cap’n Proto,浮点按绝对/相对容差判断,时间字段常被忽略;但消息数量、顺序或类型不齐会直接报错。忽略字段不等于相等证明。

    为什么回放有可能卡住?

    run_step() 的周期操作在 Timeout 内,但最后冲刷残余输出的 get_output_msgs() 不在该超时上下文,它等待的 wait_for_one_event() 默认超时为 -1;若被测进程尾部不再接收就可能卡住。该路径是源码推导的,尚未复现。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/PROCESS_REPLAY_VALIDATION_DEEP_DIVE.md(回放主链路、14 个配置与迁移/比较/测试门)
    • 项目 02 —— openpilot-research/PROCESS_REPLAY_SCHEMA_MIGRATION_RULES_OUTPUT_VALIDITY_DEEP_DIVE.md(源文档指向的 19 条迁移规则专题)

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

    更新记录

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

  • UBX 分帧的 65535 字节阻塞:GNSS 二进制解析器的三类失效边界

    结论:u-blox UBX 接收链的主要风险不在「字段解不出来」,而在三个输入契约缺口:跨块的前导前缀可能被整段丢弃、16 位长度字段可让一条未完成的长帧长期挡住后续合法帧、字段 DSL 解析完成后不检查尾随字节。三者都不会让进程崩溃,只会让上层在「看起来一直在发布数据」时误判。以下基于源码快照 c8fb906 的静态阅读与隔离探针,未接模组、未做路测。

    从字节块到完整帧:分帧器怎么切

    链路起点是串口:pigeond.run_receiving() 把读取到的字节块发布为 ubloxRaw,ubloxd.main() 按消息接收后交给 UbxFramer.add_data()。分帧器查找前导 B5 62,按头部小端长度取出 6 + payload_len + 2 字节,再用 _checksum_ok() 核对 UBX 的 CK_A/CK_B。坏校验只丢当前第一个字节并重新搜索;好帧完整消费;长度不够则暂存。

    真正微妙的是跨块边界。如果缓存里只剩单字节 B5,add_data() 会在 len(buf)<2 时保留它,下一块可以正常接上。但如果当前块是 00 B5——长度已至少为 2,又找不到完整的 B5 62——find()==-1 分支会清空缓存;下一块即使以 62 接上,合法帧也无法识别。隔离探针用一条校验正确的 B5 62 01 07 00 00 08 19 遍历所有单处切块点均能还原,但 00 B5 后接余下字节返回零帧。

    缓存尾部情形 代码行为 后果
    单字节 B5 len(buf)<2 时保留等待拼接 下一块可正常接出完整帧
    00 B5 等带杂字节前缀 找不到完整前导,find()==-1 清空缓存 下一块的合法帧前缀被丢掉,无法识别
    坏校验帧后接好帧 逐字节丢弃并重新搜索 能滑动一字节并恢复到好帧

    长度阻塞:一条 FF FF 的长帧能挡住什么

    若缓存以 B5 62 01 07 FF FF 开始,长度字段声明了 65535 字节 payload,分帧器在收到总计 65543 字节之前,既不会验证校验,也不会重新搜索前导。此时即使随后追加一条完整合法的短帧,该帧也仍留在缓存里、没有立即输出——隔离探针确认了这个状态。由于长度字段是 16 位,单条候选等待有限,但串口异常或丢字节仍能让后续帧长期被挡住,直到足够字节到达或上层重置。这里没有按消息类型限制长度、等待时间或缓冲年龄的门。

    字段 DSL:解释器契约不等于协议一致

    UBX 报文的字段用一套声明式 DSL 描述,注册、读取、组合、约束各在不同的执行路径上:

    层次 关键入口 输入约束及边界
    schema 注册 BinaryStruct.__init_subclass__() 按类注解声明顺序收集字段并安装逐字段 _read;示例 schema 在 Ubx、Gps、Glonass。
    基础读取 _parse_field() 整数/浮点/字节按 struct 读取并检查剩余长度;位字段按高位先读,切回字节字段时对齐到下一字节。
    组合字段 _parse_field() Switch 用已解析字段选分支;Array 用前序计数逐项读;Substream 先按声明长度切片再解析内部对象。
    类型约束 _parse_field() ConstType 不符即拒绝;EnumType 遇未知枚举值返回原始整数。

    BinaryStruct.from_bytes() 返回 _read() 结果,没有检查 reader 是否恰好耗尽输入;SubstreamType 也不检查内部 reader 的剩余字节。探针 Demo(u8).from_bytes(bytes([7, 99])).field 返回 7,证实尾部多余的 99 不会导致失败。另有几处边界值得记录:未知枚举保留整数;未匹配且无默认值的 Switch 返回 None 且不消费其后输入;数组计数为 2 而第二个 u16 不完整时抛 EOFError;BinaryReader.read_bytes(-1) 会返回切片并把游标从 0 移到 -1——这是通用 DSL 自定义输入的边界,UBX 顶层 length 是无符号 u16,未证明生产可触达。

    还有一层容易误读:真实 Ubx 顶层 schema 的 from_bytes() 会拒绝错误 magic,但 checksum 只是读作 u16、并不计算比对;而生产路径的 parse_frame() 直接截取 payload 按消息类型解析,根本不调用顶层 Ubx.from_bytes()。所以不能把顶层 ConstType 当成接收链的校验依据。SFRBX 是特例:手工检查 len(payload)==8+4*num_words,未知 GNSS ID 会抛异常并被主循环静默跳过。

    星历缓存与时间语义

    GPS 星历路径要求十个 32 位字,把每字右移六位去掉奇偶/填充后得到 30 字节子帧,再按 TLM/HOW 子帧号选择内容。缓存按 sv_id 收集 1/2/3 子帧,三者齐全后检查 IODC 低字节与两个 IODE 相等,才发布并清空;该 GPS 子帧缓存没有时间过期,跨重启/掉线混合只由 IOD 检查部分阻断,不能当作完整的新鲜度证明。GLONASS 路径要求四个字,转 16 字节字符串后按 freq_id 缓存 1-5 号字符串,对已知 superframe 号检查一致性,号未知时用 last_log_time 做约 10 秒窗口相关性判断。

    UbxFramer.last_log_time 在每次 add_data() 开始时更新为当前 ubloxRaw.logMonoTime,一次返回多个帧时仍是同一个最近块时间,它不是逐帧硬件采样时间。而 ubloxd.main() 对解析异常直接 continue 且不记录原因,外层若只看发布频率,无法区分坏校验、schema 错误、未知消息和缓存未齐。

    常见问题

    UBX 分帧器为什么可能丢掉合法的后一帧?

    当缓存里已经有字节却找不到完整前导 B5 62 时(例如块以 00 B5 结尾),find()==-1 分支会清空缓存;下一块即使以 62 接上,也拼不出完整前导,合法帧无法被识别。只有精确剩下单个字节 B5 时才会保留待拼。

    16 位长度字段会带来什么风险?

    若缓冲以 B5 62 01 07 FF FF 开头,长度声明为 65535 字节 payload,分帧器要等累计 65543 字节才验证校验或重搜前导;期间追加完整合法短帧也不会立即输出,且代码没有按类型限制长度、等待时间或缓冲年龄。

    字段解析成功就说明报文版本匹配吗?

    不是。from_bytes() 不检查 reader 是否恰好耗尽输入,尾部多余字节不会导致失败;EnumType 未知值会以原始整数保留。帧层核对的是 UBX 头部声明长度与校验,不代表具体消息 schema 严格匹配 payload 版本或长度。

    星历缓存能证明数据新鲜吗?

    不能。GPS 子帧缓存处没有时间过期,只靠 IODC 与两个 IODE 的检查部分阻断跨重启/掉线混合;GLONASS 在未知 superframe 号时以约 10 秒窗口做相关性判断。last_log_time 是最近块时间,不是逐帧采样时间。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/GNSS_UBX_BINARY_PARSER_DEEP_DIVE.md(UBX 分帧、字段 DSL 与星历缓存,含源码行号索引)
    • 项目 02 —— openpilot-research/probes/test_ubx_framer_stream_boundaries.py(分帧跨块与长度阻塞的四项隔离探针)
    • 项目 02 —— openpilot-research/probes/test_ubx_binary_struct_contract.py(字段 DSL 六项契约探针)

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

    更新记录

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

  • Quectel DIAG 记录契约:7E 分帧、双层长度与时间语义

    结论:Quectel DIAG 接收链的真实风险不在「能不能解出卫星数」,而在三处宽松的输入契约:AT 文本用子串判断状态、7E 分帧没有单帧长度上限与结束符超时、内外两层长度校验只保证封装边界而不保证 payload 与版本/星数匹配。这些缺口都不会让进程崩溃,只会让上层在「日志仍在发布」时误判。以下基于固定源码快照 c8fb906 的静态阅读与隔离探针,未打开 AT/DIAG 端口、未写 NV、未做实板定位测试。

    从 AT 端口到 DIAG 日志:初始化顺序与文本解析

    链路入口先等 AT 端口。qcomgpsd.main() 创建 ModemDiag 并调用 setup_quectel();AT 命令通过 at_cmd() 锁住 /dev/shm/modem.lock,与 modem、LPA 共享所有权。at_cmd() 先取进程间锁再打开串口,写入以 CR 结尾的命令、丢弃回显并返回数据行。DIAG 串口是 /dev/ttyUSB0,以 115200 波特率、RTS/CTS 与 DSR/DTR 硬件流控独占打开。初始化会依次请求 NV 写/读、日志掩码、可选关闭已有 GNSS、DPO/自启配置、可选辅助时间、NMEA 输出、GNSS 启动与 OEMDRE 命令;外层 main() 在 setup_quectel() 返回后才初始化并拉高 GNSS_PWR_EN,信号清理则先拉低该脚再调用 teardown_quectel()。

    这一层的文本解析相当宽松:ERROR 与 +CME ERROR 只作为终止行,不会转成异常;若没有数据行,函数会像收到 OK 一样返回空字符串。wait_for_modem() 只要求端口路径出现并在 AT 文本中找到 +QGPS:,因此它接受 +QGPS: 0 的关闭状态,也没有整体 deadline;gps_enabled() 用 "QGPS: 1" in response 判断,连 +QGPS: 10 都会返回真。这些是文本解析语义,不代表真实模组会给出该状态码。

    重试也是嵌套的:try_setup_logs() 的十次重试嵌在 setup_quectel() 的五次重试内。隔离探针用始终失败的假 setup_logs() 观察到 50 次调用与 55 次一秒睡眠,并且每轮外层重试前都会重复发送 NV 写/读请求。它证明的只是主机调用顺序与软件重试上限,真实串口阻塞、NV 写入效果与总耗时都未验证;两层通用异常还会替换故障报告,无法从最终异常反推底层 DIAG 失败原因。

    层次 字段/机制 核对内容
    分帧 7E / 7D / CRC16 发送附 CRC16/X-25,转义 7D/7E 并以 7E 结束;解码用断言检查结尾与 CRC
    外层封装 <BH> 外层长度须等于内层字节数
    内层封装 <HHQ> 内层长度须等于内层总字节数,并取出 log_type 与模组 log_time

    7E 分帧与一个没有上限的接收循环

    接收端 recv() 以结束符从串口块拼接,并把未配对部分留在 pend,hdlc_decapsulate() 解转义后用断言核对结尾和 CRC。真正的问题是 recv() 没有单帧最大长度,也没有结束符等待超时:连续不含 7E 的输入会持续累积在局部列表中,脚本无法从中判断它是「正在等一帧」还是「已经失控积压」。

    同样要注意容错的边界:CRC、短帧、外层长度或日志版本异常没有在 main() 循环内转成「坏帧计数并继续」,多数会抛出异常或断言,需要由进程监督重启。send_recv() 在等待命令应答时会跳过遇到的 DIAG_LOG_F,所以设置/清理日志期间不能假定每个测量都能被发布。另有一处不对称:setup_logs() 对首个「查询日志 ID 范围」响应只解析内层 operation/status,没有检查外层 opcode,而后续逐类别设置 mask 的响应则显式断言 opcode;这证明的是主机解析门缺失,而非模组会发出这种交错回包。

    三条测量记录的字节边界

    DIAG 帧取到操作码后只处理 DIAG_LOG_F,随后用 parse_struct() 把声明字符串转成无隐式对齐的小端 struct 格式;名字含 _Flt 时即使声明为 uint32 也按 IEEE-754 float 读,含 _Dbl 则按 double 读。dict_unpacker() 使用 unpack_from,只要求输入长度至少为 schema 大小,不校验恰好耗尽;因此插件若沿用 C 结构体的默认 padding,或只看类型名,会错读浮点与后续字段。

    记录 头部 / 单星长度 主循环中的检查与边界
    OEMDRE 测量 95 / 109 字节 检查版本 2,按 svCount 切片并逐星 unpack_from;切片前不验证 95+109*svCount == payload_len,短片抛解包异常、尾随字节可被忽略
    GPS 测量 28 / 70 字节 检查版本 0;svCount>0 时用 len(sats)//svCount == 70(非精确总长度,两星多 1 字节仍通过);svCount==0 时不检查剩余长度
    GLONASS 测量 29 / 70 字节 与 GPS 共用星数检查,两者状态位扩展不同

    上述长度来自对 dict_unpacker() 的纯 Python 探针:OEMDRE (95,109)、GPS (28,70)、GLONASS (29,70);同一探针确认头部末尾额外 1 字节仍可解出字典,且 (2*70+1)//2 == 70。这证明了解码器的具体边界行为,但不证明模组实际会发送异常帧。

    时间、定位与有效性语义

    qcomGnss 的 union 承载 DR 测量、SV 多项式与 GPS/GLONASS 测量等。主循环把 DIAG 内层的 log_time 原样写入 logTs,没有在此处转换成 Unix 或 Linux 单调纳秒;发布消息的 logMonoTime 是消息构造时间,不能替代模组的测量时间。

    gpsLocation 映射只接受 u_PosSource==2 且 GPS week 非 0xFFFF 的位置报告,把弧度经纬/航向转成角度、ENU 速度变为 NED;hasFix 以 verticalAccuracy != 500 判定,而该字段取自 q_FltVdop(垂直精度因子),能否当作以米计的垂直精度需要模组协议与实测核对,不能靠字段名推断。Unix 时间由 GPS 周与周内毫秒减固定 18 秒生成,随后由 timed 根据定位主题的新鲜度、hasFix 与时间范围决定是否校时。

    常见问题

    DIAG 接收为什么会持续积压?

    因为 recv() 没有单帧最大长度,也没有结束符等待超时;连续不含 7E 的输入会持续累积在局部列表中,脚本无法区分「正在等一帧」与「已经失控积压」。

    外层与内层长度校验通过就说明 payload 匹配吗?

    不是。两层长度只保证封装边界,不保证该类型 payload 与版本/星数匹配。OEMDRE 在切片前不验证 95+109*svCount == payload_len,尾随字节可被忽略。

    GPS 测量两颗星时多 1 字节为什么仍能通过?

    因为主循环用的是 len(sats)//svCount == 70,而不是精确总长度校验;整数除法让多出来的 1 字节被容忍,svCount==0 时甚至不检查剩余长度。

    verticalAccuracy 能当作以米计的垂直精度吗?

    不能直接这样用。它取自 q_FltVdop,其声明注释是垂直精度因子;是否等于以米计的垂直精度,需要模组协议与实测核对,不能仅凭 hasFix 的判定条件推断。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/GNSS_QUECTEL_DIAG_RECORD_CONTRACT_DEEP_DIVE.md(AT/DIAG 调用链、7E 分帧与三条记录的字节边界,含源码行号索引)
    • 项目 02 —— openpilot-research/probes/test_quectel_modemdiag_contract.py(CRC16/X-25、转义往返、分块保留与 send_recv() 跳帧六项探针)
    • 项目 02 —— openpilot-research/probes/test_quectel_nested_setup_retry_contract.py(内层十次嵌套外层五次的调用次数与睡眠统计)

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

    更新记录

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

  • 17 个品牌参数怎么构建:CarParams 冻结只到顶层,嵌套字典仍可变

    结论:车型参数不是一张静态表,而是「17 个品牌枚举 → PLATFORMS 候选字典 → CarSpecs 尺寸 + PlatformConfig 文档与 DBC → 品牌 _get_params() 覆写」的多段构建结果。冻结(freeze)只作用于配置对象这一层:flags 被锁定后不可改,但嵌套的 dbc_dict 与 car_docs 仍可原地修改,override() 返回的还是未冻结副本。因此「能生成 CarParams」只说明字典装载成功,既不等于该车型被识别,也不等于控制器获准发送。以下为固定源码快照 c8fb906 的静态结构分析。

    参数构建的顺序与产物

    values.py 汇集 17 个品牌的 CAR 枚举,并生成候选名到平台对象的 PLATFORMS 字典。每个平台带着三样东西:描述整车尺寸的 CarSpecs、记录文档与 DBC 映射的 PlatformConfig,以及一组 flags。枚举构造时给配置填 platform_str 并冻结配置对象;CarSpecs 本身是 frozen dataclass。

    真正把参数拼起来的是两段函数。get_std_params() 先写入车型名、横向最大加速度文档值、控制类型、轮速系数、PCM 巡航与纵向缺省;随后 get_params() 复制平台规格并调用品牌 _get_params()。这个钩子可以依据 CAN 指纹、固件、开发/发布模式设置总线、雷达、纵向与控制调参,是品牌策略的主要落点。

    品牌覆写之后,普通汽车会加 STD_CARGO_KG=136 kg,再按以 Civic 为基准的比例公式估计 rotationalInertia 与 tireStiffnessFront/Rear;notCar 跳过载荷。这里要强调:刚度与惯量是启发式缩放,不是本次车辆的在线辨识结果,自制硬件接入车型时这些动力学参数不保证准确。

    阶段 入口 产出或作用
    平台注册 values.py / PLATFORMS 17 个品牌候选名映射到平台对象与 PlatformConfig。
    共同缺省 get_std_params() 写入车型名、控制类型、轮速系数、巡航与纵向缺省。
    品牌覆写 品牌 _get_params() 按指纹/固件/模式设置总线、雷达、纵向、panda 安全配置。
    载荷与动力学 STD_CARGO_KG 与缩放公式 非 notCar 加 136 kg,估计转动惯量与轮胎刚度。

    冻结边界:顶层不可变,嵌套仍可变

    隔离探针确认了一个容易被误读的边界:创建 Platforms 子枚举时,PlatformsType.__new__() 给配置写入平台名并冻结,随后直接改 flags 会抛异常;但 dbc_dict 的条目与 car_docs 列表仍可原地修改。

    更关键的是 override() 的语义。对已冻结配置调用 override(flags=...) 会通过 dataclasses.replace() 产生一个未冻结的新对象,且新旧配置共用同一 dbc_dict 引用;相对地,CarSpecs.override() 返回的新规格仍是 frozen dataclass。也就是说,「冻结的是配置对象属性」不能被误解为整个嵌套对象图都经过不可变性与签名校验。自制插件若用 manifest 描述车型,需把嵌套映射与列表做不可变快照或深拷贝并纳入签名与哈希,不能只依赖顶层 freeze()。

    扭矩数据与横向回调

    横向能力由三份 TOML 与一组回调共同决定。get_torque_params() 读取 substitute.toml、params.toml、override.toml,以 legend 解释三列数值,结果缓存在进程内;同一候选若直接出现在多个 TOML 会抛异常,替代目标则按 override 优先、params 次之取值。configure_torque_tune() 设置 FRICTION、LAT_ACCEL_FACTOR、零 offset 与角度死区。

    缺省前馈 get_steer_feedforward_default() 约为目标转角乘车速平方,GM 等品牌可重写。缺省的 torque_from_lateral_accel_linear() 与 lateral_accel_from_torque_linear() 按 latAccelFactor 正反映射,品牌同样可替换。要注意 MAX_LAT_ACCEL_MEASURED 用于 CarParams.maxLateralAccel,而摩擦与侧向加速度因子进入扭矩调参,三者不能互换。

    源或函数 实际作用 关注点
    get_torque_params() 读取三份 TOML 并缓存结果 同候选出现在多个 TOML 会抛异常;替代目标按 override 优先。
    configure_torque_tune() 设置摩擦、侧向加速度因子、零 offset 与角度死区 数据表与平台覆写共同决定控制器输入,不能假设热改立即生效。
    get_steer_feedforward_default() 缺省前馈约为目标转角乘车速平方 GM 等品牌可重写,不是跨车型通用标定。
    torque_from_lateral_accel_linear() 按 latAccelFactor 正反映射扭矩与侧向加速度 需验证符号、单位、摩擦与零点。

    实例运行契约与基类扩展点

    CarInterfaceBase.__init__() 创建品牌 CarState、其 CANParser 集合与 CarController,并把解析器的 DBC 名传给控制器。update() 更新所有解析器、调用品牌状态函数、汇总 canValid/canTimeout 并处理仪表速度与按键;apply() 委派品牌控制器生成回报与待发 CAN 帧,但它不负责硬件限幅。

    仓储里还有几个常被忽略的基类语义:RadarInterfaceBase.update() 缺省每五次调用返回空雷达数据、其余返回 None;init()/deinit() 供品牌实施 ECU 初始化或恢复,基类为空,且 deinit() 在 card 退出时不保证被调用;get_interface_attr() 动态导入品牌 values/fingerprints 属性,ImportError/OSError 会被静默跳过,因此它并不是可靠的插件装载审计。

    基类接口 职责与边界
    __init__() 创建品牌 CarState、CANParser 与 CarController;DBC 名传给控制器。
    update() 更新解析器、汇总 canValid/canTimeout、处理仪表速度与按键。
    apply() 委派品牌控制器生成回报与待发帧,不负责硬件限幅。
    RadarInterfaceBase.update() 缺省每五次返回空雷达数据,其余返回 None,有雷达平台须覆写。
    init()/deinit() 品牌可实施 ECU 初始化或恢复;deinit() 不保证在 card 退出时被调用。
    get_interface_attr() 动态导入品牌属性,异常静默跳过,非可靠装载审计。

    插件化边界与验证缺口

    面向自制硬件,源文档给出的是一组建议设计而非已实现能力:平台插件以版本化 manifest 声明车型、硬件拓扑、DBC 哈希、固件证据、单位、参数范围与所需安全模式;参数构建拆成纯函数与受限生命周期钩子,_get_params()、init() 不应由普通诊断或 UI 插件获得原始 CAN 发送权;行车期间冻结参数与插件版本,重启后再重新识别与验证。

    同时应区分四个层级的门:「可生成 CarParams」「CANParser 正常」「控制器允许发送」「MCU 允许执行」;任一失败都应退回只读或 noOutput。需要提醒的是,现有测试只覆盖参数基本范围与空 CAN 下的不崩溃行为,不覆盖真实车型识别、DBC 真实性、全部 _get_params() 分支与实车动力学。自研硬件仍需要离线日志、台架与实车的分级验证。

    常见问题

    冻结后的 PlatformConfig 真的完全不能改吗?

    不是。冻结只锁住配置对象属性:flags 不可改会抛异常,但嵌套的 dbc_dict 条目与 car_docs 列表仍可原地修改。override() 还会通过 dataclasses.replace() 生成未冻结新对象,并且新旧配置共用同一 dbc_dict 引用。插件若依赖不可变性,需自行做深拷贝并纳入签名。

    STD_CARGO_KG 是 136 kg 吗,为什么有的车型不加?

    源文档给出 STD_CARGO_KG=136 kg,普通汽车在品牌覆写后加上,notCar 跳过载荷。转动惯量与轮胎刚度也是按以 Civic 为基准的比例公式估计的启发式缩放,不是本次车辆的在线辨识结果。

    get_non_essential_params() 能当作上车参数吗?

    不能。它用空 CAN 指纹、空固件及关闭的特性开关构建参数,自身文档明确警告缺少真实 FW/指纹时控制必需参数可能不完整或错误,适合文档、测试与离线展示,不是上车控制参数的证明。

    同一个候选车型出现在多个扭矩 TOML 会怎样?

    会抛异常。源文档说明:同一候选若直接出现在多个 TOML 会抛异常;被替换的目标则按 override 优先、params 次之取值。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/CAR_PLATFORM_PARAMS_CONTRACT_DEEP_DIVE.md(参数构建顺序、冻结边界、扭矩 TOML 与基类实例契约)。
    • 项目 02 —— openpilot-research/ 同目录探针记录:四项平台配置冻结契约与假平台枚举隔离探针结论。

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

    更新记录

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