From 1d4b6a17ce0db0f514148d2bb5becdff1d7f4ac0 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Mon, 26 Oct 2026 19:34:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E6=8E=A7=E5=88=B6=E5=8F=B0):=20=E6=9C=AA?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E6=9C=9F=E8=A3=B8=E5=8F=91=E5=8A=A0"?= =?UTF-8?q?=E6=95=B4=E8=A1=8C=E7=8B=AC=E5=8D=A0"=EF=BC=8C=E8=AE=A9?= =?UTF-8?q?=E8=B7=AF=E6=95=B4=E8=A1=8C=E5=85=A5=E7=8E=AF=E4=B8=8D=E4=B8=A2?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E6=8E=89=20O1=20=E7=AC=AC=E4=BA=8C=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 台架一手证据(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 表)。 --- App/log/console_log.c | 279 +++++++++++++++++--- App/log/console_log.h | 18 +- docs/子系统设计/控制台单写者日志设计说明.md | 52 +++- test/host/console_log/console_log_test.c | 221 +++++++++++++++- test/host/console_log/stub/stm32f4_fake.h | 40 ++- 5 files changed, 554 insertions(+), 56 deletions(-) diff --git a/App/log/console_log.c b/App/log/console_log.c index 1e2fef84..f9d65af5 100644 --- a/App/log/console_log.c +++ b/App/log/console_log.c @@ -31,6 +31,26 @@ * adcTask(B1)等周期任务此时落日志会与 defaultTask 的存储初始化 * 日志字节级交错(台架实测 [ADC_TASK] 首帧行与 [NAND_FTL] 行交错)。 * 提供就绪查询供周期任务门控自身日志,不改直发路径本身。 + * 2026-10-26 王建锋 **O1 第二个实例(台架实证)修复:裸发路径加「行级单一所有者」**。 + * 台架同镜像两次启动(fw 戳 Oct 26 2026 16:27:01,排除构建差异):16:28 那次 + * protectTask 的未标定 INFO 完好,16:37 那次同一行变成 + * "protection [blocked (UA/UN/IA VALI2D not all s0et)" —— 多出的 3 个字节是 + * '[' '2' '0',而 DBG 行前缀恰是 '[' + sys_clock_get_str() 的 "2026-…", + * **即另一条行的行首三个字符**;串口 framing 错只会改字节、不会让行变长, + * 故只可能是另一个写者在同一行内插入。机理 = 写者注册前(内核启动 -> + * default_task.c 的 dbg_console_writer_attach,中间隔着整段 storage_bring_up) + * 所有任务的整行都走 dbg_console_write_raw 逐字节轮询 USART1->DR,**零互斥** + * (docs/子系统设计/控制台单写者日志设计说明.md 在册 O1;20261012_B6 那次 + * "st[art:" 同一形态)。修法 = con_tx_try_claim()/con_tx_release() 一对 + * **非阻塞**所有权(只在几条指令的临界区里做 test-and-set,绝不把 7.8 ms + * 的发送包进临界区、绝不关中断、绝不等待);抢不到所有权的那条行**不丢**, + * 落进既有环形缓冲延后发(竞争失败不该变成丢行,更不该变成 [ERR] 假红—— + * 那个窗口每 3 次启动命中 2 次),只有环也放不下才计入既有 dropped 账 + * (真溢出仍然看得见)。顺带堵掉第二处洞:**attach 恰好撞上别人已发出的半行** + * ——dbg_console_drain 现在也必须先拿到所有权才开轮,拿不到就让位 + * (tail 不推进,环里一条不少,下轮 ≤50 ms 后重发,这条路径永不丢)。 + * 另导出 dbg_console_tx_begin()/tx_end() 供"一条行分多段裸发"的调用方收口 + * (Src/freertos.c 故障钩子是下一张卡),本模块不改任何调用方文件。 * 2026-10-24 王建锋 主机单测(test/host/console_log)照出的四条一并修复: * 冷却改判独立标志 s_con_cooling(旧写法拿 tick 值当"是否在冷却"哨兵, * 故障恰好落在 HAL_GetTick()==0 那一拍时冷却等于没记); @@ -66,7 +86,10 @@ * ① 行级原子:整行在一次临界区内拷入环形缓冲,写者按块一次 HAL_UART_Transmit * 发出 → 行与行不可能再交错(UART 只有这一个调用点); * ② 指针可控:进 UART 的缓冲恒为本文件的环形缓冲,调用方脏指针再也到不了 UART; - * ③ 写者未注册(内核启动早期/裸机)或中断上下文:回退直接阻塞发送(无并发或必须裸发)。 + * ③ 写者未注册(内核启动早期/裸机)或中断上下文:回退直接发送,但**整行独占** + * 由 con_tx_try_claim() 保证(道G task-23:未注册期原来就是"多个任务都直发、 + * 零互斥",O1 两个实例都出在这里);抢不到独占的那条行不丢,落环形缓冲延后发, + * 与当场直发逐字节相同。ISR / 调度器未启动两种上下文放行(不许等)。 * 约束:不新增任务、不动态分配;环形缓冲为 BSS(SRAM1,非 CCM),无 DMA 可达性冲突。 */ #define DBG_CON_RING_SIZE 4096U /* 环形缓冲字节数(必须为 2 的幂) */ @@ -110,12 +133,29 @@ static volatile uint8_t s_con_draining; /* 1 = 处于 drain 中(重入保 static volatile uint8_t s_con_writer_up; /* 1 = 写者已注册(此后走环形缓冲) */ static volatile uint8_t s_con_dwt_up; /* 1 = DWT CYCCNT 已使能(硬上界基准可用) */ static TaskHandle_t s_con_writer_task; /* 写者任务句柄(自等保护用) */ +/* 行级单一所有者(道G task-23,O1 第二实例):USART1 数据寄存器同一时刻只允许 + * 一个上下文在逐字节轮询写入。NULL = 空闲。 + * ⚠ 这不是"锁",是**所有权凭证**:拿不到就当场让路,绝不排队等待(见 + * con_tx_try_claim 函数头:裸发一行 @115200 约 7.8 ms,把它包进临界区等于给 + * 启动窗口塞进 7.8 ms 的中断延迟与最坏喂狗抖动,比交错本身更糟)。 */ +static volatile TaskHandle_t s_con_tx_owner; +static volatile uint8_t s_con_tx_depth; /* 同一任务重入深度(drain 轮内的裸发留痕行) */ + +/* con_tx_try_claim 的三种结果(区分开是必要的:**放行**的那些上下文没有持有凭证, + * 绝不能在释放时把别人的所有权清掉) */ +#define CON_TX_NO 0u /* 别人正持有整行 -> 调用方让路(不阻塞) */ +#define CON_TX_BYPASS 1u /* 放行:本上下文不许等也无需凭证(ISR / 调度器未启动) */ +#define CON_TX_HELD 2u /* 已持有:调用方发完必须 con_tx_release */ /* * 函数功能:计算环形缓冲已用字节数(head-tail 差值回绕取模,无锁) * 入口参数:无 * 返回值:已用字节数 uint16_t */ +static uint8_t con_ring_try(const char *p_buf, uint16_t len); + /* 定义在下方 con_append 旁;裸发让路路径要用,故前置声明 + * (ARMCC 对隐式声明报警告,本仓红线是 0 警告) */ + static uint16_t con_used(void) { return (uint16_t)((uint16_t)(s_con_head - s_con_tail) & (DBG_CON_RING_SIZE - 1U)); } @@ -217,19 +257,153 @@ static HAL_StatusTypeDef con_tx_bounded(const uint8_t *p_buf, uint16_t len) { } /* - * 函数功能:控制台裸发送(异常/早期路径专用:有界、单次、不加锁) + * 函数功能:尝试取得"USART1 整行发送所有权"(**非阻塞**,道G task-23) + * 返回值:CON_TX_HELD 已持有(发完必须 con_tx_release)/ CON_TX_BYPASS 放行且不记账 + * / CON_TX_NO 别人正持有整行,调用方须让路 + * 限定条件:任意上下文(内部只有一次几条指令的临界区,绝不睡眠、绝不长期关中断) + * 函数说明:三条规则各有硬理由,改动前请连读 docs/子系统设计/控制台单写者日志设计说明.md + * 的「行级单一所有者」一节: + * ① IPSR != 0(异常/中断,含四个 Fault 的崩溃上报)-> **放行**:这些上下文不许阻塞, + * 且一发生就要复位,日志是唯一遗言,绝不允许在这里"让路"掉。已知残余(登记在同一 + * 篇文档):ISR 可以打断某任务已发出半行的裸发,两侧仍可能交错——但 Fault 行以 + * "[ERR]" 起头且整段在同一次 con_tx_bounded 内,标记校验按字节 grep 仍能命中, + * 不构成漏检;把钩子改成"一条行一次发完"是另一张卡的活(API 已备:tx_begin/tx_end)。 + * ② 调度器未启动(xTaskGetCurrentTaskHandle() == NULL,osKernelStart 之前)-> **放行**: + * 此刻全系统只有一个执行流,本就没有第二个写者;早期启动日志一条都不该丢。 + * ③ 已调度且空闲 -> 临界区里"看 + 认领"(几条指令),拿到即整行独占;已被持有 -> + * 立刻返回 CON_TX_NO,**不排队**:裸发一行 @115200 约 7.8 ms,等锁等于把自己的 + * 日志节拍压到别人的串口耗时上,adcTask(AboveNormal,1 kHz 消费)可能被压住 + * 数十拍,比交错更糟。 + */ +static uint8_t con_tx_try_claim(void) { + TaskHandle_t me; + uint8_t got = 0U; + + if (__get_IPSR() != 0U) { + return (uint8_t)CON_TX_BYPASS; + } + me = xTaskGetCurrentTaskHandle(); + if (me == NULL) { + return (uint8_t)CON_TX_BYPASS; + } + if (s_con_tx_owner == me) { + if (s_con_tx_depth < 255U) { + s_con_tx_depth++; + } + return (uint8_t)CON_TX_HELD; + } + taskENTER_CRITICAL(); + if (s_con_tx_owner == NULL) { + s_con_tx_owner = me; + s_con_tx_depth = 1U; + got = 1U; + } + taskEXIT_CRITICAL(); + + return (got != 0U) ? (uint8_t)CON_TX_HELD : (uint8_t)CON_TX_NO; +} + +/* + * 函数功能:归还整行发送所有权(与 con_tx_try_claim 成对) + * 入口参数:held - con_tx_try_claim 的返回值(只有 CON_TX_HELD 才真正归还) + * 返回值:无 + * 限定条件:与取锁同上下文;CON_TX_BYPASS / CON_TX_NO 一律空操作——放行的那些上下文 + * 从未持有凭证,误清会把别人正持有的整行放开,等于把这次修复改没 + * 函数说明:同任务嵌套按深度递减(写者一轮 drain 内还会再裸发 [WARN]/[ERR] 留痕行, + * 那是嵌套持有者,最外层释放时才清 owner) + */ +static void con_tx_release(uint8_t held) { + TaskHandle_t me; + + if (held != (uint8_t)CON_TX_HELD) { + return; + } + me = xTaskGetCurrentTaskHandle(); + if (s_con_tx_owner != me) { + return; + } + if (s_con_tx_depth > 1U) { + s_con_tx_depth--; + return; + } + taskENTER_CRITICAL(); + if (s_con_tx_owner == me) { + s_con_tx_owner = NULL; + s_con_tx_depth = 0U; + } + taskEXIT_CRITICAL(); +} + +/* + * 函数功能:把"一条行分几段裸发"的调用方收成一个整行独占者(道G task-23) + * 返回值:1 - 已独占(此后本任务每次 dbg_console_write_raw 都直接发,段间无人插入) + * 0 - 别人正持有;调用方**照发即可**(本函数不改变任何调用方的既有语义, + * 绝不阻塞、绝不吞字,只是段间仍可能被插——调用方自己权衡) + * 限定条件:任务上下文(ISR 内调用会返回 1 但不记账,见 con_tx_try_claim ①) + * 函数说明:给多段裸发的诊断/崩溃钩子用(Src/freertos.c 的钩子把一条行拆成 + * head/name/":"/" (resetting)" 等多次直发,段与段之间就是插字节的位置)。 + * 配对使用:tx_begin() -> 若干次 write_raw -> tx_end()。嵌套安全(按深度计)。 + */ +uint8_t dbg_console_tx_begin(void) { + uint8_t held = con_tx_try_claim(); + + return (held == (uint8_t)CON_TX_NO) ? 0U : 1U; +} + +/* + * 函数功能:结束一次整行独占(与 dbg_console_tx_begin 成对) + * 入口参数:无 + * 返回值:无 + * 限定条件:只在 dbg_console_tx_begin() 之后调用;未持有时为空操作(幂等) + */ +void dbg_console_tx_end(void) { + con_tx_release((uint8_t)CON_TX_HELD); +} + +/* + * 函数功能:控制台裸发送(异常/早期路径专用:有界、单次;**整行独占发送器**) * 入口参数:buf - 文本缓冲;len - 字节数(超上限截断) - * 限定条件:串口已初始化;仅用于写者未注册、中断/异常上下文、崩溃上报 + * 返回值:无 + * 限定条件:串口已初始化;写者未注册、中断/异常上下文、崩溃上报、诊断转储 * 函数说明:走 con_tx_bounded(DWT 硬上界),tick 停摆时仍能在有界时间内返回。 + * 道G task-23 起本函数是裸发的**唯一漏斗**(所有裸发调用点都经过它,所以调用方 + * 文件一个都不用改): + * · 拿到所有权 -> 整行一次发完,中途不会有别人的字节插进来; + * · 拿不到 -> **本行不丢**,改投既有环形缓冲延后发。竞争失败不该变成丢行,更不该 + * 变成 [ERR]:台架那个未注册窗口每 3 次启动命中 2 次,按"丢弃 + [ERR]"处理只会 + * 把静默缺陷换成常红的门禁([ERR] 是 @test 的 Forbidden 标记)。 + * 入环的是**同一批字节原文**,本模块与 drain 都不补前缀、不重打时间戳(DBG_* 的 + * 前缀在调用点就已 snprintf 进调用方缓冲)-> 延后发出去的行与它当场直发时 + * 逐字节相同(判据⑦),取证时不会出现"证据说谎"; + * · 环也放不下 -> 这才计入既有 s_con_drop(真溢出,仍然可见,不静默); + * · ISR / 调度器未启动 -> 放行直发(con_tx_try_claim ①②)。 */ void dbg_console_write_raw(const char *buf, unsigned len) { + uint8_t held; + if ((buf == NULL) || (len == 0U)) { return; } if (len > DBG_CON_CHUNK_MAX) { len = DBG_CON_CHUNK_MAX; } + + held = con_tx_try_claim(); + if (held == (uint8_t)CON_TX_NO) { + if (con_ring_try(buf, (uint16_t)len) != 0U) { + /* 延后发:写者空出来就排出去(未注册时也一样——注册后第一轮 drain 就发)。 + * 唤醒事件只在事件组已建时发(内核前 sys_evtHandle 还是 NULL) */ + if (sys_evtHandle != NULL) { + (void)osEventFlagsSet(sys_evtHandle, SYS_EVT_CONSOLE_TX); + } + } else { + s_con_drop++; /* 让了路又放不下环 = 真丢,进既有那本账 */ + } + return; + } + (void)con_tx_bounded((const uint8_t *)buf, (uint16_t)len); + con_tx_release(held); } /* @@ -281,6 +455,7 @@ static void con_uart_reinit(void) { void dbg_console_drain(void) { uint16_t used; uint32_t t0; + uint8_t held; if (s_con_writer_up == 0U) { return; @@ -289,6 +464,16 @@ void dbg_console_drain(void) { return; /* 重入保护:写者自记录不触发嵌套 drain */ } + /* 整行所有权(道G task-23):本函数按块直写 USART1->DR,若此刻别的应用上下文 + * 还在未注册期那条裸发里发到自己一半(attach 就发生在 defaultTask 里,与别的 + * 任务裸发**可以**前后脚撞上),两边就会逐字节交错——那正是本卡要修的形态。 + * 拿不到就**本轮让位**:tail 不推进 => 环里一条不少,主循环 ≤50 ms 后再试, + * 所以这条路径**永不丢弃**,也就不该在这里加任何计数(加了就是把让位谎报成丢行)。 */ + held = con_tx_try_claim(); + if (held == (uint8_t)CON_TX_NO) { + return; + } + s_con_draining = 1U; t0 = (uint32_t)HAL_GetTick(); @@ -301,6 +486,7 @@ void dbg_console_drain(void) { if (s_con_cooling != 0U) { if ((uint32_t)(t0 - s_con_cool_until) < DBG_CON_FAULT_COOL_MS) { s_con_draining = 0U; + con_tx_release(held); /* 让位也要归还凭证,否则别人永远进不来 */ return; } s_con_cooling = 0U; @@ -370,7 +556,10 @@ void dbg_console_drain(void) { s_con_draining = 0U; - /* 丢弃可观测性:缓冲满丢行必须显式可见(否则会掩盖开机自检/测试标记的丢失) */ + /* 丢弃可观测性:缓冲满丢行必须显式可见(否则会掩盖开机自检/测试标记的丢失)。 + * 账上有两种成因,都算"真没落地":① 环满等不到空位(con_append);② 抢不到整行 + * 所有权**且**环也放不下(dbg_console_write_raw,道G task-23)。正常让路不入这本账, + * 所以 [ERR] 行至今仍是"控制台在丢东西"的唯一信号,没被稀释。 */ if (s_con_drop != 0U) { char note[64]; uint32_t d = s_con_drop; @@ -385,6 +574,7 @@ void dbg_console_drain(void) { /* 只有**真发出去了才销账**(2026-10-24 一并修正):旧写法先把计数清零 * 再裸发,通道不可用/僵尸时那一发也失败 -> 丢弃证据被一起冲掉,[ERR] 行 * 永远出不来("永不静默"变成"永不显示")。留到下一轮或串口恢复后补发。 */ + /* 所有权仍在手里(本凭证覆盖整轮,含这条留痕行) */ if (con_tx_bounded((const uint8_t *)note, (uint16_t)n) == HAL_OK) { /* 按"本次报出去的这条"扣账而不是清零:期间生产者新丢的行留下轮报。 * 递减只发生在这一处(单写者 + s_con_draining 重入保护),生产者只增, @@ -393,6 +583,53 @@ void dbg_console_drain(void) { } } } + + con_tx_release(held); +} + +/* + * 函数功能:尝试把一整行原文放进环形缓冲(**只试一次,绝不等待**) + * 入口参数:p_buf - 整行文本;len - 字节数(调用方已钳位) + * 返回值:1 - 已入环(head 已推进);0 - 空位不够,什么都没写 + * 限定条件:任务上下文(内部是一次几条指令至多拷满 192B 的临界区,不睡眠) + * 函数说明:两个调用方共用同一份判据,避免长出"两套入环语义": + * ① con_append 的常规路径(外面套有界等待,等写者腾位); + * ② dbg_console_write_raw 抢不到整行所有权时的让路路径(**不套等待**:延后发一条行 + * 可以,把调用者压住 200ms 就是另一场事故)。 + * 复核空位 + 整行拷贝 + 推进 head **必须在同一个临界区内**(2026-10-24 修复):旧写法 + * 先出区判 free_space >= len 再进区写,两个生产者可在环 >95% 满时同时过判据,head + * 合计越过容量 -> con_used() 回绕变小 -> 后写的行覆盖尚未发出的旧行,且既不报错也不 + * 计丢弃(静默损坏)。等待(vTaskDelay/DWT 判据)刻意留在区外。 + * ⚠ 入环的是调用方给的**字节原文**:本函数与 drain 都不补前缀、不重打时间戳 + * (DBG_* 的前缀在调用点就已 snprintf 进调用方缓冲)——延后发与当场直发逐字节 + * 相同,这是 task-23 判据⑦的结构保证。 + */ +static uint8_t con_ring_try(const char *p_buf, uint16_t len) +{ + uint8_t fitted = 0U; + + taskENTER_CRITICAL(); + if ((uint16_t)((DBG_CON_RING_SIZE - 1U) - con_used()) >= len) { + uint16_t head = s_con_head; + uint16_t first = (uint16_t)(DBG_CON_RING_SIZE - head); + uint16_t i; + + if (first > len) { + first = len; + } + for (i = 0U; i < first; i++) { + s_con_ring[head + i] = (uint8_t)p_buf[i]; + } + for (i = first; i < len; i++) { + s_con_ring[(uint16_t)((uint16_t)(head + i) & (DBG_CON_RING_SIZE - 1U))] = + (uint8_t)p_buf[i]; + } + s_con_head = (uint16_t)((uint16_t)(head + len) & (DBG_CON_RING_SIZE - 1U)); + fitted = 1U; + } + taskEXIT_CRITICAL(); + + return fitted; } /* @@ -433,37 +670,9 @@ static int con_append(const char *buf, uint16_t len) { } for (;;) { - uint16_t free_space; - int fitted = 0; - - /* 复核空位 + 整行拷贝 + 推进 head **必须在同一个临界区内**(2026-10-24 修复): - * 旧写法先出区判 free_space >= len 再进区写,两个生产者可在环 >95% 满时同时过 - * 判据,head 合计越过容量 -> con_used() 回绕变小 -> 后写的行覆盖尚未发出的旧行, - * 且既不报错也不计丢弃(静默损坏)。本区最长拷 192B(168MHz 亚微秒级),相对 - * 115200 波特下一行约 16ms 可忽略;等待(vTaskDelay/DWT 判据)刻意留在区外。 */ - taskENTER_CRITICAL(); - free_space = (uint16_t)((DBG_CON_RING_SIZE - 1U) - con_used()); - if (free_space >= len) { - uint16_t head = s_con_head; - uint16_t first = (uint16_t)(DBG_CON_RING_SIZE - head); - uint16_t i; - - if (first > len) { - first = len; - } - for (i = 0U; i < first; i++) { - s_con_ring[head + i] = (uint8_t)buf[i]; - } - for (i = first; i < len; i++) { - s_con_ring[(uint16_t)((uint16_t)(head + i) & (DBG_CON_RING_SIZE - 1U))] = - (uint8_t)buf[i]; - } - s_con_head = (uint16_t)((uint16_t)(head + len) & (DBG_CON_RING_SIZE - 1U)); - fitted = 1; - } - taskEXIT_CRITICAL(); - - if (fitted != 0) { + /* 判据与拷贝都在 con_ring_try 的那一个临界区里完成(函数头有为什么); + * 本函数只负责"等多久"——等待一律在区外,区内绝不睡眠 */ + if (con_ring_try(buf, len) != 0U) { break; } if (have_dwt != 0U) { diff --git a/App/log/console_log.h b/App/log/console_log.h index b73e60ef..d1fccc8c 100644 --- a/App/log/console_log.h +++ b/App/log/console_log.h @@ -15,6 +15,9 @@ * 修改记录: * 2026-09-24 王建锋 创建(自 Src/usart.c USER CODE 区迁出: * HAL 库生成文件不得包含应用代码) + * 2026-10-26 王建锋 道G task-23:裸发路径加「行级单一所有者」(O1 第二实例), + * 新增 dbg_console_tx_begin()/dbg_console_tx_end() 供"一条行分多段裸发" + * 的调用方收口;dbg_console_write_raw() 的"不加锁"三个字从接口上取消 */ #ifndef CONSOLE_LOG_H @@ -32,15 +35,22 @@ extern "C" { * dbg_console_write_line - 生产者入口:调用方已格式化好的整行文本一次入环形缓冲 * (刻意**不用**自研可变参数函数——规范 S12 7.6 禁止;格式化在 * DBG_PRINT 宏内用库 snprintf 完成) - * dbg_console_write_raw - 异常/早期路径:有界单次直发(不加锁) + * dbg_console_write_raw - 异常/早期路径:有界单次直发;**整行独占发送器** + * (道G task-23:拿不到独占就把整行原文转入环形缓冲延后发, + * 与当场直发逐字节相同——不补前缀、不重打时间戳; + * 连环都放不下才计入 dbg_console_dropped()) * dbg_console_writer_attach- 写者任务(defaultTask)注册自己为唯一发送者 * dbg_console_writer_ready - 写者是否已注册(1=已注册)。供**周期性任务**在写者就绪前 * 抑制日志用:写者未注册时 con_append 走无锁直发路径 * (仅假设 defaultTask 在日志),其余任务此时落日志会与 * defaultTask 的存储初始化日志字节级交错(2026-10-10 台架实测) * dbg_console_drain - 写者任务排空缓冲并发送(单次有界;TX 失败自动软复位自愈) - * dbg_console_dropped - 真没落地的丢弃数:缓冲满丢的一行,或一个没能确认送达 - * 而出环的块(诊断;[ERR] CONSOLE dropped 行报的就是它) + * dbg_console_dropped - 真没落地的丢弃数:① 缓冲满丢的一行;② 一个没能确认 + * 送达而出环的块;③ 抢不到整行所有权**且**环也放不下的裸发 + * (道G task-23)。诊断;[ERR] CONSOLE dropped 行报的就是它。 + * ⚠ 抢不到所有权但成功让路入环的那条行**不入本账**(它没丢, + * 只是晚一点发)——这本账必须保持"红得有意义",因为 + * [ERR] 是 @test 的 Forbidden 标记) * dbg_console_tx_fault - 因僵尸通道软复位自愈的次数(诊断,语义不变) * dbg_console_tx_timeout - 写者排空时观察到的块超时次数(TXE / TC 两条硬上界都算)。 * 三本账分开:看日志有没有在丢 -> dropped;看外设自愈了几次 @@ -55,6 +65,8 @@ extern "C" { #define DBG_CON_LINE_MAX 192U /* 单行文本上限(含结尾 '\0',超出截断) */ int dbg_console_write_line(const char *line, unsigned len); void dbg_console_write_raw(const char *buf, unsigned len); +uint8_t dbg_console_tx_begin(void); /* 1=已独占一条行;0=别人持有(调用方照发即可) */ +void dbg_console_tx_end(void); /* 与 tx_begin 成对;未持有时空操作(幂等) */ void dbg_console_writer_attach(void); uint8_t dbg_console_writer_ready(void); void dbg_console_drain(void); diff --git a/docs/子系统设计/控制台单写者日志设计说明.md b/docs/子系统设计/控制台单写者日志设计说明.md index b5ea0fed..dbccb6fc 100644 --- a/docs/子系统设计/控制台单写者日志设计说明.md +++ b/docs/子系统设计/控制台单写者日志设计说明.md @@ -1,7 +1,7 @@ # 控制台单写者日志设计说明 -> **版本**:v1.5(已实施;修订史见文末「修订记录」) -> **日期**:2026-10-24(v1.5;初版 v1.0 于 2026-09-25) +> **版本**:v1.6(已实施;修订史见文末「修订记录」) +> **日期**:2026-10-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「平台约束」 @@ -111,10 +111,15 @@ flowchart LR | 区间 | 发送者构成 | 并发行为 | |------|-----------|---------| | 内核启动前 | 仅主线程(单线程) | 无并发,直发安全 | -| 写者未注册(内核启动 → attach) | **约定只有 `defaultTask` 记日志** | 遵守约定 → 无并发;**其他任务此时记日志 → 逐字节交错**(裸发无互斥) | +| 写者未注册(内核启动 → attach) | 任何任务都可能记日志(2026-10-26 起这是**常态而非违规**) | **整行独占**(行级单一所有者,§4.4.1):同一时刻只有一个上下文在写 `USART1->DR`,抢不到独占的整行转入环形缓冲延后发 → 交错结构性消除,且不丢行 | | 写者已注册(稳态) | 任务上下文唯一调用者 = 写者 | 任务上下文零竞争;残余窗口 = ISR 直发撞写者发送中(丢失、不挂死,§7) | -**纪律(2026-10-10 固化)**:周期 / 常驻任务**不得**在写者注册前记日志——用 `dbg_console_writer_ready()`(§5)门控自身日志(`adcTask` 的首帧与 10 s 心跳、`protectTask` 的启动锚点行即按此门控——后者 2026-10-12 B6 台架实证:B5 接线时启动行未门控,注册前裸发被交错损坏致 `@test` 锚点 MISS,见 §11 变更记录)。内核前代码仍可直接裸发(单线程,无并发)。 +**纪律(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. 详细设计 @@ -197,12 +202,36 @@ sequenceDiagram ### 4.4 裸发(`dbg_console_write_raw`) -- 有界:`len > 256 → 256`(截尾);发送走 `con_tx_bounded`(**寄存器级轮询 TXE/TC**,超时判据 = DWT `CYCCNT`,`CON_TX_TIMEOUT_CYC` = 100 ms 对应周期数),不持临界区、不等待状态; +- 有界:`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 补充)**:`con_tx_bounded` **无互斥**(寄存器级轮询,无 `HAL` 的 `gState` 锁)→ 写者注册前**只允许 `defaultTask`**(存储 bring-up 阶段)走此路径;其他常驻任务须用 `dbg_console_writer_ready()` 门控日志(见 §3.4)。 +- ~~**调用方纪律(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 与主机单测;**调用点改造另卡跟进**(`Src/freertos.c` 栈溢出/堆失败钩子把一条行拆成 head/name/":"/" (resetting)" 多次直发,段与段之间仍是可插入的缝 → task-26)。 +- **已知残余(不在本卡修)**:`IPSR != 0` 的放行仍可能打断某任务已发出半行。判断依据:Fault 上报以 `[ERR]` 起头且整段在同一次 `con_tx_bounded` 内,标记校验按**字节** grep 仍能命中,不构成漏检;且该路径随后即 `NVIC_SystemReset()`。 + +**为什么不做成"整段进环"**(曾评估):那会让 `storage_bring_up()` 期间串口**一个字都不出**,而这段正是 §「启动看门狗延后」注明的"死机表现为无任何输出"窗口(2026-10-02 那几轮就靠 bring-up 期流式日志取证)——等于把交错换成死机全瞎。现方案保留**胜者实时直发**,只有真撞车才让路一条行。 + ### 4.5 `fputc` 重定向(MicroLIB `printf`) `fputc(ch, f)` → `con_append(&b, 1)`:库内部 / 遗留 `printf` 调用同样收敛到单写者路径(注册期逐字节入环;一条完整 `printf` 行 = 环内连续字节,写者按块发出 → 仍行级原子)。 @@ -227,7 +256,9 @@ sequenceDiagram | 函数 | 签名 | 调用上下文 | 语义 | 备注 | |------|------|-----------|------|------| | `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_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 主循环周期调用 | @@ -255,8 +286,8 @@ sequenceDiagram |------|------|---------|---------| | 缓冲满(日志突发 / 写者卡滞) | 生产者等 200ms 后丢行 | `s_con_drop` 累计 → 写者刷 `[ERR] CONSOLE dropped N lines`(@test FAIL,永不静默) | — | | 写者未注册(内核前) | 单线程直发 | — | 无(无并发) | -| 未注册期其他任务直发 | **逐字节交错**(`con_tx_bounded` 无互斥,非旧 `HAL_BUSY`) | 交错行可致 `@test` 标记失配(2026-10-10 台架实证) | 已固化纪律:周期任务用 `dbg_console_writer_ready()` 门控(§3.4 / §9-D5) | -| ISR 直发撞写者发送中 | 与写者发送竞争 → 丢失,不挂死 | 未计数 | ISR 日志频率极低,best-effort 语义 | +| 未注册期其他任务直发 | **已消除**(2026-10-26):整行独占,抢不到就整行入环延后发(§4.4.1) | 让路不计数;只有"抢不到且环也满"才进 `s_con_drop` → `[ERR] CONSOLE dropped N lines` | 残余 = 让路行在流里位置后移(内容与直发逐字节相同);主机单测 `TC-CONLOG-170..179` 钉死 | +| ISR 直发撞写者发送中 | 与写者发送竞争 → 交错/丢失,不挂死(`IPSR != 0` 一律放行,§4.4.1 规则①) | 未计数 | Fault 行以 `[ERR]` 起头且整段在同一次 `con_tx_bounded` 内 → 按字节 grep 仍命中,不构成标记漏检;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` **调用图成环**,静态栈在每个任务上多叠一条日志尾巴) | 已知缺口:丢弃计数与故障计数不分离 | @@ -308,6 +339,7 @@ sequenceDiagram | 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 | **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..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 台架实证) @@ -332,7 +364,7 @@ sequenceDiagram | # | 项 | 说明 | |---|----|------| -| O1 | 未注册期裸发无互斥 | 启动窗口(内核启动 → attach)`dbg_console_write_raw` 无互斥 → 多任务并发会**逐字节交错**(非旧 `HAL_BUSY` 丢整行)。2026-10-10 已固化"周期任务用 `dbg_console_writer_ready()` 门控"纪律;若未来开机日志密度上升,可升级为"内核前静态待发行 + attach 后统一补发"或给裸发加轻量互斥 | +| 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) | diff --git a/test/host/console_log/console_log_test.c b/test/host/console_log/console_log_test.c index 9637f392..867948b4 100644 --- a/test/host/console_log/console_log_test.c +++ b/test/host/console_log/console_log_test.c @@ -2,9 +2,12 @@ * 模块名称:控制台单写者日志(console_log)主机侧单元测试 * 模块功能:在 PC 上离线验证 App/log/console_log.c 的单写者契约(G1~G5 / K1~K5,见 * docs/子系统设计/控制台单写者日志设计说明.md):环形缓冲入环与分块发送、 - * 行级原子、192B 截断、写者未注册/ISR 上下文的无锁直发、缓冲满有界等待与 + * 行级原子、192B 截断、写者未注册/ISR 上下文的直发(未注册期自 2026-10-26 + * 起是**整行独占**,不再是"无锁":M 族 TC-CONLOG-170..179 钉"别的写者插不进 + * 一行、让路行逐字节等同直发、让位不丢、真溢出才 [ERR]")、缓冲满有界等待与 * 丢弃可观测、TX 僵尸通道软复位自愈 + 300ms 冷却、DWT 与指令计数两种硬上界、 - * huart1.Instance 未初始化的 NULL 短路、fputc 重定向、drain 250ms 预算。 + * huart1.Instance 未初始化的 NULL 短路、fputc 重定向、drain 250ms 预算、 + * 整行所有权(行级单一所有者,道G task-23)。 * * 为什么要主机侧单测(而不是只用台架): * 本模块的风险全在"异常时刻"——UART 僵尸通道卡死、TIM7 停摆导致 uwTick 不往前走、 @@ -63,7 +66,16 @@ * 四条全退回 -> 红 22 条(再红 TC-CONLOG-140/141/143/145)。 * 改回修复版即 127/127 全绿;每一步的失败集合都是上一步的超集,说明四组断言各自 * 咬住自己的那条语义,没有互相顶包。 - * 也就是说:这四组断言不是"写在假件规则上"的自证,而是真能咬住生产语义的。 + * 2026-10-26(道G task-23,行级单一所有者)另做四处注入,逐个单独注入、各自归因、 + * 每次改完逐字节还原(152 条基线复跑绿): + * 取消整行独占(退回"无条件直发") -> 红 11 条(含 178 = 台架原文形态那条行); + * 让路改成静默丢弃(不入环也不计数) -> 红 5 条(170b/170c/171/178/179b); + * 归还所有权时不比属主(两道卫兵都拆) -> 红 1 条(179c;只拆外层里层仍守住时 + * 测试全绿是**正确结果**,不是漏测); + * 把整段发送包进临界区(关中断 7.8ms) -> 红 3 条(015/015b/177 与 176)。 + * ⚠ 顺带记一条注入方法论:第一版"取消整行独占"的锚点在 raw 与 drain 里各出现一次, + * 被写入前的唯一性守卫挡下——模糊锚点会一改变两个地方,等于同时测两个变异。 + * 也就是说:这些断言不是"写在假件规则上"的自证,而是真能咬住生产语义的。 * * 故意没测(逐行说明见交付报告): * - console_log.c:383 唯一未覆盖行:snprintf 溢出后的钳位 `n = sizeof(note)-1`。 @@ -115,6 +127,9 @@ int con_uart_txe_ready_left; int con_uart_tc_after; int con_uart_tc_stuck; int con_uart_pending; +uint32_t con_uart_tx_hook_at; /* 道G task-23:第 N 个字节上线后触发一次 */ +void (*con_uart_tx_hook)(void); +uint32_t con_uart_crit_at_tx_max; /* 上线字节所见最大临界区深度(硬红线水位) */ uint32_t con_dwt_step; int con_dwt_ctrl_sticky; @@ -307,7 +322,16 @@ static void reset_all(void) s_con_writer_up = 0U; s_con_dwt_up = 0U; s_con_writer_task = 0; + /* 道G task-23 的整行所有权凭证:跨场景必须显式清,否则上一场景留下的持有者会让 + * 下一场景的裸发全部走环,症状正是"单独跑绿、连跑偶红" */ + s_con_tx_owner = 0; + s_con_tx_depth = 0U; + /* 一次性把手先归零:上一条用例装填的钩子如果留在这儿,会把"第二个生产者"塞进 + * 本条用例的某个随机字节之后(那种红比真缺陷还难查) */ + con_uart_tx_hook_at = 0U; + con_uart_tx_hook = 0; + con_uart_crit_at_tx_max = 0U; con_uart_reset(); con_dwt_reset(); @@ -528,8 +552,21 @@ static void tc_unattached(void) CHECK(tx_is(0U, "hello"), "TC-CONLOG-012 direct send reaches the wire verbatim (never went through the ring)"); CHECK(used_now() == 0U, "TC-CONLOG-013 direct send does not enter the ring (head == tail)"); CHECK(con_evt_sets == 0U, "TC-CONLOG-014 direct send does not set SYS_EVT_CONSOLE_TX (no writer to wake yet)"); - CHECK(con_crit_enter == 0U, - "TC-CONLOG-015 direct send uses zero critical sections = the 2026-10-10 byte-interleaving root cause (not whole-line loss)"); + /* ⚠ 本条判据在道G task-23(2026-10-26)**换了钉法**,这里留全过程:旧写法断言 + * "裸发用 0 次临界区",钉住的正是当时的**缺陷机理本身**(零互斥 => 多个任务可以 + * 在同一行里互相插字节;台架 20261012_B6 的 "st[art:" 与本次 protectTask 未标定 + * 行内多出 "[" "2" "0" 三字节都是它)。修复后裸发要取"整行所有权",取与还各一次、 + * 每次只有几条指令的 test-and-set,所以计数从 0 变 2。 + * 换来两条更硬的判据(缺一条就该红),断言总数只增不减: + * ① 每个上线字节都发生在临界区**之外**(con_uart_crit_at_tx_max == 0)——整行 + * 独占绝不允许把 @115200 约 7.8 ms 的发送包进关中断区,那等于往启动窗口塞 + * 7.8 ms 中断延迟与最坏喂狗抖动,比交错本身更糟(Lead 红线); + * ② 全程零次 vTaskDelay:拿不到所有权就当场让路,不排队(见 TC-CONLOG-170 族)。 + */ + CHECK((con_crit_enter == 2U) && (con_crit_exit == 2U) && (con_crit_max_depth == 1U), + "TC-CONLOG-015 direct send takes exactly two few-instruction critical sections (claim + return) and never nests them"); + CHECK((con_uart_crit_at_tx_max == 0U) && (con_delay_calls == 0U), + "TC-CONLOG-015b whole-line ownership puts every wire byte outside any critical section and never waits"); reset_all(); memset(big, 'B', sizeof(big)); @@ -1171,6 +1208,179 @@ static void tc_check_then_act(void) "TC-CONLOG-167 no producer-side ring access ever happened outside a critical section"); } +/* ===================== M 整行所有权(道G task-23,O1 第二个实例) ===================== */ + +/* 装进"A 行发送中途"的第二个生产者:换任务句柄后再裸发一行,精确模拟真实抢占 + * —— A(s_task_other)此刻持有整行所有权,B(s_task_writer)必须让路。 + * 这是本套唯一能把"两个生产者并发时,写进 DR 的字节流里不得出现另一条行的字节" + * 做成必然失败断言的把手:修复后是**非阻塞**设计,永远不会调 vTaskDelay,所以既有的 + * con_delay_hook 在这条路径上根本不会被触发(拿它当注入点会静默地什么都测不到)。 + * 台架一手证据:docs/测试说明/boot_log_data/20261026_prod_console_interleave_evidence.txt + * —— PROT 行内多出 [ 2 0 三字节,紧接其后的 NAND_FTL 行恰好缺这三个字节的行首。 */ +/* 台架 16:37 那次被插字的原文行(boot_log_data/20261026_prod_console_interleave_evidence.txt + * OCCURRENCE 1 去掉三个外来字节之后的样子)。M 族拿它当"真实行长与真实前缀"的样本, + * 免得用例自己造一个不像现场的理想行。 */ +static const char s_prot_line[] = + "[2026-10-26 16:37:07][PROT] uncalibrated -> protection blocked" + " (UA/UN/IA VALID not all set)\r\n"; + +static void s_tx_second_writer(void) +{ + con_task_self = (TaskHandle_t)&s_task_writer; + dbg_console_write_raw("BBBBBB", 6U); + con_task_self = (TaskHandle_t)&s_task_other; +} + +static void tc_ownership(void) +{ + char fill[DBG_CON_LINE_MAX]; + uint32_t i; + + printf("-- TC-CONLOG-170..179 whole-line ownership (O1 instance 2, bench byte account) --\n"); + + /* 170 A 行发到第 4 个字节时被 B 抢占:A 逐字节完整,B 整行让路入环 */ + reset_all(); + con_uart_tx_hook_at = 4U; + con_uart_tx_hook = s_tx_second_writer; + dbg_console_write_raw("AAAAAAAA", 8U); + CHECK(tx_is(0U, "AAAAAAAA") && (con_uart_tx_n == 8U), + "TC-CONLOG-170 the line already on its way out stays byte-intact when a second producer preempts mid-line"); + CHECK(used_now() == 6U, + "TC-CONLOG-170b the preempted producer books its whole line into the ring instead of spraying bytes into another line"); + CHECK((dbg_console_dropped() == 0U) && (con_delay_calls == 0U), + "TC-CONLOG-170c yielding is neither a drop nor a wait (this is what the earlier whole-line-loss variant would have cost)"); + + /* 171 判据⑦:延后发出去的内容 = 当场直发本会产出的同一批字节(同时间戳/单前缀/单 CRLF) */ + con_uart_tx_hook = 0; + con_task_self = (TaskHandle_t)&s_task_writer; + dbg_console_writer_attach(); + writer_drain(); + CHECK((con_uart_tx_n == 14U) && tx_is(8U, "BBBBBB") && (used_now() == 0U), + "TC-CONLOG-171 the deferred line reaches the wire byte-identical to immediate sending (no second prefix, no re-stamped time, single CRLF)"); + + /* 172 抢不到所有权 + 环也放不下 -> 才计真丢弃(真溢出必须仍然看得见) */ + reset_all(); + (void)memset(fill, 0x58, sizeof(fill)); + con_task_self = (TaskHandle_t)&s_task_writer; + dbg_console_writer_attach(); + con_task_self = (TaskHandle_t)&s_task_other; + CHECK(dbg_console_tx_begin() == 1U, "TC-CONLOG-172 a task can take whole-line ownership while the wire is free"); + con_task_self = (TaskHandle_t)&s_task_writer; + for (i = 0U; i < 21U; i++) { + (void)dbg_console_write_line(fill, sizeof(fill)); /* 21*192=4032,环可用 4095 -> 只剩 63 字节 */ + } + CHECK((used_now() == (21U * (uint32_t)DBG_CON_LINE_MAX)) && (con_uart_tx_n == 0U), + "TC-CONLOG-172b the writer yields the whole round while another task owns the wire: nothing sent, nothing dequeued"); + dbg_console_write_raw(fill, 70U); + CHECK((dbg_console_dropped() == 1U) && (con_uart_tx_n == 0U) + && (used_now() == (21U * (uint32_t)DBG_CON_LINE_MAX)), + "TC-CONLOG-172c only a line that can neither own the wire nor fit the ring counts as a real drop, and it never half-sends"); + con_task_self = (TaskHandle_t)&s_task_other; + dbg_console_tx_end(); + writer_drain(); + CHECK((con_uart_tx_n >= (21U * (uint32_t)DBG_CON_LINE_MAX)) + && tx_contains("[ERR] CONSOLE dropped 1"), + "TC-CONLOG-172d once ownership frees up the deferred round goes out complete, and the true overflow surfaces as [ERR]"); + /* 172e 报出去就销账(既有语义,本卡不许退化):账清零 + 不重复报同一笔 */ + CHECK((dbg_console_dropped() == 0U) && (tx_count("[ERR] CONSOLE dropped 1") == 1U), + "TC-CONLOG-172e the drop ledger is settled only after the [ERR] note actually left the wire, and it is not reported twice"); + + /* 173 写者一轮 drain 被所有权挡住:让位 != 丢 */ + reset_all(); + con_task_self = (TaskHandle_t)&s_task_other; + (void)dbg_console_tx_begin(); + con_task_self = (TaskHandle_t)&s_task_writer; + dbg_console_writer_attach(); + (void)dbg_console_write_line("L1\r\n", 4U); + CHECK((con_uart_tx_n == 0U) && (used_now() == 4U), + "TC-CONLOG-173 while another task owns the wire the writer sends nothing and keeps every byte queued"); + writer_drain(); + CHECK((con_uart_tx_n == 0U) && (used_now() == 4U) && (dbg_console_dropped() == 0U), + "TC-CONLOG-173b yielding a round is not a drop: the tail never advances, so no ledger moves"); + con_task_self = (TaskHandle_t)&s_task_other; + dbg_console_tx_end(); + CHECK(s_con_tx_owner == 0, + "TC-CONLOG-173c ownership is really returned (a leaked token would push every later line into the ring forever)"); + writer_drain(); + CHECK(tx_is(0U, "L1\r\n") && (used_now() == 0U) && (dbg_console_dropped() == 0U), + "TC-CONLOG-173d the deferred round goes out complete as soon as the wire is free"); + + /* 174 同任务嵌套(写者一轮里再裸发留痕行)不得自我丢弃 */ + reset_all(); + con_task_self = (TaskHandle_t)&s_task_writer; + (void)dbg_console_tx_begin(); + dbg_console_write_raw("SEGA", 4U); + dbg_console_write_raw("SEGB", 4U); + dbg_console_tx_end(); + CHECK((con_uart_tx_n == 8U) && tx_is(0U, "SEGASEGB") && (dbg_console_dropped() == 0U) + && (used_now() == 0U), + "TC-CONLOG-174 nested raw writes by the owner go out at once, in order, with zero drops"); + con_task_self = (TaskHandle_t)&s_task_other; + dbg_console_write_raw("NEXT", 4U); + CHECK((con_uart_tx_n == 12U) && tx_is(8U, "NEXT") && (used_now() == 0U), + "TC-CONLOG-174b after tx_end the next task owns the wire again (no permanent ring detour)"); + + /* 175 异常上下文:放行,不等、不计丢弃(崩溃遗言必须能出去) */ + reset_all(); + con_task_self = (TaskHandle_t)&s_task_other; + (void)dbg_console_tx_begin(); + con_ipsr = 1U; + dbg_console_write_raw("[ERR] HARD", 10U); + CHECK((con_uart_tx_n == 10U) && tx_is(0U, "[ERR] HARD"), + "TC-CONLOG-175 an exception context sends at once even while a task owns the wire (crash logs are the last words)"); + CHECK((dbg_console_dropped() == 0U) && (used_now() == 0U) + && (s_con_tx_owner == (TaskHandle_t)&s_task_other), + "TC-CONLOG-175b the bypass neither counts a drop nor touches the owner token (that would free a line in flight)"); + con_ipsr = 0U; + + /* 176 调度器未启动:单执行流,不取凭证、不开临界区、一条不丢 */ + reset_all(); + con_task_self = 0; + (void)dbg_console_tx_begin(); + dbg_console_write_raw("EARLY", 5U); + CHECK((con_uart_tx_n == 5U) && tx_is(0U, "EARLY") && (dbg_console_dropped() == 0U) + && (s_con_tx_owner == 0) && (con_crit_enter == 0U), + "TC-CONLOG-176 before the scheduler starts, the single execution stream sends directly, taking no token and no critical section"); + + /* 177 硬红线:整行独占绝不能把 @115200 约 7.8 ms 的发送包进关中断区 */ + reset_all(); + con_task_self = (TaskHandle_t)&s_task_writer; + dbg_console_writer_attach(); + (void)dbg_console_write_line(s_prot_line, (unsigned)strlen(s_prot_line)); + writer_drain(); + CHECK((con_uart_tx_n == (uint32_t)strlen(s_prot_line)) && (con_uart_crit_at_tx_max == 0U) + && tx_is(0U, s_prot_line), + "TC-CONLOG-177 every byte reaches the wire with interrupts enabled (ownership never wraps the transmit)"); + + /* 178 台架原文形态复演:那条被插进 [20 的 PROT 行,现在要么完整、要么整条让路 */ + /* 逐字节照抄台架那一行的原文(含时间戳前缀与 CRLF),长度交给 strlen 现算: + * 手数字节数自己就踩过一次坑(把 93 数成 39,用例先红的是我的算术) */ + reset_all(); + con_uart_tx_hook_at = (uint32_t)strlen(s_prot_line) - 30U; + con_uart_tx_hook = s_tx_second_writer; + dbg_console_write_raw(s_prot_line, (unsigned)strlen(s_prot_line)); + CHECK((con_uart_tx_n == (uint32_t)strlen(s_prot_line)) && tx_is(0U, s_prot_line) + && (used_now() == 6U) && (dbg_console_dropped() == 0U), + "TC-CONLOG-178 the exact bench shape (that PROT line, interrupted mid-transmit) comes out unmangled: the [20 insertion is structurally gone"); + + /* 179 begin 失败不吞字、不越权归还 */ + reset_all(); + con_task_self = (TaskHandle_t)&s_task_other; + (void)dbg_console_tx_begin(); + con_task_self = (TaskHandle_t)&s_task_writer; + CHECK(dbg_console_tx_begin() == 0U, + "TC-CONLOG-179 tx_begin reports failure instead of blocking when another task owns the wire"); + dbg_console_write_raw("FREE", 4U); + CHECK((con_uart_tx_n == 0U) && (used_now() == 4U) && (dbg_console_dropped() == 0U), + "TC-CONLOG-179b a failed begin still delivers the caller bytes (deferred, never swallowed)"); + dbg_console_tx_end(); + CHECK(s_con_tx_owner == (TaskHandle_t)&s_task_other, + "TC-CONLOG-179c a non-owner tx_end cannot release someone else ownership (that would re-open this very hole)"); + con_task_self = (TaskHandle_t)&s_task_other; + dbg_console_tx_end(); + CHECK(s_con_tx_owner == 0, "TC-CONLOG-179d the real owner releases normally"); +} + /* ===================== main ===================== */ int main(void) { @@ -1189,6 +1399,7 @@ int main(void) tc_counted_paths(); tc_three_ledgers(); tc_check_then_act(); + tc_ownership(); printf("=== Console Log Tests: %d/%d PASSED (failed=%d) ===\n", s_passed, s_total, s_failed); diff --git a/test/host/console_log/stub/stm32f4_fake.h b/test/host/console_log/stub/stm32f4_fake.h index 2fb6b672..ab46bc50 100644 --- a/test/host/console_log/stub/stm32f4_fake.h +++ b/test/host/console_log/stub/stm32f4_fake.h @@ -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) {