Files
STM32F4-Base/App/log/console_log.c
T
wandering1 4a4ad6bc5f chore(全仓): @build/@test 旧文档别名清退为技能调用名
现状口径:/keil-build 与 /serial-boot-test 是 DSH 会话的技能调用名,
不是 shell 命令;「名 + 命令行参数」的命令形态改为可执行体
run_boot_test.py 的真实调用。

- AGENTS.md:别名说明重写为「技能调用名不是 shell 命令」,两条目改
  「编译(/keil-build)」「串口回归(/serial-boot-test)」
- keil-build / serial-boot-test 两 skill 调用名与示例同步;task-worktree
  方案文档去掉 @build/@test 惯例从句;@task 保留(不在点名范围)
- 123 文件现状陈述全改;台架捕获、变更记录/时间线、rename 映射输入
  等 41 行历史证据保留旧名
- gen_memory_svg.py 6 处措辞随改,3 张 SVG 产物同批重跑
- 顺手补并行方案同行漏网计数 51→52 钩子

验证:提交期快钩子对 123 文件实跑全 Passed;check_doc_links --all
rc=0(3038 处、无法判定 0);check_mermaid --all rc=0;YAML 解析 52
钩子不变;skill-size rc=0;py_compile 全过;字节卫生零漂移。
2026-10-30 13:03:12 +08:00

838 lines
46 KiB
C
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.
/*
* 模块名称:Console Single-Writer Logging — 控制台单写者日志(RS232#1 / UART5)实现
* 模块功能:控制台单写者日志的环形缓冲 + 唯一写者发送实现(API 见 console_log.h);
* 兼载 MicroLIB printf 底层 fputc 重定向
* 适用平台:STM32F407ZGTx(仅 MCU 端)
* 作者:王建锋
* 创建日期:2026-09-24
* 修改记录:
* 2026-09-24 王建锋 创建(自 Src/usart.c USER CODE 区迁出,实现逻辑不变,
* 仅变更文件位置:HAL 库生成文件不得包含应用代码)
* 2026-10-02 王建锋 僵尸通道喂狗节奏保护(台架日志代次 288 实证):第 3 轮的
* "单次 drain 预算 250ms" 只限单次调用——ORE 锁存在外设软复位后
* 毫秒级重锁(PA10 悬空 + RX 使能),背靠背 drain 每轮再耗 100ms
* 块超时 → 主循环周期拉到 450~500ms → 100ms 节流的喂狗抽样间隙
* 超过 IWDG 最坏容限 1.7s → 停喂 → 误复位(且心跳位全员健康、
* 无 stall 日志,纯节奏性饥饿)。修复:发送故障后立即结束本轮
* drain 并进入 300ms 发送冷却(冷却期内 drain 直接让位主循环),
* 保证喂狗节奏 ≤ ~300ms。根治(把 USART1 改成只发不收)已于 2026-10-26
* 由用户裁定不做,本缓解即长期方案;机制详述 docs/子系统设计/控制台单写者日志设计说明.md §12。
* 2026-10-02 王建锋 第 8b 轮加固:发送超时判据改用 DWT CYCCNT(时间源无关)。
* 台架第 8a 轮取证(见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录)定位到:defaultTask
* 冻结在 console 突发行内、无 STALL、复位为 IWDGRSTF。唯一自洽机理 =
* HAL_UART_Transmit 的超时判据 `HAL_GetTick() - Tickstart` 依赖
* TIM7 的 uwTick —— TIM7 一旦停摆,超时永不成立 → 写者死等 TXE →
* 唯一喂狗者停摆 → IWDG 复位。本改动给"裸发送"与"缓冲满等待"两处
* 都换成 DWT 周期计数硬上界(DWT 使能失败时退回旧 HAL 行为)。
* 另加 USART1 未初始化(huart1.Instance == NULL)短路:防止在
* HAL_Init 早期/断言-故障上报路径上解引用 NULL 读写地址 0。
* 2026-10-10 王建锋 新增 dbg_console_writer_ready() 查询:写者未注册时
* con_append 走无锁直发(前提是"此窗口只有 defaultTask 记日志"),
* 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 那一拍时冷却等于没记);
* con_append 的"复核空位 + 整行拷贝 + 推进 head"收进同一临界区(旧写法
* 判据在区外,多生产者可在环将满时同时过判据 -> head 越过容量把未发出
* 的旧行静默覆盖,既不报错也不计丢弃);
* drain 失败分诊:Instance==NULL(未初始化)不再走外设软复位自愈(那会
* 在 HAL 未就绪时重配 RCC/GPIO),改记 dropped;僵尸通道仍自愈;
* 三本账分开(docs §13-O2):新增 dbg_console_tx_timeout(),dropped 只记
* 真丢的,tx_fault 仍只记软复位自愈次数;[ERR] dropped 行改为"发成功才
* 销账",通道不可用期间不再把丢弃证据一起冲掉。
* 2026-10-28 王建锋 **根因归因更正(纯注释,无行为变更)**:原注释把 ORE 锁存
* 写成"PA10 悬空 / 未接线",用户确认 PA10 **一直是接线的**,该归因不成立。
* 真正的充分条件是"**RX 使能了却从不读 DR**"——线上任意一个字节或一个
* 噪声沿使 RXNE 置位后无人读,下一个事件即锁存 ORE(ORE 只能靠"先读 SR
* 再读 DR"清除 ⇒ 永不自愈);引脚悬空只影响发生率,不是必要条件。
* 缓解措施与结论不变,仅改注释;机制详见
* docs/子系统设计/控制台单写者日志设计说明.md §12。
*/
#include "console_log.h"
#include <stdio.h>
#include "usart.h"
#include "FreeRTOS.h"
#include "task.h"
#include "cmsis_os.h"
#include "sys_events.h"
/* 刻意不 include "sys_log.h":日志子系统不得回调本模块(调用图成环 →
* armlink 静态栈深在每个任务上虚高,见 dbg_console_drain 故障路径注释)。
* TX 故障留痕改由写者任务在 drain 之外补记 */
/* ===== 控制台单写者日志(2026-09-24 加固,HardFault 定位结论驱动)=====
* 背景:旧路径 DBG_* → printf → fputc **每字符一次** HAL_UART_Transmit。HAL 的锁只保证
* "单字节"原子,任务切换发生在字符之间 → 两行日志逐字符交错(2026-09-24 崩溃前
* 实测:"[2026-[2026-0909-24 15:2-24 15:27:37:39]…");更严重的是字符串指针一旦
* 被破坏,脏指针会直接进入 UART 缓冲遍历(同日 HardFault:BFAR=0x10010000 恰为
* CCM 末尾+1,PC 落在 HAL_UART_Transmit 的缓冲读取处)。
* 架构:**defaultTask 是唯一串口发送者**;其余任务/上下文只把文本写入环形缓冲。
* ① 行级原子:整行在一次临界区内拷入环形缓冲,写者按块一次 HAL_UART_Transmit
* 发出 → 行与行不可能再交错(UART 只有这一个调用点);
* ② 指针可控:进 UART 的缓冲恒为本文件的环形缓冲,调用方脏指针再也到不了 UART;
* ③ 写者未注册(内核启动早期/裸机)或中断上下文:回退直接发送,但**整行独占**
* 由 con_tx_try_claim() 保证(道G task-23:未注册期原来就是"多个任务都直发、
* 零互斥",O1 两个实例都出在这里);抢不到独占的那条行不丢,落环形缓冲延后发,
* 与当场直发逐字节相同。ISR / 调度器未启动两种上下文放行(不许等)。
* 约束:不新增任务、不动态分配;环形缓冲为 BSS(SRAM1,非 CCM),无 DMA 可达性冲突。
*/
/* ===== 控制台端口(单点收口)=====
* 换口只改这一段:句柄 / IRQn / 初始化函数。时钟宏随句柄走——UART5 在 APB1、
* USART1 在 APB2,别照抄;串口模式与波特率在 Src/usart.c 的 MX_*_Init 里,本模块不管。
* 控制台在 RS232#1/UART5(USART1 专用给屏板链路——700 kbaud 只有它 MCU 引脚直出)。
* 端口排布、台架端子与判据见 docs/子系统设计/屏板链路设计说明.md 的 §2 与 §3.2。 */
#define CON_UART huart5
#define CON_UART_IRQN UART5_IRQn
#define CON_UART_CLK_DISABLE() __HAL_RCC_UART5_CLK_DISABLE()
#define CON_UART_CLK_ENABLE() __HAL_RCC_UART5_CLK_ENABLE()
#define CON_UART_INIT() MX_UART5_Init()
#define DBG_CON_RING_SIZE 4096U /* 环形缓冲字节数(必须为 2 的幂) */
#define DBG_CON_CHUNK_MAX 256U /* 写者单次 HAL_UART_Transmit 字节上限 */
#define DBG_CON_WAIT_MS 200U /* 缓冲满时生产者最长等待(ms),仍满则丢弃并计数 */
#define DBG_CON_TX_TIMEOUT_MS 100U /* 单次发送超时:256B 块 @115200 标称 22ms;僵尸通道限时 + 自愈 */
#define DBG_CON_DRAIN_BUDGET_MS 250U /* 单次 drain 连续发送上限:让位喂狗轮询(缺失容忍 800ms) */
#define DBG_CON_FAULT_COOL_MS 300U /* 故障后发送冷却:ORE 锁存毫秒级重锁,背靠连发无益
且拖长主循环周期 → 喂狗节奏饥饿(台架日志代次 288 实证);
冷却期内 drain 只让位、不发送,下轮重试 */
/* ==== 第 8b 轮:与时间源无关的硬上界(DWT CYCCNT)====
* HAL_UART_Transmit 的超时判据是 `HAL_GetTick() - Tickstart > Timeout`,而 HAL 时基
* = TIM7 的 uwTick。TIM7 停摆 → 超时永不成立 → 写者死等 TXE → 唯一喂狗者停摆 →
* IWDG 误复位(第 8a 轮台架现场)。改用 DWT CYCCNT(内核硬件周期计数,不依赖任何
* 中断/uwTick)做判据;DWT 使能失败时退回 HAL_UART_Transmit(旧行为,功能不退化)。 */
#define CON_TX_TIMEOUT_CYC (SystemCoreClock / 10U) /* 发送硬上界 = 100ms 对应周期数 */
/*
* DWT 不可用时的**指令计数**上界(替代 HAL_UART_Transmit 回退,2026-10-10)。
* 为什么不再回退 HAL:① HAL_UART_Transmit 的超时判据是 HAL_GetTick——断言/停摆
* 现场 tick 未必在走,等待无上界;② 它的调用链 `HAL_UART_Transmit →
* UART_WaitOnFlagUntilTimeout` 会被 armlink 挂进**每个**碰日志的任务的最坏链
* (configASSERT 在 FreeRTOS 内部可达 → 三个内核钩子都在链尾),实测每条链多背
* 64 B 栈。计数上界同样有界、零外部依赖。
* 取值:115200 bps 下一字节 87 µs;轮询循环 ~5 周期 → 每字节约 3000 次迭代,
* 取 20000 留 6 倍余量(单字节卡死最多数毫秒级即放弃,不无限自旋)
*/
#define CON_TX_GUARD_MAX 20000u
#define CON_APPEND_WAIT_CYC (SystemCoreClock / 5U) /* 缓冲满等待硬上界 = 200ms 对应周期数 */
static uint8_t s_con_ring[DBG_CON_RING_SIZE];
static volatile uint16_t s_con_head; /* 生产者写入位置 */
static volatile uint16_t s_con_tail; /* 写者读取位置 */
static volatile uint32_t s_con_drop; /* 真没落地的丢弃数(一次一行/一块) */
static volatile uint32_t s_con_tx_fault; /* TX 失败(僵尸通道)次数,均经外设软复位自愈 */
static volatile uint8_t s_con_tx_fault_log; /* TX 故障留痕待上报(由写者任务在 drain 之外补记 run.log) */
static volatile uint32_t s_con_cool_until; /* 故障冷却截止 tick(只存时刻,不再兼作哨兵) */
static volatile uint8_t s_con_cooling; /* 1 = 正在故障冷却(独立标志:tick 恰好等于 0 的那一拍也必须算在冷却) */
static volatile uint32_t s_con_tx_timeout; /* 发送块超时次数(写者排空所见,与丢弃账分开) */
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 第二实例):控制台串口数据寄存器同一时刻只允许
* 一个上下文在逐字节轮询写入。NULL = 空闲。
* ⚠ 这不是"锁",是**所有权凭证**:拿不到就当场让路,绝不排队等待(见
* con_tx_try_claim 函数头:裸发一行 @115200 约 7.8 ms,把它包进临界区等于给
* 启动窗口塞进 7.8 ms 的中断延迟与最坏喂狗抖动,比交错本身更糟)。 */
/*
* 本模块"只有几条指令"的关中断窗口(道G task-26 收口时定,2026-10-26)
* ---------------------------------------------------------------------
* 刻意**不用** taskENTER_CRITICAL()/taskEXIT_CRITICAL(),改用 CMSIS 的 PRIMASK
* 保存 + 恢复。理由是一条只在**静态调用图**上看得见、真机上却很贵的坑:
* taskEXIT_CRITICAL() => vPortExitCritical() => configASSERT()
* => vApplicationAssertFailed() (本仓断言钩子自 2026-09-29 起就调
* dbg_console_write_raw 上报 file:line)
* => 又回到本模块 => 又进临界区
* armlink 因此在本模块周围长出三条互递归环(con_tx_try_claim => vPortExitCritical、
* dbg_console_write_raw => con_tx_release、con_ring_try => vPortExitCritical),并把
* 这坨算进**每一条含临界区的任务链**:实测 vApplicationAssertFailed 的 Max Depth 从
* 约 40 B 变 128 B + In Cycle,defaultTask 3,040->3,120、netTask 1,920->2,000、
* iec104Task 1,464->1,544 —— 三条同涨 80 B 就是"加深在共享帧上"的铁证,iec104 的
* margin 因此掉到 1,016 < 1,024 ISR 底线。
* 换成 PRIMASK 之后:
* ① 三个原语都是 core_cmFunc.h 里的静态内联 —— 零调用帧、零断言,那三条环不成立;
* ② **屏蔽面变宽是有意的**:本 port(RVDS/ARM_CM4F)的临界区走 BASEPRI,只挡
* 优先级数值 >= configMAX_SYSCALL_INTERRUPT_PRIORITY 的中断,PRIMASK 连更高
* 优先级的也一起挡(NMI 与 HardFault 除外)。这是**语义变强**,不是变弱;窗口
* 只有几条指令(一次 test-and-set / 一次清属主 / 一次整行入环拷贝,与换之前
* 同宽度同时长),代价是几微秒级的额外屏蔽,读代码的人必须知道这一点。
* ③ 不依赖调度器与 uxCriticalNesting,未注册期与中断里同样可用。
* ⚠ **为什么必须把 PRIMASK 存进局部变量、退出时原样写回,而不是直接 __enable_irq()**:
* 本模块的窗口可能嵌套在调用者已有的临界区里(断言/故障钩子、写者 drain 轮内的
* 留痕行都是)。无条件开中断 = **替别人把任务级临界区提前打开**,那是比交错严重
* 一个量级的竞态;保存/恢复则天然支持嵌套。保存量放局部而不是共享静态量,是因为
* 窗口可能被更高优先级上下文再次进入,共享量会被它改写、退出时就恢复错了。
* ⚠ 只允许包住"几条指令"的量级,**绝不允许**包住串口发送本身:一行 90 B @115200
* 约 7.8 ms,把它关进中断等于给启动窗口塞 7.8 ms 中断延迟与最坏喂狗抖动
* (红线与机器判据见 con_tx_bounded 函数头与主机单测 TC-CONLOG-015b 与 TC-CONLOG-177)。
*/
#define CON_CRIT_ENTER(pm_var) do { (pm_var) = __get_PRIMASK(); __disable_irq(); } while (0)
#define CON_CRIT_EXIT(pm_var) do { __set_PRIMASK(pm_var); } while (0)
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));
}
/*
* 函数功能:惰性使能 DWT 周期计数器(时间源无关的硬上界基准)
* 入口参数:无
* 返回值:无
* 限定条件:任意上下文可调(幂等;纯寄存器写,不创建任何内核对象)
* 函数说明:CoreDebug->DEMCR.TRCENA=1 → DWT->CYCCNT=0 → DWT->CTRL.CYCCNTENA=1;
* 使能成功置 s_con_dwt_up=1(供超时判据选择),失败保持 0(退回 HAL 超时)。
* 在 Cortex-M4 上 DWT 恒存在;失败仅可能出现在调试单元被硬件关闭时。
*/
static void con_dwt_ensure(void) {
if (s_con_dwt_up != 0U) {
return;
}
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
DWT->CYCCNT = 0U;
DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
if ((DWT->CTRL & DWT_CTRL_CYCCNTENA_Msk) != 0U) {
s_con_dwt_up = 1U;
}
}
/*
* 函数功能:DWT 不可用时的计数上界发送(每字节/TC 各数 CON_TX_GUARD_MAX 次)
* 入口参数:u - USART 寄存器基址(调用方已判非 NULL) USART_TypeDef *
* p_buf - 待发数据;len - 字节数
* 返回值:HAL_OK - 已发出;HAL_TIMEOUT - 计数上界内未完成(本块放弃)
* 限定条件:控制台串口已初始化
* 函数说明:刻意不用 HAL_UART_Transmit——理由见 CON_TX_GUARD_MAX 处注释
* (tick 依赖不可信 + 其调用链污染所有任务的最坏栈链)
*/
static HAL_StatusTypeDef con_tx_counted(USART_TypeDef *u, const uint8_t *p_buf, uint16_t len) {
uint32_t guard;
uint16_t i;
for (i = 0U; i < len; i++) {
guard = 0U;
while ((u->SR & USART_SR_TXE) == 0U) {
if (++guard >= CON_TX_GUARD_MAX) {
return HAL_TIMEOUT;
}
}
u->DR = (uint16_t)p_buf[i];
}
guard = 0U;
while ((u->SR & USART_SR_TC) == 0U) {
if (++guard >= CON_TX_GUARD_MAX) {
return HAL_TIMEOUT;
}
}
return HAL_OK;
}
/*
* 函数功能:有界裸发送(不依赖 uwTick / HAL 超时)
* 入口参数:p_buf - 待发数据;len - 字节数(调用方已限 ≤ DBG_CON_CHUNK_MAX)
* 返回值:HAL_OK - 已全部写入发送寄存器;HAL_TIMEOUT - 有界上界内未完成
* 限定条件:控制台串口已初始化
* 函数说明:寄存器级轮询 TXE/TC,超时判据用 DWT CYCCNT(CON_TX_TIMEOUT_CYC);
* DWT 未使能时走 con_tx_counted(指令计数上界,2026-10-10 取代
* HAL_UART_Transmit 回退)。本函数只做 TX,
* 不触碰 RX/错误标志——僵尸通道自愈由调用方按返回值处理。
* ⚠ 控制台串口未初始化(CON_UART.Instance == NULL,如 HAL_Init 早期或
* 断言/故障上报路径)时立即返回 HAL_ERROR:否则会解引用 NULL 去读写
* 地址 0(flash 别名区)→ 在故障处理里再触发故障(2026-10-02 台架实测)。
*/
static HAL_StatusTypeDef con_tx_bounded(const uint8_t *p_buf, uint16_t len) {
USART_TypeDef *u;
uint32_t t0;
uint16_t i;
con_dwt_ensure();
if (CON_UART.Instance == NULL) {
return HAL_ERROR; /* 控制台串口未初始化:无可用通道,安全让位(不是超时,不记超时账) */
}
if (s_con_dwt_up == 0U) {
return con_tx_counted(CON_UART.Instance, p_buf, len);
}
u = CON_UART.Instance;
t0 = DWT->CYCCNT;
for (i = 0U; i < len; i++) {
while ((u->SR & USART_SR_TXE) == 0U) {
if ((uint32_t)(DWT->CYCCNT - t0) >= CON_TX_TIMEOUT_CYC) {
return HAL_TIMEOUT; /* 有界:本块放弃(记账由调用方按返回值做) */
}
}
u->DR = (uint16_t)p_buf[i];
}
while ((u->SR & USART_SR_TC) == 0U) {
if ((uint32_t)(DWT->CYCCNT - t0) >= CON_TX_TIMEOUT_CYC) {
return HAL_TIMEOUT; /* TC 超时:字节已交给发送器,调用方分账 */
}
}
return HAL_OK;
}
/*
* 函数功能:尝试取得"控制台串口整行发送所有权"(**非阻塞**,道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;
uint32_t pm;
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;
}
CON_CRIT_ENTER(pm); /* 窗口选型与嵌套语义理由见 CON_CRIT_ENTER 定义处 */
if (s_con_tx_owner == NULL) {
s_con_tx_owner = me;
s_con_tx_depth = 1U;
got = 1U;
}
CON_CRIT_EXIT(pm);
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;
uint32_t pm;
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;
}
CON_CRIT_ENTER(pm);
if (s_con_tx_owner == me) {
s_con_tx_owner = NULL;
s_con_tx_depth = 0U;
}
CON_CRIT_EXIT(pm);
}
/*
* 函数功能:把"一条行分几段裸发"的调用方收成一个整行独占者(道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] 是 /serial-boot-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);
}
/*
* 函数功能:注册控制台写者(defaultTask 在进入主循环前调用一次)
* 函数说明:注册后生产者改走环形缓冲;未注册时直接阻塞发送(内核启动早期)
*/
void dbg_console_writer_attach(void) {
s_con_writer_task = xTaskGetCurrentTaskHandle();
s_con_writer_up = 1U;
}
/*
* 函数功能:查询控制台写者是否已注册(1=已注册,此后日志走环形缓冲行级原子路径)
* 返回值:s_con_writer_up(volatile 读,任意任务上下文可调)
* 限定条件:无
* 函数说明:供**周期性任务**在写者就绪前抑制自身日志。写者未注册时 con_append 走
* dbg_console_write_raw 无锁直发(设计前提是"此窗口只有 defaultTask 记日志"),
* 其余任务此时落日志会与 defaultTask 的存储初始化日志逐字节交错
* (2026-10-10 台架:adcTask 首帧日志与 [NAND_FTL] 初始化行交错)。
*/
uint8_t dbg_console_writer_ready(void) {
return s_con_writer_up;
}
/*
* 函数功能:控制台串口僵尸通道自愈(清错误锁存 + 整外设重初始化)
* 函数说明:**RX 使能了却从不读 DR** → 线上任意一个字节或一个噪声沿使 RXNE 置位后无人读,
* 下一个事件即锁存 ORE(常伴 NE/FE/PE);ORE 只能靠"先读 SR 再读 DR"清除 ⇒ 永不自愈。
* 锁存后 HAL 轮询 TX 恒快速失败(控制台静默、系统不复位);发送器卡死状态则使 TXE
* 等待耗满超时(冻结唯一写者 → 看门狗误复位,2026-10-02 台架实证)。外设软复位清锁存与卡死状态后
* 重新初始化,故障时间被单块超时限定。
* ⚠ 2026-10-28 更正:原注释把根因写成"PA10 未接线 / 悬空",用户确认 PA10 一直是接线的;
* 充分条件是"RX 使能却不消费 DR",与引脚是否悬空无关
* (详见 docs/子系统设计/控制台单写者日志设计说明.md §12)。
*/
static void con_uart_reinit(void) {
__NVIC_ClearPendingIRQ(CON_UART_IRQN);
CON_UART_CLK_DISABLE();
CON_UART_CLK_ENABLE();
CON_UART.gState = HAL_UART_STATE_RESET;
CON_UART.RxState = HAL_UART_STATE_RESET;
CON_UART.ErrorCode = HAL_UART_ERROR_NONE;
(void)CON_UART_INIT();
}
/*
* 函数功能:排空环形缓冲(仅写者任务调用)
* 函数说明:按块一次 con_tx_bounded 发出(DWT 硬上界,不依赖 uwTick);每块完成后
* 再推进读指针(临界区保护);发送失败 → 外设软复位自愈(丢当前块);
* 单次调用最多连续发 DBG_CON_DRAIN_BUDGET_MS 后让位主循环,保证看门狗轮询
* 不被僵尸通道饿死
*/
void dbg_console_drain(void) {
uint16_t used;
uint32_t t0;
uint8_t held;
if (s_con_writer_up == 0U) {
return;
}
if (s_con_draining != 0U) {
return; /* 重入保护:写者自记录不触发嵌套 drain */
}
/* 整行所有权(道G task-23):本函数按块直写 CON_UART->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();
/* 故障冷却:僵尸通道(ORE 重锁)秒内不自愈,冷却期内不发送——
* 本轮 drain 直接让位主循环,保住喂狗节奏(日志代次 288 停喂误复位的
* 直接诱因 = 背靠连发的块超时链)。是否在冷却看 s_con_cooling 独立标志,
* **不再拿 tick 值当哨兵**(2026-10-24 实测:故障恰好落在 tick==0 那一拍时,旧写法
* 记出的 cool_until==0 等于没记冷却,下一轮立刻再耗一次 100ms 块超时)。
* 无符号差值跨 49.7 天回绕安全 */
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;
}
while ((used = con_used()) != 0U) {
uint16_t tail;
uint16_t contig;
uint16_t n;
HAL_StatusTypeDef st;
tail = s_con_tail;
contig = (uint16_t)(DBG_CON_RING_SIZE - tail); /* 到环尾的连续段 */
n = (used < contig) ? used : contig;
if (n > DBG_CON_CHUNK_MAX) {
n = DBG_CON_CHUNK_MAX;
}
st = con_tx_bounded(&s_con_ring[tail], n);
taskENTER_CRITICAL();
s_con_tail = (uint16_t)((uint16_t)(s_con_tail + n) & (DBG_CON_RING_SIZE - 1U));
taskEXIT_CRITICAL();
if (st != HAL_OK) {
if (st == HAL_ERROR) {
/* 2026-10-24 修复:控制台串口 **未初始化**(CON_UART.Instance == NULL,
* 只可能出现在 HAL_Init 早期或断言/故障上报路径)与"僵尸通道"是两回事:
* 这里**绝不做外设重初始化**——那会在 HAL 尚未就绪时跑 MspInit 重配
* RCC/GPIO;也不计 tx_fault(那本账的语义是"软复位自愈次数")。
* 本块字节确实一个都没上线 -> 计入 dropped(只记真丢的),
* 末尾的 [ERR] dropped 行负责留痕;本轮到此为止(失败即止)。 */
s_con_drop++;
break;
}
/* 僵尸通道:软复位自愈 + 进入发送冷却(ORE 毫秒级重锁,
* 继续背靠连发只会把主循环耗在块超时链上);本块丢弃
* (行已出环,不可重放),本块在超时账与丢弃账上各记一笔 */
s_con_tx_timeout++; /* 新增账:块超时次数(TXE 与 TC 两条硬上界
* 都算,口径 = 写者排空时观察到的块超时) */
con_uart_reinit();
s_con_tx_fault++;
s_con_drop++; /* 确认不了的块算真丢(一次丢弃事件),
* 旧写法只涨 tx_fault -> 账上看不出日志在丢,
* docs §13-O2 记的正是这条 */
s_con_cool_until = (uint32_t)HAL_GetTick();
s_con_cooling = 1U;
if (s_con_tx_fault == 1U) {
static const char note[] = "\r\n[WARN] CONSOLE tx fault: UART reinit (self-healed)\r\n";
/* 刻意**不在这里**调 syslog_post():syslog_post → dbg_console_write_line
* → con_append → dbg_console_drain 会与本函数构成**调用图成环**。运行时
* 有 s_con_draining 重入标志挡着,不会真递归;但 armlink 静态调用图会把
* 整条日志尾巴在每个任务的最坏链上多叠一层(实测 6 个任务的最坏链末尾
* 全是这个环)→ 栈深数字虚高、真实风险反而被掩盖。
* 改法:只置标志,由写者任务(defaultTask 主循环,本就是 syslog 唯一
* 写者)在 drain **之外**补记 run.log(见 dbg_console_tx_fault_log_take) */
s_con_tx_fault_log = 1U;
(void)dbg_console_write_raw(note, (unsigned)sizeof(note) - 1U);
}
break; /* 故障即止:本轮 drain 到此为止,让位主循环 */
}
if ((uint32_t)(HAL_GetTick() - t0) >= DBG_CON_DRAIN_BUDGET_MS) {
break; /* 预算用尽:让位主循环(看门狗轮询),下轮续排 */
}
}
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;
int n;
n = snprintf(note, sizeof(note), "\r\n[ERR] CONSOLE dropped %lu lines\r\n",
(unsigned long)d);
if (n > 0) {
if (n > (int)sizeof(note) - 1) {
n = (int)sizeof(note) - 1;
}
/* 只有**真发出去了才销账**(2026-10-24 一并修正):旧写法先把计数清零
* 再裸发,通道不可用/僵尸时那一发也失败 -> 丢弃证据被一起冲掉,[ERR] 行
* 永远出不来("永不静默"变成"永不显示")。留到下一轮或串口恢复后补发。 */
/* 所有权仍在手里(本凭证覆盖整轮,含这条留痕行) */
if (con_tx_bounded((const uint8_t *)note, (uint16_t)n) == HAL_OK) {
/* 按"本次报出去的这条"扣账而不是清零:期间生产者新丢的行留下轮报。
* 递减只发生在这一处(单写者 + s_con_draining 重入保护),生产者只增,
* 故 s_con_drop >= d 恒成立,无需再写下溢分支(写了也永远测不到)。 */
s_con_drop -= d;
}
}
}
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)
{
uint32_t pm;
uint8_t fitted = 0U;
/* 窗口用 PRIMASK 而不是 taskENTER_CRITICAL():后者会经 vPortExitCritical 里的
* configASSERT 接回断言钩子,再回到本模块,静态调用图上长出一条环,armlink 把它
* 整条记进**每一条含临界区的任务链**(实测三条链同涨 80 B)。理由全文见
* CON_CRIT_ENTER 定义处。屏蔽时长与换之前一致(就是这一次整行拷贝)。 */
CON_CRIT_ENTER(pm);
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;
}
CON_CRIT_EXIT(pm);
return fitted;
}
/*
* 函数功能:把一段文本放入控制台(写者已注册)/直接发出(未注册或中断上下文)
* 返回值:0 - 已入缓冲或已直发;-1 - 缓冲满丢弃(计入 dbg_console_dropped)
* 函数说明:生产者不持锁等待过久:最多 DBG_CON_WAIT_MS,仍满则丢弃该行;
* "复核空位 -> 整行拷贝 -> 推进 head"是一个原子的临界区动作;
* 环满等待(vTaskDelay / DWT 判据)一律在临界区之外,区内绝不睡眠。
* 写者自身记录日志时先排空缓冲,避免自等。
* 等待上界优先用 DWT 真实时间(CON_APPEND_WAIT_CYC,时间源无关)——
* tick 停摆时 vTaskDelay 本身也会停,故不能用它作唯一判据(第 8b 轮加固)。
*/
static int con_append(const char *buf, uint16_t len) {
uint32_t waited = 0U;
uint32_t d0 = 0U;
uint8_t have_dwt = 0U;
if ((buf == NULL) || (len == 0U)) {
return -1;
}
if ((s_con_writer_up == 0U) || (__get_IPSR() != 0U)) {
dbg_console_write_raw(buf, len);
return 0;
}
if (len > (DBG_CON_RING_SIZE / 2U)) {
len = (uint16_t)(DBG_CON_RING_SIZE / 2U); /* 超长保护(正常路径不会触发) */
}
if ((xTaskGetCurrentTaskHandle() == s_con_writer_task) && (s_con_draining == 0U)) {
dbg_console_drain(); /* 写者自己记录:先腾空,避免自等(drain 进行中则跳过,防嵌套) */
}
con_dwt_ensure();
if (s_con_dwt_up != 0U) {
d0 = DWT->CYCCNT;
have_dwt = 1U;
}
for (;;) {
/* 判据与拷贝都在 con_ring_try 的那一个临界区里完成(函数头有为什么);
* 本函数只负责"等多久"——等待一律在区外,区内绝不睡眠 */
if (con_ring_try(buf, len) != 0U) {
break;
}
if (have_dwt != 0U) {
if ((uint32_t)(DWT->CYCCNT - d0) >= CON_APPEND_WAIT_CYC) {
s_con_drop++;
return -1;
}
} else if (waited >= DBG_CON_WAIT_MS) {
s_con_drop++;
return -1;
}
vTaskDelay(1);
waited++;
}
if (sys_evtHandle != NULL) {
(void)osEventFlagsSet(sys_evtHandle, SYS_EVT_CONSOLE_TX); /* 唤醒写者 */
}
return 0;
}
/*
* 函数功能:把一行已格式化的文本交给单写者控制台(生产者入口,**非可变参数**)
* 入口参数:line - 整行文本(含结尾换行);len - 字节数
* 返回值:写入字节数;-1 - 参数非法或被丢弃
* 函数说明:整行一次入环形缓冲 → 行级原子(不会与他行交错);调用方缓冲随即可复用。
* 刻意不用自研可变参数函数(规范 S12 7.6);格式化由 DBG_PRINT 宏内
* 库 snprintf 在调用点完成。
*/
int dbg_console_write_line(const char *line, unsigned len) {
if ((line == NULL) || (len == 0U)) {
return -1;
}
if (len > DBG_CON_LINE_MAX) {
len = DBG_CON_LINE_MAX; /* 截断(丢尾,保开头的标记信息) */
}
if (con_append(line, (uint16_t)len) != 0) {
return -1;
}
return (int)len;
}
/*
* 函数功能:已丢弃的日志行数(诊断用)
*/
uint32_t dbg_console_dropped(void) {
return s_con_drop;
}
/*
* 函数功能:已发生的 TX 故障(僵尸通道自愈)次数(诊断用)
*/
uint32_t dbg_console_tx_fault(void) {
return s_con_tx_fault;
}
/*
* 函数功能:已发生的发送块超时次数(诊断用)
* 返回值:块超时累计次数 uint32_t
* 限定条件:无(任意上下文可读,含 ISR)
* 函数说明:三本账分开(docs/子系统设计/控制台单写者日志设计说明.md §13-O2)——
* dbg_console_dropped() = **真没落地**的丢弃数(缓冲满丢的一行,或一个
* 没能确认送达而出环的块;[ERR] 行报的就是它);
* dbg_console_tx_fault() = 外设软复位**自愈次数**(语义不变);
* dbg_console_tx_timeout() = 本账:写者排空时观察到的**块超时**次数
* (TXE 与 TC 两条硬上界都算)。
* 裸发路径(内核前 / ISR / 两条告警行自身)的超时不记本账——那些上下文里
* 没有"块"的概念,也只可能发生在启动极早期,混进来只会让运维读数失去意义。
* UART 物理失效但字节已发出的情形只涨本账,不污染另两本。 */
uint32_t dbg_console_tx_timeout(void) {
return s_con_tx_timeout;
}
/*
* 函数功能:取走"控制台 TX 故障待上报"标志(读后清)
* 返回值:1 - 需要补记一条 run.log 证据,0 - 无
* 限定条件:**写者任务**(defaultTask)在 dbg_console_drain() 之外调用
* 函数说明:断调用图成环用,见 dbg_console_drain 故障路径注释
*/
uint8_t dbg_console_tx_fault_log_take(void) {
uint8_t pending = s_con_tx_fault_log;
s_con_tx_fault_log = 0U;
return pending;
}
/*
* 函数功能:MicroLIB printf 底层重定向(库内部/遗留 printf 调用)
* 函数说明:同样只把字节交给单写者路径;未注册写者时直发(与旧行为一致)
*/
int fputc(int ch, FILE *f) {
uint8_t b = (uint8_t)ch;
(void)f;
(void)con_append((const char *)&b, 1U);
return ch;
}