控制台单写者日志设计说明
版本:v1.1(已实施)
日期: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 日志税 / S8 非阻塞日志,需求出处) · 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)——同类竞态的温和形态,当时列为 S8 待办(日志非阻塞化)。
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 P0-1 → 全部状态静态 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 拓扑
3.4 HAL 并发语义与本设计的配合(实现实测)
轮询 HAL_UART_Transmit(stm32f4xx_hal_uart.c:1135):gState READY → BUSY_TX(全程)→ READY;并发调用见非 READY 立即返回 HAL_BUSY(不排队)。本设计的三个运行区间:
| 区间 |
发送者构成 |
并发行为 |
| 内核启动前 |
仅主线程(单线程) |
无并发,直发安全 |
| 写者未注册(内核启动 → defaultTask attach,时长取决于存储 bring-up,通常秒级) |
多任务直发 |
竞争失败方立即 HAL_BUSY → 整行丢失(未计数、未告警);gState 检查跨整行保持 → 只丢不交错 |
| 写者已注册(稳态) |
任务上下文唯一调用者 = 写者 |
任务上下文零竞争;残余窗口 = ISR 直发撞写者发送中(立即返回、丢失、不挂死,§7) |
4. 详细设计
4.1 环形缓冲
| 参数 |
值 |
理由 |
容量 DBG_CON_RING_SIZE |
4096B |
2 的幂 → &(SIZE-1) 回绕免除法;≈21 整行(192B/行)余量 |
| head/tail |
uint16 + volatile,单生产者/单写者 |
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)
关键机制:
- 整行原子入环:拷贝与 head 推进在同一次
taskENTER_CRITICAL 内完成 → 写者永不见"半行";临界区时长有界(≤192B 拷贝,微秒级)。
- 自等待规避:写者自身记录日志时先
drain 腾空(否则"等写者腾出的空间"会等自己,死锁);腾空后入环必立即成功。
- 丢弃可观测:丢弃累计
s_con_drop,写者每次 drain 后把累计值刷成 [ERR] 行(§4.3)——[ERR] 触发 @test Forbidden 判定,丢行永不静默。
- 唤醒:入环后置
SYS_EVT_CONSOLE_TX 命令位 → 写者立即被唤醒(零时延);写者循环另有 50ms 超时兜底(事件位异常也不滞留日志)。
4.3 写者路径(attach / drain)
drain 算法(仅写者任务调用):
- 循环
while (used != 0):取连续段 tail → 环尾,截断至 DBG_CON_CHUNK_MAX(256B) → HAL_UART_Transmit(&huart1, &s_con_ring[tail], n, 0xFFFFu) → 临界区内推进 tail(交给 UART 的缓冲指针恒为环形缓冲——G2);
- 刷丢弃计数:
s_con_drop != 0 → 组帧 "\r\n[ERR] CONSOLE dropped N lines\r\n" → write_raw 直发(不经环:写者自身输出若走环会 con_append → drain 重入当前 drain,无限递归)。
稳态时序:
4.4 裸发(dbg_console_write_raw)
- 有界:
len > 256 → 256(截尾);单次阻塞 HAL_UART_Transmit(超时 0xFFFF),不持临界区、不等待状态;
- 适用:内核前 / 未注册期、ISR、异常钩子(栈溢出 / 堆失败)、崩溃上报;
- 调用方纪律:只交已知合法的缓冲——钩子侧先做指针范围校验(Flash / SRAM / CCM 窗口)并拷入本地缓冲再发,TCB 指针不接触 UART(
Src/freertos.c vApplicationStackOverflowHook,2026-09-24 加固;指针不可信时发占位文本,"上报崩溃的代码不能再崩一次")。
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,不等待、不加锁 |
缓冲须已知合法 |
dbg_console_writer_attach |
void (void) |
写者任务、主循环前 |
注册唯一写者 |
仅一次(defaultTask) |
dbg_console_drain |
void (void) |
仅写者任务 |
排空环并发出 + 刷丢弃计数 |
defaultTask 主循环周期调用 |
dbg_console_dropped |
uint32_t (void) |
任意 |
诊断:累计丢弃行数 |
外部读取 |
fputc |
int (int ch, FILE *f) |
printf 调用点 |
MicroLIB retarget 钩子 → 逐字节 con_append |
须为全局符号(C 库链接) |
6. 常量
| 常量 |
值 |
定义处 |
含义 |
DBG_CON_LINE_MAX |
192 |
console_log.h |
单行文本上限(含结尾 '\0');DBG_PRINT 宏行缓冲尺寸;超限截断 |
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/轮,仍满 → 丢弃 + 计数) |
| 写者循环超时 |
50 ms |
App/task/default_task.c |
osEventFlagsWait 兜底周期 |
7. 失效模式与可观测性
| 失效 |
行为 |
可观测性 |
残余风险 |
| 缓冲满(日志突发 / 写者卡滞) |
生产者等 200ms 后丢行 |
s_con_drop 累计 → 写者刷 [ERR] CONSOLE dropped N lines(@test FAIL,永不静默) |
— |
| 写者未注册(内核前) |
单线程直发 |
— |
无(无并发) |
| 未注册期多任务并发直发 |
竞争失败方立即 HAL_BUSY → 整行丢失 |
未计数、未告警(已知折中,§3.4 / §9-D5) |
启动窗口(秒级);2026-09-25 实测未发生 |
| ISR 直发撞写者发送中 |
立即 HAL_BUSY 返回 → 丢失,不挂死 |
未计数 |
ISR 日志频率极低,best-effort 语义 |
| 内核状态损坏(栈溢出 / 堆耗尽) |
钩子裸发固定文本 → 复位(栈溢出)/ 仅告警(堆) |
[ERR] STACK OVERFLOW task=… / [ERR] MALLOC FAILED …;任务名范围校验 + 本地拷贝,TCB 指针不入 UART |
— |
| 调用方指针损坏(脏指针) |
无法进入 UART(缓冲恒为环 / 模块本地) |
2026-09-24 HardFault 路径结构性消除 |
— |
| UART 物理失效(发送卡死 / 超时) |
HAL_UART_Transmit 返回 HAL_TIMEOUT;调用点忽略返回值(best-effort 控制台),该块丢弃 |
不入丢弃计数(与"缓冲满"独立);UART 故障由其他可观测面暴露(自检 / 其余日志) |
已知缺口:超时未计数(v2 可加超时计数) |
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_PRINT 宏局部) |
192 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)。
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:不等待、不死锁、零内核对象);未注册期"只丢不交错、未计数"是 K1/K3 约束下的代价,启动窗口秒级、实测未发生(§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 复验 |
12. 开放问题
| # |
项 |
说明 |
| O1 |
未注册期窗口丢行未计数 |
启动窗口(内核启动 → attach)直发竞争丢整行且无计数(HAL HAL_BUSY 立即返回);若未来开机日志密度上升,可升级为"内核前静态待发行 + attach 后统一补发"或有界等待直发 |
| O2 |
HAL_UART_Transmit 超时未计数 |
超时块(UART 物理失效)丢弃但不入 s_con_drop;v2 可加独立超时计数 |
| O3 |
printf 逐字节开销 |
遗留 printf 路径 = 逐字节临界区 + 逐字节事件置位;遗留调用点全部切 DBG_* 宏后开销自然消失(不改变机制) |
13. 相关文档
项目架构总览.md —— 六层分层 / 启动时序 / 6 任务(写者任务与就绪屏障位置)
运行与自检记录设计说明.md —— sys_log 持久化日志(姊妹模块:同写者任务、不同通道)
测试说明/链路延迟测试说明.md —— D6 日志税 / S8 非阻塞日志(需求出处与 09-22 丢行实证)
测试说明/自动化测试流程.md —— @test 标记体系(Required / Forbidden 模式,[ERR] 语义)
- AGENTS.md —— 平台约束(内核前禁内核对象 / C 堆禁令 / 事件位语义 / 内存预算)
修订记录
| 版本 |
日期 |
说明 |
| 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 位置引用更新 |