Files
STM32F4-Base/docs/子系统设计/控制台单写者日志设计说明.md
T
wandering1 f50e74c63b docs+log: 撤销"PA10 未接线"结论,ORE 机理改为"RX 使能却不读 DR"
用户反馈:PA10 这个端口是接线的,当年那个分析应该是错的。

核查确认:console_log.c(文件头与 con_uart_reinit() 注释)和
控制台单写者日志设计说明.md 12 节里的"PA10 悬空 / 未接线"是 2026-10-02 的
归因推断,不是实测,且与用户确认的接线状态相反。

ORE 的充分条件本来就不依赖引脚悬空:RX 使能了却从不读 DR,
线上任意一个字节或一个噪声沿使 RXNE 置位后无人读,下一个事件即锁存 ORE
(常伴 NE/FE/PE);而 ORE 只能靠"先读 SR 再读 DR"清除,所以永不自愈。
引脚悬空只影响发生率,不是必要条件。

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

改动:
- console_log.c:函数注释改按正确机理表述;修改记录加 2026-10-28 一条
  说明归因更正。纯注释,Code 236456 B 与上版一致,@build 0/0。
- 控制台单写者日志设计说明.md 12 节:机理段后加更正块,写明观察与缓解
  措施仍成立、只有归因需要改写。
- 产品需求文档:4.2 表 USART1 行改为"PA10 双向均已接线";5.16.2 的
  "PA10 没接线"警示块整段重写为"旧结论已撤销 + 真正机理 + 两条硬要求"
  (屏板链路必须消费 DR 并在错误回调清 ORE;新日志口仍设 UART_MODE_TX);
  9.13 前置阻塞由"两条物理前置"改为"一条物理前置 + 一条实现纪律";
  12 节加 v1.9。
- 屏板链路规划:0 章第 2 行标为已撤销、执行顺序去掉"接 PA10";
  批 0 由 4 项减为 3 项;批 1 第 3 项判据补"拔插 TX 线制造噪声后能自动
  恢复";风险表把"PA10 没接"换成"一次噪声后链路永久静默"。
- 任务规划 README 独立轨道行同步为两条前置。

体积凭证随索引刷新(scripts/size_attestation.json),数字未变。
2026-10-28 10:41:32 +08:00

498 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 控制台单写者日志设计说明
> **版本**:v1.7(已实施;修订史见文末「修订记录」)
> **日期**:2026-10-26(v1.7:崩溃上报归一进单写者 + 多段裸发收口,道G task-26;v1.6:未注册期裸发加「行级单一所有者」,O1 第二个实例收口;v1.5 于 2026-10-24;初版 v1.0 于 2026-09-25)
> **状态**:已实施并验证——`@build` 0 错误 0 警告、`@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`(姊妹模块:持久化日志,同一写者任务) · `测试说明/链路延迟测试说明.md`(D6 日志税 / 步骤 8 非阻塞日志,需求出处) · AGENTS.md「平台约束」
## 1. 背景:实测故障与根因
### 1.1 实测故障(2026-09-24,双证据)
| # | 症状 | 实测证据 | 后果 |
|---|------|---------|------|
| F1 | 日志行字符级交错 | 崩溃前两行日志逐字符交错:`"[2026-[2026-0909-24 15:2-24 15:27:37:39]…"` | @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]` 模式,`@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()` |
| 物理通道 | USART1(PA9 TX / PA10 RX,115200,8N1) | 轮询发送(`HAL_UART_Transmit`);CubeMX 配置的 DMA TX(DMA2_Stream7)未使用 |
| 唤醒 | `SYS_EVT_CONSOLE_TX`(`sys_evt` bit 6) | **消费型命令位**(`osFlagsWaitAny` 默认消费,与 AGENTS.md 事件位语义一致;区别于就绪位必须 `osFlagsNoClear`) |
**与其他日志子系统的关系**(同一"单写者纪律",同一属主任务):
| 子系统 | 通道 | 写者 | 持久化 |
|--------|------|------|--------|
| 控制台单写者(本文档) | USART1 串口 | 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 拓扑
```mermaid
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["USART1 115200<br/>(PA9/PA10, 轮询)"]
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):同一时刻只有一个上下文在写 `USART1->DR`,抢不到独占的整行转入环形缓冲延后发 → 交错结构性消除,且不丢行 |
| 写者已注册(稳态) | 任务上下文唯一调用者 = 写者 | 任务上下文零竞争;残余窗口 = ISR 直发撞写者发送中(丢失、不挂死,§7) |
**纪律(2026-10-10 固化,2026-10-10 / 2026-10-26 两次台架实证其不足)**:周期 / 常驻任务**不得**在写者注册前记日志——用 `dbg_console_writer_ready()`(§5)门控自身日志(`adcTask` 的首帧与 10 s 心跳、`protectTask` 的启动锚点行即按此门控——后者 2026-10-12 B6 台架实证:B5 接线时启动行未门控,注册前裸发被交错损坏致 `@test` 锚点 MISS,见 §11 变更记录)。内核前代码仍可直接裸发(单线程,无并发)。
>
> ⚠ **自愿门控挡不住边沿留痕**——它在 2026-10-12(B6,启动锚点)与 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`)
```mermaid
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]` 触发 `@test` Forbidden 判定,丢行永不静默。
- **唤醒**:入环后置 `SYS_EVT_CONSOLE_TX` 命令位 → 写者立即被唤醒(零时延);写者循环另有 50ms 超时兜底(事件位异常也不滞留日志)。
### 4.3 写者路径(`attach` / `drain`)
```c
/* 注册(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(&huart1, &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`,无限递归)。
稳态时序:
```mermaid
sequenceDiagram
participant P as 生产者任务(netTask 等)
participant R as 环形缓冲 4KB(BSS)
participant E as sys_evt 事件标志
participant W as 写者 defaultTask
participant U as USART1(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]` 崩溃行整条蒸发**,而它正是台架「出现即不通过」的判据来源;③ `huart1.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 脏标志仍在(成因 = USART1 RX 悬空噪声,§12;用户已裁定不改 RX 配置)。本笔消除的是「崩溃日志整条蒸发」这一类,**不是**「控制台输出可能被脏标志污染」那一类,后者仍挂 §12 既有观察项。**极早期崩溃仍然完全静默**:`MX_USART1_UART_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. `@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 @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`(@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_USART1_UART_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 B6)。修法 = 非阻塞行所有权(`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 | **纪律盲区台架实证(B6)**:`protectTask`(B5 接线,台架腿当时未跑)启动锚点行在写者注册前裸发 → 与 defaultTask 启动日志**逐字节交错**(`st[art: cycle=220ms elm=70`,两次开机同形复现 = 启动调度确定性的竞态,非 USB 噪声)→ `@test` 锚点 MISS。修复 = 启动行改 `dbg_console_writer_ready()` 首拍补打(adcTask 首帧同款),`@build` 0/0 + `@test` PASS 复验(锚点完好 +577 ms)。§3.4 纪律示例更新 |
## 12. ORE 僵尸通道(2026-10-02 台架实证)
**机理**:USART1 使能了 RX(PA10 悬空、无上拉)→ 线上噪声持续产生帧错误/溢出 →
**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 时间源无关的超时判据)。机制根源继续记录在本节,将来若要重评不必重新踩一遍。
## 13. 开放问题
| # | 项 | 说明 |
|---|----|------|
| O1 | 未注册期裸发无互斥 —— **已处置(2026-10-26,v1.6)** | 启动窗口(内核启动 → attach)`dbg_console_write_raw` 无互斥 → 多任务并发**逐字节交错**。两个台架实例:2026-10-12 B6(`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 超时),机制根源是 USART1 使能了 RX。根治 = `.ioc` 改只发不收 + CubeMX 重生成,**2026-10-26 用户裁定不做** → O4 是**长期现状**而非待办(见 §12) |
## 14. 相关文档
- `项目架构总览.md` —— 六层分层 / 启动时序 / 6 任务(写者任务与就绪屏障位置)
- `运行与自检记录设计说明.md` —— sys_log 持久化日志(姊妹模块:同写者任务、不同通道)
- `测试说明/链路延迟测试说明.md` —— D6 日志税 / 步骤 8 非阻塞日志(需求出处与 09-22 丢行实证)
- `测试说明/自动化测试流程.md` —— `@test` 标记体系(Required / Forbidden 模式,`[ERR]` 语义)
- AGENTS.md —— 平台约束(内核前禁内核对象 / C 堆禁令 / 事件位语义 / 内存预算)
## 附录:日志静默观察项 × 步骤 8 合并评估(2026-10-25,道F 批 2 / 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 僵尸通道**(PA10 悬空 + RX 使能,软复位后毫秒级重锁 → 每轮块超时
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]` → `@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) |
---
## 修订记录
| 版本 | 日期 | 说明 |
|------|------|------|
| v1.0 | 2026-09-25 | 初版:完整设计说明(背景根因 / 单写者模型 / 详细设计 / API / 参数 / 失效模式 / 内存预算 / 设计决策 / 验证记录);实现位于 `App/log/console_log.{h,c}` |
| v1.1 | 2026-09-25 | 文件拆分同步:写者任务体迁至 `App/task/default_task.c`(`Src/freertos.c` 只留 CubeMX 接线与崩溃钩子);§3.2 文件清单 / §4.3 / §6 / §11 位置引用更新 |
| v1.2 | 2026-10-02 | 新增 §12 ORE 僵尸通道(台架日志代次 288 实证:节奏性喂狗饥饿,非死等;缓解已做、TX-only 根治待办)+ §11 时间线两条 10-02 记录;§13 新增 O4;原 §12/§13 顺延为 §13/§14 |
| v1.3 | 2026-10-10 | 栈链整改(**未抬栈**)三处:① 裸发改为纯寄存器轮询——DWT 不可用时走 `con_tx_counted`(计数上界),**不再回退 `HAL_UART_Transmit`**(tick 依赖不可信 + 其调用链污染所有任务最坏栈链);② 切断 `drain → syslog_post → write_line` **调用图成环**,改为置标志 + 写者任务在 drain 之外补记(新 API `dbg_console_tx_fault_log_take`);③ 行缓冲按模块可收紧(`dbg_log.h` 新增 `DBG_LINE_BUF_MAX`,深链模块 128)+ 时间戳缓冲并入行缓冲(32 → 24)。§4.4 / §5 / §6 / §7 / §8 同步;实测 defaultTask 静态最坏链 5312 → 2776 B |
| v1.4 | 2026-10-10 | 新增 `dbg_console_writer_ready()`(写者就绪查询)并固化"未注册期只有 defaultTask 记日志、周期任务用就绪门控"纪律;更正 §3.4 / §7 / §9-D5 / §13-O1 的"只丢不交错"旧结论(裸发已改寄存器级轮询、无 `gState` 互斥) |
| v1.6 | 2026-10-25 | 新增文末**附录:日志静默观察项 × 步骤 8 合并评估**(道F 批 2 / task-18,零代码):A.1 步骤 8 已由单写者形态在生产者侧达成(4402µs/行 → µs 级入环);A.2 TX 中断/DMA 维持 D4 否决、复议绑定 S-3 落地后 `tx_timeout` 实测;A.3 该观察项判据②已具备、①给出「无源 vs 真停滞」判别口径与 V5 复跑销项条件;A.4 遗留面全部既有、无新增。§11 时间线同步 |
| v1.5 | 2026-10-24 | **主机单测批次照出的四条现状全部处置**(生产码在 `App/log/console_log.c`,用例在 `test/host/console_log/`):① 冷却判据改用独立 `s_con_cooling` 标志,不再拿 `HAL_GetTick()` 的 0 值当哨兵——旧写法在 tick==0 那一拍(开机头 1 ms,或 49.7 天回绕到 0)冷却等于没记;② `con_append` 的空位判据 / 推进 head / 整行拷贝收进同一临界区——旧实现是临界区外 check-then-act,多生产者可在环 >95% 满时双双过判据把 head 推过容量 → 静默覆盖且**既不打 [ERR] 也不计 dropped**;③ `Instance == NULL`(未初始化)与僵尸通道**分诊**,未初始化绝不 reinit(旧路径会在 HAL 未就绪时经 MspInit 重配 RCC/GPIO);④ 三本账,见本文 §13-O2。**dropped 的单位口径变了**:一次 = 一行(缓冲满)或一个块(没能确认送达 / 通道不可用),故 `[ERR] CONSOLE dropped N lines` 的 N **不恒等于行数**(为兼容 `@test` 标记匹配,文案未改词)。本文 §3.1 与 §4.1 的「单生产者 vs 全上下文生产者」矛盾一并消除 |