Files
STM32F4-Base/docs/子系统设计/控制台单写者日志设计说明.md
T
super_agent_bot 72b4b0943c docs: clear task and layer shorthand IDs
Replace task/batch shorthand IDs and residual layer labels in
human-facing docs with semantic phrases. Add the new cleanup
policy to docs/编号规范.md and update the entry docs.

Machine keys (TC/CODE-RULE/MENU-STATE/TRAP/FR), hardware
designators/pins/MAC, protocol/version numbers and frozen
historical evidence are intentionally kept.

Docs only; no code behavior change. Verified with
check_doc_links.py --all and git diff --check.
2026-11-02 03:33:51 +08:00

61 KiB
Raw Blame History

控制台单写者日志设计说明

版本:v1.8(已实施;修订史见文末 §11「实施时间线」) 日期:2026-10-30(v1.8:控制台端口由 USART1 改为 UART5/RS232#1,全文端口口径同步——物理口与台架端子见 屏板链路设计说明.md §2 / §3.2;v1.7 于 2026-10-26:崩溃上报归一进单写者 + 多段裸发收口,道G task-26;v1.6 同日:未注册期裸发加「行级单一所有者」;v1.5 于 2026-10-24;初版 v1.0 于 2026-09-25) 状态:已实施并验证——/keil-build 0 错误 0 警告、/serial-boot-test 硬件闭环 PASS(2026-09-25,见 §10);设计 2026-09-24 加固实施(当时位于 Src/usart.c USER CODE 区),2026-09-25 实现迁出至 App/log/(HAL 生成文件不含应用代码);同日写者任务体自 Src/freertos.c 迁至 App/task/default_task.c(与其余 5 任务文件同址归位) 关联文档:项目架构总览.md · 运行与自检记录设计说明.md(姊妹模块:持久化日志,同一写者任务) · docs/测试说明/专项验证/链路延迟测试说明.md(D6 日志税 / 步骤 8 非阻塞日志,需求出处) · AGENTS.md「平台约束」

1. 背景:实测故障与根因

1.1 实测故障(2026-09-24,双证据)

# 症状 实测证据 后果
F1 日志行字符级交错 崩溃前两行日志逐字符交错:"[2026-[2026-0909-24 15:2-24 15:27:37:39]…" /serial-boot-test 必检标记可能假性丢失、日志不可读
F2 HardFault(脏指针直接进入 UART) BFAR=0x10010000(恰为 CCM 末尾+1),PC 落在 HAL_UART_Transmit 的缓冲读取处 崩溃上报路径自身再崩;被破坏的字符串指针被 UART 逐字节读取

前兆:2026-09-22 链路延迟测试实测开机连续丢行(多任务并发 printf → 非重入 fputc/HAL_UART_Transmit 丢整行,~2s 自恢复;链路延迟测试说明.md §9.2)——同类竞态的温和形态,当时列为待办(日志非阻塞化)。

1.2 根因分析(旧路径:DBG_* → printf → fputc)

# 根因 机理 对应故障
C1 逐字符发送 fputc 每字符一次 HAL_UART_Transmit(1 字节);两次调用之间可发生任务切换 F1 交错
C2 调用方缓冲直达 UART HAL_UART_Transmit 的缓冲指针是调用方(可能已被破坏)的指针 F2 HardFault
C3 多任务并发直触 UART 轮询 Transmit 的并发保护是 gState 状态标志(§3.4):竞争失败方立即返回 HAL_BUSY(不等待、不重试),返回值被忽略 → 静默丢数据 09-22 丢行

HAL 并发语义(本仓库实现实测,stm32f4xx_hal_uart.c:1135):轮询版 HAL_UART_Transmit 不加 __HAL_LOCK(huart->Lock 标志仅 IT/DMA 路径使用,stm32f4xx_hal_def.h:93);入函数检查 gState==READY,全程保持 BUSY_TX 直到发完 —— 单次调用是原子的,但调用之间没有排队机制:并发调用方立即拿到 HAL_BUSY。逐字符路径下(C1)"单次调用 = 单字节",原子性退化为字节级(→ F1);整行路径下竞争失败方整行丢失(→ C3)。

2. 设计目标与约束

2.1 目标

# 目标 达成方式
G1 行级原子:不同行不再逐字符交错 整行一次性入环形缓冲;唯一写者发出(UART 全局只有一个发送调用点)
G2 指针可控:进入 HAL_UART_Transmit 的缓冲恒受本模块控制 调用方指针只经拷贝入环、到不了 UART;写者发出的缓冲恒为环形缓冲或模块本地静态缓冲
G3 不新增任务、不用 C 库堆、不新增内核对象 复用既有 defaultTask 作写者;环形缓冲 = 4KB 静态 BSS;复用 sys_evt 事件标志位
G4 内核启动前 / 写者未注册 / ISR / 异常钩子路径不降级 回退:有界直发(dbg_console_write_raw,≤256B 截断、不等待、不死锁、零内核对象)
G5 缓冲满丢行必须可观测 丢弃计数 + 写者刷出 [ERR] CONSOLE dropped N lines —— 含 [ERR] 模式,/serial-boot-test Forbidden 判 FAIL,丢失永不静默

2.2 约束

# 约束 出处
K1 内核启动前禁止创建/使用任何 FreeRTOS/CMSIS-RTOS 对象 AGENTS.md 平台约束(2026-09-20 启动失败实证根因)→ 本模块内核前纯 BSS/寄存器机制
K2 禁止 malloc/calloc/realloc(C 堆仅 512B) AGENTS.md 平台约束(禁 C 库堆分配条)→ 全部状态静态 BSS
K3 HAL 生成文件(Src/、Inc/)不含应用代码 2026-09-25 迁移动机 → 实现位于 App/log/
K4 生产者阻塞时间有界 最多 DBG_CON_WAIT_MS(200ms),日志不得阻塞业务
K5 RAM 预算紧张,新增静态内存必须核算 §8

3. 总体架构

3.1 单写者模型

角色 实体 职责
生产者 全部任务 + 内核前主线程 + ISR 调用点格式化整行(库 snprintf)→ dbg_console_write_line()
写者 defaultTask(唯一) 进主循环前 dbg_console_writer_attach() 注册为唯一发送者;循环内周期 dbg_console_drain()
物理通道 UART5(RS232#1:PC12 TX / PD2 RX,经 U9 隔离 → U13 TPT3232E → CN1;115200 8N1) 轮询发送(HAL_UART_Transmit);端口以 CON_UART* 宏收口在 console_log.c;DMA2_Stream7 现由屏板链路 USART1 TX 占用,与本模块无关
唤醒 SYS_EVT_CONSOLE_TX(sys_evt bit 6) 消费型命令位(osFlagsWaitAny 默认消费,与 AGENTS.md 事件位语义一致;区别于就绪位必须 osFlagsNoClear)

与其他日志子系统的关系(同一"单写者纪律",同一属主任务):

子系统 通道 写者 持久化
控制台单写者(本文档) UART5 串口(RS232#1) defaultTask 无(易失,复位丢失)——实时调试流
sys_log 运行/自检记录 FatFS /log/*.log defaultTask(同一任务) 是(定长 64B 记录,运行与自检记录设计说明.md)
配置引擎 FatFS /config.ini defaultTask(同一任务) 是

defaultTask 主循环按序处理:控制台排空 → 配置保存执行 → 2s 配置轮询 → LED 心跳 → syslog 落盘 → 栈水位观测——同一任务串行化三类 I/O,任务间零竞争。

3.2 文件清单

文件 端 角色
App/log/console_log.h MCU API 声明:5 个控制台函数 + fputc retarget + DBG_CON_LINE_MAX
App/log/console_log.c MCU 实现:环形缓冲 / 生产者 / 写者 / 裸发 / fputc
Drivers/BSP/dbg_log.h 共享 #include "console_log.h";DBG_PRINT 宏绑定 dbg_console_write_line(整行一次入环)
simulator/dbg_log.h 模拟器 printf stub——模拟器不编译本模块(宿主 printf 天然串行,无需单写者)
App/task/default_task.c MCU 写者任务体:dbg_console_writer_attach() 注册 + 主循环 dbg_console_drain();含存储 bring-up / 配置引擎 / syslog 落盘 / LED / 栈水位(2026-09-25 自 Src/freertos.c 迁出)
Src/freertos.c MCU CubeMX 接线:6 任务创建 + netMsgQueue + sys_evt;栈溢出/堆失败钩子裸发(任务名先范围校验再拷入本地缓冲)
Src/usart.c MCU 纯 CubeMX 生成(UART 初始化 / MspInit / 句柄声明)——零应用代码

3.3 拓扑

flowchart LR
    subgraph P["生产者(任意上下文)"]
        P1["netTask / ftpTask / uiTask / timeSync / testTask"]
        P2["内核前主线程<br/>(MX init / app_main_init)"]
        P3["ISR / 异常钩子"]
    end
    R["环形缓冲 4KB<br/>BSS(SRAM1)<br/>head/tail 16 位回绕"]
    E["sys_evt bit6<br/>SYS_EVT_CONSOLE_TX"]
    W["写者 = defaultTask<br/>dbg_console_drain()"]
    U["UART5 115200<br/>(RS232#1, 轮询)"]

    P1 -- "dbg_console_write_line(整行 ≤192B)" --> R
    R --> E --> W
    W -- "HAL_UART_Transmit(块 ≤256B)" --> U
    P2 -- "写者未注册:整行直发" --> U
    P3 -- "有界直发 ≤256B" --> U

3.4 并发语义与本设计的配合(实现实测)

写者已注册后(稳态):任务上下文唯一调用者 = 写者(defaultTask),零竞争;残余窗口 = ISR 直发撞写者发送中(丢失、不挂死,§7)。

写者未注册期(内核启动 → defaultTask attach,时长取决于存储 bring-up,通常秒级):con_append 走 dbg_console_write_raw → con_tx_bounded(寄存器级轮询 TXE/TC,无 gState 锁,见 §4.4)。⚠ 2026-10-10 起此路径无互斥——旧实现走 HAL_UART_Transmit,其 gState 检查跨整行保持、竞争失败方立即 HAL_BUSY,故当时"只丢不交错";改为寄存器级轮询后该保证不再成立:两个任务同时裸发会逐字节交错(2026-10-10 台架实测:adcTask 首帧日志与 defaultTask 的 [NAND_FTL] 初始化行交错)。

区间 发送者构成 并发行为
内核启动前 仅主线程(单线程) 无并发,直发安全
写者未注册(内核启动 → attach) 任何任务都可能记日志(2026-10-26 起这是常态而非违规) 整行独占(行级单一所有者,§4.4.1):同一时刻只有一个上下文在写 CON_UART->DR,抢不到独占的整行转入环形缓冲延后发 → 交错结构性消除,且不丢行
写者已注册(稳态) 任务上下文唯一调用者 = 写者 任务上下文零竞争;残余窗口 = ISR 直发撞写者发送中(丢失、不挂死,§7)

纪律(2026-10-10 固化,2026-10-10 / 2026-10-26 两次台架实证其不足):周期 / 常驻任务不得在写者注册前记日志——用 dbg_console_writer_ready()(§5)门控自身日志(adcTask 的首帧与 10 s 心跳、protectTask 的启动锚点行即按此门控——后者 2026-10-12 台架复测实证:保护任务 接线时启动行未门控,注册前裸发被交错损坏致 /serial-boot-test 锚点 MISS,见 §11 变更记录)。内核前代码仍可直接裸发(单线程,无并发)。

⚠ 自愿门控挡不住边沿留痕——它在 2026-10-12(台架复测,启动锚点)与 2026-10-26(未标定闭锁边沿,见 §4.4.1)两次被同一个窗口咬到, 因为"这条行要不要留痕"是业务边沿决定的,调用方无法预知它是否落在未注册期。2026-10-26 起该窗口改由机制兜住 (§4.4.1 行级单一所有者);dbg_console_writer_ready() 保留,但语义降级为降延迟(少让路一次、少占一点环位), 不再是防交错的唯一手段——新代码不必再为它改写业务分支。

4. 详细设计

4.1 环形缓冲

参数 值 理由
容量 DBG_CON_RING_SIZE 4096B 2 的幂 → &(SIZE-1) 回绕免除法;≈21 整行(192B/行)余量
head/tail uint16 + volatile;多生产者安全 / 单写者(2026-10-24 更正:本表旧写「单生产者」,与本文 §3.1「生产者 = 全部任务 + 内核前主线程 + ISR」互相矛盾,现由代码侧统一——复核空位 + 推进 head + 整行拷贝收进同一个临界区,最长 192 B 拷贝在 168 MHz 下约亚微秒;vTaskDelay 与 DWT/tick 上界判据一律留在区外,区内绝不睡眠) head = 生产者写位置(临界区内推进),tail = 写者读位置
已用字节 (head - tail) & (SIZE-1) 无符号差自然回绕,免锁可读
单行可接收上限 SIZE/2 = 2048B 保证"一行恒可入环";正常路径受 DBG_CON_LINE_MAX=192B 约束,不触发
位置 BSS(SRAM1,非 CCM) CCM = FreeRTOS 堆,放 BSS 不占堆;轮询发送不依赖 DMA 可达性(CCM 的 DMA 不可达约束不适用)

4.2 生产者路径(con_append)

flowchart TD
    A["dbg_console_write_line / fputc"] --> B{"写者已注册?<br/>(writer_up)"}
    B -- "否(内核前 / 未 attach)" --> R["write_raw 整行直发<br/>(≤256B 截断, 不等待)"]
    B -- 是 --> C{"中断/异常上下文?<br/>(__get_IPSR() ≠ 0)"}
    C -- 是 --> R
    C -- 否 --> D{"当前任务 == 写者?"}
    D -- 是 --> E["先 dbg_console_drain()<br/>(腾空环形缓冲, 避免自等)"]
    D -- 否 --> F{"环内空间 ≥ 行长?"}
    E --> F
    F -- 是 --> G["临界区:整行拷贝<br/>(head→环尾 + 回绕两段) + 推进 head"]
    F -- 否 --> H["vTaskDelay(1) 等待<br/>最多 200ms (200 tick)"]
    H -- 出现空间 --> G
    H -- 超时 --> I["丢弃:s_con_drop++<br/>(写者随后刷 [ERR] CONSOLE dropped N lines)"]
    G --> J["osEventFlagsSet(SYS_EVT_CONSOLE_TX)<br/>唤醒写者(句柄 NULL 保护)"]

关键机制:

  • 整行原子入环:拷贝与 head 推进在同一次 taskENTER_CRITICAL 内完成 → 写者永不见"半行";临界区时长有界(≤192B 拷贝,微秒级)。
  • 自等待规避:写者自身记录日志时先 drain 腾空(否则"等写者腾出的空间"会等自己,死锁);腾空后入环必立即成功。
  • 丢弃可观测:丢弃累计 s_con_drop,写者每次 drain 后把累计值刷成 [ERR] 行(§4.3)——[ERR] 触发 /serial-boot-test Forbidden 判定,丢行永不静默。
  • 唤醒:入环后置 SYS_EVT_CONSOLE_TX 命令位 → 写者立即被唤醒(零时延);写者循环另有 50ms 超时兜底(事件位异常也不滞留日志)。

4.3 写者路径(attach / drain)

/* 注册(defaultTask 进主循环前一次,App/task/default_task.c) */
dbg_console_writer_attach();   /* 记录任务句柄 + writer_up=1 */

/* 写者主循环(事件唤醒 / 50ms 兜底) */
osEventFlagsWait(sys_evtHandle, SYS_EVT_CFG_SAVE | SYS_EVT_CONSOLE_TX,
                 osFlagsWaitAny, 50u);
dbg_console_drain();

drain 算法(仅写者任务调用):

  1. 循环 while (used != 0):取连续段 tail → 环尾,截断至 DBG_CON_CHUNK_MAX(256B) → HAL_UART_Transmit(&CON_UART, &s_con_ring[tail], n, 0xFFFFu) → 临界区内推进 tail(交给 UART 的缓冲指针恒为环形缓冲——G2);
  2. 刷丢弃计数:s_con_drop != 0 → 组帧 "\r\n[ERR] CONSOLE dropped N lines\r\n" → write_raw 直发(不经环:写者自身输出若走环会 con_append → drain 重入当前 drain,无限递归)。

稳态时序:

sequenceDiagram
    participant P as 生产者任务(netTask 等)
    participant R as 环形缓冲 4KB(BSS)
    participant E as sys_evt 事件标志
    participant W as 写者 defaultTask
    participant U as UART5(115200)

    P->>R: dbg_console_write_line(整行 ≤192B)
    Note over P,R: 临界区:整行拷贝 + 推进 head
    P->>E: osEventFlagsSet(SYS_EVT_CONSOLE_TX)
    E-->>W: 唤醒(50ms 超时兜底)
    loop 直到 used == 0
        W->>R: dbg_console_drain():取连续段 ≤256B
        W->>U: HAL_UART_Transmit(块)
        Note over W,R: 临界区:推进 tail
    end
    opt s_con_drop != 0
        W->>U: "[ERR] CONSOLE dropped N lines"(直发)
    end

4.4 裸发(dbg_console_write_raw)

  • 有界:len > 256 → 256(截尾);发送走 con_tx_bounded(寄存器级轮询 TXE/TC,超时判据 = DWT CYCCNT,CON_TX_TIMEOUT_CYC = 100 ms 对应周期数),发送全程不持临界区、不等待状态(这条是硬红线:一行 90 B @115200 约 7.8 ms,把发送包进关中断区等于往启动窗口塞 7.8 ms 中断延迟与最坏喂狗抖动,比交错本身更糟——由主机单测 TC-CONLOG-015b/TC-CONLOG-177 用「每个上线字节所见临界区深度恒 0」钉住);
  • 2026-10-10:DWT 不可用时改走 con_tx_counted(指令计数上界 CON_TX_GUARD_MAX),不再回退 HAL_UART_Transmit。两个理由:① HAL 的超时判据是 HAL_GetTick——断言/停摆现场 tick 未必在走;② HAL_UART_Transmit → UART_WaitOnFlagUntilTimeout 这条尾巴会被 armlink 挂进每个碰日志的任务的最坏栈链(configASSERT 在 FreeRTOS 内部可达 → 三个内核钩子都在链尾),实测每条链多背 64 B;
  • 适用:内核前 / 未注册期、ISR、异常钩子(栈溢出 / 堆失败)、崩溃上报;
  • 调用方纪律(2026-10-10 补充):写者注册前只允许 defaultTask 走此路径 → 2026-10-26 起由机制保证:con_tx_bounded 仍无互斥(刻意——见上一条红线),互斥做在它外面一层:整行所有权(§4.4.1)。调用方纪律降级为性能建议。
  • 调用方纪律:只交已知合法的缓冲——钩子侧先做指针范围校验(Flash / SRAM / CCM 窗口)并拷入本地缓冲再发,TCB 指针不接触 UART(Src/freertos.c vApplicationStackOverflowHook,2026-09-24 加固;指针不可信时发占位文本,"上报崩溃的代码不能再崩一次")。2026-10-10 补充:钩子里的 name[49]/num[16] 改函数级 static(终点路径、打印完即复位,无并发)——放栈上会让这 81 B 出现在每个任务的最坏链上;行号转换改原地反转,去掉 HAL_Delay(con_tx_bounded 已等 TC)。

4.4.1 行级单一所有者(2026-10-26 道G task-23,O1 第二个实例收口)

台架一手证据(docs/测试说明/测试数据/boot_log_data/20261026_prod_console_interleave_evidence.txt,同一镜像 fw 戳 Oct 26 2026 16:27:01,排除构建差异):

启动 方式 结果
A 16:28 ST-LINK 烧录 + 复位 行完好:…protection blocked (UA/UN/IA VALID not all set)
B 16:37 仅复位 行内多出 [ 2 0 三字节:protection [blocked (UA/UN/IA VALI2D not all s0et)
C 16:44 仅复位 行内多出 [ 一字节,位置不同

字节账(这是本节最硬的一段证据):紧接其后的那一行恰好缺自己被抢走的那几个字节、且顺序一致——B 次的下一行原文是 26-10-26 08:37:07][NAND_FTL] Initializing NAND FTL...,缺的正是 [20;C 次的下一行缺 [。串口 framing 错只会改字节、绝不会让一行变长,所以多出的字节只能来自另一个写者。而 [ + 2 0 正是 DBG_* 行前缀 "[%s]" + sys_clock_get_str() 输出的 "2026-…" 的头三个字符 —— 两个写者对得上:[PROT](App/task/protect_task.c 未标定闭锁边沿 DBG_INFO)与 [NAND_FTL] Initializing NAND FTL...(defaultTask 的 storage_bring_up()),二者都在写者注册之前裸发(后者由位置决定:dbg_console_writer_attach() 排在 bring-up 之后)。三次启动命中两次、只出现在"仅复位"不出现在"烧录"启动 = 时序竞态,与构建无关。

修法(选"真正原子"这一支,不是扩缓冲、不是再叠一层自愿门控):

  • con_tx_try_claim() / con_tx_release():非阻塞的行所有权。取凭证只在几条指令的临界区里做一次 test-and-set;拿到 = 独占整行发送,拿不到 = 当场让路。三条规则:① IPSR != 0(异常/中断,含四个 Fault 的上报)→ 放行(不许阻塞,日志是唯一遗言);② xTaskGetCurrentTaskHandle() == NULL(调度器未启动)→ 放行(单执行流,本就无第二写者,早期日志一条不丢);③ 同一任务可嵌套持有(s_con_tx_depth)——写者一轮 drain 内还要裸发 [WARN]/[ERR] 留痕行,那是嵌套持有者。

  • 为什么绝不排队等锁:裸发一行约 7.8 ms,等锁等于把自己的日志节拍压到别人的串口耗时上;adcTask(AboveNormal,1 kHz 消费)可能被压住数十拍 → 比交错更糟。这也是否决"把写者注册点提前"方案(A 案)的同一条理由:提前注册只是把窗口挪短,且要么让 mount/mkfs 里面去 drain 串口(把"等串口"塞进存储时序敏感段),要么让生产者在整个 bring-up 期间堵在满环上。

  • 让路 ≠ 丢弃:抢不到所有权的那条行整行原文转入既有环形缓冲延后发。前缀与时间戳在 DBG_* 调用点就已 snprintf 进调用方缓冲,本模块与 drain 都不补前缀、不重打时间戳 → 延后发出的行与它当场直发时逐字节相同(TC-CONLOG-171/TC-CONLOG-178),取证时不会出现"某条行晚了几百毫秒、时间戳却写着晚的时间"这种证据说谎。

  • [ERR] 仍然是"控制台在丢东西"的唯一信号:只有「既抢不到所有权且环也放不下」才计入既有 s_con_drop(真溢出)。按 3 次启动命中 2 次的频率,若把"让路"本身也计 [ERR],门禁会从此常红——那是把静默缺陷换成假失败(TC-CONLOG-172c/TC-CONLOG-172d/TC-CONLOG-172e 分别钉住"真溢出仍红""让路不红""报成才销账")。

  • 顺带堵掉第二个洞:dbg_console_drain() 现在也必须先拿到所有权才开轮,拿不到就让位(tail 不推进 → 环里一条不少,≤50 ms 后重发)。旧写法下 attach 就发生在 defaultTask 里,完全可能撞上别任务已发出半行的裸发,两边逐字节交错。

  • 新增 API:dbg_console_tx_begin() / dbg_console_tx_end()(同样非阻塞:拿不到返回 0,调用方照发即可)——给"一条行分多段裸发"的调用方收口用。本模块只提供 API 与主机单测;调用点改造已随 task-26(2026-10-26) 落地,但只落在任务态:App/util/task_watchdog.c 的 STALL 行(正文 + CRLF 共 2 段)包进一次独占,调用点一律写 (void)dbg_console_tx_begin();、不拿返回值分支;Src/freertos.c 栈溢出/断言钩子与 Src/stm32f4xx_it.c hard_fault_report 三处刻意不包,理由见下面那条 bullet。

  • 异常态的整行性不靠凭证(task-26 语义,必须写清免得误读):IPSR != 0 时 dbg_console_tx_begin() 返回 1 但并不持有凭证(异常上下文绝不允许等待或失败)。所以四个 Fault 与栈溢出钩子的整行性来自「Fault 优先级 -1、外设中断 ≥5、任务全冻结 ⇒ 没有第二个写者能跑进来」。唯一残留 = 被抢占任务已在移位寄存器里的那 ≤1 个字节,它出现在故障行之前,不影响 [ERR] 前缀可 grep。已明确否决「发送循环逐字节检查让位标志」那类修法——那会把正在发的启动日志行截断,换不来什么。

  • 崩溃上报也归一(task-26):Src/stm32f4xx_it.c 的 hard_fault_report(四个 Fault 共用,原本 14 段裸发)自己调 HAL_UART_Transmit,是绕过本模块行级独占的第三个写者;现改为 dbg_console_write_raw(但刻意不包 tx_begin()/tx_end(),见下面两条 bullet)。三条理由(详见该函数 hard_fault_write 头):① HAL 超时判据是 HAL_GetTick()(TIM7 停摆则永不成立,第 8a/8b 轮实证;正常上限也是 0xFFFF ≈ 65 s),本口是 DWT/指令计数双硬上界;② HAL 状态机在僵尸通道(ORE 锁存)下恒快速失败 ⇒ [ERR] 崩溃行整条蒸发,而它正是台架「出现即不通过」的判据来源;③ CON_UART.Instance == NULL 时本口立即短路,绝不解引用地址 0(机器判据 = TC-CONLOG-100/TC-CONLOG-102)。同处删除输出后的 HAL_Delay(200):本口等到 TC 才返回,延时冗余,且 HAL_Delay 也依赖 TIM7 tick——tick 停摆会卡在复位之前(启动段 IWDG 尚未启动,无兜底)。

  • ⚠ 行为变化:让路会改变行序与时间戳顺序(不是回归)。让路的整行落进环形缓冲,因此它在流里位置后移(最多一轮 drain,实测毫秒级)。台架 2026-10-26 复验 5 次(1 次烧录 + 4 次仅复位)稳定观察到 [NAND_FTL] Initializing NAND FTL... 排在 [PROT] uncalibrated -> protection blocked … 前一行;而缺陷形态下的旧一手记录正好相反(PROT 在前,NAND 缺 [20 行首,docs/测试说明/测试数据/boot_log_data/20261026_prod_console_interleave_evidence.txt)。原因:谁先抢到整行所有权谁立即发完,另一方整行让路延后;常态下 defaultTask 的存储 bring-up 行先开火,protectTask 的未标定闭锁边沿要等 adcTask 首次给出标志才产生。所以:① 行序不是判据——它是竞态结果,两台设备/两次启动都可能不同;② 时间戳先后也不是判据——让路行带的是它自己产生时刻的时间戳(刻意不重打,见上面判据⑦),于是可能出现"后出现的那一行时间戳更早";③ 正确的判据写法是按整行逐字节锚定两行各自是否完整(本次台架就是这么判的,5/5 命中),不要锚定相对顺序、也不要锚定"某行必须在另一行之前"。

  • 哪些调用点包整行独占、哪些刻意不包(task-26 定案,不是遗漏):任务态多段裸发才包(现网唯一实例 = App/util/task_watchdog.c 的 STALL 行:既不空转,也不挂在 configASSERT 子树上)。异常态与终点路径一律不包——hard_fault_report(14 段)、vApplicationStackOverflowHook(3 段)、vApplicationAssertFailed(5 段):① IPSR != 0 时 tx_begin() 放行且不持有凭证、tx_end() 又只对持有者有效 ⇒ 包上是纯空转,保护本来就来自"Fault 优先级 -1 + 外设 ≥5 + 任务全冻结";② 这三处都挂在 configASSERT 子树上(vPortEnterCritical/vPortExitCritical 会进断言钩子),在那上面每加一句,armlink 就把它乘进每一条含临界区的任务链——实测包上时 con_tx_release 子树 24→152 B、发送环单趟贡献 128→208 B,defaultTask/netTask/iec104Task 三条链各 +80 B,iec104Task margin 从 1,096 被顶到 1,016 < 1,024 ISR 底线(栈记账 FAIL);③ 崩溃行发完就 NVIC_SystemReset(),"归还所有权"没有下游读者。断言钩子那对 begin/end 还曾只包住"有文件名"一支(else 分支不在窗口内)——半套独比亚没有更误导人。

  • ⚠ 结构性失效模式(这条环是我们自己造的,随时可能复活):断言钩子为了留痕要写控制台,而写控制台若要进内核临界区,就形成 taskEXIT_CRITICAL() ⇒ vPortExitCritical() ⇒ configASSERT() ⇒ vApplicationAssertFailed() ⇒ dbg_console_write_raw ⇒ …⇒ 再进临界区 这条静态互递归环;armlink 不是按运行时报错,而是把它记进每一条含临界区的任务链——一次性污染 9 条链的栈记账,且没有任何编译期或运行期告警。为此本模块的裸发/入环/取还凭证一律用 PRIMASK 窗口(CON_CRIT_ENTER/EXIT,机器判据 = TC-CONLOG-015 的 con_crit_enter == 0 + TC-CONLOG-015b 两种屏蔽水位恒 0)。判据留给后来人:任何人在 con_tx_* / con_ring_try / dbg_console_write_raw / dbg_console_drain 的窗口里再放一处 taskENTER_CRITICAL() 或任何含 configASSERT 的内核 API,这条环立刻复活;提交前必须跑 scripts/stack_report.py(且注明数字出自哪棵工作树、哪次构建——本仓今天两次错误归因都是拿错了树的数字)。反向风险同样要记(task-38):若哪天把 configASSERT 裁成空宏,每条链"回收"的 80~90 B 是静态记账口径变化,不是固件变好,不得写成优化收益。

  • 本笔没有消除的(别混):ORE/NE/FE 脏标志仍在(成因 = 控制台口 RX 使能不消费,§12——当时端口 USART1、现 UART5;用户已裁定不改 RX 配置)。本笔消除的是「崩溃日志整条蒸发」这一类,不是「控制台输出可能被脏标志污染」那一类,后者仍挂 §12 既有观察项。极早期崩溃仍然完全静默:MX_UART5_Init() 之前进 Fault 时本口的 NULL 短路一个字节都不发——旧写法在同场景也不发(gState != READY ⇒ HAL_BUSY),所以没有变差,但也不能写成「现在能看到早期崩溃」。

  • 实机验证缺口:崩溃路径不能只靠静态论证(本仓最贵的教训就是「0 警告不代表安全」)。可执行验证设计见下方「验证设计」,本笔未跑(台架在别的任务手里)。

为什么不做成"整段进环"(曾评估):那会让 storage_bring_up() 期间串口一个字都不出,而这段正是 §「启动看门狗延后」注明的"死机表现为无任何输出"窗口(2026-10-02 那几轮就靠 bring-up 期流式日志取证)——等于把交错换成死机全瞎。现方案保留胜者实时直发,只有真撞车才让路一条行。

验证设计(崩溃路径,两腿分工,2026-10-26 台架更正后定稿):这条路径不能只靠静态论证(本仓最贵的教训就是「0 警告不代表安全」),但两腿各证各的,别互相顶包。

腿 A — 主机侧套件承担全部关于 IPSR 与异常态整行性的主张(已落地、可重复、确定性证据):

  • 假 USART + con_ipsr + 「每个上线字节的临界区深度水位」+ 一次性注入把手(在第 N 个字节上线后跑第二个生产者),把「Fault 上下文照发、不等、不丢、不越权归还」做成必然失败的断言:TC-CONLOG-175 / TC-CONLOG-175b(IPSR 直发且不动别人的凭证)、TC-CONLOG-174 / TC-CONLOG-174b(一次独占内多段连发不自我丢弃、tx_end 后别人能接手)、TC-CONLOG-015 / TC-CONLOG-015b / TC-CONLOG-177(发送全程临界区深度恒 0,即"绝不把整行包进关中断区"这条硬红线)、TC-CONLOG-170 至 TC-CONLOG-172/178(两个生产者并发时行内不得出现别人的字节;TC-CONLOG-178 直接复演台架那行原文)。
  • ⚠ 「硬件上多跑几次启动照不出 IPSR」——产品构建正常启动根本不进异常路径,跑 100 次也只是重复证明"没崩溃"。任何人想推翻或确认本节关于异常态的说法,请改主机侧套件,不要去台架上碰运气。

腿 B — 实机腿:本腿含故意复位(不是常规回归)

  1. 硬前提:触发器必须由代码道提交进仓(测试宏下编入的一条非法写即可,例如 *(volatile uint32_t *)0x00000000U = 0U; + 前置裸发锚点 [FAULTINJ] arm);台架不得自行制造触发条件——现场不可复现、也无法归因,且会污染"这一轮到底测了什么"的记录。
  2. 判据只用原子性,不碰 [ERR] 计数:① 崩溃行一整行逐字节完整([ERR] MEMFAULT PC=… LR=… xPSR=… R0..R3=… CFSR=… HFSR=… 字段齐全、无邻居行字节插入);② 该行在本次启动日志里恰好出现一次(既不丢也不重);③ CONSOLE dropped = 0。首个字符应为 [;前面若出现 ≤1 个移位寄存器残留字节,记为已知残余,不判失败。
  3. ⚠ 不得在同一条腿要求 [ERR] = 0:崩溃上报本身就以 [ERR] 起头,在同一路径上要求「无 [ERR]」等于要求这条路径别工作(本次就是这么被台架纠正的)。[ERR] 零命中的判据只属于腿 B 之外的常规回归腿(3~5 次仅复位不重烧 + 两行整行锚定)。
  4. 故意复位的注入腿不得并入「连续 N 次仅复位不重烧」的计数:注入会主动 NVIC_SystemReset(),混进常规序列会把正常行为判成异常(栈溢出钩子同理)。两条腿分开跑、分开记台账。
  5. /serial-boot-test 在注入腿只在命令行运行时放宽(故障注入的既有台架口径见 docs/测试说明/板级测试/实机测试实施指南.md §4,其中已写明"故障注入用例会复位设备多次 → 确认设备非在用/非已部署"):放宽 --forbidden / --max-boot-count;不改仓库配置(改了就等于把"永不出崩溃日志"设成期望状态)。

4.5 fputc 重定向(MicroLIB printf)

fputc(ch, f) → con_append(&b, 1):库内部 / 遗留 printf 调用同样收敛到单写者路径(注册期逐字节入环;一条完整 printf 行 = 环内连续字节,写者按块发出 → 仍行级原子)。

printf 是遗留路径:逐字节 = 一次临界区 + 一次事件置位;新代码一律用 DBG_* 宏(调用点格式化整行、一次入环、一次事件)。两路行为一致,仅效率差异。

4.6 时间参数(实测 / 推导)

参数 值 推导
字节率 @115200 8N1 86.8 µs/B(10 bit/字节) 115200 / 10
单块 256B ≈22 ms 写者单次 HAL_UART_Transmit 上限
单行 192B(最坏) ≈17 ms DBG_CON_LINE_MAX
生产者最长等待 200 ms(200 × 1ms tick) ≈9 个发送块的时间,稳态下环恒被及时排空
写者兜底周期 50 ms 事件位失效时日志滞留上限
实测日志时延 事件唤醒级(无丢失) 2026-09-25 /serial-boot-test:67 行开机日志完整、无交错、无丢行告警

5. API 参考

全部声明位于 App/log/console_log.h(仅 MCU 端;模拟器无对应实现,共享代码勿调用):

函数 签名 调用上下文 语义 备注
dbg_console_write_line int (const char *line, unsigned len) 生产者(任务上下文) 整行入环 / 直发;0 = 写入字节数,-1 = 参数非法 / 被丢弃 截断 ≤192B(保行首时间戳/标记信息)
dbg_console_write_raw void (const char *buf, unsigned len) 任意(ISR / 钩子 / 内核前) 有界直发 ≤256B;整行独占发送器(§4.4.1),抢不到独占则整行原文入环延后发 缓冲须已知合法;2026-10-26 起接口上取消"不加锁"
dbg_console_tx_begin uint8_t (void) 任务上下文 1 = 已取得整行独占(此后本任务多次 write_raw 段间无人插入);0 = 别人持有,调用方照发即可 非阻塞、不吞字;给多段裸发的钩子收口用(2026-10-26 加,调用点改造见 task-26)
dbg_console_tx_end void (void) 与 tx_begin 配对 归还所有权;非持有者调用为空操作(幂等) 同任务嵌套按 s_con_tx_depth 递减
dbg_console_writer_attach void (void) 写者任务、主循环前 注册唯一写者 仅一次(defaultTask)
dbg_console_writer_ready uint8_t (void) 任意(常驻任务) 写者是否已注册(1=已注册) 2026-10-10 加:周期任务据此门控日志,避免写者注册前裸发交错(§3.4)
dbg_console_drain void (void) 仅写者任务 排空环并发出 + 刷丢弃计数 defaultTask 主循环周期调用
dbg_console_dropped uint32_t (void) 任意 诊断:累计丢弃行数 外部读取
dbg_console_tx_fault_log_take uint8_t (void) 仅写者任务 取并清"TX 故障待上报"标志 2026-10-10 加:drain 内不得调 syslog_post(会成环),由 defaultTask 在 drain 之外补记 run.log
fputc int (int ch, FILE *f) printf 调用点 MicroLIB retarget 钩子 → 逐字节 con_append 须为全局符号(C 库链接)

6. 常量

常量 值 定义处 含义
DBG_CON_LINE_MAX 192 console_log.h 单行文本上限(含结尾 '\0');环形缓冲/入环截断上限;超限截断
DBG_LINE_BUF_MAX = DBG_CON_LINE_MAX(深链模块设 128) dbg_log.h 调用点栈上行缓冲尺寸(2026-10-10 加)。已收紧:nand_ftl/gd5f2gq5ue/cfg_file/net_cfg/net_socket/ch395f/time_sync/default_task。取值须 ≥ 该模块最长日志行,否则该行被截断
DBG_TS_BUF_MAX 24 dbg_log.h 时间戳文本缓冲(sys_clock_get_str 恒写 19 字符 + '\0');与行缓冲共用一个数组
DBG_CON_RING_SIZE 4096 console_log.c 环形缓冲字节数(必须为 2 的幂)
DBG_CON_CHUNK_MAX 256 console_log.c 写者单次发送上限 = 裸发上限
DBG_CON_WAIT_MS 200 console_log.c 缓冲满时生产者最长等待(1ms/轮,仍满 → 丢弃 + 计数)
CON_TX_TIMEOUT_CYC SystemCoreClock/10 console_log.c 裸发硬上界 = 100 ms 对应 DWT 周期数
CON_TX_GUARD_MAX 20000 console_log.c DWT 不可用时的计数上界(每字节 / TC 各数一次;115200 下一字节约需 3000 次轮询,留 6 倍余量)
写者循环超时 50 ms App/task/default_task.c osEventFlagsWait 兜底周期

7. 失效模式与可观测性

失效 行为 可观测性 残余风险
缓冲满(日志突发 / 写者卡滞) 生产者等 200ms 后丢行 s_con_drop 累计 → 写者刷 [ERR] CONSOLE dropped N lines(/serial-boot-test FAIL,永不静默) —
写者未注册(内核前) 单线程直发 — 无(无并发)
未注册期其他任务直发 已消除(2026-10-26):整行独占,抢不到就整行入环延后发(§4.4.1) 让路不计数;只有"抢不到且环也满"才进 s_con_drop → [ERR] CONSOLE dropped N lines 残余 = 让路行在流里位置后移(内容与直发逐字节相同);主机单测 TC-CONLOG-170 至 TC-CONLOG-179 钉死
崩溃上报(四个 Fault)原走独立 HAL 发送口 已归一(2026-10-26 task-26):hard_fault_report 的 14 段裸发改走 dbg_console_write_raw(整行独占不用在这里——异常态空转,见 §4.4.1);ORE 快速失败(= 证据整条蒸发)与 HAL_GetTick/HAL_Delay 的 tick 依赖随之消失 崩溃行 [ERR] <TAG> PC=… CFSR=… 现在是单写者输出;实机验证设计见 §4.4.1 末(待台架窗口) 极早期(MX_UART5_Init() 之前)崩溃仍完全静默(NULL 短路);ORE 脏标志本身未消除(§12)
ISR 直发撞写者发送中 与写者发送竞争 → 交错/丢失,不挂死(IPSR != 0 一律放行,§4.4.1 规则①);异常态没有第二写者(Fault 优先级 -1 + 任务全冻结),残留 ≤1 个移位寄存器字节且落在行前 未计数 ISR 日志频率极低,best-effort 语义
内核状态损坏(栈溢出 / 堆耗尽) 钩子裸发固定文本 → 复位(栈溢出)/ 仅告警(堆) [ERR] STACK OVERFLOW task=… / [ERR] MALLOC FAILED …;任务名范围校验 + 本地拷贝,TCB 指针不入 UART —
调用方指针损坏(脏指针) 无法进入 UART(缓冲恒为环 / 模块本地) 2026-09-24 HardFault 路径结构性消除 —
UART 物理失效(发送卡死 / 超时) con_tx_bounded 在 DWT 上界内返回 HAL_TIMEOUT → 该块丢弃 + UART 重初始化(僵尸通道自愈,§12) 2026-10-10 起:drain 内只置标志 s_con_tx_fault_log,由 defaultTask 在 drain 之外 syslog_post(SYSLOG_RUN, "console tx fault, uart reinit") 补记 run.log(旧实现直接在 drain 里调 syslog_post → write_line → con_append → drain → syslog_post → write_line 调用图成环,静态栈在每个任务上多叠一条日志尾巴) 已知缺口:丢弃计数与故障计数不分离
sys_evt 对象缺失(句柄 NULL) 唤醒空操作,50ms 兜底 至多 50ms 时延 —

8. 内存预算

项 大小 位置
s_con_ring 4096 B BSS(SRAM1,非 CCM)
s_con_head/tail/drop/writer_up/writer_task ≈14 B BSS
生产者行缓冲 __con_line(DBG_EMIT 宏局部) 默认 192 B + 时间戳 24 B;深链模块 128 + 24 B/调用 调用者任务栈(入环后即释放,峰值非累积)

结论:静态 +4.1KB(SRAM1);FreeRTOS 堆零增量、无新任务/栈、无新内核对象。 2026-09-25 实测:heap free=8776B, min_ever=4816B(CCM 堆不受影响);__con_line 192B 峰值在各任务既有栈预算内(任务栈按 2026-09-21 实测峰值 ×1.5 + 1KB ISR 余量定档,见 AGENTS.md 任务栈表;当日栈水位复测 6 任务全 PASS)。

⚠️ 2026-10-10 修正"行缓冲不贵"的结论:行缓冲是每个日志调用点在栈上内联的一份数组,会进 armlink 最坏调用链。实测 defaultTask 那条最深链上 9 个帧各含一份(192 + 32 B)→ 约 1.5 KB 纯粹是日志行缓冲,是该项目静态栈链的第一大项。处置 = dbg_log.h 新增 DBG_LINE_BUF_MAX(深链模块收到 128)+ 时间戳缓冲并入行缓冲(32 → 24),未抬栈即把该链 5312 → 2776 B。核算与前后对比见 docs/内存管理/RAM使用分析与优化方案.md"2026-10-10 复测"。

9. 设计决策与否决的替代方案

# 替代方案 否决 / 采纳理由
D1 逐字符 fputc + gState 检查(旧方案) 原子性退化为字节级 → 字符交错(F1);调用方脏指针直达 UART(F2);并发立即 HAL_BUSY → 静默丢行(C3)
D2 整行 HAL_UART_Transmit + gState 检查 消除交错,但不解决脏指针(调用方缓冲仍入 UART);竞争失败方无等待/重试,整行静默丢;调用方阻塞时长不受控(等 UART 物理速率)
D3 专用日志任务 违反 G3(RAM 紧张不新增任务/栈);defaultTask 本就是存储子系统唯一写者,单写者纪律收敛到同一任务,且主循环已是 50ms 事件驱动
D4 TX DMA / 中断驱动发送 发送率低(115200、日志行稀疏),收益不足;不解决 G2(DMA 同样读调用方缓冲);DMA 缓冲可达性 / 所有权 / 双缓冲复杂度不值
D5 环 + 直发双路径(采纳) 注册稳态走环(零丢失、有计数);内核前 / ISR / 钩子走有界直发(G4:不等待、不死锁、零内核对象);未注册期此路径无互斥(2026-10-10 起为寄存器级轮询,无 HAL gState)→ 约定只有 defaultTask 记日志,其他常驻任务用 dbg_console_writer_ready() 门控(§3.4)
D6 每生产者 SPSC 无锁队列 多生产者仍需 MPMC;单环 + 临界区整行拷贝更简,生产者临界区 ≤192B 微秒级;事件位复用免新增内核对象
D7 printf 保为主路径 MicroLIB 逐字符回调开销 + 遗留语义;DBG_* 宏调用点格式化整行一次入环(一次入环、一次事件)

10. 验证记录

日期 验证 结果
2026-09-24 HardFault 定位:BFAR=0x10010000(=CCM 末尾+1),PC 在 HAL_UART_Transmit 缓冲读取处;崩溃前交错行留证 根因确认(F1/F2)→ 单写者加固实施
2026-09-25 @build(Keil MDK v5 / ARMCC C99) 0 Error, 0 Warning(console_log.c 注册于 App 组)
2026-09-25 @test 硬件闭环(烧录 → 开机日志采集 → 标记校验) PASS:Required 3/3(App init OK / Network Init / net init: OK);Forbidden 5/5 缺席(FAIL/ERROR/HardFault/assert/[ERR],大小写不敏感);67 行开机日志完整、无交错、无丢行
2026-09-25 静态门禁:C 代码规范 V1.0 + U+FFFD 乱码 + cppcheck(warning, performance, portability, style) 0 违规
2026-09-25 迁移验收:实现迁出 Src/usart.c(该文件恢复 478 行纯 CubeMX 形态),双端行为不变 @build 0/0 + @test 复测 PASS;引用关系图重生成

11. 实施时间线

日期 事件
2026-09-22 链路延迟测试:开机并发 printf 丢行(连续 4 行)实证 → S8 非阻塞日志立项(链路延迟测试说明.md §10.2)
2026-09-24 字符交错 + HardFault 实证 → 单写者加固实施(4KB 环 + defaultTask 唯一发送 + 裸发回退 + fputc 收敛);当时位于 Src/usart.c USER CODE 区
2026-09-25 迁出至 App/log/console_log.{h,c}(HAL 生成文件不含应用代码);dbg_log.h 改 #include "console_log.h";Keil 工程 App 组注册;@build 0/0、@test PASS、引用关系图与本文档同步
2026-09-25 写者任务体迁出:Src/freertos.c 的 StartDefaultTask 任务体(存储 bring-up / 配置引擎 / 控制台 attach+drain / syslog / LED / 栈水位)整体移入 App/task/default_task.c(default_task_func(),与其余 5 任务文件同址);freertos.c 恢复"接线 + 崩溃钩子"的 CubeMX 形态(719→361 行);行为零变化,@build 0/0 + @test PASS 复验
2026-10-02 ORE 僵尸通道喂狗节奏保护(台架 gen-288):round-3 的"单次 drain 预算 250ms"只限单次调用——ORE 锁存在外设软复位后毫秒级重锁(PA10 悬空 + RX 使能),背靠背 drain 每轮再耗 100ms 块超时 → 主循环周期拉到 450~500ms → 100ms 节流的喂狗抽样间隙超过 IWDG 最坏容限 1.7s → 停喂 → 误复位(且心跳位全员健康、无 stall 日志,纯节奏性饥饿,非死等)。修复:发送故障后立即结束本轮 drain 并进入 300ms 发送冷却(冷却期内 drain 直接让位主循环),保证喂狗节奏 ≤ ~300ms
2026-10-02 round 8b 加固:发送超时判据改用 DWT CYCCNT(时间源无关)。原 HAL_UART_Transmit 的 HAL_GetTick() - Tickstart 依赖 TIM7 的 uwTick,TIM7 一旦停摆超时永不成立 → 写者死等 TXE → 唯一喂狗者停摆 → IWDG 复位。裸发送与缓冲满等待两处均换成 DWT 周期计数硬上界(DWT 使能失败时退回旧 HAL 行为);另加 huart1.Instance == NULL 短路,防早期/断言路径解引用 NULL
2026-10-10 新增 dbg_console_writer_ready() + 未注册期纪律固化:裸发为寄存器级轮询(无 gState)→ 未注册期多任务并发逐字节交错(台架 adcTask 首帧日志与 [NAND_FTL] 行交错实证)。新增 dbg_console_writer_ready() 供周期任务门控;adcTask 首帧/心跳日志据此门控(§3.4 / §4.4)
2026-10-25 N-2 × S8 合并评估收口(评估任务,零代码):S8 生产者侧判定已达成、TX IT/DMA 维持否决并绑定 S-3 复议条件、N-2 收敛到 V5 复跑双零判据——全文见文末附录
2026-10-26 崩溃上报归一 + 多段裸发收口(道G task-26,接 task-23 第二段):Src/stm32f4xx_it.c hard_fault_report(四个 Fault 共用,14 段裸发)原自行调用 HAL_UART_Transmit ⇒ 绕过本模块行级独占的第三个写者,并带两条已实证失效:HAL 超时依赖 HAL_GetTick()(TIM7 停摆永不成立)与僵尸通道下恒快速失败(关键 [ERR] 证据整条蒸发)。现改 dbg_console_write_raw(不包 tx_begin()/tx_end(),理由见 §4.4.1);hard_fault_report 与栈溢出钩子输出后的 HAL_Delay(200) 一并删除(本口已等 TC;少一条 tick 依赖,避免卡在复位之前)。同类收口只落在任务态:App/util/task_watchdog.c STALL 行 2 段包进一次独占;Src/freertos.c 栈溢出钩子(3 段)与断言钩子(5 段)以及 hard_fault_report(14 段)不包(异常态空转 + 代价 ×9 条任务链,见 §4.4.1)。语义:异常上下文 tx_begin() 放行不持有,故障行整行性靠 Fault 优先级与任务冻结,残留 ≤1 个移位寄存器字节(落在行前,不影响 [ERR] grep)。未消除:ORE/NE/FE 脏标志、极早期(串口未初始化)崩溃静默。主机侧复用既有判据 TC-CONLOG-100 与 TC-CONLOG-102(NULL 短路)与 TC-CONLOG-174(一次独占内多段连发不自我丢弃),未新增用例;实机验证设计见 §4.4.1 末。行序口径:让路行入环后位置后移,台架 5/5 观察到 [NAND_FTL] 稳定排在 [PROT] 前一行(旧缺陷形态相反)⇒ 行序与时间戳先后都不再是判据,只能按整行逐字节锚定(§4.4.1)
2026-10-26 O1 第二个实例 → 未注册期裸发加「行级单一所有者」(道G task-23):台架同镜像两次"仅复位"启动,[PROT] 未标定闭锁边沿行内分别多出 [20(3 字节)与 [(1 字节),而紧随其后的 [NAND_FTL] Initializing NAND FTL... 行恰好缺同样那几个字节的行首(字节账与三次启动命中两次的频率见 §4.4.1)→ 判据"framing 错不会让行变长"锁定为两个写者并发裸发,即 O1 的第二个实例(第一个是 2026-10-12 台架复测)。修法 = 非阻塞行所有权(con_tx_try_claim/con_tx_release,只在几条指令的临界区里 test-and-set;发送全程不关中断)+ 让路整行原文入环延后发(不丢、不补前缀、不重打时间戳)+ drain 每轮先取凭证(堵掉"attach 撞上别人半行")+ [ERR] 只在"抢不到且环也满"时红。新增 dbg_console_tx_begin/tx_end(多段裸发收口,调用点改造 → task-26)。主机单测 127 → 152 条(M 族 TC-CONLOG-170 至 TC-CONLOG-179,含逐字节相同判据),四处生产码注入反证各自转红(取消独占 11 红 / 让路改静默丢 5 红 / 非属主可归还 1 红 / 把发送包进临界区 3 红)。§3.4 / §4.4 / §7 / §13-O1 同步
2026-10-12 纪律盲区台架实证(台架复测):protectTask(保护任务 接线,台架腿当时未跑)启动锚点行在写者注册前裸发 → 与 defaultTask 启动日志逐字节交错(st[art: cycle=220ms elm=70,两次开机同形复现 = 启动调度确定性的竞态,非 USB 噪声)→ @test 锚点 MISS。修复 = 启动行改 dbg_console_writer_ready() 首拍补打(adcTask 首帧同款),@build 0/0 + @test PASS 复验(锚点完好 +577 ms)。§3.4 纪律示例更新
2026-10-29 端口迁移:控制台从 USART1 迁到 UART5(RS232#1,PC12 / PD2,经 U9 → U13 → CN1),console_log.c 收口成 CON_UART* 单点宏(句柄 / IRQn / 时钟 / 重初始化一体);USART1 现仅服务屏板链路(700 kbaud,屏板链路设计说明.md);台架日志口因此为 COM3(USB 转 RS-232 适配器 → CN1 端子,.serialtest/config.json 已配)

12. ORE 僵尸通道(2026-10-02 台架实证;当时端口 USART1,现 UART5 同机理)

机理(当时端口 USART1;控制台现走 UART5,同一机理):控制台口使能了 RX 却无人消费 → 线上噪声或对端字节产生帧错误/溢出 → ORE(Overrun Error)置位 → HAL 发送路径卡在错误状态。对外设做软复位后 ORE 毫秒级重锁,于是每轮 drain 都会耗掉 100ms 量级的块超时。

⚠ 2026-10-28 更正(用户反馈):上面"PA10 悬空、无上拉"是归因推断,不是实测—— 用户确认 PA10 一直是接线的。而 ORE 的充分条件本来就不依赖引脚悬空: RX 使能了却从不读 DR,线上任意一个字节或一个噪声沿使 RXNE 置位后无人读, 下一个事件即锁存 ORE;ORE 只能靠"先读 SR 再读 DR"清除 ⇒ 永不自愈。悬空只是提高发生率。

因此本节的观察与缓解措施仍然成立(ORE 确实锁存、软复位后确实毫秒级重锁、drain 确实被拉长), 但归因需要改写为"RX 使能却不消费 DR",不是"引脚悬空"。

当年那场风暴的真因仍待定:接线正常时更常见的是对端波特率不匹配(适配器不是 115200) 或适配器 TX 在 USB 掉线时浮空。下次复现先量适配器 TX 的空闲电平与波特率, 别再默认"引脚悬空"。

为什么会被误判:本设计是"心跳位全员健康、无 STALL 日志"的纯节奏性饥饿—— 没有任何任务真的死等,只是主循环周期被 drain 拉长到 450~500ms,而喂狗抽样是 100ms 节流的、IWDG 最坏容限仅 1.7s,间歇被拉长就触发停喂。看日志会以为"系统很健康"。

已做(缓解):单次 drain 250ms 预算(第 3 轮)+ 发送故障后 300ms 冷却(第 6 轮)

  • 超时判据换 DWT(第 8b 轮台架)。

已裁定不做(2026-10-26 用户决定):让 USART1 变成只发不收(.ioc 里不使能 RX、PA10 不再复用为 USART1_RX)确实能从机制上消除 ORE,但它是 .ioc 结构性改动 + CubeMX 重生成——要清空全部工作树、 独占一轮、复跑台架闭环,代价不抵收益。现行缓解即长期方案(单次 drain 250 ms 预算 + 发送故障后 300 ms 冷却 + DWT 时间源无关的超时判据)。2026-10-29 起控制台在 UART5(RS232#1),RX 使能未消费, 本节机理与缓解在新端口原样成立;USART1 的 RX 已由屏板链路(sb_link)消费,与本节无关。

13. 开放问题

# 项 说明
O1 未注册期裸发无互斥 —— 已处置(2026-10-26,v1.6) 启动窗口(内核启动 → attach)dbg_console_write_raw 无互斥 → 多任务并发逐字节交错。两个台架实例:2026-10-12 台架复测(protectTask 启动锚点,靠自愿门控修)与 2026-10-26(protectTask 未标定闭锁边沿 × defaultTask [NAND_FTL] bring-up 行,字节账见 §4.4.1)——第二个实例证明自愿门控本身不可靠(边沿留痕无法预知自己落在哪个窗口)。现由机制收口:非阻塞整行独占 + 让路行入环延后发(§4.4.1),dbg_console_writer_ready() 降级为延迟优化。曾评估的两个替代方案(提前 attach / 整段进环)均因"把等串口塞进存储时序敏感段"与"死机期全瞎"被否,理由记录在 §4.4.1 末。残余:IPSR != 0 放行仍可能插入他任务半行(不构成标记漏检,§7);多段裸发钩子的段间缝 → task-26
O2 HAL_UART_Transmit 超时未计数 —— 已处置(2026-10-24,v1.5) 三本账已分离:dbg_console_dropped() = 真没落地的数(缓冲满丢的一行,或没能确认送达而出环的一个块);dbg_console_tx_fault() = 外设软复位自愈次数(语义不变);新增 dbg_console_tx_timeout() = 写者排空时看到的块超时次数(TXE 与 TC 两条硬上界都算)。口径边界:裸发路径(内核前 / ISR / 两条告警行自身)的超时不进超时账——裸发没有「块」的概念,混进来读数失去意义。尚未接线:本读口暂无生产调用者;候选位置 App/util/task_watchdog.c 的 STALL 行是被解析的证据行,加字段要连解析端与台架判据一起改 → 列为后续项
O3 printf 逐字节开销 遗留 printf 路径 = 逐字节临界区 + 逐字节事件置位;遗留调用点全部切 DBG_* 宏后开销自然消失(不改变机制)
O4 ORE 僵尸未根治 §12:现仅有缓解(250ms 预算 / 300ms 冷却 / DWT 超时),机制根源是控制台口(现 UART5)使能 RX 不消费。根治 = .ioc 改只发不收 + CubeMX 重生成,2026-10-26 用户裁定不做 → O4 是长期现状而非待办(见 §12)

14. 相关文档

  • 项目架构总览.md —— 六层分层 / 启动时序 / 6 任务(写者任务与就绪屏障位置)
  • 运行与自检记录设计说明.md —— sys_log 持久化日志(姊妹模块:同写者任务、不同通道)
  • docs/测试说明/专项验证/链路延迟测试说明.md —— D6 日志税 / 步骤 8 非阻塞日志(需求出处与 09-22 丢行实证)
  • docs/测试说明/流程与规范/自动化测试流程.md —— /serial-boot-test 标记体系(Required / Forbidden 模式,[ERR] 语义)
  • AGENTS.md —— 平台约束(内核前禁内核对象 / C 堆禁令 / 事件位语义 / 内存预算)

附录:日志静默观察项 × 步骤 8 合并评估(2026-10-25,道F 第二轮 / task-18,零代码改动)

评估任务(登记簿曾登记的日志静默观察项,已销项;残余动作是「与步骤 8 合并评估」):该观察项 (49s 全系统日志静默,2026-09-22 L3 台架世代号 181)与步骤 8(日志非阻塞化,需求 出处 = docs/测试说明/专项验证/链路延迟测试说明.md §9.2/§10.2)的合并性价比判定。 评估纪律 = 结论即交付,不顺手重构;本节结论:无需最小实现(不新增代码)。

A.1 步骤 8:目标已由「单写者形态」在生产者侧达成

步骤 8 要治的是阻塞式日志税(L10 实测 logtax_line 4402 µs/行,fputc 逐字节 @115200 阻塞调用方整行)与并发丢行(实测 docs/测试说明/专项验证/链路延迟测试说明.md §9.2: 必检标记被竞态吞)。2026-09-24 单写者 改造正是同一需求落的方案(§1/§2 G1-G5):

步骤 8 子项(需求出处 docs/测试说明/专项验证/链路延迟测试说明.md §10.2 第 9 条) 原案 现状(代码实证)
fputc → 环形缓冲 串口 TX 环形 ✅ 4KB BSS 环 + 事件位唤醒(§4.1/§4.2);生产者 = 亚微秒~µs 级临界区整行入环(≤192B 拷贝),日志税较 4402µs/行降 3 个量级
TX 中断/DMA 非阻塞发送 ❌ 有意不做(轮询单写者,D4);复议时机见 A.2
溢出丢弃策略 待定义 ✅ 有界等待 200ms(K4)→ 丢弃 + dropped 账 → [ERR] CONSOLE dropped(永不静默,§4.2/§7)
HardFault 冲刷 待处理 ✅ 有界裸发 ≤256B(G4/§4.4,DWT/计数硬上界,不依赖 tick、零内核对象)

热路径日志税另已由 D6 单独削掉(删 per-recv 日志,queue_rt_recv 5997→1223µs)。 结论:步骤 8 按“生产者不阻塞业务”的意图已闭环;剩余的“阻塞”只在写者任务 (defaultTask)自身——drain 预算 250ms + 故障冷却 300ms + DWT 硬上界(§11/§12), 不在业务任务关键链上,且预算值按“让位 800ms 停喂容忍”定档,属设计内行为。

A.2 TX 中断/DMA(原案后半):维持否决

D4 否决理由对现状仍逐条成立(115200 发送率低、日志行稀疏;IT/DMA 引入新内核 对象、缓冲所有权/双缓冲复杂度、DMA 可达性清单)。三点补充判断:

  1. 把 drain 拖长的真凶是 ORE 僵尸通道(当时 USART1 的 PA10 悬空 + RX 使能不消费——现 UART5 同机理见 §12,软复位后毫秒级重锁 → 每轮块超时 100ms 级);根治办法(.ioc 改只发不收)已于 2026-10-26 裁定不做 → 该形态长期存在,靠缓解措施兜住;
  2. 根治前后数据已可机读:dbg_console_tx_timeout()(§13-O2 三本账,v1.5 起) 就是为“IT/DMA 还剩多少收益”留的量尺;
  3. 根治既已裁定不做,TX 路径的形态就是终态:动它不再需要"等根治",但必须拿 tx_timeout 的实测收益说话。

**结论:不采。**原复议触发条件绑定根治项,已随 2026-10-26 裁定不做而失效;将来若再议 TX 中断/DMA,直接以 dbg_console_tx_timeout() 的实测增量为依据。

A.3 日志静默观察项:观察窗收敛到 V5 复跑;“真停滞 vs 无源”判别成立

  • 判据②(静默 → 可观测)= 已具备:控制台三本账 dbg_console_dropped / dbg_console_tx_fault / dbg_console_tx_timeout + syslog 的 queue full, drop / preboot full, drop / hold-over drop (App/log/sys_log_mcu.c);任何“静默丢行”现形为 [ERR] → /serial-boot-test FAIL。
  • 判据①(复现 49s 并定位):V5 30min 未复现;实测(docs/测试说明/板级测试/实机测试实施指南.md §7.2) 运行期 >10s 静默 24 处全部落在 e02_probe 窗口、长度 ≈28s——其 同源阻塞机制(drop_conn 同步占用 netTask 400-600ms)已随登记式拆链改造修复 (登记式拆链,docs/网络/网络自愈架构设计.md §4.6)。本评估补充一个此前 缺位的替代解释:探针窗内的静默很可能本来就是“无日志源”的正常形态—— 探针一轮(挂起 30s + 短暂复连)业务日志天然稀疏,defaultTask 无周期串口输出 (LED 静默;[STACK]/[PROF] 仅新峰值/条件满足才打印),~28s ≈ time_sync 两条 INFO 之间的静默带,不是排空停滞。
  • 若再现的判别口径(已具备,无需新增):静默段前后对照三本账与 syslog 丢弃账——账目零增量且静默后无 [ERR] CONSOLE dropped = 无源(正常, 不开卡);账目有增量或出现 dropped 行 = 真停滞(开卡,按 §12 ORE 与 冷却路径核对)。
  • 处置建议:该观察项保留为低风险观察,至拆链修复版(merge 969fc5c)的 V5 复跑(判据 = 运行期 [WD] task stall 与非开机 MISS start 双零,台架由 lead 排窗)达成后销项;本附录即销项结论落点。登记簿相应两行与波次行 的清理归单写者(lead)。

A.4 评估后的遗留面(全部既有,无新增)

项 归属 处置
ORE 僵尸根治(改只发不收) §12 / §13-O4 2026-10-26 用户裁定不做;量尺 dbg_console_tx_timeout() 保留,将来重评直接用
tx_timeout 读口接线(STALL 证据行加字段) §13-O2 涉 App/util/task_watchdog.c 与被解析证据行的台架判据,非本卡写域;维持 O2“后续项”
未注册期裸发互斥 / printf 遗留开销 §13-O1/O3 现状 = 纪律已落地,开机日志密度上升再议
日志静默观察项 登记簿曾登记(已销项) 见 A.3:V5 复跑双零后销项(归 lead)