标签: openpilot

  • 座舱 DMS 推理链:1440×960 warp 与 6 维不确定度解码

    结论:座舱 DMS 的链路是 camerad 座舱帧经固定 1440×960 luma warp 进入 tinygrad 编译模型,输出一维向量再按元数据切片解码为左右驾驶员的脸姿态、位置与八类概率。发布消息的顶层 valid 恒为真,它只表示组包正常,不是摄像头视角、帧年龄、标定有效性与模型置信度都合格。本文为固定源码快照 c8fb906 的静态分析,未运行模型或图像回放。

    进程、构建与消息链

    manager 进程表在驾驶员视角条件下启动模型进程,非 webcam 的 PC 默认禁用。main() 等待 camerad 的座舱 VisionIPC,连接后按流报告的宽高加载模型和 warp 产物;每次 recv() 返回缓冲才运行并发布 driverStateV2。模型输出服务名义 20 Hz,真正频率由相机帧与推理耗时决定。进程只订阅 extrinsicsCalibration,没有在循环中对该主题做 SubMaster.all_checks() 门。

    构建侧 SConscript 从 dmonitoring_model.onnx 编译 dmonitoring_model_tinygrad.pkl,并为源码列出的两组座舱相机尺寸生成独立 dm_warp_<宽>x<高>_tinygrad.pkl。warp 目标为 DM_INPUT_SIZE=(1440,960),只使用亮度平面、边界填充值 16、变换矩阵放在 NPY 设备;NV12 stride/高度/缓冲大小由 get_nv12_info() 计算。缺少匹配尺寸的 warp 产物时,模型初始化会在打开文件处失败,而不是自动支持任意 USB 摄像头。

    组件/函数 实现功能 边界
    ModelState.__init__() 读取 OOB 模型,解析输入形状/输出切片元数据,预分配模型输出及相机尺寸对应 warp。 load_oob() 依平台选 QCOM/METAL/CPU:LLVM,构建产物与后端必须兼容。
    ModelState.run() 写入外参 calib,用 VisionBuf 内存指针建立 tinygrad 张量,执行 warp 与模型,拷贝一维 float32 输出。 按缓冲指针缓存零拷贝张量;前提是上游环形缓冲布局和生命周期稳定。
    slice_outputs() 按 OOB 元数据中的切片把一维输出分为各预测头。 分割布局不硬编码在 Python 中;模型升级必须同步元数据。
    parse_model_output() sigmoid 转概率,脸部描述向量末六维经 safe_exp 变为标准差。 safe_exp() 裁上界 11 抑制指数溢出;这里不做完整 isfinite 校验。
    fill_driver_data() 将左右侧各自的姿态、脸位置/标准差和八类概率写入消息。 姿态取描述向量前三维,位置取第 4-5 维。
    main() 接流、读取最新外参、逐帧推理、解码并发送。 只在有新外参消息时复制 rpyCalib,未显式检查其顶层 valid。

    图像几何与内参选择

    首次收到帧后,main() 按 buf.width 是否等于 _os_fisheye.width 选择 _os_fisheye 或 _ar_ox_fisheye 内参,并计算目标模型内参到原相机内参的逆变换;其后不随外参变化重算图像 warp。八项原 main() 假 VisionIPC/模型探针确认:首个非空帧按宽度是否等于 _os_fisheye.width 选择内参、不检查高度,生成的 model_transform 此后锁存,后续缓冲宽度变化也不重算。recv() 为空时立即继续,既不轮询外参也不发布。

    外参 rpyCalib 被单独送进模型 calib 输入,不是这个固定的图像 warp 矩阵。外参长度不匹配会在推理前抛错;推理抛错则本帧不发布。SEND_RAW_PRED 判断的是非空字符串,值为 '0' 同样会携带原始输出字节。两组相机的宽高/焦距来自硬编码配置,源码还提示其中一组鱼眼焦距近似值可能不精确。自制相机不能只通过改 VisionIPC 流名完成适配,需重做标定/warp 与质量验证。

    输出切片与不确定度解码

    slice_outputs() 仅按元数据切片并加 batch 轴,切片超出原向量时得到短数组而不报长度错误。parse_model_output() 把每侧描述向量的末六维经 safe_exp 转标准差,其余前段作为姿态/位置;fill_driver_data() 只取前三项姿态和第 4-5 项位置及对应五项标准差。极大标准差 logit 在 11 截顶,NaN 仍传播;sigmoid 借用这个截顶指数,因此极负 logit 的概率下限约为 1/(1+exp(11)),不是精确零。

    解码同时输出 LHD/RHD 两套人脸结果及 wheelOnRightProb。下游 DriverMonitoring._update_states() 按 RHD 学习结果选择一侧,然后根据脸概率/姿态标准差决定是否把分心类型纳入提醒。因此 driverStateV2 是预测,不是已完成的驾驶员状态判定。八项原切片/解析/组包探针确认:合成的九维脸描述可在位置切片为空时仍由 get_driverstate_packet() 组出顶层 valid=True 消息,这只证明缺少形状校验,不代表真实模型会输出九维描述。

    构造输入 原函数观察结果 验证范围
    切片超出原向量 得到短数组,不报长度错误 只证明切片函数不独立校验长度。
    极负 logit 概率 下限约 1/(1+exp(11)),非精确零 sigmoid 借用 11 截顶指数。
    位置切片为空的九维描述 仍组出顶层 valid=True 消息 缺少字段完整性校验。
    改变 location_ts 假消息各字段不受影响 发布结构不含相机 SOF 时刻。

    发布函数直接创建 valid=True 的顶层消息;它表示此函数正常组包,不是摄像头视角、帧年龄、标定有效性、模型置信度均合格的保证。main() 调用时传入 vipc_client.timestamp_sof,但 get_driverstate_packet() 不使用该参数;消费者只有消息总线自己的 logMonoTime、帧 ID 和模型耗时,不能从这个字段推断原始相机 SOF。策略端的脸姿态转换使用固定 1928×1208 参考尺寸和焦距 598,而相机配置中的 cabin 焦距为 567;自制镜头/warp 不能只替换上游像素流而保留这套转换假设。

    验证现状与插件边界

    本地 dmonitoring_model.onnx 只有 Git LFS 指针,目录未有运行所需的座舱 tinygrad .pkl/warp 文件,故不能在当前归档执行真实推理或验证数值精度。process_replay 定义座舱模型回放,使用 cabinCameraState/VisionIPC 帧作为输入并忽略运行耗时字段;这仅证明仓库有回放入口,不代表当前快照已通过。环境变量 SEND_RAW_PRED 非空时把原始浮点输出写进 rawPredictions;此数据可能含座舱行为信息,日志/插件应考虑隐私和存储边界。

    插件化时,可先把座舱采集和推理作为只读影子插件,契约至少包含像素格式、宽高/stride/缓冲生命周期、内参与 warp 版本、外参来源、模型/输出切片版本、SOF 时间戳、帧年龄、概率及标准差校验、隐私分级。权威 driverStateV2 只能有一个发布者;插件不能把缺帧或低质量预测宣称为「驾驶员注意」,也不能绕开 dmonitoringd 安全策略与独立执行网关。

    常见问题

    driverStateV2 的 valid=True 能说明什么?

    它只表示 get_driverstate_packet() 这个函数正常组包,不是摄像头视角、帧年龄、标定有效性、模型置信度均合格的保证。探针确认合成的九维脸描述在位置切片为空时仍可组出顶层 valid=True 消息。

    座舱相机换成别的分辨率还能直接用吗?

    不能。warp 目标是固定的 DM_INPUT_SIZE=(1440,960),构建脚本只为源码列出的两组座舱相机尺寸生成 dm_warp_<宽>x<高>_tinygrad.pkl。缺少匹配尺寸的 warp 产物时,模型初始化会在打开文件处失败。

    为什么极负的 logit 概率不是 0?

    因为 safe_exp() 把指数上界裁到 11,而 sigmoid 借用了这个截顶指数,所以极负 logit 的概率下限约为 1/(1+exp(11)),并非精确零。

    帧 ID 能用来还原相机的原始时刻吗?

    不能。main() 虽然传入 vipc_client.timestamp_sof,但 get_driverstate_packet() 不使用该参数,发布结构里只有帧 ID、CPU/模型耗时与总线时间戳,不能据此推断原始相机 SOF。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/DRIVER_MONITORING_MODEL_PIPELINE_DEEP_DIVE.md(座舱图像、模型推理与预测契约、输出解码与几何假设)。
    • 项目 02 —— 同目录探针记录:六项初始化产物、八项主循环帧、七项缓冲缓存、八项切片/解析/组包、七项脸姿态几何隔离探针结论。

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

    更新记录

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

  • 更新到启动要跨 3 个标记:一致标记不是签名证明

    结论:openpilot 的代码更新从「候选版本」走到「启动版本」要跨过三个独立标记:staging 目录、finalized 定稿、以及 .overlay_consistent 一致标记。它们各自的含义不同——一致标记只是「定稿流程走完」的本地记录,不是代码签名证明;UI 的「可安装」与「更新进程退出」也都不等于新代码已经激活。真正切换发生在启动脚本移动目录并 exec 重启的那一刻。以下为固定源码快照 c8fb906 的静态分析与假依赖探针结论。

    生命周期与关键阶段

    updated 被配置为非 PC 的驻车进程;main() 在 DisableUpdates 时退出,随后取得排他锁、初始化参数与等待器。初次循环或系统时间无效会等待 60 秒;之后检查远端,下载成功后长间隔等待,失败后较短间隔重试。需要强调:更新进程退出或 UI 显示「可安装」都不等于已经激活新代码。

    人工请求走信号而非持久任务:UI 给 updater 发 SIGUSR1 表示只检查、SIGHUP 请求下载,请求由内存状态消费,不是持久队列。远端检查用 git ls-remote 获取分支 SHA,跳过旧 release2 分支,tizi/mici 对 release3 目标分支有设备映射。

    阶段 关键入口 实际行为
    人工请求 WaitTimeHelper、SoftwareLayout._on_download_update() UI 发 SIGUSR1 只检查、SIGHUP 请求下载;由内存状态消费,非持久队列。
    overlay 初始化 init_overlay() 依据 .overlay_init 与 .git 修改时间决定复用或重建 staging;重建会删除整个 STAGING_ROOT。
    远端检查 Updater.check_for_update()、target_branch 用 git ls-remote 获取分支 SHA,跳过旧 release2;设备型号映射 release3 目标分支。
    下载与系统维护 Updater.fetch_update()、handle_agnos_update() 先清一致标记,再 fetch/强制 checkout/reset/clean 与递归子模块更新;AGNOS 目标版本不同时写目标 slot。
    finalized 定稿 finalize_update()、set_consistent_flag() 把 merged 拷到独立 finalized,重置主仓/子模块并尝试 Git/LFS 清理,最后写 .overlay_consistent。
    状态发布 Updater.set_params() 写失败计数、分支、版本/说明、下载/安装可用标志与离线告警。
    激活 launch_chffrplus.sh 检查标记与 mtime,把旧目录移作备份、把 finalized 移入运行目录,exec 重启脚本。

    staging:overlay 的复用与重建

    init_overlay() 依据 .overlay_init 与 .git 修改时间决定复用还是重建 staging。重建前会清 UpdateAvailable 和一致标记、卸载旧 merged,再创建 OverlayFS 并记录 GitDiff;重建会删除整个 STAGING_ROOT。fetch_update() 先清一致标记,再在 merged 中 fetch、强制 checkout/reset/clean 和递归子模块更新。假命令探针确认了这一阶段顺序,并提示破坏性 Git 命令不应在研究工作区执行。

    三个标记分别证明什么

    第一个标记是 staging 的 .overlay_init,表示本地 overlay 已初始化;启动脚本用它判断是否复用。第二个是 finalized 目录,表示 merged 已被复制成独立候选,且主仓与子模块被重置。第三个是 .overlay_consistent,由 finalize_update() 最后写入;隔离探针确认:git gc 抛 CalledProcessError 时 LFS prune 被跳过但仍置标记,LFS prune 自身抛错也仍置标记;主仓 reset 抛错则函数离开且不置标记。也就是说,一致标记不等于复制完整性、fsync、签名或断电恢复已通过。

    标记或状态 计算依据 不保证什么
    .overlay_init 本地 overlay 是否初始化 不代表候选代码有效或可启动。
    finalized + .overlay_consistent 定稿流程是否走完 不校验 finalized 提交签名,清理失败仍可能置位。
    UpdateAvailable finalized 一致标记 + 当前版本差异 + 目标分支一致 探针显示远端分支表为空时仍可能为真,语义是「可拉取」。
    UpdaterFetchAvailable 分支表非空后比较 merged 与远端 SHA/分支 表示「可拉取」,不是「可启动」。

    激活与失败路径

    启动脚本 launch_chffrplus.sh 在启动时检查当前目录 .overlay_init、Git 目录 mtime、finalized 一致标记,且不能已有 old_openpilot;然后把旧目录移到 staging 备份、把 finalized 移入运行目录并 exec 重启脚本。UI 的安装按钮只是写 DoReboot,不是就地切换。当启动脚本发现 old_openpilot 已存在时只打印「backup found, not updating」,源码中的 TODO 明确尚未实现自动恢复。

    失败路径同样需要看清:main() 对 CalledProcessError 与其他异常记录错误并删除 .overlay_init,下一轮倾向重建 overlay;失败次数在进程内重新从 0 开始(源码有加载 Params 的 TODO)。成功检查但因计费网络跳过 fetch 时,循环仍把失败次数归零并更新检查时间,只有实际 fetch 成功才写 UpdaterLastFetchTime。因此不能把目录备份等同于经验证的断电回滚方案:若新版本能启动到 init_overlay(),重建 STAGING_ROOT 会清掉旧备份;若新版本在此之前无法启动,当前脚本没有自动回滚分支。

    对自制硬件的约束

    源文档给出一组建议:插件包、代码、模型、DBC 与独立安全固件需要同一个可验证兼容性集合,仅依赖 .overlay_consistent 或 Git 分支名不足以证明可上车;下载、完整性验证、激活、启动成功确认与回滚应是分离的受信任阶段,控制相关插件只能在驻车维护窗口替换且失败后保留可启动的旧版本。

    此外,UI 的 UpdateAvailable、下载状态与安装按钮只是 Params 视图;硬件平台应另有启动槽健康、镜像签名、反回滚与 watchdog 证据,不能据页面状态宣称部署完成。设计断电、存储满、子模块缺失、镜像版本不匹配等注入测试,验证「旧版本仍能启动」,是当前源码没有提供的验证结果。

    常见问题

    UI 显示「可安装」是不是就能直接用了?

    不是。UI 的安装按钮只写 DoReboot,真正切换由启动脚本移动目录并 exec 重启完成。更新进程退出或显示「可安装」都不等于新代码已激活。

    .overlay_consistent 能证明代码签名吗?

    不能。它由定稿流程最后写入,探针显示清理命令失败仍会置标记,主仓 reset 失败才不置位;源码没有单独校验 finalized 提交的签名。它不是代码签名证明。

    升级失败会自动回滚到旧版本吗?

    依源码没有自动回滚实现。发现 old_openpilot 时只打印「backup found, not updating」,源码 TODO 明确尚未实现;若新版本无法启动,启动脚本也没有自动回滚分支。

    UpdateAvailable 和 UpdaterFetchAvailable 有何区别?

    UpdateAvailable 依据 finalized 一致标记、当前版本差异与目标分支一致性计算;UpdaterFetchAvailable 在分支表非空后比较 merged 与远端 SHA/分支,语义是「可拉取」。两者都不是「可启动」的证明。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/UPDATER_STAGING_ACTIVATION_DEEP_DIVE.md(updated 生命周期、overlay staging、三个标记、激活与失败路径)。
    • 项目 02 —— openpilot-research/ 同目录探针记录:定稿、远端分支与拉取阶段各四项假依赖隔离探针结论。

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

    更新记录

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

  • 路段来源发现的缓存陷阱:HEAD 2xx 与无 TTL 假值

    结论:路段读取的可靠性瓶颈不在解码,而在「来源发现」这一步:internal_source_available() 与 file_exists() 用无期限 functools.cache 保存布尔结论,端点恢复或文件删除后本进程仍会复用旧值;HTTP HEAD 只要状态为 2xx(哪怕缺 Content-Length)就判存在;auto_source() 的返回顺序取决于首次找到该段的来源,而非段号。以下基于本地 openpilot c8fb906 源码归档的静态阅读与隔离探针,未访问网络、未运行日志回放。

    标识解析:分享链接与直接输入分流

    入口 LogReader._parse_identifier() 先把 useradmin/connect 分享链接改写成路段标识,再把 HTTP(S)、cd:/ 或当前存在的本地文件视作直接输入,其余按 SegmentRange 走多来源发现。分流规则里有几处只做「字符串处理」却不做「身份校验」的地方。

    输入 原函数结果 解释
    useradmin ?onebox=<route>&onebox=other parse_qs() 解码后取第一个 onebox 值;缺键抛 KeyError 这是参数提取,不是分享链接真实性验证
    https://example.invalid/?ref=useradmin.comma.ai&onebox=<route> 仍触发 useradmin 分支并提取路段 代码检查整串是否包含域名字样,而非 urlparse(...).hostname;不能把此分支当作可信来源认证
    connect 路段 /119/120/q 秒数整除 60 后输出 /1:3/q 结束片段再加一,形成右开区间;跨边界会包含结束秒所在片段
    connect 路段 /180/0 输出 /3:1;非数字秒数抛 ValueError 此层不检查时间先后或范围非空,后续切片可能给出空清单
    不存在的 HTTP(S) URL、cd:/、本地路径 前两者仍判直接输入;本地路径只有 Path.exists() 为真才直读 直读分类不证明远端可达或文件内容完整;本地新路径会落到路段解析

    来源序列与回退

    auto_source() 默认按 internal → comma API → openpilotci → car segments 的顺序收集所需段号。RLOG 只查 rlog,AUTO 先查 rlog 再为缺段找 qlog;部分来源的结果被合并进字典,异常被记录后继续下一个来源。回退因此可以混合来源和日志类型。涉及具体函数时:eval_source() 对每个段号按候选 URL 顺序调用 file_exists()、首个存在即选中,候选优先级只反映可访问性和列表顺序,不反映版本、完整性或内容覆盖。

    一个容易忽略的实现细节是返回顺序:auto_source() 返回 list(valid_files.values()),而不按段号排序。隔离探针用内存假来源确认,请求片段 [0,1,2] 而来源依次提供 2、0、1 时,返回路径顺序为 [2,0,1],即「晚段先找到」会让输出顺序偏离段号。

    来源回退是按轮累积的:每轮保留已找到的片段,只为仍缺的段继续尝试下一个来源,因此最终清单可能同时包含 rlog 与 qlog;AUTO 会在 rlog 缺失时再为该段补查 qlog。

    无 TTL 缓存:假值与真值都会持续

    internal_source_available() 与 file_exists() 都使用无期限 functools.cache。举例来说,某段 rlog 的 HEAD 初次返回非 2xx,这个结果会在同一 Python 进程内保持为假;稍后远端上传完成,即使重新调用 LogReader.reset(),来源探测仍可复用旧假值——因为 reset() 只重建标识列表,不清这两个函数的缓存。反过来,旧真值在远端删除或权限撤销后,也会让发现阶段继续选中,直到真正读取时才失败。

    file_exists() 以 HEAD 状态为 2xx 且长度不等于 -1 判存在;2xx 且缺 Content-Length 时长度默认为 0,仍判存在。它既不验证可读取,也不核对内容摘要。这些结论是缓存键和调用链推导,未做网络时序故障注入。

    混合来源与额外段号

    混合来源隔离探针进一步显示宿主边界的缺口:AUTO 模式在缺 1 时只对 [1] 继续查询 qlog,但最终结果仍按发现顺序为 [rlog-2, rlog-0, qlog-1];若来源返回 {0: path0, 99: path99},即使只请求片段 0,auto_source() 也返回额外的 99。这里没有对来源返回的键集合做交集或身份校验。也正因如此,comma_api_source() 若被要求一个超出该路线 max_seg_number 的段号,列表索引会抛异常,字典推导式不能返回其他已找到的片段,上层只能改尝试下一来源。

    三套缓存键规则的差异

    归档里同时存在三套缓存命名规则,只有前一套是遗留辅助函数,另外两套才是活跃路径;把它们混为一谈会得出错误的「碰撞」结论。

    命名空间 键规则 备注
    遗留 tools/lib/cache.py 导入时读 CACHE_ROOT,否则 ~/.commacache;把 / 直接替换为 _,远端键忽略 scheme、端口、查询串 归档内检索只找到定义、无调用方;隔离探针确认 /a/b 与 /a_b、带不同查询串的两个 URL 会得到相同键
    URLFile.hash_url() 用各自 URL 哈希 活跃下载缓存路径
    file_downloader.cache_file_path() 用各自 URL 哈希,目录来自 Paths.download_cache_root() 的 COMMA_CACHE/OPENPILOT_PREFIX 规则 不用旧辅助函数的 CACHE_ROOT

    三种键规则都不提供内容哈希验证。迁移插件缓存时应只保留明确使用的一套命名空间,并测试规范化和版本键,不要把未调用的辅助函数误当运行事实。

    来源拼接本身也不做校验:openpilotci.get_url() 把 route 名中的 | 换成 /,再直接把 segment_num/filename 拼到固定容器 URL,函数自身不校验或 URL 编码路径片段;FileReader() 则把 cd:/ 先映射内部端点、本地路径包装 DiskFile、远端用 URLFile。再次强调,发现阶段的 HEAD 成功与读取阶段的成功是两个独立事件。

    常见问题

    为什么远端文件已上传,重试仍然失败?

    因为 internal_source_available() 和 file_exists() 使用无期限 functools.cache,旧假值会保留;LogReader.reset() 只重建标识列表,不清这两个函数的缓存。

    HEAD 返回 2xx 就代表文件可用吗?

    不代表。2xx 且缺 Content-Length 时长度默认为 0、仍判存在;它既不验证可读取,也不核对内容摘要。发现阶段成功与读取阶段成功是两个独立事件。

    auto_source() 的返回列表是按段号排序的吗?

    不是。顺序取决于首次找到该段的来源;探针确认请求 [0,1,2] 而来源依次给出 2、0、1 时,返回顺序就是 [2,0,1]。需要按段号处理时应显式排序。

    来源插件返回了额外段号会被过滤吗?

    不会。探针确认即使只请求片段 0,auto_source() 也会返回来源附带的额外段号 99,宿主没有对来源返回的键集合做交集或身份校验。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/LOG_SOURCE_AVAILABILITY_CACHE_DEEP_DIVE.md(来源发现、存在性缓存与回退契约,含源码行号索引)
    • 项目 02 —— openpilot-research/probes/test_logreader_identifier_dispatch_contract.py(分享链接与直接输入分流的五项隔离探针)
    • 项目 02 —— openpilot-research/probes/test_logreader_auto_source_contract.py(混合来源顺序与额外段号的隔离探针)

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

    更新记录

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

  • 纵向 MPC 候选合成:10 秒 12 段时域与最小加速度选择

    结论:纵向规划器每帧把 MPC 目标加速度、巡航候选和实验模式的 e2e 候选放在一起,取最小加速度作为 aTarget,但 shouldStop 是对全部候选求或——所以两者可能来自不同候选,不能按单一来源重构。最终还要裁到车型接口常量 ACCEL_MIN=-3.5、ACCEL_MAX=2.0,再交给 LongControl。本文为固定源码快照 c8fb906 的静态分析,数学建模说明不等于数值收敛或车辆级认证。

    进程与消息链

    manager 进程表在非纵向机动实验的行车模式运行 plannerd。main() 等待 CarParams,以 modelV2 轮询;只有本轮模型主题更新才调用纵向规划并发布 longitudinalPlan,然后运行车道偏离检测、发布 driverAssistance。两个输出各自写 sm.all_checks() 作为顶层有效位,规划计算本身没有被该检查提前短路。longitudinalPlan 名义频率为 20 Hz,真实时延受输入消息年龄和求解耗时影响。

    输入包括 modelV2 模型动作与油门概率、radarState 两个前车、carState 车速/巡航速度/转角、controlsState 纵向状态与强制减速、vehicleParameters 转向零偏,以及 selfdriveState 模式与驾驶风格。MPC 本体把自车建模为位置、速度、加速度三维状态,以 jerk 为控制量。

    候选是怎么合成的

    LongitudinalPlanner.update() 同步车况、MPC、巡航和可选 e2e 候选,选择目标并推进期望车速滤波。它在 controlsState.forceDecel 时把巡航目标速度置零;纵向控制关闭、巡航速度未初始化或非自控纵向且尚未使能时重置内部目标。carControl.orientationNED 可用时以俯仰估计滑行加速度,否则以 ACCEL_MAX 作缺省值;vehicleParameters.angleOffsetDeg 从转角中扣除,用于非实验巡航候选的横向加速度预算。

    get_cruise_accel() 的巡航候选按车速限制正加速度,并在非实验模式下用 sqrt(max(a_total²-a_y²,0)) 限制弯中纵向余量;若 allow_throttle=False,还把正加速度上限向坡度估得的滑行加速度收紧。allow_throttle 来自模型 gasPressProbs[1] > 0.4 或车速 <= 2.5 m/s;若数组不足两项,代码回退概率 1.0。它的计算顺序是先将速度误差裁到巡航/弯道/滑行候选范围,再按上一帧 a_cruise 和 j_cruise * dt 限变化率。

    四项原函数 NumPy 探针确认了这种「先裁范围、后限变化率」的瞬态:10 m/s、上一帧 2.0 m/s² 时返回 1.94 m/s²,高于该速度的 1.2 m/s² 巡航上限;20 m/s 且弯道预算降为零时仍返回约 0.453 m/s²;禁油门滑行上限为 -0.3 m/s² 时可暂时返回 -0.07 m/s²。这是平滑历史带来的候选瞬态,不等于最终 aTarget 或车辆执行值必然越界。

    最终选择上,MPC 轨迹经 get_accel_from_plan() 按车型纵向延迟插值得到当前目标;再与巡航候选比较,实验模式增加 modelV2.action.desiredAcceleration 候选。代码取最小加速度,再裁到车型接口常量 ACCEL_MIN=-3.5、ACCEL_MAX=2.0;shouldStop 却对全部候选求或。因此 aTarget 与 shouldStop 可来自不同候选。controlsd controlsd 将两者交给 LongControl.update(),后者按 off/stopping/pid 状态与车型 PID 限值给执行器目标;不能把 aTarget 当作直接 CAN 帧。

    字段 来源 相位与边界
    aTarget MPC、巡航与实验模式 e2e 候选取最小加速度后再裁到车型常量。 不是直接 CAN 帧;需经 LongControl。
    shouldStop 对全部候选求或。 可与 aTarget 来自不同候选。
    allowThrottle 模型 gasPressProbs[1] > 0.4 或车速 <= 2.5 m/s。 只提示与巡航候选约束;controlsd 未把它当独立布尔门。
    消息 valid publish() 时写 sm.all_checks()。 与候选计算相分离,计算本身不被它短路。
    函数/组件 作用 关键边界
    get_cruise_accel() 按巡航速度差给目标加速度,非实验模式考虑弯道横向与滑行/油门约束,再做 jerk 限幅。 后置 jerk 限幅可使返回值暂时高于前面算出的候选上限。
    LongitudinalPlanner.update() 同步车况、MPC、巡航与可选 e2e 候选,选择目标并推进期望车速滤波。 允许在输入检查失败时计算;valid 只在发布时反映 sm.all_checks()。
    LongitudinalPlanner.publish() 发布速度/加速度/jerk 轨迹、aTarget、shouldStop、FCW、来源与油门提示。 hasLead 取 leadOne.present,不表达雷达消息有效性。
    get_T_FOLLOW() 选择跟车时距:宽松 1.75 s、标准 1.45 s、激进 1.25 s。 个性化只改变优化参数,不改变独立安全网关策略。

    MPC 时域与跟车时距

    gen_long_model() 在构建期由 CasADi/acados 生成求解器,运行时不是 Python 手工积分器。gen_long_ocp() 建立 12 段、10 秒非均匀预测时域,设置距离/jerk 成本与速度、加速度、危险距离软约束;约束与权重在运行时更新,不能只看静态模型推断车辆一定在物理极限内。get_T_FOLLOW() 按驾驶风格选择 1.75 s、1.45 s 或 1.25 s 的跟车时距。

    process_lead() 处理前车状态、裁剪异常值并外推轨迹;无前车时注入虚拟高速前车。六项原 MPC 前车方法隔离探针确认:无目标时首点为前方 50 m、车速比自车高 10 m/s 的虚拟前车;近目标距离会被可刹距离公式抬高,输入加速度被裁到 [-10,5] m/s²。LongitudinalMpc.update() 把两个前车换算成等效静止障碍,逐节点选择更近约束,运行求解并累计 FCW 近碰撞帧;source 取第零节点最近障碍,后续节点可换前车,FCW 只用 leadOne 且要求 modelProb>0.9。探针确认即使后续节点的逐时刻最小障碍已切换到另一前车,source 只由第零节点决定。set_cur_state() 与上次速度差严格大于 2 m/s 时重设所有 solver 节点状态,等于 2 m/s 不重设。

    求解失败与 FCW 边界

    LongitudinalMpc.run() 在求解状态非零时先读取本次解,再按单调时钟判断是否记录警告并调用 reset();LongitudinalPlanner.update() 随后仍插值当前(已清零的)速度/加速度/jerk 轨迹并参与候选选择,没有专门的「求解失败」消息位。reset() 同时将 solve_time、solution_status、crash_cnt 与 last_cloudlog_t 置零;因此告警时间戳不跨失败保存,当单调时钟已超过 5 秒时,连续失败可每次记录,而非真正跨失败限频。发布的求解耗时为清零后的值,原始失败状态没有发布。

    FCW 侧,LongitudinalMpc.update() 在 5 秒预测窗口内存在距离小于 0.25 m 且首个前车模型概率 >0.9 时累加 crash_cnt;规划器要求计数 >2 且车辆未静止才发布 FCW。selfdrived 还会结合另一条模型硬制动预测生成 FCW 事件,两者不能混为单一碰撞检测器。探针还确认:第一前车模型概率从 0.91 降到严格边界 0.9 时计数清零;dRel=NaN 可继续形成 NaN 位置轨迹,当前纯输入处理层没有有限性校验。

    常见问题

    为什么 aTarget 和 shouldStop 可能来自不同的候选?

    因为最终 aTarget 是「取最小加速度」,而 shouldStop 是对全部候选「求或」。探针确认:MPC 的 -0.5 m/s² 目标获选时,较大的 e2e 候选仍可提供 shouldStop=True。

    跟车距离有哪几档?

    get_T_FOLLOW() 提供宽松 1.75 s、标准 1.45 s、激进 1.25 s 三档;个性化只改变 MPC 的优化参数,不改变独立的安全网关策略。

    MPC 求解失败会怎样?

    run() 遇非零求解状态会调用 reset(),清零轨迹、计数和状态;规划器随后仍插值已清零的轨迹参与候选选择,且没有专门的「求解失败」消息位,发布的求解耗时也是清零后的值。

    allowThrottle=False 是不是就等于禁止加速?

    不完全是。allowThrottle 被发布供 UI 调整显示;本快照 controlsd.state_control() 只显式消费 aTarget/shouldStop,未把 allowThrottle 作为独立布尔门传给 LongControl。它对正常巡航候选的限幅有作用,但不能据此断言所有候选都被禁止正加速度。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/LONGITUDINAL_PLANNER_MPC_DEEP_DIVE.md(plannerd 消息链、候选合成、MPC 时域与求解失败边界)。
    • 项目 02 —— 同目录探针记录:四项巡航数值、四项规划器候选、六项 MPC 前车、四项求解器生命周期隔离探针结论。

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

    更新记录

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

  • test_runner 的并行分组:继承的 setUpClass 为什么会被拆开

    结论:test_runner 的分组只认「类自身」定义的 fixture。make_batches() 检查类的 __dict__,只有类自己写了 setUpClass/tearDownClass 才按类合并;仅仅继承 setUpClass 的测试会被切到多个批次、甚至不同 worker,于是类级 fixture 分别运行。这不是「测试失败」,而是分组契约的静态结果。退出码由批次与收集决定:收集错误或失败返回 1,无记录返回 5,中断返回 2。

    调用与数据流

    main() 先切换到仓库根目录、设置警告策略,再在主进程收集测试。多进程模式把 test ID 字符串而不是测试对象交给 worker,每个批次在 worker 内重新导入/加载。默认 -j 为 CPU 数、-W error 将警告升级为异常,只有 workers >= 2 才创建进程池。

    环节 关键函数/组件 静态契约与观察
    路径转模块名 module_name() 目标必须位于 ROOT 下;将相对 .py 路径转为 dotted 模块名。
    测试收集 collect()、flatten() 默认递归 openpilot/test_*.py;仅无显式目标时应用 IGNORED;导入错误进入 errors,过滤后按完整 ID 去重。
    批次规划 make_batches() 模块有 setUpModule/tearDownModule 则按模块分组;否则仅类自身 __dict__ 含 setUpClass/tearDownClass 才按类分组;其他 ID 按 ceil(tests/(jobs*4)) 分块。
    单批执行 run_batch() 每批重载 suite;批次外层捕获 fixture/导入输出。
    多进程调度 run_parallel() future 完成顺序决定进度输出顺序;批次抛异常时,仅生成该批首个 ID 的 error 记录。
    输出捕获 Capture dup2 在进程级替换 stdout/stderr 文件描述符;仅失败、错误、意外通过保留测试输出。
    结果聚合 Result 六种状态:passed、skipped、xfailed、failed、error、xpassed;子测试失败/错误合并在父测试记录中。
    汇总退出 report() 收集错误或失败状态返回 1;有记录且无失败返回 0;无记录且无收集错误返回 5。

    fixture 与失败路径

    unittest.TestSuite 负责调用模块/类 fixture,runner 只决定哪些 test ID 同批。因为 make_batches() 检查的是类自身字典,它不会把仅继承 setUpClass/tearDownClass 的测试自动按类合并。例如 TestParams 继承 OpenpilotTestCase.setUpClass();若被切到多个批次,其类级 fixture 会分别运行,也可能在不同 worker 中运行。这是静态分组结论,不代表该测试已出现并发故障。

    反向地,显式定义类级 fixture 的测试按类同批,有模块级 fixture 的按模块同批。而 TestSensord 和 TestOnroad 只定义小写 setup_class,会被基类调用,但同样不满足 runner 的按类分组条件;对其硬件副作用应使用串行/独占设备执行。

    OpenpilotTestCase.run() 在每个测试外包住环境变量快照、OpenpilotPrefix、manager 清理与 GC 恢复;setUpClass() 另有类级环境快照。它们是测试辅助类自己的隔离语义,不是 runner 对任意测试的全局保证。带 COMMA_HARDWARE_TEST 的用例还会在 setUp() 初始化硬件、关闭省电并终止 athena 进程。

    失败记录也有讲究:Result.mark() 在 fixture 早于 startTest 失败时生成独立的 fixture ID 记录,严重状态 error 优先于 failed;跳过和预期失败的详细原因/堆栈未写入报告。单 worker 模式如果 run_batch() 抛非中断异常,main() 没有对应兜底,不能期待得到统一汇总。

    因此对带硬件副作用的测试,应显式串行并声明独占资源,而不是假设 runner 会自动隔离;显式目标也不会因此变得无副作用。

    参数与副作用的边界

    现象 源码事实 含义
    -j 0 没有参数校验,会在批次大小计算中除零 负数同样不构成可靠 worker 配置。
    -k 过滤 过滤发生在加载 suite 之后 不能阻止模块导入副作用。
    IGNORED 只约束默认无目标扫描 它不是安全沙箱,显式目录仍会递归发现 test_*.py。
    worker 异常 仅首个批次 ID 被标为 error 不能把未列出的同批测试当作通过。

    另外两点会影响结论的可信度:其一,收集会导入测试模块,即使只用 -k 过滤,过滤也发生在加载 suite 之后,不能阻止模块导入副作用;IGNORED 只约束默认无目标扫描。其二,CLI 的退出码固定为 0/1/2/5:main() 捕获 KeyboardInterrupt 返回 2,空结果返回 5,收集错误或失败返回 1。假 loader 探针验证了重复目标只加载一次、过滤发生在加载后,假 future 验证批次异常时仅首个 ID 被记为 error;这些均不是实进程池或真实 fixture 的执行证据。

    对硬件与插件化重构的意义

    1. 权限分层:纯算法/协议解析测试可以进入默认并行池;任何枚举真实 panda、串口、CAN、相机、刷写、Params 全局路径或终止系统进程的测试都应有显式硬件标签与独立执行环境。
    2. 资源声明:硬件插件测试应声明设备实例、总线、固件模式和独占锁;不要仅依赖类级 fixture 或 -j 1 防止其他进程争用。
    3. 可复现证据:记录源码提交、子模块版本、插件版本、硬件序列号、-W/-j/目标 ID、日志来源与结果;批次异常须标记整批为「未完成/未知」。
    4. 分级门禁:单元测试、离线回放、SIL、隔离台架 HIL、实车验证逐级放行;车端控制或固件修改不可由开发机 runner 的绿色结果单独授权。

    常见问题

    为什么继承来的 setUpClass 不会被按类分组?

    因为 make_batches() 检查的是类自身 __dict__,仅继承 setUpClass/tearDownClass 不满足条件。例如 TestParams 继承 OpenpilotTestCase.setUpClass(),若被切到多个批次,其类级 fixture 会分别运行,也可能在不同 worker 中运行。

    -j 0 会怎样?

    没有参数校验,会在批次大小计算中除零;负数同样不构成可靠 worker 配置。该行为由隔离探针验证,不是实进程池执行证据。

    跳过和预期失败的原因会写进报告吗?

    不会。源码中跳过和预期失败的详细原因/堆栈未写入报告,只有测试级耗时与六种状态。因此不能仅凭报告里的 skipped 数量判断原因。

    单 worker 抛异常会得到统一汇总吗?

    不一定。单 worker 模式如果 run_batch() 抛非中断异常,main() 没有对应兜底;多 worker 模式则仅首个批次 ID 被标为 error,不能把未列出的同批测试当作通过。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/TEST_RUNNER_PARALLEL_FIXTURE_CONTRACT_DEEP_DIVE.md(取用了收集/分组/执行契约、fixture 与失败路径、结果与退出码,以及硬件插件重构意义)。
    • 项目 02 —— openpilot-research/NATIVE_TEST_DISCOVERY_BUILD_SKIP_CONTRACT_DEEP_DIVE.md(仅用于确认原生测试缺失即跳过的发现契约)。

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

    更新记录

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

  • Toyota CarState:bus_timeout 为什么不等于信号健康

    结论:在 Toyota 输入链里,can_valid 与 bus_timeout 是两种不同的健康语义。can_valid 只对已注册报文做活性检查;bus_timeout 只代表总线流量空窗,而且 CANParser.update() 对该总线的任何帧都会刷新 last_nonempty_nanos,即使不是已注册地址。因此「总线有流量」不等于「每个必需信号都健康」,单看 vl 值也无法区分「未收到有效样本」和「物理零」。

    入口与关键函数

    Toyota 的输入链从 card 解码 CAN 帧开始,经车型接口把信号归一成 CarState,再向下游发布与消费。

    组件/函数 源码 作用
    CarInterface 绑定 interface.py 指定 Toyota CarState/CarController/雷达接口;参数构建决定车型标志、角度模式和纵向能力。
    card.state_update() card.py 接收 can,转为帧列表,再调用车型接口和雷达接口。
    CarInterfaceBase.update() interfaces.py 先更新所有 CANParser,后调用 CarState.update(),再汇总 canValid/canTimeout、速度显示迟滞和按钮启用。
    CarState.get_can_parsers() carstate.py 为动力总线 0 和相机总线 2 创建解析器,仅预注册 BLINKERS_STATE。
    parse_wheel_speeds() interfaces.py 四轮均值乘单位及车型系数,再进入 KF1D 得到 vEgo/aEgo。

    总线注册与健康的准确含义

    Toyota 的动力总线只预注册 BLINKERS_STATE,频率传入 NaN;NaN 在 _add_message() 中令该报文 ignore_alive=True,所以转向灯报文本身不参与超时失效,并非所有 Toyota 输入都忽略超时。其余报文是在 CarState.update() 第一次读 cp.vl[...] 时由 VLDict 动态注册;首次读发生在本轮 cp.update() 之后,即该轮此前收到的报文尚未由新注册状态解码。

    普通动态报文先以 1 Hz 假定频率、10 秒超时阈值注册,收到足够样本后解析器估计频率并改为约十个周期的阈值。这里有两个容易混淆的点:其一,CANParser.update() 对该总线的任何帧都会刷新 last_nonempty_nanos,所以 bus_timeout 只代表总线流量空窗;其二,MessageState.parse() 遇 DBC checksum 或 counter 错误不更新缓存值与时间戳。最终 CarInterfaceBase.update() 对两路解析器取 all(can_valid) 和 any(bus_timeout)。

    还有一个细节:转角偏移更新分支内部也读取 cp.can_valid,接口汇总时又读取一次;当前 can_valid 属性读取会更新 can_invalid_cnt,不是纯查询。因此不能把它解释为严格的「连续五次 update 失败后失效」。

    信号到 CarState 的映射

    类别 来源与条件 输出/状态
    车身与踏板 动力总线 BODY_CONTROL_STATE、BRAKE_MODULE、ESP_CONTROL;SecOC 用 GAS_PEDAL,其他用 PCM_CRUISE.GAS_RELEASED doorOpen、安全带、驻车制动、制动/保持、gasPressed。
    档位与车速 SecOC 用 GEAR_PACKET_HYBRID,其他用 GEAR_PACKET;四轮 WHEEL_SPEEDS 枚举映射档位;轮速先得 vEgoRaw,再滤波为 vEgo/aEgo;vEgoCluster 初始设为 vEgo * 1.015。
    转向 STEER_ANGLE_SENSOR、STEER_TORQUE_SENSOR、EPS_STATUS 转角/角速度、扭矩、驾驶员转向判定、LKA/LTA 临时与永久故障。
    巡航 UNSUPPORTED_DSU 用 DSU_CRUISE 与 PCM_CRUISE_ALT;其他用 PCM_CRUISE_2 与 PCM_CRUISE_SM;运行态来自 PCM_CRUISE 主开关、设定车速、仪表设定车速、启用/停止/非自适应状态和故障。
    按键/HUD 相机 LKAS_HUD;部分平台的 ACC_CONTROL.DISTANCE LKAS 边沿合成按钮事件、gap 调整事件;相机 HUD 缓存供控制器构帧。

    其中 stockAeb 仅在非 SecOC、未禁雷达路径读取,且要求 PRECOLLISION_ACTIVE 与负 FORCE;accFaulted 的低速 lockout 附加判据仅在源码指定的 TSS2/ACC 类型和 openpilot 纵向控制条件下执行,不应泛化为所有 Toyota 车型。

    来源选择也是常被误解的点:cp_acc 在 TSS2 且非 RADAR_ACC 时指相机解析器,否则指动力解析器;这是 ACC/PCS 的信号来源选择,不是对两路数据作一致性比对。相机解析器初始没有注册报文,全部依赖动态注册;原车 ADAS 的 stockAeb、stockFcw 与左右盲区还受 TSS2、HAS_BSM、DISABLE_RADAR 条件控制,ACC_CONTROL.ACC_TYPE 影响低速 lockout 分支。

    转角标定、故障与按钮时序

    STEER_TORQUE_SENSOR.STEER_ANGLE 在非零、未初始化时才让 accurate_steer_angle_seen 永久锁存为真。此后仅在原角度小于 90 度、角速度小于 100 度/秒且 cp.can_valid 为真时,才更新 60 秒时间常数的 FirstOrderFilter 偏移;首次合格样本直接初始化。偏移一旦初始化,后续即便本轮不再更新,也会继续用于 torque_sensor_angle - offset。这里没有单独的偏移年龄或置信度字段。

    steeringPressed 是驾驶员力矩绝对值直接大于 STEER_THRESHOLD,不是调用基类的去抖函数。EPS_STATUS.LKA_STATE 总是参与故障映射,仅角度控制模式再并入 LTA_STATE;代码列出的临时故障为 0/9/11/21/25,永久故障为 3/17,这只是软件分类。TSS2 相机 LDA_ON_MESSAGE 从非零值切换到不同值时,代码合成一次 LKAS 按下和释放事件。lkas_hud 与 SecOC 同步字段是从解析器字典浅拷贝的最近缓存,没有在此单独检查样本年龄。

    消费与插件设计约束

    card 将 CS.canValid 作为 carState 外层 valid,也用于 sendcan 消息的外层有效位;后者是否真正被 MCU 拒绝仍取决于车端传输和 safety 链,不能把外层位等同于物理禁发。CarEvents 把 vehicleSensorsInvalid、accFaulted、转向故障、驾驶员施力等转成事件。重构成车型输入插件时,输出至少应为逐信号的来源总线、DBC/车型版本、原始采样时间、年龄、有效/缺失/故障状态及标准物理单位。

    常见问题

    can_valid 和 bus_timeout 到底有什么区别?

    can_valid 对已注册报文做活性检查,并把计数器达到失败阈值视为无效;bus_timeout 只代表总线流量空窗,任何帧(即使未注册)都会刷新 last_nonempty_nanos。所以有总线流量仍可能缺少某个必需信号。

    为什么转向灯报文不参与超时失效?

    因为动力总线只预注册 BLINKERS_STATE,其频率传入 NaN,在 _add_message() 中令该报文 ignore_alive=True。这只针对转向灯报文本身,并非所有 Toyota 输入都忽略超时。

    动态注册的报文首轮会被解码吗?

    不会。其他 cp.vl[...] 首次读取时 VLDict 才动态注册,而首次读发生在该轮 cp.update() 之后,因此该轮此前收到的报文尚未由新注册状态解码,初值零可能只是「未收到有效样本」。

    can_valid 的读取会改变状态吗?

    会。当前 can_valid 属性读取会更新 can_invalid_cnt,不是纯查询;而且转角偏移分支与接口汇总都会读取它。因此不能把它当成「连续五次失败后才失效」的严格计数语义。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/TOYOTA_CARSTATE_INPUT_HEALTH_CONTRACT_DEEP_DIVE.md(取用了总线注册与健康语义、信号到 CarState 映射、转角标定与按钮时序、消费与插件约束)。
    • 项目 02 —— openpilot-research/TOYOTA_SECOC_KEY_FRESHNESS_SAFETY_BOUNDARY_DEEP_DIVE.md(仅用于确认 SecOC 同步 MAC 与 freshness 的上游验证边界)。

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

    更新记录

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

  • Hyundai CAN FD safety:LKA 与 LFA 的总线矩阵差在哪

    结论:Hyundai CAN FD safety 的「模式」不是一个开关,而是LKA/LFA × 纵向/非纵向 × 按钮格式 × SCC 来源的组合,每一组对应不同的 RX 检查表与 TX 白名单。纵向(LONG)只在 ALLOW_DEBUG 构建下生效:无该宏的构建强制 hyundai_longitudinal=false,因此上层设置 openpilotLongitudinalControl 不会自动改变固件事实。此外,替代转向地址是条件性接口不匹配,不能靠扩充普通插件的 CAN 发送权限来规避。

    车型资格与总线编号:LKA 与 LFA 是两套拓扑

    资格判定在 CarInterface._get_params():根据相机指纹判定 LKA/LFA,确定按钮、SCC 来源和 safetyParam。源文档特别强调,alphaLongitudinalAvailable、实际启用和 ECU 静默成功是三个不同状态,不能混为一谈。

    总线编号由 CanBus.__init__() 决定,而且两种模式恰好相反:

    模式 A-CAN E-CAN 相机
    LKA 0 1 2
    LFA 1 0 2

    相机总线固定为 2,再叠加多设备偏移。这里要注意:表中 bus 号是单个 panda safety 实例的本地编号,多 panda 场景的偏移由上层配置处理,不能把逻辑编号硬编码成自制板引脚。

    模式与总线矩阵

    hyundai_canfd_init() 用 LKA/LFA、LONG、替代按钮、相机 SCC 和替代转向地址选择 RX 检查与 TX 白名单。下面这张表是整篇的核心:

    模式 动力总线 / 原车 SCC RX 关键 TX 白名单
    LKA 非纵向 1 / 1 0x50+0x2A4 或替代 0x110+0x362 在 bus 0;0x1CF 在 bus 1
    LKA 纵向 1 / 不检查原车 SCC LKA 0x50+0x2A4 在 bus 0;LFA、HUD、SCC、若干 ADRV 与 0x730 在 bus 1;0x51 在 bus 0
    LFA 雷达 SCC 非纵向 0 / 0 LFA、HUD、0x1A0 在 bus 0;0x1CF 在 bus 2
    LFA 相机 SCC 非纵向 0 / 2 上行相同,另有 0x160 在 bus 0
    LFA 雷达 SCC 纵向 0 / 不检查原车 SCC LFA、HUD、SCC、0x160、0x7D0 在 bus 0;0x1CF 在 bus 2
    LFA 相机 SCC 纵向 0 / 不检查原车 SCC LFA、HUD、SCC、0x160 在 bus 0;0x1CF 在 bus 2

    表头里的「原车 SCC RX」指的是 safety 必需 RX 检查与巡航状态消费,不是该总线上物理帧是否存在。几个细节:LKA 两种分支的按钮 RX 固定使用 0x1CF;LFA 分支依据替代按钮标志,选择动力总线上的 0x1CF 或 0x1AA。所有配置均监视油门备选组、制动 0x175、轮速 0xA0、驾驶员扭矩 0xEA——油门三种地址是同一检查组的备选格式,不等于三种都必须到达。

    构建门:LONG 参数只在 ALLOW_DEBUG 下生效

    hyundai_common_init() 只在 ALLOW_DEBUG 下读取 LONG 参数;无该宏的构建强制 hyundai_longitudinal=false。这是一道编译期门,而不是运行期开关。

    校验侧码分布同样要记住:hyundai_canfd_get_counter() 对 8 字节普通按钮取字节 1 高四位的计数,其余帧取字节 2;校验值取前两字节。hyundai_common_canfd_compute_checksum() 对字节 2 起的载荷与地址执行 CRC16,24/32 字节分别异或长度常数。特别之处在于:按钮 RX 明确忽略 checksum,但仍检查计数。

    权限与失效边界

    这些边界在源文档里逐条列出,直接决定「白名单里有这帧」和「这帧会被执行」之间的差距:

    1. 按钮:普通按钮 TX 0x1CF 仅允许「原车巡航已接合时 Cancel」或「controls_allowed 时 Resume」。替代按钮 RX 0x1AA 不在 TX 白名单中;但设置替代按钮并不会从全部 TX 表删除 0x1CF,不能误写成「该模式彻底禁止发送任何按钮帧」。
    2. 转向:非纵向 LKA 对 0x50/0x110 检查扭矩;LKA 纵向及全部 LFA 对 0x12A 检查。共享扭矩门限制最大 270、实时变化 112、每帧增加 2/减少 3,并含驾驶员反扭矩与转向请求位节拍。
    3. SCC:非纵向 LFA 的 0x1A0 仅允许 ACCMode=4 且 aReqRaw/aReqValue 同时为零;该钩子未逐位审核 0x1A0 的其它信号。
    4. 诊断与附属帧:0x730/0x7D0 的 8 字节数据仅可为 02 3E 80 00 00 00 00 00,且仅相应模式在白名单中。0x51、0x160、0x1EA、0x200、0x345、0x1DA、HUD 和相机抑制帧没有同等级的专属载荷校验。
    5. 转发:Hyundai CAN FD 没有专属 fwd 钩子,使用通用 0↔2 转发规则及 check_relay 静态阻断;bus 1 不在该通用转发对内。

    第 4 条尤其重要:白名单准入不等于 ECU 接受,也不等于 AEB 不受影响。缺少逐字段校验的帧,只能保证「被允许发送」,不能保证「被接受后效果正确」。

    替代 LKA 与纵向的跨层组合

    CarInterface._get_params() 可从相机指纹识别 0x110 替代 LKA,并把该标志传到 safety 参数;若有 ADAS ECU 且未被其他条件禁止,纵向能力仍可能开启。此时主机侧 create_steering_messages() 继续生成 0x110 LKAS_ALT,create_suppress_lfa() 继续生成 0x362。

    但 hyundai_canfd_init() 的纵向 LKA 分支固定选择普通 0x50/0x2A4 白名单,而非按替代标志切换;原测试只有普通 LKA 纵向类,没有替代 LKA 纵向类。离线探针确认:同一替代标志加 LONG 后,调试构建拒绝 0x110/0x362、接受 0x50/0x2A4;无调试宏构建则忽略 LONG,采用非纵向替代 LKA 白名单。也就是说该组合在两类构建中的失败形态不同,均不能仅凭主机 CarParams 宣称纵向或替代转向可执行。

    源文档对此的定性是关键:这是条件性接口不匹配,不应通过扩充普通插件的 CAN 发送权限来规避;正确做法是先确定该车型/纵向组合是否应被允许,再联合修订主机消息选择、固件白名单、转向限制和回归测试。现有 test_hyundai_canfd.py 在测试基座默认编译 ALLOW_DEBUG;本机运行该模块共 981 项、117 项跳过,跳过项及未覆盖的替代 LKA 纵向组合不能视为验证通过。

    常见问题

    LKA 和 LFA 的总线编号为什么会相反?

    因为 CanBus.__init__() 明确按模式分配:LKA 时 A-CAN/E-CAN 为 0/1,LFA 时为 1/0,相机固定为 2。这是逻辑编号,多 panda 场景的偏移由上层配置处理,不能直接当作物理引脚。

    启用纵向控制后,固件就一定允许吗?

    不一定。hyundai_common_init() 只在 ALLOW_DEBUG 下读取 LONG 参数,无该宏的构建会强制 hyundai_longitudinal=false。所以上层设置 openpilotLongitudinalControl 不会自动改变固件事实,alphaLongitudinalAvailable、实际启用和 ECU 静默成功是不同状态。

    0x1A0 在白名单里就可以自由发送吗?

    不可以。非纵向 LFA 的 0x1A0 仅允许 ACCMode=4 且 aReqRaw/aReqValue 同时为零;纵向则分别调用共享加速度检查。而且该钩子并未逐位审核 0x1A0 的全部其它信号。

    为什么设置「替代按钮」不等于禁用 0x1CF?

    因为替代按钮 RX 0x1AA 虽不在 TX 白名单,但设置替代按钮并不会从全部 TX 表删除 0x1CF。现有 test_button_sends() 构造的是替代格式 0x1AA,因此没有证明 0x1CF 被禁用。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/HYUNDAI_CANFD_PANDA_SAFETY_DEEP_DIVE.md(取用了模式与总线矩阵、构建门、权限与失效边界、替代 LKA 与纵向跨层组合)。
    • 项目 02 —— openpilot-research/CAR_PROTOCOL_IMPLEMENTATIONS.md(仅用于确认 Hyundai CAN FD 分支与 set_safety_hooks() 的注册位置)。

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

    更新记录

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

  • modelV2 的三个 valid 位不能合并:modeld 三路输出各差一道门

    结论:modeld 一轮里会依次发布 modelV2、drivingModelData、cameraOdometry 三路消息,名义节拍 20 Hz,但它们是三条独立消息,不是一次原子多主题提交。三者的顶层 valid 语义也各不相同:modelV2 只反映「外参标定曾到达」,drivingModelData 直接复制它,cameraOdometry 还额外要求本轮主相机零丢帧。把三个 valid 合并理解成「模型可靠」是错的。以下为固定源码快照 c8fb906 的静态代码契约分析,不代表模型预测精度。

    三路消息与发布节拍

    manager 进程表让 modeld 在 onroad 运行,服务表把 cameraOdometry、drivingModelData、modelV2 设为名义 20 Hz。真正发布发生在 main() 成功取得相机帧、且模型输出非 None 之后:同轮依次发布三个主题。若外置 Chestnut 大模型推理失败,本轮回退小模型且 model_output=None,这一轮就不发布三路输出,后续才由小模型继续。

    这意味着下游看到的「三路输出」在时间上并非同源同刻,而是同一轮内先后写入;消费进程各自按自己的频率与 valid 判断是否使用。

    组件与关键函数

    ModelState.__init__() 加载 OOB 模型、相机尺寸对应的 warp 与输出切片元数据,建立递归状态输入/输出;这些切片来自编译产物,不是 Python 文件里固定的完整模型 ABI。pack_inputs() 把两路 NV12 帧、变换矩阵、意图、驾驶位与动作时间拼成一次设备上传,连续缓冲布局与 NV12 stride 是模型与硬件的适配契约。

    ModelState.run() 复制两路帧、在意图上升沿产生脉冲、运行 warp 与模型并解码输出。一个易被忽略的不对称:只有 Chestnut 路径显式检查整块原始输出是否全为有限值,常规模型路径没有同等检查。解析侧 Parser.parse_outputs() 负责轨迹、车道、路沿、前车的 MDN 解码与概率的 sigmoid/softmax。

    函数/组件 功能 边界
    ModelState.__init__() 加载 OOB 模型、warp 与输出切片元数据 切片来自编译产物,不是 Python 文件中的完整模型 ABI。
    pack_inputs() 拼两路 NV12 帧、变换矩阵、意图、驾驶位与动作时间 连续缓冲布局与 NV12 stride 是硬件适配契约。
    ModelState.run() 复制帧、意图上升沿脉冲、warp 与模型、解码输出 仅 Chestnut 路径检查整块输出有限性,常规路径无同等检查。
    Parser.parse_outputs() 轨迹/车道/路沿/前车 MDN 解码,概率 sigmoid/softmax parse_mdn() 分出均值/标准差;前车头兼容单/多假设。
    get_action_from_model() 优先取 action 头,缺省从 plan 推导动作 加速度 0.3 s 平滑;车速低于 0.3 m/s 保持上次曲率。

    预测字段、时序与有效位

    ModelConstants 定义 33 个非线性时间采样点,T_IDXS 最远 10 秒,距离采样 X_IDXS 最远 192 米。道路模型对轨迹、四条车道线、两条路沿、三组前车预测与意图/驾驶元信息分别解码。这里有两个必须澄清的常见误读:车道线概率取原始概率头的奇数槽;三条 leadsV3 带 probTime 对应 0/2/4 秒,是三个时间假设,不能理解为三辆已确认的真实车辆。

    帧新鲜度方面,main() 从 narrowRoadCameraState.frameId 与 VisionIPC 主帧 ID 计算 frameAge,从相邻主帧 ID 估计丢帧率;若窄路相机状态尚未出现,frameAge 不足以证明帧新鲜度。模型输入的横纵向 action_t 包含车型/在线估计延迟、约 50 ms 帧时延与半帧预测间隔,这两项帧延迟是固定近似,不是逐帧由采集/执行时钟实测得到,更换自制相机或计算平台时必须重新验证。

    消息 顶层 valid 的真实条件 易误判点
    modelV2 只反映 extrinsics_calibration_seen,即收到外参更新且见过设备/窄路相机状态 更新该标志时没有显式检查外参已校准。
    drivingModelData 直接复制 modelV2.valid 是同源精简消息,不是独立第二次推理。
    cameraOdometry 标定曾到达,且本轮主相机没有跳帧 比 modelV2 多一个丢帧门。

    此外 modelV2.frameDropPerc 被 selfdrived 用于超过 1% 的掉帧事件,但该比例是 10 秒低通且前 10 次暖机后的运行指标,不是每帧硬性失效门。

    告警、控制与融合消费者

    get_action_from_model() 输出 desiredCurvature、desiredAcceleration 与 shouldStop。controlsd 在没有有效 lateralManeuverPlan 时取模型目标曲率,LongitudinalPlanner 消费目标加速度与停车建议;RadarD.update() 取前车预测与模型车速做雷达轨迹融合;LaneDepartureWarning 使用车道线概率;locationd/calibrationd 使用 cameraOdometry 观测而非 modelV2 的行驶动作。它们都仍受状态机、车辆能力、限幅与硬件网关约束,模型不是 CAN 发送者。

    告警侧 fill_model_msg() 在连续硬制动概率缓冲上计算 hardBrakePredicted,由 selfdrived 与驾驶员制动、原车纵向制动状态结合成 FCW 事件——它是告警信号,不是直接制动请求。modelV2.confidence 由预测脱离概率的滚动评分映射绿/黄/红,不是 modelV2.valid 的替代。可选 SEND_RAW_PRED 把数组字节写进消息,但该数组是在解析之后才复制的,softmax() 对其切片做过原地归一化,因此不能无条件把 rawPredictions 当作完全未经后处理的原始张量。

    验证缺口与插件边界

    process_replay 有 modeld 三路输出的回放配置,model_replay 对比参考结果时会忽略时间和部分数值差异。但当前归档缺少 LFS 模型实体及常规模型产物,不能据这些测试定义宣称预测、光学或实车时序已通过;本轮仅为静态核对,没有运行。

    插件化应把图像源、warp、模型、解码器与安全消费分层:影子模型只能发布独立候选主题,权威三路输出仍需单写者;契约至少固定相机内外参、NV12 布局、两路同步阈值、单调时间戳、输出切片 schema、MDN 分布含义、非有限值处理、帧丢失/年龄与三种有效位;控制与 FCW 不可由插件直接绕过 selfdrived 与 panda。

    常见问题

    三个有效位能合并成「模型可靠」吗?

    不能。modelV2.valid 只反映外参标定曾到达,drivingModelData.valid 直接复制它,cameraOdometry.valid 还要求本轮主相机零丢帧。三者语义不同,不能合并为一个可靠性结论。

    三组前车预测是不是三辆车?

    不是。三条 leadsV3 带 probTime 对应 0/2/4 秒的时间假设,不能理解为三辆已确认的真实车辆。车道线概率取的是原始概率头的奇数槽。

    为什么一轮里可能没有模型输出?

    当外置 Chestnut 大模型推理失败时,本轮回退小模型且 model_output=None,这一轮就不发布三路输出,后续才由小模型继续。常规模型路径失败则抛出异常。

    rawPredictions 是未经处理的原始张量吗?

    不完全是。sendraw 复制数组字节发生在解析之后,而 softmax() 已对其切片做过原地归一化,因此不能无条件把它当作完全未经后处理的模型原始张量;它还涉及日志体积与数据隐私。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/ROAD_MODEL_OUTPUT_CONTRACTS_DEEP_DIVE.md(modeld 三路消息、MDN 解码、三种有效位与消费链)。
    • 项目 02 —— openpilot-research/ 同目录探针记录:两项意图脉冲契约假推理隔离探针结论。

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

    更新记录

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

  • 蜂窝模组六态状态机:CREG 与 CGREG 的漫游门为何不对称

    结论:蜂窝数据链的核心问题不是「有没有连上」,而是一组被代码门控范围和发布相位放大的信任缺口:漫游许可只门控 CREG=roaming、并不单独拒绝 CGREG=roaming;ICCID 只校验字符集、不校验长度;connected=True 只表示看到了 ppp0 的接口 IP,不等于外网端到端连通或 DNS 已成功。以下基于固定源码快照 c8fb906 的静态调用链与三组假依赖探针,未接 Quectel 模组、SIM 或运营商网络。

    进程与接口边界

    蜂窝链是一个常驻 Python 进程加三个对外接口:控制口、数据口与状态文件。它以「模组可用」为目标,而不是以 onroad 为前提;宿主还会在启动时临时接管系统网络服务,这一接管本身也带状态恢复风险。

    层 入口 行为
    生命周期 process_config.py、Modem.run()、stop() modem 是常驻进程,不以 onroad 为前提。启动时若服务文件存在则尝试临时屏蔽/停止 ModemManager;正常执行到 stop() 末才尝试解除屏蔽/启动,不记录其原状态,也不检查命令返回码
    控制口 Modem._at() /dev/modem_at0 以 9600 波特率打开;抢不到 /dev/shm/modem.lock、收到错误或超时均返回空响应。该锁也由 qcomgpsd 和 LPA 使用
    数据口 PPPSession.start() pppd 使用 /dev/modem_at1 拨号 CID 1;connected 不是由进程启动直接确认,而是在 ppp0 发现 IP 后更新
    配置 _do_initializing()、_params_changed() 从持久参数读取 GsmApn、GsmRoaming;SIM 身份变更、APN/漫游变更引发重新初始化
    状态输出 _publish_state()、HardwareComma.get_modem_state() 状态先写 /dev/shm 临时文件再原子替换为 /dev/shm/modem;硬件抽象读取 JSON 后由 hardwared 发布 deviceState

    六态状态机与关键函数

    状态机在 INITIALIZING → SEARCHING → CONNECTING → CONNECTED → DISCONNECTING 之间流转。_do_initializing() 清理旧 PPP/路由、配置 AT、读取 IMEI/ICCID/IMSI/版本,对 EG25 应用专用设置并写入 APN;身份不完整会留在初始化态。部分 AT 配置调用没有逐条检查返回值,因此不能仅凭进入 SEARCHING 判定所有模组配置成功——探针也确认,AT+CGDCONT 返回空列表仍会发布身份并进入搜索态。

    _poll_iface() 从 ppp0 提取 IP/peer,并调用 maybe_install_routes() 配置默认路由与策略路由表 1000,成功后调用 maybe_install_dns()。所以 connected=True 表示看到了接口 IP,不是外网端到端连通或 DNS 已成功的证明。

    注册门:CREG 与 CGREG 的不对称

    搜索态先读 CREG,没有响应则不查询 CGREG;拨号要求两者均报告 home 或 roaming。但漫游禁止分支只在 CREG 为 roaming 时阻断:当 CREG 为 home、CGREG 为 roaming 时,代码仍进入 CONNECTING。这是代码门控范围的问题——它不等于运营商实际会建立数据链,也不证明资费策略正确。

    此外,_check_iccid() 周期性发现换卡,ICCID 查询空响应不会触发换卡,收到非空且不同的值才设置标记。PPP 退出后的处理是:前两次失败各执行一次数据口 DTR 复位并再次启动 pppd,第三次转入断开、不再复位/重拨;若此时已有 SIM 变更标记则直接转断开,不增加失败计数。

    身份与遥测的宽边界

    身份解析与遥测采样的接纳规则普遍宽松,且错误常常以「整组不更新」而不是「逐字段报错」的形式出现。

    字段 接纳规则 边界
    IMEI 要求全数字且长度为 14 至 17 长度有门,格式受限
    ICCID 只检查字符是否属于十六进制集合 没有长度门,单字符 A 也可作为非空身份穿过初始化门
    _atv() 前缀 用子串判断前缀并按第一个冒号切分 示例 noise:+QCCID:12345 得到 +QCCID:12345,非精确解析所请求响应
    mcc_mnc 仅取全数字 IMSI 的前六位 不单独判定 MCC/MNC 的实际长度
    采样项 边界行为
    CSQ CSQ=99 视为缺测,却未拒绝 -1 或大于 31 的值;-1 产生负质量,40 的质量被上限钳成 100
    COPS 制式码 网络制式码无效时仍可更新运营商名
    频段通道号 无法解析时整组频段/通道都不更新
    温度 列表中的 255 被跳过,但任一非数字项会令整组返回空增量
    流量计数 tx_bytes 或 rx_bytes 其中一个读取/解析失败,整组返回空增量

    结合增量发布,这些「整组返回空增量」会让旧值继续保留,因此这些状态字段不是逐字段实时状态。

    启动接管与退出恢复

    run() 只以 ModemManager 单元文件是否存在决定执行 mask --runtime、stop,不检查两个命令的退出码;stop() 无条件尝试 unmask --runtime、start,不回忆服务接管前是否已停止或屏蔽。恢复顺序为杀 PPP、清路由、删除状态文件、恢复服务,若中途清理抛异常,后续恢复不会执行。main() 在 run() 与 stop() 之间没有 finally,所以循环外启动异常会跳过 stop()。

    另一个值得记录的相位问题是:run() 的异常捕获只修改内存中的枚举态,不在该分支发布 DISCONNECTING;随后正常的断开处理器先发布带 INITIALIZING 字段的初始快照,返回时又发布 INITIALIZING。因此若没有其他正常转移,消费者可能完全看不到这次异常的 DISCONNECTING 阶段。

    AT 初始化同样是「逐条调用、宽验收」:最终只依据 AT+CGMI 的首行是否有回显决定通过,前面 AT_INIT 的空/错误应答不会阻止前进;EG25 专用六条命令也不检查应答,非 EG25 直接跳过。此外,特定 Webbing ICCID 前缀会直接允许漫游而不读取 GsmRoaming——这是代码识别规则,不是实卡资费保证。

    断开处理的顺序也值得注意:它先发布 connected=False、state=INITIALIZING 的初始快照,随后才杀 PPP、清路由和复位数据口,因此状态发布与资源撤销不是同一事务——消费方可能先看到「已断开」,而旧 PPP 与路由仍在。

    常见问题

    connected=True 就代表设备能上网吗?

    不代表。它只表示在 ppp0 上看到了接口 IP,不是外网端到端连通,也不是 DNS 已成功的证明;路由与 DNS 是后续独立步骤。

    漫游被禁止时为什么仍可能进入拨号?

    因为漫游允许判断只门控 CREG=roaming,没有单独拒绝 CGREG=roaming。当 CREG 为 home、CGREG 为 roaming 时,代码仍进入 CONNECTING。

    ICCID 的解析有什么风险?

    它只检查字符是否属于十六进制集合,没有长度门,单字符 A 也能作为非空身份穿过初始化门;这属于代码接纳规则,不表示模组会给出这种异常应答。

    为什么模组状态字段会显示旧值?

    温度、流量等采样项在任一子项解析失败时会让整组返回空增量,结合增量发布机制,旧值可能继续保留,因此这些字段不是逐字段实时状态。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/MODEM_DEEP_DIVE.md(蜂窝模组六态状态机、身份/遥测解析与生命周期接管,含源码行号索引)
    • 项目 02 —— openpilot-research/probes/test_modem_state_machine_contract.py(十一项注册门与发布相位隔离探针)
    • 项目 02 —— openpilot-research/probes/test_modem_identity_telemetry_contract.py(八项身份与遥测字段解析探针)

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

    更新记录

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

  • 163 个 IRQ 槽与寄存器巡检:panda 诊断契约里的四处不一致

    结论:panda 的故障、寄存器巡检与制造身份是一条观测链,不是执行许可链。故障路径只把「记录过的 fault」送到 cereal.PandaState;「记录了 fault」不等于执行器已被独立切断。并且源码里至少存在四处静态契约不一致:寄存器哈希上界越界 1 项、故障位图与状态不是同一真值、位 27 的故障无法被正确表达、TIM1 更新中断的向量符号与 C 包装器不匹配。以下基于源码快照 c8fb906,未做实机故障注入、内存安全与制造写入验证。

    故障路径:从位图到 PandaState

    故障聚合由 fault_occurred() 与 fault_recovered() 完成,维护位图、首次打印与状态级别;本版本 PERMANENT_FAULTS=0。get_health_pkt() 把 fault_status 与 32 位 faults 作为两个独立字段返回,fill_panda_state() 再把健康字段映射进 PandaState,故障位转列表,枚举定义在 log.capnp。整条链的职责是观测:实际 CAN 许可仍须另追 safety hook、心跳与主机执行门。

    组件 关键实现 作用与边界
    临界区 ENTER/EXIT_CRITICAL、enable/disable_interrupts 嵌套深度与全局使能状态共同决定退出时是否恢复 IRQ;这不是跨核锁。
    IRQ 统计 handle_interrupt()、interrupt_timer_handler() 中断调用计数与忙闲时间;每秒换算负载,超出注册上限记故障。
    寄存器跟踪 register_set()、check_registers() 写寄存器时记录期望值/掩码,主固件每秒检查漂移;仅覆盖经此 API 登记且掩码不含硬件自变位的寄存器。
    故障聚合 fault_occurred()/fault_recovered() 位图聚合、首次打印与状态级别;本版本 PERMANENT_FAULTS=0。
    身份读取 get_provision_chunk()、控制请求 0xd0 读固定地址的 32 字节制造块或 16 字节序列号;全 0xff 时返回内置 unprovisioned 占位值。

    中断速率与负载的真实语义

    临界区实现值得单说:ENTER_CRITICAL() 直接执行 __disable_irq() 并把全局 uint8_t global_critical_depth 加一,EXIT_CRITICAL() 减一,仅在深度回到零且 interrupts_enabled 为真时才 __enable_irq()。这是单核全局 IRQ 屏蔽与嵌套约定,不是自动保存/恢复进入时的 PRIMASK,也不是按外设分别关中断;若绕过包装器直接改 PRIMASK,或进入/退出不配对,8 位深度可下溢或回绕;源码没有运行期断言阻止这两种误用。enter_stop_mode() 刻意使用原始 __disable_irq(),清理 NVIC、只留唤醒源,WFI 返回后立即请求系统复位——这段代码不能当作可返回的通用临界区。

    本版本为 IRQ 分配 163 个槽位,init_interrupts(true) 先把每个外设槽设为「未使用」处理器,再启动 TIM6 每秒统计中断。每次中断先增 call_counter 再调用处理器,若当前秒内次数大于登记上限就 fault_occurred();interrupt_timer_handler() 每秒把计数复制到 call_rate 并清零。这里没有对速率故障调用 fault_recovered(),所以即使下一秒速率恢复,故障位仍可能保留,不能把这一位解释为「当前这一秒仍超限」。

    字段/入口 取值与换算 不能代表什么
    call_rate / EP0 0xc4 上一个完整统计窗的 IRQ 调用次数,Python 侧再解码为整数 不是当前秒的实时值
    interrupt_load busy_time/(busy_time+idle_time),量化到 0..255,主机除以 255 是 ISR 占用比例,不是 MCU 总 CPU 使用率
    速率故障位 超限即置位,不做自动恢复 不等于设备已停止 CAN 或复位

    普通主循环或非 IRQ 的阻塞时间会计入「闲」,因此不能仅靠 interruptLoad 证明控制任务有足够计算余量。无效 IRQ 号在固件侧没有 4 字节响应。

    寄存器巡检与哈希上界

    register_set() 在写寄存器时记录期望值与掩码,主固件每秒调用 check_registers() 检查漂移。它的覆盖范围有明确前提:只包含经此 API 登记、且掩码不含硬件自变位的寄存器。更值得记录的是数组容量与索引的错位:容量是 0x3ff 个元素,合法索引为 0..0x3fe;但 hash_addr() 用 & 0x3ff 计算,冲突检查又先以该值索引。若某个寄存器地址或再探测值哈希到 0x3ff,就会越界,且 check_registers() 不会扫描该槽。触发地址及硬件后果需要针对实际地址清单验证。

    四处静态契约差异

    下面这些差异都属于「代码能编译、单看一侧也自洽,但两侧对不上」的类型:

    差异 源码事实 含义
    寄存器哈希上界 容量 0x3ff,但 & 0x3ff 可算出 0x3ff 该槽越界且不被巡检
    状态与位图 fault_recovered() 仅清位图,不重算 fault_status 位图可为 0 而状态仍为 TEMPORARY
    故障枚举范围 固件位 27 有定义,但 cereal 与 pandad 止于位 26 该故障不能被正确表达
    TIM1 更新向量 汇编指向 TIM1_UP_IRQHandler,C 包装器却是 TIM1_UP_TIM10_IRQHandler 当前 NVIC 该通道被禁用,未触发

    关于 TIM1:汇编向量槽与弱别名把它默认映射到死循环,而 C 包装器定义的是另一个符号名。静态比对 140 个非保留外设向量与 140 个 C 包装器后,除芯片头文件明确支持的 COMP_IRQHandler 别名外,只有这一组不匹配。当前时钟输出初始化虽置了 TIM1 更新中断使能位,却明确 NVIC_DisableIRQ(TIM1_UP_TIM10_IRQn),因此不能表述为已触发的运行期故障;若后续启用该通道,须先校正向量符号并在无负载台架验证。另外 body 的诊断能力不同:它用 tick 与中断计数,但没有普通 panda tick 中的寄存器巡检,其 EP0 请求分派也不是普通 get_health_pkt() 契约。

    制造身份与插件界线

    制造身份应被当作受控的只读能力:MCU UID、OTP 序列号与 provision chunk 分开建模;未知或全 0xff 必须显式标记「未灌录」,不能用测试占位值生成生产身份或信任根。诊断插件可以订阅、分类和呈现故障,但不得单凭 fault_status 清除控制禁用——安全控制权属于独立固件与经验证的主机门控。台架验证应覆盖寄存器哈希边界、故障出现/恢复的双字段序列、位 27 的序列化,以及 IRQ 过载下健康包的发布时延,且都在无车辆执行器的隔离环境完成。

    常见问题

    panda 记录了 fault,执行器就已被切断了吗?

    没有。故障路径只是把故障位与状态送到 PandaState,属于观测链;实际 CAN 许可由 safety hook、心跳与主机执行门决定。

    速率故障位会自己恢复吗?

    不会自动恢复。超限时置位,但代码没有对速率故障调用 fault_recovered(),所以即使下一秒速率恢复,故障位仍可能保留,不能当作当前秒的实时读数。

    interruptLoad 能代表 MCU 的总 CPU 占用吗?

    不能。它只统计 ISR 的忙闲比例,普通主循环或非 IRQ 的阻塞时间会计入「闲」。因此它不能单独证明控制任务有足够计算余量。

    为什么故障位图是 0,状态却仍显示临时故障?

    因为 fault_recovered() 只清位图,不重算 fault_status。消费端应把位图与状态分别解释,不能只读状态来推断当前故障。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/PANDA_FAULT_REGISTER_PROVISION_CONTRACT_DEEP_DIVE.md(panda 故障、寄存器巡检与制造身份契约,含源码行号索引)
    • 项目 02 —— openpilot-research/PANDA_BOOTSTUB_SIGNATURE_FLASH_BOUNDARY_DEEP_DIVE.md(同目录 panda 启动与维护面专题,供制造身份边界的延伸阅读)

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

    更新记录

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