fix(控制台): 未注册期裸发加"整行独占",让路整行入环不丢,修掉 O1 第二实例

台架一手证据(boot_log_data/20261026_prod_console_interleave_evidence.txt):两次
「仅复位不重烧」启动里,[PROT] 未标定 INFO 行内分别多出 3 个和 1 个字节,而紧邻其后
的 [NAND_FTL] 行正好缺自己行首同样数量的字节("[20" 与 "[")——串口 framing 错不会
让行变长,字节按原序出现在邻居里 ⇒ 两个写者并发逐字节写 USART1->DR。写者对:
protectTask 的未标定闭锁边沿 × defaultTask 的 storage_bring_up(),两者都落在
dbg_console_writer_attach() 之前(attach 排在存储 bring-up 之后),该窗口
s_con_writer_up == 0 ⇒ 任何任务的整行都走零互斥裸发。这是控制台单写者设计说明在册
O1 的**第二个实例**;2026-10-12 那次只补了"自愿门控",而自愿门控挡不住业务边沿留痕
(业务无法预知某条边沿落在哪个窗口),所以这次被同一窗口第二次咬到。

修法是**非阻塞整行独占**:con_tx_try_claim/release 只在几条指令的临界区里
test-and-set,绝不把 7.8 ms 的逐字节发送包进关中断区、绝不等待;IPSR 上下文与调度
器未启动两种情况显式放行(崩溃遗言必须能出去;早期本就单执行流,一条不丢);同任务
按深度嵌套。抢不到独占的那条行**原文入环**延后发——前缀与时间戳在 DBG_* 调用点就
格式化进调用方缓冲,本模块与 drain 都不补前缀、不重打时间戳,所以延后发出的行与当场
直发逐字节相同;[ERR] 只在「抢不到独占且环也放不下」时才红,按 3 次启动命中 2 次的
频率,让路若计 [ERR] 就会把静默缺陷换成常红门禁。顺带堵掉第二处洞:drain 每轮先取
凭证,拿不到就让位且 tail 不推进(旧写法 attach 可以撞上别人已发出的半行)。
新导出 dbg_console_tx_begin()/tx_end() 供 task-26 收口故障钩子的多段裸发。

否决过的两条路写进文档 §4.4.1:提前 attach 会把等串口塞进 mount/mkfs 时序敏感段
或让生产者在整个 bring-up 里堵在满环上;整段进环会让「无看门狗窗口」死机时一个字都
不出(2026-10-02 那几轮正是靠 bring-up 期流式日志取证的)。

判据:test/host/console_log 127 → 152 全绿(M 族 TC-CONLOG-170..179 共 +25,既有
127 条零删除)。唯一换钉法的是 TC-CONLOG-015——旧断言钉的正是缺陷机理本身(裸发 0
次临界区 = 零互斥),现改钉「恰 2 次几条指令的临界区 + 每个上线字节都在临界区之外 +
零等待」;硬红线由假 USART 在每个上线字节处记录临界区深度水位恒 0 来机器守住。四处
注入反证各自转红 11 / 5 / 1 / 3 条并逐字节还原复跑;覆盖率 console_log.c 199/200 行
→ 256/257 行、16/16 → 21/21 函数。邻域复跑:task_prof 27/27、sys_log_mcu 137/137、
iec104_serv 37/37。CONLOG 游标抬到 179。

台架复验判据(**本笔尚未上机**,下一轮请台架跑):连续 3~5 次「仅复位不重烧」启动
日志 [ERR] 零命中,且 [PROT] 未标定与 [NAND_FTL] Initializing 两行都完整。
成本:.bss +5 B、Flash 估 +100~150 B,零新增 DBG_* / 零新增缓冲 / 零堆 / 零内核对象。
文档:控制台单写者日志设计说明.md 升 v1.6(O1 改「已处置」含两实例与字节账、新增
§4.4.1、§3.4 并发区间、§4.4 取消「不加锁」口径、§7 失效表、§11 变更记录、API 表)。
This commit is contained in:
2026-10-26 19:34:29 +08:00
parent e7136cbea3
commit 1d4b6a17ce
5 changed files with 554 additions and 56 deletions
+37 -3
View File
@@ -107,20 +107,54 @@ extern int con_uart_txe_ready_left; /* >=0 = 只准就绪 N 次
extern int con_uart_tc_after; /* 前 N 次 TC 读不就绪,之后就绪 */
extern int con_uart_tc_stuck; /* 1 = TC 永不就绪 */
extern int con_uart_pending; /* 1 = 有个字节已进 DR 待收 */
/* 前置声明:con_uart_tx_put 要看当前临界区深度(定义在本文件下方的④内核入口段,
* 这里必须先声明,否则 C89 隐式声明在 ARMCC/MSVC 上都是错误或告警) */
uint32_t con_crit_depth(void);
/* —— 道G task-23 新增的两个把手(判据说明见 con_uart_tx_put 内注释)—— */
extern uint32_t con_uart_tx_hook_at; /* 第 N 个字节上线后触发一次(0=不触发) */
extern void (*con_uart_tx_hook)(void); /* 触发时跑的函数(测试装填) */
extern uint32_t con_uart_crit_at_tx_max; /* 所有上线字节里见过的最大临界区深度 */
void con_uart_reset(void);
/*
* 上线一个字节 = 记进字节日志,并在此处给测试两个观察/介入点(道G task-23):
* ① con_uart_crit_at_tx_max:本字节上线时的临界区深度水位。整行所有权只能靠一次
* 几条指令的 test-and-set 取得,**绝不允许把 7.8 ms 的发送包进关中断区**(那等于
* 把中断延迟与最坏喂狗抖动塞进启动窗口,比交错本身更糟)——本水位恒 0 就是这条
* 硬红线的机器证据(判据见 console_log_test.c 的 M 族 TC-CONLOG-170..179)。
* ② con_uart_tx_hook/_at:在"第 N 个字节上线之后"跑一遍测试装填的函数,用来精确
* 模拟**发送任务在两个字节之间被抢占**。行级所有权是"拿不到就当场让路"的非阻塞
* 设计,所以既有 con_delay_hook(vTaskDelay 时才跑)在这条路径上永远不会触发,
* 必须另有塞入点,否则"两个生产者并发时不得出现另一条行的字节"这条判据没法做
* 成必然失败的断言(只能靠静态论证,那不算交付)。
* 钩子一次性:进入即摘钩,防递归;钩子里再发的字节照常记账。
*/
static void con_uart_tx_put(uint8_t b) {
uint32_t depth;
if (con_uart_tx_n + 1U >= CON_UART_TX_MAX) {
printf("[FATAL] fake USART: tx log overflow (>=%u)\n", CON_UART_TX_MAX);
printf("[FATAL] fake uart: wire log overflow (>=%u bytes)\n", (unsigned)CON_UART_TX_MAX);
exit(2);
}
con_uart_tx[con_uart_tx_n++] = b;
/* 每次都在尾部补一个 NUL:让 strstr 一类字符串函数不可能读过缓冲末尾 */
/* NUL 结尾(tx 是二进制缓冲,但让 CHECK 的 %s 打印能读) */
con_uart_tx[con_uart_tx_n < CON_UART_TX_MAX ? con_uart_tx_n : (CON_UART_TX_MAX - 1U)] = 0U;
con_uart_cur_len++;
}
depth = con_crit_depth();
if (depth > con_uart_crit_at_tx_max) {
con_uart_crit_at_tx_max = depth;
}
if ((con_uart_tx_hook != 0) && (con_uart_tx_hook_at != 0U)
&& (con_uart_tx_n == con_uart_tx_hook_at)) {
void (*h)(void) = con_uart_tx_hook;
con_uart_tx_hook_at = 0U; /* 一次性:先摘钩再跑,钩子内部的发送不再触发 */
h();
}
}
/* 收走「上次就绪之后写进 DR 的字节」:它此刻才真正上线 */
static void con_uart_take_byte(void) {
if (con_uart_pending == 0) {