标签: 状态机

  • lateral_maneuversd:第 41 次就绪才启动与末帧有效曲率

    结论:横向实验需在速度偏差 < 0.7 m/s、latActive、绝对曲率 < 0.004 1/m、绝对 roll < 0.12 rad 全部成立后才计数,且严格大于 40(理想至少 41 个合格循环)才进入活动并记录基线曲率;就绪计数失败时每轮只减一,所以并非严格连续两秒。活动期只对驾驶员转向/油门输入和车速偏差过大触发 reset(),latActive=False、曲率或 roll 越界本身不会撤销动作。完成保持期前 19 次循环计划无效,第 21 帧会短暂发出一帧有效基线曲率。以下为固定快照 c8fb906 的静态阅读与隔离探针,未运行回放、CAN、硬件或实车。

    关键函数与字段

    该进程是开发实验进程,不应被当作常规横向规划器。下表按入口列出源码行为与契约边界。

    入口 源码行为 契约边界
    进程条件 / 配置项 started && LateralManeuverMode 运行实验进程;参数在 manager 启动和下线切换清理 它与纵向的 plannerd 不互斥;UI 令实验开关互斥只是配置层约束
    _sine_action() / MANEUVERS 构造 20/30 mph 的阶跃、0.5 Hz 正弦和十段交替 jitter,共八组,每组 repeat=2 Action 只断言时间与加速度数组等长,不保证时间节点单调、有限或有界
    Maneuver.get_accel() 速度与初速差小于 0.7 m/s、latActive、绝对曲率小于 0.004 1/m、绝对 roll 小于 0.12 rad 时计数;不满足时计数每轮减一 严格大于 40;失败仅减一,并非严格连续两秒;活动后不再以 ready 作前置条件
    Maneuver.reset() 清动作帧/序号/活动标志,再清就绪计数 重复轮次须重新积累就绪计数;_repeated、_finished 不由 reset() 清除
    main() 中止分支 转向按压、油门按压使 aborted_cnt 设为名义一秒并 reset();活动时车速偏差大于 0.7 m/s 也重置 未把 latActive=False、曲率/roll 超阈值、订阅输入无效或模型失联列为活动中止条件
    main() 发布分支 每轮发布 alertDebug.valid=True 和 lateralManeuverPlan;计划仅在有动作、active 且 complete_cnt==0 时有效 有效位不取 sm.all_checks();sm.update() 默认 100 ms 超时也可返回,动作帧与提示计数按 DT_MDL=0.05 推进
    Controls.state_control() / SubMaster.update_msgs() 只看该主题最近一次收到的 valid 来选择实验曲率,随后由 clip_curvature() 限幅 发布进程停发后,上次有效计划可能被继续选择;订阅者未在该分支检查 updated、alive 或年龄

    就绪计数与中止边界

    进入活动要同时满足四个严格条件,并累计超过 40 次。get_accel() 在满足时计数、不满足时只减一,因此短暂中断不会清零计数,也就不是严格的「连续两秒合格」。此外,内置实验曲线不是车型认证极限,Action 只断言时间与加速度数组等长,不保证插件提供的时间节点单调、有限或有界。

    项目 阈值 / 计数
    速度与初速差 小于 0.7 m/s
    绝对曲率 小于 0.004 1/m
    绝对 roll 小于 0.12 rad
    就绪计数 严格大于 40(理想至少 41 个合格循环)
    完成提示保持 int(1/DT_MDL)=20 次循环
    动作时间步 DT_MDL=0.05

    中止条件并不覆盖全部「异常」:main() 的中止分支只把转向按压、油门按压,以及活动期间车速偏差大于 0.7 m/s 作为触发。也就是说,latActive=False、曲率或 roll 超出就绪门本身,不会重置动作计划。这是进程状态事实,不代表失活时执行器仍会转向。

    完成保持期的边界

    继承的 _step() 在最终轮次结束时设置 _finished=True 和 _run_completed=True,但没有清 _active。main() 因 _run_completed 把 complete_cnt 设为 int(1/DT_MDL)=20。保持期前 19 次循环计划无效;最后一次循环先把计数从 1 减至 0,再执行 plan_send.valid = maneuver is not None and maneuver.active and complete_cnt == 0,因此按当前控制流会发送一帧有效的基线曲率(本轮 accel 初始值 0),然后才把完成的 maneuver 清空。

    五项原 main() 假发布器探针确认:正常活动时目标为 baseline + accel / vEgo²;最终保持序列中,前 20 帧无效,第 21 帧短暂有效且目标等于基线曲率,下一帧清空动作并提示全部完成。驾驶员转向输入和活动期间车速偏差超限会在计算前调用 reset();仅 latActive=False、曲率或 roll 超出就绪门本身不会触发该复位。这里是隔离消息构造证据,不是已观测到的实际转向脉冲;controlsd 可能在不同调度相位收到该帧,且仍有 latActive、曲率限幅、车型和 panda 门。离线报告生成器按最后一条 valid 计划确定截取边界,这一额外有效帧也可能改变报告边界。

    对硬件与插件化的要求

    横向实验插件必须定义独立的计划租约、真实采样时戳、计划年龄上限、曲率单位/符号和适用车速;宿主统一处理 valid、存活和失效后的模型回退。armed → active → aborted/completed 状态应显式清理;横向失活、车速/曲率/roll 越界、输入失联和驾驶员干预的中止策略须分别定义。提示保持计数不能决定计划有效性。

    动作曲线、基线曲率和加速度到曲率的转换须按目标车型及硬件版本验证;回放重点覆盖第 41 次就绪、就绪中断、活动中失活、重复轮次、最终保持帧和发布者停发。当前归档源码目录未见针对这些状态转移或最终保持帧的专门原仓库自动化测试;本篇十项隔离探针补充了部分控制流证据,未运行真实消息总线、动态回放或实车验证。

    常见问题

    为什么是第 41 次而不是第 40 次才启动?

    因为 get_accel() 用严格大于 40 判定,理想情况下至少 41 个合格循环才记录基线并开始;失败时计数只减一而不清零,所以也并非严格连续两秒。

    横向失活会自动中止实验吗?

    不会自动中止。main() 的中止分支只把转向按压、油门按压和活动时车速偏差大于 0.7 m/s 列为中止;latActive=False、曲率/roll 超阈值、输入无效、模型失联未被列为活动中止条件。

    实验结束后会多发一帧转向吗?

    按当前控制流,完成保持期前 19 次循环计划无效,最后一次先把计数从 1 减到 0 再判定 valid,因此会发出一帧有效的基线曲率(本轮 accel 初值 0),随后清空。这是隔离消息构造证据,不是已观测到的实际转向脉冲。

    发布者停发后计划会立刻失效吗?

    不一定。controlsd 只看该主题最近一次收到的 valid,而 SubMaster 的 alive 独立按收包时间计算;发布进程停发后,上次有效计划可能被继续选择。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/LATERAL_MANEUVER_STATE_MACHINE_DEEP_DIVE.md(横向实验的状态机、阈值与完成保持帧)
    • 项目 02 —— openpilot-research/LATERAL_TARGET_ACTUATION_DEEP_DIVE.md(横向执行链,目标曲率下游的独立门,作为本文的补充阅读)

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

    更新记录

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

  • selfdrived 动态告警:49 个槽位与软退出的真实边界

    结论:在 EVENTS 主映射里共有 49 个回调槽位、22 种回调形式:两个工厂覆盖 27 槽位,20 个直接函数覆盖 22 槽位。回调在 creation_delay 延迟过滤之前执行,即使告警最终未显示也会读取输入、构造文本,且这里没有逐回调异常隔离。更关键的是,回调返回的 ImmediateDisableAlert 只改变文案、声音、视觉标记、优先级与持续时间,是否立刻退出仍由事件类型决定,而不是由告警子类名里有 Immediate 决定。本文为固定源码快照 c8fb906 的静态阅读,未实测墙上时间与声音输出延迟。

    调用链:从输入到仲裁

    SelfdriveD.update_alerts() 提供六个实参:CP、CS、sm、is_metric、soft_disable_timer 和驾驶风格枚举字符串。Events.create_alerts() 只处理当前状态允许的事件类型:值若是 Alert 实例则直接使用,否则调用回调创建新实例。随后按连续帧计数判断 creation_delay,写入 EventName/类型 键和事件类型。

    环节 行为 边界
    update_alerts() 传入 CP、CS、sm、公英制、软退出计时与驾驶风格 输入字段来自当前 SubMaster 消息,跨主题时序需回放确认
    create_alerts() 只处理当前状态允许的事件类型;静态 Alert 直接用,否则调用回调 回调在 creation_delay 过滤前执行,无逐回调异常隔离
    AlertManager.add_many() 以 EventName/类型 键保存告警及结束帧 「事件已出现」与「UI 当前展示」可能不同步
    process_alerts() 按优先级、起始帧选当前项 最终仲裁仍在受控核心,不由插件决定

    这里有一个容易被忽略的顺序细节:create_alerts() 在判断 creation_delay 之前就已经执行了回调,因此告警文案与动态数值是在过滤之前构造的。若某回调依赖尚未更新的输入,它可能读到上一帧的字段;反过来,一个最终因延迟未显示的告警,其回调副作用——例如读取 sm、构造字符串——仍已发生。由于没有逐回调异常隔离,单个回调抛错会影响同一容器内的处理顺序,这一点在插件化时必须由受控核心兜底。

    两类软退出工厂:27 槽位

    软退出相关的槽位由两个工厂覆盖,共 27 个。soft_disable_alert() 覆盖 24 个事件,包括 bigModelFailed、cameraMalfunction、cameraFrameRate、locationdTemporaryError、paramsdTemporaryError、steerTempUnavailable、steerTimeLimit、sensorDataInvalid、excessiveActuation、overheat、calibrationInvalid、calibrationIncomplete、calibrationRecalibrating、espDisabled、commIssue、commIssueAvgFreq、selfdrivedLagging、processNotRunning、radarFault、radarTempUnavailable、modeldLagging、posenetInvalid、lowMemory、espActive;user_soft_disable_alert() 覆盖 3 个:wrongGear、doorOpen、seatbeltNotLatched。

    回调形式 槽位数与事件 运行结果
    soft_disable_alert() 24 个(含 overheat、radarFault、modeldLagging、lowMemory 等) 软退出计数低于 int(0.5/DT_CTRL) 时构造 ImmediateDisableAlert 外观,否则构造 SoftDisableAlert
    user_soft_disable_alert() 3 个:wrongGear、doorOpen、seatbeltNotLatched 同样在剩余约 0.5 秒改成紧急外观,之前使用文案较缓和的 UserSoftDisableAlert

    关键区别在于:回调返回的 ImmediateDisableAlert 只改变告警的文案、声音、视觉标记、优先级与持续时间,Events.create_alerts() 仍将 event_type 标为映射键 SOFT_DISABLE。StateMachine.update() 是否立刻退出由事件类型决定,不会因为 Alert 子类名含 Immediate 就改走 IMMEDIATE_DISABLE。计数按 DT_CTRL=0.01 的名义周期换算,墙上时间和声音输出延迟尚未实测。

    20 个直接回调:22 槽位

    回调与主映射事件 消费字段和动态内容
    joystick_alert():joystickDebug.WARNING 从 carControl.actuators.accel/torque 计算调试文案;不是实车执行反馈
    longitudinal_maneuver_alert():longitudinalManeuver.WARNING、lateralManeuver.WARNING 两个事件都读 alertDebug 的两行文案;横向事件由 lateralManeuverPlan 触发但回调仍读 alertDebug
    invalid_lkas_setting_alert():invalidLkasSetting.PERMANENT 根据 Tesla/Mazda/Nissan 品牌调整原车 LKAS 说明,其余用通用文案
    paramsd_invalid_alert():paramsdTemporaryError.NO_ENTRY 按 angleOffsetValid、steerRatioValid、stiffnessFactorValid 顺序挑第一个错误并附数值
    camera_malfunction_alert():cameraMalfunction.PERMANENT 对三路摄像头逐个调用 sm.all_checks([service]) 拼出失败名称
    overheat_alert():overheat.PERMANENT 取 CPU、GPU、内存温度的最大值显示;空列表用 0 兜底
    comm_issue_alert():commIssue.NO_ENTRY 对 sm.data 各主题重新执行 all_checks,只列前四个失败主题
    personality_changed_alert():personalityChanged.WARNING 显示当前驾驶风格字符串,持续时间 1.5 秒

    其余直接回调包括 startup_master_alert()、below_steer_speed_alert()、wrong_car_mode_alert()、out_of_space_alert()、below_engage_speed_alert()、too_distracted_alert()、calibration_invalid_alert()、calibration_incomplete_alert()、process_not_running_alert()、modeld_lagging_alert()、posenet_invalid_alert()、low_memory_alert()。需要强调「槽位」是 EVENTS 中事件名与事件类型的组合,不等于 22 个独立事件,也不等于 49 条屏幕上同时可见的告警。此外 NoEntryAlert 在 mici 上会调换两行文字,EVENTS.update() 还覆写 9 个条目中的告警定义,实际设备呈现必须按设备类型重核。

    从这些实现里还能看出一个共性:动态文案普遍消费当前订阅到的消息字段,例如 overheat_alert() 取 CPU/GPU/内存温度最大值、modeld_lagging_alert() 读 modelV2.frameDropPerc、posenet_invalid_alert() 计算 CS.vEgo - modelV2.velocity.x[0]。这些字段来自不同进程、不同采样时刻,回调本身不做新鲜度校验,因此「文案里显示的数值」不宜直接当作同一时刻的联合状态。

    仲裁、测试与插件边界

    AlertManager.add_many() 以 EventName/类型 键保存告警及结束帧,process_alerts() 按优先级、起始帧选当前项。update_alerts() 在不活动时清理 WARNING,已使能时清理 NO_ENTRY,这可能让「事件已出现」和「UI 当前展示」不同步。selfdriveState 再发布当前文案、声音与 HUD 标记。

    测试覆盖存在缺口:test_alerts.py 的静态告警属性检查明确跳过回调值,test_alertmanager.py 主要验证持续帧逻辑,不能证明 20 个直接回调的所有输入边界正确。隔离探针确认回调在 creation_delay 过滤前执行、异常不在容器中隔离。重构成插件时应把输入观测和告警策略分层:插件提供可信字段与新鲜度,受控核心保留事件类型、软退出计时、回调异常处理和最终仲裁;不得让插件仅靠改文案或改告警子类来更改安全状态。

    常见问题

    回调返回 ImmediateDisableAlert 会让车辆立刻退出吗?

    不会。create_alerts() 仍把 event_type 标为 SOFT_DISABLE,是否立刻退出由 StateMachine.update() 按事件类型决定,与告警子类名是否含 Immediate 无关。

    回调只在告警最终显示时才执行吗?

    不是。回调在 creation_delay 延迟过滤之前执行,因此即使最终未显示也会读取输入并构造文本,且这里没有逐回调异常隔离。

    49 个槽位等于 49 条同时可见的告警吗?

    不等于。槽位是 EVENTS 中事件名与事件类型的组合,既不是 22 个独立事件,也不是屏幕上同时可见的告警条数;mici 覆写还会改变实际呈现。

    「事件已出现」和「UI 当前展示」总是同步吗?

    不一定。update_alerts() 在不活动时清理 WARNING、已使能时清理 NO_ENTRY,因此事件状态与 selfdriveState 发布给 UI 的展示可能不同步。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/DYNAMIC_ALERT_CALLBACKS_DEEP_DIVE.md(取用了回调槽位统计、两类软退出工厂、20 个直接回调、仲裁与测试缺口、插件边界及隔离探针结论)。
    • 项目 02 —— openpilot-research/CAR_MESSAGES_FIELD_CONTRACT_DEEP_DIVE.md(仅用于确认回调消费字段的消息来源)。

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

    更新记录

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

  • maneuversd 纵向动作状态机:为什么第 61 次循环才激活

    结论:maneuversd 是 openpilot 中一个实验性的纵向动作发布进程,它用 DT_MDL=0.05 秒按主循环次数推进动作,而不是按真实秒数。它的就绪门要求车速偏离初速小于 0.3 m/s 且纵向处于活动状态,连续超过 60 次合格循环后第 61 次才置为 _active;进入活动后若 longActive 变假,函数仍会继续推进当前动作帧。它替换的是 plannerd 的计划发布者身份,不替换 controlsd、card 与 panda 的执行门。本文为固定源码快照 c8fb906 的静态阅读,未连接车辆与硬件。

    进程身份:发布者切换不等于安全许可

    在进程配置里,plannerd 与 maneuversd 通过条件互斥:plannerd 使用 not_long_maneuver,maneuversd 使用 long_maneuver,后者要求 started 为真且 LongitudinalManeuverMode 处于开启状态。这是 manager 正常管理路径上的发布者切换,并不是一份独立的安全许可。

    main() 先等待 CarParams 存在,再轮询 modelV2,依次取用内置的七个 Maneuver。内置计划固定 allowBrake=True、allowThrottle=True、hasLead=True、speeds=[0.2],并按 should_stop(v_ego, accel) 设置停止位;这些是实验消息的构造字段,不能代表正常纵向规划器的前车识别、速度轨迹或 FCW 能力。

    入口 源码事实 边界
    process_config.py 进程配置 plannerd 用 not_long_maneuver,maneuversd 用 long_maneuver 是 manager 正常路径的发布者切换,不是独立安全许可
    Action.__post_init__() 只断言加速度与时间节点数量一致 未校验节点非空、单调、有限、加速度范围或持续时间
    main() 等 CarParams,轮询 modelV2,每轮发三路主题 未在状态推进前用 all_checks() 做门控

    就绪门与「第 61 次循环」的由来

    Maneuver.get_accel() 在车速偏离初速小于 0.3 m/s、longActive 为真且巡航不处于 standstill 时累加就绪计数;当初速接近零时,还额外要求 carState.standstill 为真。它把状态机推进建立在「连续满足条件」的计数上,而不是单次采样。

    只有 _ready_cnt > 3/DT_MDL 才置 _active=True。由于 DT_MDL=0.05,3/DT_MDL 等于 60,而这里用的是严格大于,所以至少需要 61 次合格循环,而不是精确的 3.00 秒墙钟。未进入活动状态时,函数返回一个限幅到 ±2 m/s² 的初速追赶目标。

    条件 阈值 说明
    车速偏离初速 小于 0.3 m/s 偏离越大越不计入就绪计数
    纵向激活 longActive 为真 初速接近零时还要 carState.standstill
    就绪计数门 _ready_cnt > 3/DT_MDL 严格大于,至少 61 次合格循环
    未活动追赶目标 ±2 m/s² 限幅后的初速追赶,不是最终执行命令

    动作插值、重复与 reset 边界

    Maneuver._step() 按 _action_frames * DT_MDL 对当前 Action 插值;帧计数加一后,只有超过末节点时间才切换到下一个动作、重做或结束,因此最后一个时间节点需要再推进一帧才会切换。以主循环次数近似时间,意味着卡顿或模型节拍异常都会改变墙钟持续时长。

    repeat=2 使内置动作总共执行三轮。reset() 只清 _active、_action_frames、_action_index,不清 _ready_cnt、_repeated、_finished;它与横向子类的 reset() 不同,不能当作完整的状态重置。若 repeat 之后就绪条件继续成立,下一循环可以立即再次激活。

    三路发布与有效位语义

    每轮 main() 依次发出 alertDebug、longitudinalPlan 和 driverAssistance。计划的 valid=sm.all_checks(),另两路直接为真;其中 driverAssistance 是空默认字段,不提供常规 LDW 结果。动作列表耗尽后,仍会按同样顺序持续发送 aTarget=0 的计划和完成提示。

    源码没有在进入 get_accel() 之前用 sm.all_checks() 阻止状态推进,因此无效计划也会先完成计算并填入目标,只是把外层有效位标为假。

    主题 有效位 内容 边界
    longitudinalPlan sm.all_checks() 按动作插值填入 aTarget、停止位等 有效位不冻结状态机
    alertDebug 恒为真 纵向动作模式告警 由 selfdrived 消费,不做执行门
    driverAssistance 恒为真 空默认字段 不提供常规 LDW 结果

    失效路径与插件化要求

    SubMaster.update() 的默认 100 ms 超时也会让循环继续,所以「循环次数 × DT_MDL」并不等于真实经过的时间。sm.all_checks() 只设置发布事件的有效位,不冻结状态机;本进程本身没有「数据失联即重置动作」的独立分支。若模型消息或其他输入失联,外层消息健康和下游自驾状态机可能触发退出,但那是另一层。

    若要把这类实验规划做成插件,至少应:用单调时钟或样本时间推进,而不是用消息循环数冒充秒数;建立显式的 idle → armed → active → abort/complete 转移,纵向失活、速度越界、数据失联与进程重启都要撤销动作;把实验规划插件与量产规划插件分开注册、隔离主题与权限,即使替换计划发布者,也不能成为 carControl 或 sendcan 的直接所有者。

    常见问题

    为什么是「第 61 次循环」而不是精确 3 秒?

    就绪门写的是 _ready_cnt > 3/DT_MDL。DT_MDL=0.05 秒,3/DT_MDL 等于 60,而比较是严格大于,所以至少需要 61 次合格循环才置为活动。动作推进也按主循环次数乘 DT_MDL 计时,轮询超时同样会让循环继续,因此它不是精确的 3.00 秒墙钟。

    活动后 longActive 变假,动作会立刻停止吗?

    函数本身不会在这一点重置 _active。活动后若 longActive 变假或速度越界,get_accel() 仍会进入 _step() 继续推进动作帧;这不等于执行器仍有输出,controlsd 有独立的 CC.longActive 门。

    longitudinalPlan.valid 为假时 controlsd 会丢弃目标值吗?

    不能这样断言。controlsd 的 publish() 处没有按 self.sm.valid['longitudinalPlan'] 包住目标读取;selfdrived 会对该主题做通信健康检查并可能产生 commIssue,但它与 controlsd 的字段消费是不同层。计划外层 valid=False 不必然让控制器直接丢弃数值。

    reset() 是完整的状态重置吗?

    不是。reset() 只清 _active、_action_frames、_action_index,不清 _ready_cnt、_repeated、_finished。若 repeat 之后就绪条件继续成立,下一个循环可能立即再次激活,因此不能把它当作完整状态重置。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/LONGITUDINAL_MANEUVER_STATE_MACHINE_DEEP_DIVE.md(取用了进程互斥、就绪门与第 61 次循环阈值、三路发布有效位与失效路径)。
    • 项目 02 —— openpilot-research/LONGITUDINAL_MANEUVER_TEST_MATRIX_DEEP_DIVE.md(关联的回归测试矩阵专题,用于区分发布控制流与测试覆盖,本文未展开其用例)。

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

    更新记录

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

  • 600 秒预算与三次重试:外置模块刷写为何不能只看退出码

    结论:外置计算模块的固件刷写里,脚本退出码 0 不等于升级完成。刷写脚本对镜像只做长度上限、魔数、字节校验和与 CRC32 的格式/传输完整性校验,它不是来源认证或数字签名验证;更关键的是,ROM 恢复已提交但设备没有重新出现时,脚本仍会正常返回,上层据退出码 0 记为「刷写成功」,而此时运行态镜像并未被回读校验。正确的做法是把「发现、写入、激活、运行」拆成六种状态,不让一个布尔值吞掉它们。

    它是什么,也不是什么

    该模块通过 USB/PCIe 接入外置 GPU,服务于大模型推理,属于推理设备。它不是车辆 CAN 安全网关:车辆控制输出仍须经过原有的车辆接口、进程与 panda 安全链。这条定位决定了后面所有设计约束——外置模块可以失联、可以回退,但不能绕过控制安全链。

    发现与状态:USB 快照不是同步断线检测器

    设备发现从系统总线读取 VID/PID、链路速率、产品字符串、链路错误与接口朝向,再写入设备状态;其中「模块在位」的判定只识别运行态 VID/PID,不要求产品字符串版本匹配。状态线程约每 10 秒或总线拓扑变化时更新一次快照,队列容量为 1,主线程可以沿用旧值。因此它是一份采样快照,不是同步的断线检测器——真正判断「用哪个模型」的是另外两层证据。

    刷写路径:格式校验不等于来源认证

    刷写入口读取内置封装镜像,检验封装格式、产品字符串与调用方期望版本;已匹配则跳过,并设定 600 秒全局预算。镜像校验检查的是长度上限、魔数、字节校验和与 CRC32——这些字段能发现传输或格式损坏,但不能证明镜像来源或版本内容不可替换。固件写入按 4 KiB 扇区擦除、128 B 页写入,并逐页与整扇区回读验证;配置页备份为 0x100 字节,且备份文件以主机名确定路径,已有备份会优先于当前设备配置。

    校验 / 步骤 检查什么 不保证什么
    格式与产品字符串 封装结构、目标产品名、期望版本 不证明镜像内容可信
    长度上限 / 魔数 / 校验和 / CRC32 传输与格式完整性 不是来源认证或数字签名
    页 / 扇区回读 写入范围与内存目标一致 不证明新固件已启动
    配置页备份(0x100 字节) 保留设备侧配置 备份与具体设备的绑定需台架确认

    六种状态不该合并成一个退出码

    刷写与恢复会经过多条分支,每条分支得到的「证据」并不相同:首次发现时无设备会直接正常返回(没写入、没激活);产品字符串已匹配且未强制刷写会直接返回(只核对枚举字符串,没读闪存);普通路径完成写入并回读一致后才调用激活;而 ROM 路径若已提交但仍停留在 ROM 或已离线,恢复函数返回失败,外层却正常返回,于是退出码 0 被记成「成功」。此外,激活函数的所有普通完成分支都返回空值——调用方无法从返回值判断设备到底是否已激活并重枚举。

    源码分支 脚本退出码 / 上层状态 实际得到的证据
    首次发现时无设备 正常返回,记为已刷写 仅说明发现时无设备,未写入、未激活
    产品字符串已匹配且未强制 正常返回,记为已刷写 只核对枚举字符串,未读闪存、未验证 GPU/PCIe
    普通路径完成写入并回读 调用激活,正常返回 写入范围与内存目标一致,不一定代表已启动
    ROM 已提交但仍停留 ROM 或离线 恢复返回失败,外层正常返回 未完成运行态回读校验,属「提交成功 / 验证完成」混淆
    镜像、备份、读写或预算抛异常 退出码 1,记为失败 明确失败,但不自动说明设备是否仍可恢复

    重试额度同样是状态的一部分:尝试次数在进程生命周期内不复位,产品字符串恢复匹配只清「已刷写」标志,不清尝试次数;同一进程用满三次后,即使后来版本又出现不匹配,也不会再自动重试。600 秒预算是协作式收敛预算,只在重试/恢复循环的调用点检查,不是能中断所有 USB 操作的硬截止。

    模型回退与插件约束

    推理侧的设计是分层的:先尝试加载并预热大模型,超时或失败则走小模型;运行中大模型异常也会回退,并把状态写入消息供上层生成加载/失败事件。监测主题标称 10 Hz,但 USB 不存在时监测线程不会按 10 Hz 发送有效消息。插件化时,固件维护接口应返回分离的状态:未找到、已匹配、已写入并验证、等待激活、运行态已验证、失败,并附目标设备身份、镜像摘要、采样时间与失败阶段;重试额度应绑定设备/版本/维护事务,而不是复用进程内布尔值。安全侧只有运行态重新枚举、产品版本与推理健康全部确认后,才能把模块标记为可用——这仍不能替代车辆控制安全链的验证,外置模块也不得获得发送 CAN 的权限。

    常见问题

    镜像通过 CRC32 校验,就能说明固件可信吗?

    不能。长度上限、魔数、字节校验和与 CRC32 属于传输与格式完整性校验,用于发现损坏或截断;它不是来源认证或数字签名验证,改变镜像内容并重新计算校验值后仍可通过校验,因此不能据此判断镜像来源或版本不可替换。

    刷写脚本返回成功,就代表升级完成了吗?

    不一定。ROM 恢复已提交但设备未重新出现时会返回失败,外层却正常返回、退出码 0 被记成成功,而此时还没有完成运行态镜像校验;激活函数也无法从返回值告知调用方设备是否真的已激活并重枚举。

    为什么要有三次尝试上限?

    为了限制自动刷写的次数与风险。尝试次数在进程生命周期内不复位,产品字符串恢复匹配也只清「已刷写」标志、不清次数,因此同一进程用满三次后不会再自动重试,避免在异常设备上反复写入。

    外置计算模块能接管车辆控制吗?

    不能。它只是推理设备,车辆控制输出仍须经过原有的车辆接口、进程与 panda 安全链;固件升级是驻车维护事务,模块本身不得获得发送 CAN 的权限,也不能替代独立安全网关。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/CHESTNUT_USB_FIRMWARE_DEEP_DIVE.md(USB 发现、固件刷写路径、镜像与备份、ROM 恢复、模型回退与插件约束)

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

    更新记录

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

  • 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