diff --git a/App/cfg/cfg_file_store_mcu.c b/App/cfg/cfg_file_store_mcu.c index b908fdd4..cc07ab90 100644 --- a/App/cfg/cfg_file_store_mcu.c +++ b/App/cfg/cfg_file_store_mcu.c @@ -18,6 +18,10 @@ * dhara journal 竞争实证);busy 返回 -1 由调用方 * 按既有失败语义处理(poll 保留 RAM/保存待重试), * 本任务 = 看门狗喂狗点,绝不阻塞等锁 + * v1.5 2026-10-24 王建锋 仅注释更正:cfg_store_read 的"限定条件"引用了 + * FRESULT 里不存在的 FR_NO_SYSTEM,改成实际会返回的 + * FR_NOT_READY / FR_NO_FILESYSTEM 并写明"非 FR_OK 一律 + * 转 -1,不要枚举错误码集合"(无行为改动) * v1.4 2026-10-05 王建锋 原子写临时文件由 p_path 推导(P3-2):与目标同目录的 * 固定 8.3 名 config.TMP,不再硬编码卷根;目标恒为 * "/config.ini" 时推导结果与旧值逐字节一致 @@ -47,7 +51,15 @@ static const char s_path_tmp[] = "/config.TMP"; * cap - 缓冲容量 size_t * p_nread - 实际读字节数输出(不可为 NULL) size_t * * 返回值:0 - 成功;-1 - 文件缺失/读失败 int - * 限定条件:卷已挂载(未挂载时 f_open 返回 FR_NO_SYSTEM,转 -1) + * 限定条件:卷已挂载。未挂载时本函数一律转 -1,但**别照着不存在的错误码去写 + * 判据**:Lib/FatFs/ff.h 的 FRESULT 共 20 个码(FR_OK..FR_INVALID_PARAMETER), + * 没有 FR_NO_SYSTEM(旧注释此处笔误,2026-10-24 更正)。本移植里卷未挂载 + * 实际返回 FR_NOT_READY(3)(物理驱动器不可用)或 FF_VOLUMES 内无已挂载卷时 + * 由 mount_volume 直接给错;卷上无有效 FAT 则返回 FR_NO_FILESYSTEM(13)。 + * 本函数(以及 write_atomic / stat / remove)对**任何**非 FR_OK 一律转 -1, + * 所以这些码的差异不影响对外语义——也正因如此,谁都不该去枚举错误码集合, + * 除了自愈触发集那种"必须区分能不能靠删文件修好"的场合(见 sys_log_mcu.c + * 的 s_is_selfheal_code)。 * 函数说明:FIL 为**函数级 static**而非局部——本 FatFs 移植 FF_FS_TINY=0, * `FIL` 内含 `buf[FF_MAX_SS]`=512 B(Lib/FatFs/ff.h),一个局部对象就吃掉 * 528 B 栈,是配置/syslog 调用链栈深的主要来源(2026-10-10 栈预算整改)。 diff --git a/App/log/console_log.c b/App/log/console_log.c index 994a9c43..d93a0711 100644 --- a/App/log/console_log.c +++ b/App/log/console_log.c @@ -31,6 +31,17 @@ * adcTask(B1)等周期任务此时落日志会与 defaultTask 的存储初始化 * 日志字节级交错(台架实测 [ADC_TASK] 首帧行与 [NAND_FTL] 行交错)。 * 提供就绪查询供周期任务门控自身日志,不改直发路径本身。 + * 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 行改为"发成功才 + * 销账",通道不可用期间不再把丢弃证据一起冲掉。 */ #include "console_log.h" @@ -89,10 +100,12 @@ 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_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(0 = 无冷却) */ +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 已使能(硬上界基准可用) */ @@ -179,7 +192,7 @@ static HAL_StatusTypeDef con_tx_bounded(const uint8_t *p_buf, uint16_t len) { con_dwt_ensure(); if (huart1.Instance == NULL) { - return HAL_ERROR; /* USART1 未初始化:无可用通道,安全让位 */ + return HAL_ERROR; /* USART1 未初始化:无可用通道,安全让位(不是超时,不记超时账) */ } if (s_con_dwt_up == 0U) { return con_tx_counted(huart1.Instance, p_buf, len); @@ -190,14 +203,14 @@ static HAL_StatusTypeDef con_tx_bounded(const uint8_t *p_buf, uint16_t len) { 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; /* 有界:本块放弃(调用方自愈) */ + 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; + return HAL_TIMEOUT; /* TC 超时:字节已交给发送器,调用方分账 */ } } return HAL_OK; @@ -281,13 +294,16 @@ void dbg_console_drain(void) { /* 故障冷却:僵尸通道(ORE 重锁)秒内不自愈,冷却期内不发送—— * 本轮 drain 直接让位主循环,保住喂狗节奏(gen-288 停喂误复位的 - * 直接诱因 = 背靠连发的块超时链)。无符号差值跨 49.7 天回绕安全 */ - if (s_con_cool_until != 0U) { + * 直接诱因 = 背靠连发的块超时链)。是否在冷却看 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; return; } - s_con_cool_until = 0U; + s_con_cooling = 0U; } while ((used = con_used()) != 0U) { @@ -309,12 +325,28 @@ void dbg_console_drain(void) { taskEXIT_CRITICAL(); if (st != HAL_OK) { + if (st == HAL_ERROR) { + /* 2026-10-24 修复:USART1 **未初始化**(huart1.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"; @@ -344,14 +376,21 @@ void dbg_console_drain(void) { uint32_t d = s_con_drop; int n; - s_con_drop = 0U; 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; } - dbg_console_write_raw(note, (unsigned)n); + /* 只有**真发出去了才销账**(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; + } } } } @@ -360,6 +399,8 @@ void dbg_console_drain(void) { * 函数功能:把一段文本放入控制台(写者已注册)/直接发出(未注册或中断上下文) * 返回值:0 - 已入缓冲或已直发;-1 - 缓冲满丢弃(计入 dbg_console_dropped) * 函数说明:生产者不持锁等待过久:最多 DBG_CON_WAIT_MS,仍满则丢弃该行; + * "复核空位 -> 整行拷贝 -> 推进 head"是一个原子的临界区动作; + * 环满等待(vTaskDelay / DWT 判据)一律在临界区之外,区内绝不睡眠。 * 写者自身记录日志时先排空缓冲,避免自等。 * 等待上界优先用 DWT 真实时间(CON_APPEND_WAIT_CYC,时间源无关)—— * tick 停摆时 vTaskDelay 本身也会停,故不能用它作唯一判据(round 8b)。 @@ -392,10 +433,37 @@ static int con_append(const char *buf, uint16_t len) { } for (;;) { - uint16_t used = con_used(); - uint16_t free_space = (uint16_t)((DBG_CON_RING_SIZE - 1U) - used); + 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) { break; } if (have_dwt != 0U) { @@ -411,26 +479,6 @@ static int con_append(const char *buf, uint16_t len) { waited++; } - taskENTER_CRITICAL(); - { - 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)); - } - taskEXIT_CRITICAL(); - if (sys_evtHandle != NULL) { (void)osEventFlagsSet(sys_evtHandle, SYS_EVT_CONSOLE_TX); /* 唤醒写者 */ } @@ -472,6 +520,23 @@ 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 - 无 diff --git a/App/log/console_log.h b/App/log/console_log.h index 1566ae9e..b73e60ef 100644 --- a/App/log/console_log.h +++ b/App/log/console_log.h @@ -39,8 +39,13 @@ extern "C" { * (仅假设 defaultTask 在日志),其余任务此时落日志会与 * defaultTask 的存储初始化日志字节级交错(2026-10-10 台架实测) * dbg_console_drain - 写者任务排空缓冲并发送(单次有界;TX 失败自动软复位自愈) - * dbg_console_dropped - 因缓冲满丢弃的行数(诊断) - * dbg_console_tx_fault - 因僵尸通道软复位自愈的次数(诊断) + * dbg_console_dropped - 真没落地的丢弃数:缓冲满丢的一行,或一个没能确认送达 + * 而出环的块(诊断;[ERR] CONSOLE dropped 行报的就是它) + * dbg_console_tx_fault - 因僵尸通道软复位自愈的次数(诊断,语义不变) + * dbg_console_tx_timeout - 写者排空时观察到的块超时次数(TXE / TC 两条硬上界都算)。 + * 三本账分开:看日志有没有在丢 -> dropped;看外设自愈了几次 + * -> tx_fault;看 UART 物理失效(含"字节已发出但 TC 不置位") + * -> tx_timeout。别把三本账混成一个数(docs §13-O2 ) * dbg_console_tx_fault_log_take * - 取走"TX 故障待上报"标志(读后清)。**必须**由写者任务在 * dbg_console_drain() 之外调用后自行 syslog_post——本模块 @@ -55,6 +60,7 @@ uint8_t dbg_console_writer_ready(void); void dbg_console_drain(void); uint32_t dbg_console_dropped(void); uint32_t dbg_console_tx_fault(void); +uint32_t dbg_console_tx_timeout(void); uint8_t dbg_console_tx_fault_log_take(void); /* diff --git a/App/log/soe_store.c b/App/log/soe_store.c index 719b0b64..77b65864 100644 --- a/App/log/soe_store.c +++ b/App/log/soe_store.c @@ -3,6 +3,16 @@ * 模块功能:实现 soe_store.h——protectTask 投递(取时标 + soe_rec_build + * SPSC 待发环 / 消息队列,零 FS);defaultTask(syslog_drain 尾部) * 排空落 /log/soe.log(64B 定长,容量满删除重建)。 + * 修改记录: + * v1.0 2026-10-12 王建锋 创建初始版本(B9,设计见 docs/算法/继电保护功能复现规划.md §3.4) + * v1.1 2026-10-24 王建锋 仅注释/文档更正(D 项,无行为改动):s_soe_append + * 函数头原写"打开/写入结构性错误 → unlink 重试一次 + * (同 run.log)",与实现不符且方向错误——实现里 + * f_open 失败直接返回 -1、不 unlink,这是**有意**的: + * SOE 是事件序列,打开失败就删文件等于销毁已发生的 + * 事件。改为如实描述"仅写失败才重试一次",并补上 + * 与 sys_log_mcu.c s_is_selfheal_code 同源的逐码表 + * (名字/参数类错误码两侧都绝不 unlink) * 创建日期:2026-10-12(B9) */ @@ -88,6 +98,30 @@ void soe_store_post(uint8_t type, uint8_t elm_id, uint32_t value) { } } +/* + * ── 自愈触发集逐码依据(与 sys_log_mcu.c 的同名资料块同源;2026-10-24 逐条核对 + * Lib/FatFs/ff.c)───────────────────────────────────────────────────────── + * 进触发集(run.log 侧允许 unlink 重建一次):介质/结构类 FR_DISK_ERR(1) / + * FR_INT_ERR(2) / FR_NO_FILESYSTEM(13) / FR_DENIED(7)。本模块**只在写失败路径** + * 上碰自己的文件——打开失败连这些码也不 unlink。 + * 不算自愈:FR_NO_FILE(4)——带 FA_OPEN_APPEND 打开时文件会被直接建出来;返回本 + * 码的唯一现实场景是同名对象是目录(ff.c:3854),unlink 修不好它。 + * 两侧都绝不 unlink:名字/参数类 FR_INVALID_NAME(6) / FR_NO_PATH(5) / + * FR_INVALID_OBJECT(9),以及 FR_NOT_READY(3) / FR_NOT_ENABLED(12) / + * FR_TIMEOUT(15)——同名同因,删了也修不好,反而每轮丢一份历史(§10.1 第 10 条 + * 严禁的正是这种归集)。 + * 写失败路径不接这套判定:f_write 的返回码里不存在名字/参数类(能走到那里说明 + * 已经打开成功),而簇分配失败是 FR_OK + 短写、根本没有错误码可判,只能无条件 + * 重试一次。 + * 为什么 SOE 比 run.log 保守(两侧资料块各写一份、互指,改动必须同步):run.log + * 是滚动运行日志,坏了重建损失有限;SOE 是**事件序列**,文件里是一条条已经发生 + * 过的保护事件,"打开失败就删整个历史文件"等于**销毁事件证据**——SOE 的存在就 + * 是为了留这些事件,删了比写不进去更糟。正确出路是等 syslog_mcu_mounted() 每 2s + * 重试补目录 / 等卷恢复,条目留在待发环与队列里,不丢。 + * (run.log 侧的同一张表 + 同一理由见 App/log/sys_log_mcu.c 的同名资料块,测试 + * 侧两侧对齐由 TC-SYSLOG-101/102 与 TC-SOESTORE-022/022b 分别钉住。) + */ + /* * 函数功能:追加一条 64B SOE 记录到 /log/soe.log * 入口参数:rec - 完整记录(SOE_REC_SIZE 字节) const uint8_t * @@ -98,11 +132,20 @@ void soe_store_post(uint8_t type, uint8_t elm_id, uint32_t value) { * ② FA_WRITE|FA_OPEN_APPEND(本移植 OPEN_APPEND 不含 WRITE, * 缺写位 f_write 恒 FR_DENIED,2026-09-20 实证); * ③ 判失败必须校验写回字节数(FR_OK+短写 = 失败,簇分配无错误码); - * ④ 容量满 unlink 重建;打开/写入结构性错误 → unlink 重试一次 - * (L1 文件级自愈,同 run.log)。 + * ④ 容量满 unlink 重建;**仅写入失败(含 FR_OK+短写)才 unlink 重试 + * 一次**——f_open 失败一律直接返回 -1、不 unlink。 + * (2026-10-24 定案:本条旧注释写的是"打开/写入结构性错误都 unlink + * 重试一次(同 run.log)",与实际实现不符,也与本模块的语义相反: + * run.log 是**滚动运行日志**,坏了重建损失有限;SOE 是**事件序列**, + * 文件里是已经发生的事件,打开失败就把整个历史文件删掉等于销毁 + * 证据,方向是错的。正确出路是让 syslog_mcu_mounted() 每 2s 重试 + * 补目录/等卷恢复,条目留在待发环与队列里,不丢。) + * ⑤ 自愈触发集与 sys_log_mcu.c 的 s_is_selfheal_code() 同源,但本模块取 + * 更保守的子集:打开失败**任何错误码都不 unlink**,只有写失败才重试 + * 一次。逐码依据与两侧差异见上方同名资料块。 * ⚠ 本模块**没有**任何"卷损坏"归类/重格式化路径:unlink 失败、 * 名字/参数类错误码一律只留痕(存储架构 §10.1 教训),条目丢弃 - * 计数,绝不升级成卷操作。 + * 计数,绝不升级成卷操作(全模块无 f_mount/f_mkfs/f_opendir)。 */ static int s_soe_append(const uint8_t *rec) { static FIL f; /* static:FF_FS_TINY=0 → FIL=528B 栈; @@ -129,8 +172,9 @@ static int s_soe_append(const uint8_t *rec) { fr = f_write(&f, rec, SOE_REC_SIZE, &bw); (void)f_close(&f); if ((fr != FR_OK) || (bw != SOE_REC_SIZE)) { - /* L1 文件级自愈:陈旧/损坏条目 → 删除重建、重试一次(同 run.log; - * unlink 返回值刻意不检查——失败也只是重试失败 → 留痕,不升级) */ + /* L1 文件级自愈:**只有写失败**才删除重建、重试一次(打开失败走下面 + * 的 else,不删——见函数头 ④)。unlink 返回值刻意不检查:失败也只是 + * 重试失败 → 留痕计数,绝不升级成卷操作。 */ (void)f_unlink(SOE_PATH); bw = 0u; fr = f_open(&f, SOE_PATH, FA_WRITE | FA_OPEN_APPEND); @@ -143,6 +187,8 @@ static int s_soe_append(const uint8_t *rec) { } } } else { + /* 打开失败:只留痕、**不 unlink**(SOE 是事件序列,删文件 = 销毁历史; + * 名字/参数类错误码 unlink 也修不好,见函数头的逐码表) */ rc = -1; } if (rc == 0) { diff --git a/App/log/sys_log.h b/App/log/sys_log.h index a8c81691..3d7c588b 100644 --- a/App/log/sys_log.h +++ b/App/log/sys_log.h @@ -7,6 +7,13 @@ * 创建日期:2026-09-14 * 修改记录: * v1.0 2026-09-14 王建锋 创建初始版本(设计见 docs/子系统设计/运行与自检记录设计说明.md §4) + * v1.1 2026-10-24 王建锋 仅契约注释更正,与 App/log/sys_log_mcu.c 的 C/F 两项 + * 整改同步(无签名、无行为改动): + * syslog_get_record 写死容量要求(ts_len > SYSLOG_TS_LEN、 + * desc_len > SYSLOG_DESC_MAX)并明确"容量不足一律 -1、 + * 不截断、不写调用方缓冲"——旧注释像是"给少了会被截断", + * 实际是整条读不到;syslog_post 写明必须假设丢弃存在、 + * 且丢弃留痕已按通道节流(节流后无丢弃计数) */ #ifndef SYS_LOG_H @@ -61,6 +68,14 @@ typedef enum { * desc - 描述串(UTF-8,≤SYSLOG_DESC_MAX 字节;超长截断) const char * * 返回值:无 * 限定条件:MCU 端 FreeRTOS 启动前调用走静态待发数组(由 defaultTask 挂载后落盘) + * 函数说明:调用方**必须假设投递可能被丢弃**——MCU 后端(sys_log_mcu.c)队列 8 槽、 + * 启动前待发数组 8 槽,卷被 ftpTask 长期占用时 defaultTask 会把整批回投 + * 队列(队列因此可长时间处于满态),此时本函数直接丢弃该条,并只在 + * "转入故障"的那一次留痕(丢弃留痕按正常→故障节流,F 项整改 2026-10-24; + * 节流后**丢弃总数没有计数**,要计量得另加计数器)。必须留住的证据走 + * sys_log_evidence(),不要靠 syslog_post 重试。 + * 模拟器后端(simulator/sim/sim_syslog.c)是单线程直写宿主文件、**没有 + * 队列与丢弃**,因此上面这段满态语义只约束 MCU 端。 */ void syslog_post(syslog_id_t id, const char *desc); @@ -75,11 +90,19 @@ uint32_t syslog_count(syslog_id_t id); * 函数功能:读取第 idx 条记录(时间戳 + 描述,均 NUL 结尾) * 入口参数:id - 日志 ID syslog_id_t * idx - 记录序号(0 起) uint32_t - * p_ts - 时间戳输出缓冲(≥SYSLOG_TS_LEN+1 字节;不可为 NULL) char * - * ts_len- p_ts 容量 size_t - * p_desc- 描述输出缓冲(≥SYSLOG_DESC_MAX+1 字节;不可为 NULL) char * - * desc_len - p_desc 容量 size_t - * 返回值:0 - 成功;-1 - 越界/读失败 int + * p_ts - 时间戳输出缓冲(不可为 NULL) char * + * ts_len- p_ts 容量,必须 > SYSLOG_TS_LEN(即 ≥ SYSLOG_TS_LEN+1 = 20 字节) size_t + * p_desc- 描述输出缓冲(不可为 NULL) char * + * desc_len - p_desc 容量,必须 > SYSLOG_DESC_MAX(即 ≥ 29 字节) size_t + * 返回值:0 - 成功;-1 - 越界 / 读失败 / **缓冲容量不足** int + * 限定条件:容量不足一律返回 -1,**不做静默截断、也不写调用方一个字节** + * (C 项整改 2026-10-24;旧注释只写"≥…+1 字节"像是"给少了会被截断", + * 照它给 28 字节缓冲的结果是整条读不到而不是读到 27 字符)。 + * 描述字段在写入侧已截到 SYSLOG_DESC_MAX,所以按上面的最小容量给缓冲 + * 就一定装得下,读侧不需要第二道截断。 + * MCU 后端与模拟器后端(sim_syslog.c)在这条守卫上取值相同,行为一致; + * 模拟器侧那份同样不可达的截断支还留在文件里(未列入本轮改动范围)。 + * 调用方:uiTask 的日志页(行缓冲 32B)与 SYSLOG 取证打印。 */ int syslog_get_record(syslog_id_t id, uint32_t idx, char *p_ts, size_t ts_len, char *p_desc, size_t desc_len); diff --git a/App/log/sys_log_mcu.c b/App/log/sys_log_mcu.c index 31ec693e..d379b1ff 100644 --- a/App/log/sys_log_mcu.c +++ b/App/log/sys_log_mcu.c @@ -16,6 +16,24 @@ * v1.3 2026-10-02 王建锋 新增 sys_log_evidence():把看门狗停喂现场 * 证据落 /log/wdev.log(掉电不丢)+ flush 诱导 * (round 7 纯取证,见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录) + * v1.5 2026-10-24 王建锋 主机单测批次照出的缺陷一起修,本文件内 5 条 + * (A/B/C/D/F;E 在 App/cfg/cfg_file_store_mcu.c): + * A 卷忙整批静默丢日志——syslog_drain 批量路径补 + * rc==-2 分支把 done 置 0,整批回投队列(不计故障、 + * 不打日志),与 -3 慢路径遇 -2 的处置对齐; + * B 同分支文案 "drop N entries" 与行为相反(其实全 + * 部回投),改成如实描述并按"正常→故障"节流一条; + * C 删掉 syslog_get_record 里永不可达的截断支,容量 + * 要求写进函数头与 sys_log.h(宁可拒绝不静默截断); + * D 自愈触发集按语义拆类(新增 s_is_selfheal_code), + * 把 FR_INVALID_NAME / FR_NO_PATH / FR_NO_FILE / + * FR_INVALID_OBJECT 移出 unlink 重建集合——旧集合在 + * 名字/参数类错误下每轮删一次自己的历史日志且永不 + * 自愈,违反存储架构 §10.1 第 10 条 + * F syslog_post 的丢弃留痕按通道节流(队列满 / 启动前 + * 数组满各立 volatile 抑制位,与 B 的 drain 侧那位分 + * 开):卷被长期占用 + A 项回投让队列常驻满时,串口 + * 不再按投递频率刷 "queue full, drop" * v1.4 2026-10-04 王建锋 P2-9:syslog_mcu_mounted() 改为幂等 + 卷锁保护; * defaultTask 每 2s 复查,建 /log 曾失败时自动重试 * (原"仅调用一次"→ 失败后 drain 永久 no-op)。 @@ -65,6 +83,22 @@ static osMessageQueueId_t s_queue; /* 运行期消息队列(s static const osMessageQueueAttr_t s_queue_attr = { .name = "syslog" }; static volatile uint8_t s_fs_ready; /* 卷挂载 + /log 目录就绪后置位 */ static volatile uint8_t s_mount_fail_reported; /* P2-9:建目录失败日志去重(成功复位) */ +/* B 项整改(2026-10-24):syslog_drain 批量写失败"整批回投"这条留痕的节流位—— + * 只在"从正常转入故障"的那一次打一条,成功一轮后复位。与 s_append_many 内部的 + * s_batch_fault 各管一层:那位压的是"这个文件写坏了",本位压的是"这批条目回投了"。 + * 唯一写者 defaultTask,免锁。 */ +static uint8_t s_requeue_reported[SYSLOG_COUNT]; + +/* F 项整改(2026-10-24,Lead 批准并入本轮):syslog_post 的丢弃留痕节流。 + * 场景:卷被 ftpTask 长期占用 -> A 项修复后整批回投 -> 队列常驻满 -> 此后每一条 + * 日志投递都会"队列满 + 打一条 DBG_ERROR",串口按业务投递频率刷屏(日志本身 + * 变成负载)。与上面 s_requeue_reported **分开**是两个必然:那位归 defaultTask + * (唯一写者,免锁),本位由**任意上下文**的投递者读写,且恢复时机是"下一次投递 + * 成功"而不是"下一批落盘成功"。两个位各管一个通道:队列 / 启动前待发数组。 + * volatile + 单字节:Cortex-M 上对齐字节读写原子;跨任务竞态的最坏后果是"同一轮 + * 故障多打一行"或"少打一行",不会漏丢条目、也不会长时间静默(下一次成功会复位)。 */ +static volatile uint8_t s_post_qfull_reported; /* 队列满丢弃:已留痕? */ +static volatile uint8_t s_post_pboot_full_reported;/* 启动前数组满丢弃:已留痕? */ /* N-24(2026-10-15,报告 N-16 读侧):8 条滑动读窗口 + 写者代数。 * 窗口 512 B 走静态(不吃任务栈;defaultTask/uiTask 最坏链见 stack_report.py, @@ -80,6 +114,72 @@ static volatile uint32_t s_rd_gen[SYSLOG_COUNT]; /* 每次成功落盘 / unli /* 函数定义区 */ +/* + * ── 自愈触发集逐码依据(配套下方 s_is_selfheal_code;2026-10-24 逐条核对 + * Lib/FatFs/ff.c,行号即该文件)──────────────────────────────────────── + * 旧实现把 FR_INVALID_NAME / FR_NO_PATH / FR_NO_FILE / FR_INVALID_OBJECT 一并 + * 放进触发集,违反《STM32F4-Base 存储架构说明》§10.1 第 10 条:名字/参数类错误 + * 码严禁归入"卷损坏"处置集——这类码下 unlink 自己的文件必然同样失败(同名同因), + * 后果是每轮排空删一次自己的历史日志、且永远修不好(run.log 1024 条 / + * selftest.log 全是被删对象)。 + * + * 进触发集(删了这个文件真能修好): + * FR_DISK_ERR(1) 低层 I/O 失败(ff.c:1683/1739/1785 等)——陈旧簇链或坏 + * 目录项,unlink 后重建换新链有效; + * FR_INT_ERR(2) 内部一致性断言失败(ff.c:1379/1460/3869)——本文件的目录 + * 项/簇号已不自洽,只能废弃重建; + * FR_NO_FILESYSTEM(13) 卷上找不到有效 FAT(ff.c:3483 起)——本模块**不**做任何 + * 卷级动作(全模块无 f_mount/f_mkfs),只对本文件废弃重建 + * 一次并留痕,卷怎么办归挂载门; + * FR_DENIED(7) 两条真实成因都能被 unlink 治好:目标被置只读属性 + * (ff.c:3808/3859),以及目录区无空目录项(ff.c:1850 由 + * FR_NO_FILE 改写而来)——后者删掉自己这个文件正好腾出一 + * 个目录项。陷阱一那类"缺写位"的 FR_DENIED 出现在 f_write + * 上,不走本判定。 + * + * 不进触发集(一律立即失败,不 unlink、不重开、不计卷损坏): + * FR_INVALID_NAME(6) 8.3 短名违规 / 非法字符 / 以点开头(非 LFN 配置下的 + * create_name)——unlink 同名同因失败,永不自愈; + * FR_NO_PATH(5) 中间路径段缺失(ff.c:3144/4357)= /log 目录不在:重建 + * 文件不解决目录,补目录归 syslog_mcu_mounted() 的每 2s 重试 + * (见该函数注释里的幂等改造说明),两侧各管一层; + * FR_NO_FILE(4) 本模块用 FA_WRITE|FA_OPEN_APPEND(含 FA_OPEN_ALWAYS), + * "文件不存在"会被直接创建出来;返回本码的唯一现实场景是 + * 同名对象是目录(ff.c:3854),unlink 一个目录修不好它; + * FR_INVALID_OBJECT(9) 仅 ff.c:3774 的 !fp 分支,即句柄指针为 NULL——本模块用 + * 函数级 static FIL,地址恒非 0,属不可达码; + * FR_NOT_READY(3) / FR_NOT_ENABLED(12) / FR_TIMEOUT(15)——卷未挂载、无工作区、 + * FF_FS_REENTRANT 取卷超时,属挂载门与并发范畴,与"这个 + * 文件坏了"无关。 + * + * 两点边界,免得后人当成漏改: + * 1) f_write 之后那条"失败/短写 -> unlink 重建重试一次"**不接**本判定:其前提 + * 是已经打开成功,名字/参数类错误到不了那里;而簇分配失败是 FR_OK + 短写 + * (根本没有错误码可判),必须无条件重试。 + * 2) App/log/soe_store.c 对同一张表取**更保守的子集**:那边**打开失败连介质类 + * 码也不 unlink**。原因不是它漏改,而是两个文件的对象不同——run.log 是滚动 + * 运行日志,文件坏了重建损失有限;soe.log 是**事件序列**,里面是一条条已经 + * 发生过的保护事件,"打开失败就删掉整个文件"等于**销毁事件证据**(SOE 的存 + * 在即目的,删了比写不进去更糟)。所以 SOE 侧的出路是等卷/目录恢复(条目留 + * 在待发环与队列里,不丢),只有"已经打开成功之后写失败"才允许废弃重建。 + * 两侧资料块必须同步修改、互指(彼处在 App/log/soe_store.c 的同名资料块)。 + */ +/* + * 函数功能:判定一个 f_open 返回码是否属于"把自己的日志文件删掉重建一次"能修好 + * 的那一类,即 L1 文件级自愈的触发集(D 项整改,逐码依据见上方资料块) + * 入口参数:fr - f_open 的返回码 FRESULT + * 返回值:1 - 属触发集,可 unlink 后重开一次;0 - 不属,一律立即失败、不 unlink、 + * 不计卷损坏 int + * 限定条件:纯分类、无任何副作用;调用点只有 s_append_rec() 与 s_append_many() + * 的"打开失败"分支 + * 函数说明:拆成函数是为了两处调用点共用同一张表,避免历史上"改一处漏一处" + */ +static int s_is_selfheal_code(FRESULT fr) { + return ((fr == FR_DISK_ERR) || (fr == FR_INT_ERR) || + (fr == FR_NO_FILESYSTEM) || (fr == FR_DENIED)) ? 1 : 0; +} + + /* * 函数功能:格式化一条 64 B 定长记录(时间戳 + 空格 + epoch + 空格 + 描述) * 入口参数:id - 日志 ID(当前不参与编码,仅为接口对称) syslog_id_t @@ -155,10 +255,9 @@ static int s_append_rec(syslog_id_t id, const uint8_t *p_rec) { * FA_OPEN_APPEND(0x30) 不含 FA_WRITE(0x02),不带写位打开的文件 f_write * 恒返回 FR_DENIED(7)——2026-09-20 台架实测 /log 写失败 ×5/开机 真根因 */ fr = f_open(&f, s_paths[id], FA_WRITE | FA_OPEN_APPEND); - if (fr != FR_OK && (fr == FR_DENIED || fr == FR_INVALID_OBJECT || - fr == FR_INT_ERR || fr == FR_DISK_ERR || - fr == FR_NO_FILE || fr == FR_NO_PATH || - fr == FR_INVALID_NAME || fr == FR_NO_FILESYSTEM)) { + /* D 项整改(2026-10-24):只有"删了这个文件能修好"的介质/结构类错误码才 + * 进这里;名字/参数类一律不 unlink —— 判据见 s_is_selfheal_code() */ + if ((fr != FR_OK) && (s_is_selfheal_code(fr) != 0)) { (void)f_unlink(s_paths[id]); fr = f_open(&f, s_paths[id], FA_WRITE | FA_OPEN_APPEND); } @@ -255,10 +354,9 @@ static int s_append_many(syslog_id_t id, const uint8_t *p_buf, uint32_t n) { total = (UINT)(n * SYSLOG_REC_SIZE); fr = f_open(&f, s_paths[id], FA_WRITE | FA_OPEN_APPEND); - if (fr != FR_OK && (fr == FR_DENIED || fr == FR_INVALID_OBJECT || - fr == FR_INT_ERR || fr == FR_DISK_ERR || - fr == FR_NO_FILE || fr == FR_NO_PATH || - fr == FR_INVALID_NAME || fr == FR_NO_FILESYSTEM)) { + /* D 项整改(2026-10-24):只有"删了这个文件能修好"的介质/结构类错误码才 + * 进这里;名字/参数类一律不 unlink —— 判据见 s_is_selfheal_code() */ + if ((fr != FR_OK) && (s_is_selfheal_code(fr) != 0)) { (void)f_unlink(s_paths[id]); fr = f_open(&f, s_paths[id], FA_WRITE | FA_OPEN_APPEND); } @@ -524,19 +622,36 @@ void syslog_drain(void) { break; /* 写失败:该条已丢,尾部回投 */ } } - } else if (rc == -1) { + } else if (rc == -2) { + /* A 项整改(2026-10-24,主机单测照出):卷忙 = **整批回投队列**, + * 静默顺延——不计故障、不打日志(本函数每 2s 排空一次,一打就刷屏), + * 与上面 -3 慢路径遇 -2 的处置对齐。旧实现在这里没有分支:done 保持 n + * → 下面的回投循环空转 → 最多 8 条既不落盘也不回投,静默丢失。 */ done = 0u; - DBG_ERROR("drop %lu entries id=%d", (unsigned long)n, (int)bid); + } else if (rc == -1) { + /* B 项整改(2026-10-24):旧文案写 "drop %lu entries",但本分支 + * done = 0 → 整批其实**全部回投**、一条没丢(下轮重试),文案与行为 + * 相反会把排障方向带错;改成如实描述,并加"正常→故障"节流(持续故障 + * 时每 2s 一条会自己刷成新一轮刷屏,真丢另有下面 hold-over drop 兜底)。 */ + done = 0u; + if (s_requeue_reported[bid] == 0u) { + s_requeue_reported[bid] = 1u; + DBG_ERROR("write fail, requeue %lu entries id=%d", + (unsigned long)n, (int)bid); + } } - /* rc == -2(卷忙):整批回投队列,静默顺延,不计故障 */ for (i = done; i < n; i++) { if (osMessageQueuePut(s_queue, &s_batch_src[i], 0u, 0u) != osOK) { DBG_ERROR("hold-over drop (queue full): id=%d", (int)s_batch_src[i].id); } } - /* 本批消耗的 preboot 条目:已落盘的删除;未落盘的已转投队列,同样删除 - * (否则会重复落盘) */ + if (rc == 0) { + s_requeue_reported[bid] = 0u; /* 恢复正常:允许下一次故障重新留痕 */ + } + /* 本批消耗的 preboot 条目:已落盘的删除;未落盘的(-1/-2/-3 尾部)已在 + * 上面转投队列,同样从数组删除——**由队列接手重投**,不是丢弃 + * (A 项整改前 -2 走不到回投,这里删掉就等于吞掉) */ if (n_pre > 0u) { memmove(&s_preboot[0], &s_preboot[n_pre], sizeof(syslog_entry_t) * (size_t)(s_preboot_cnt - n_pre)); @@ -558,6 +673,12 @@ void syslog_drain(void) { * desc - 描述串(UTF-8,静态存储期,超长截断) const char * * 返回值:无 * 限定条件:FreeRTOS 启动前走静态待发数组;desc 必须为静态/常量串 + * 函数说明:丢弃留痕按"正常→故障"节流(F 项整改 2026-10-24):每个通道只在 + * 转入故障的那一次打一条,恢复(下一次成功收下)后复位。 + * **丢弃语义本身没变**(队列满/数组满仍然是丢),变的只有留痕次数—— + * 卷被 FTP 长期占用时 A 项修复让整批回投、队列常驻满,若这里不节流, + * 串口就按业务投递频率刷"queue full, drop",日志自己变成负载。 + * 抑制位是 volatile 单字节、由任意上下文读写:竞态最坏只多打/少打一行。 */ void syslog_post(syslog_id_t id, const char *desc) { syslog_entry_t e; @@ -569,15 +690,33 @@ void syslog_post(syslog_id_t id, const char *desc) { e.desc = desc; if (s_queue != NULL && osKernelGetState() == osKernelRunning) { if (osMessageQueuePut(s_queue, &e, 0u, 0u) != osOK) { - DBG_ERROR("queue full, drop: %s", desc); + /* F 项:只报"从正常转入故障"那一次;期间条目照常丢弃(丢弃语义不变, + * 变的只是留痕次数)。下一次投递成功即复位。 */ + if (s_post_qfull_reported == 0u) { + s_post_qfull_reported = 1u; + DBG_ERROR("queue full, drop: %s", desc); + } + } else { + /* 恢复正常:清抑制位,下一次故障重新留痕。这里刻意无条件写 + * (曾试成"仅置位时才清",被 cppcheck duplicateConditionalAssign + * 判成等价赋值并拦提交闸;本函数上方已做过一次条目拷贝,多一条 + * STR 可以忽略,别为它加 inline-suppr)。 */ + s_post_qfull_reported = 0u; } } else { uint8_t n = s_preboot_cnt; if (n < SYSLOG_QUEUE_MAX) { s_preboot[n] = e; s_preboot_cnt = n + 1u; - } else { + s_post_pboot_full_reported = 0u; /* 恢复正常:同上,无条件复位 */ + } else if (s_post_pboot_full_reported == 0u) { + /* 同一纪律的另一通道:启动前数组只有 SYSLOG_QUEUE_MAX 槽,挂载慢 + + * 开机事件密集时会在整个"未就绪窗口"里每条投递打一行(比队列通道更早 + * 发生、也更密集)。数组重新收下第一条即视为恢复。 */ + s_post_pboot_full_reported = 1u; DBG_ERROR("preboot full, drop: %s", desc); + } else { + /* 故障期间静默,条目照常丢弃 */ } } } @@ -603,11 +742,14 @@ uint32_t syslog_count(syslog_id_t id) { * 函数功能:读取第 idx 条记录(时间戳 + 描述,均 NUL 结尾) * 入口参数:id - 日志 ID syslog_id_t * idx - 记录序号(0 起) uint32_t - * p_ts - 时间戳输出缓冲(≥SYSLOG_TS_LEN+1 字节,不可为 NULL) char * - * ts_len - p_ts 容量 size_t - * p_desc - 描述输出缓冲(≥SYSLOG_DESC_MAX+1 字节,不可为 NULL) char * - * desc_len - p_desc 容量 size_t - * 返回值:0 - 成功;-1 - 越界/读失败 int + * p_ts - 时间戳输出缓冲(不可为 NULL) char * + * ts_len - p_ts 容量,**必须 > SYSLOG_TS_LEN**(即至少 20 字节) size_t + * p_desc - 描述输出缓冲(不可为 NULL) char * + * desc_len - p_desc 容量,**必须 > SYSLOG_DESC_MAX**(即至少 29 字节) size_t + * 返回值:0 - 成功;-1 - 越界/读失败/缓冲容量不足(不截断、不写入调用方缓冲) int + * 限定条件:容量不足一律拒绝而不是截断(C 项整改 2026-10-24;理由见函数体注释)。 + * 记录里的描述字段本身已在写入侧截到 SYSLOG_DESC_MAX,所以按上面 minimum + * 给缓冲就一定装得下,读侧无需第二道截断。 */ int syslog_get_record(syslog_id_t id, uint32_t idx, char *p_ts, size_t ts_len, char *p_desc, size_t desc_len) { uint8_t rec[SYSLOG_REC_SIZE]; @@ -624,9 +766,12 @@ int syslog_get_record(syslog_id_t id, uint32_t idx, char *p_ts, size_t ts_len, c while (dl < SYSLOG_DESC_MAX && rec[SYSLOG_DESC_OFFS + dl] != '\0') { dl++; } - if (dl >= desc_len) { - dl = desc_len - 1u; - } + /* C 项整改(2026-10-24):这里原来还有一句 "if (dl >= desc_len) dl = desc_len + * - 1u"——**永不可达**:上面的守卫已把 desc_len <= SYSLOG_DESC_MAX 全部挡掉, + * 而 dl 由定长记录的内容决定、恒 <= SYSLOG_DESC_MAX。删掉它,容量要求改由 + * 函数头与 sys_log.h 写死:**宁可整条拒绝,也不做静默截断**(UI 拿到截断串 + * 会显示成一条被删短过的日志,比读不到更难查)。desc_len 的校验仍保留, + * 它是"调用方给够缓冲"这条契约的执行点,不是截断开关。 */ memcpy(p_desc, rec + SYSLOG_DESC_OFFS, dl); p_desc[dl] = '\0'; return 0; diff --git a/Drivers/BSP/NET/lftpd/lftpd.c b/Drivers/BSP/NET/lftpd/lftpd.c index ffec9da4..66dbdb8a 100644 --- a/Drivers/BSP/NET/lftpd/lftpd.c +++ b/Drivers/BSP/NET/lftpd/lftpd.c @@ -545,6 +545,10 @@ static int cmd_pasv(lftpd_client_t *client, const char *arg) return -1; } + /* s_addr 是网络序整数(本层唯一约定,见 net_inet_addr 的声明处),这里 + * 先转主机序再按高字节到低字节拆 = 线序顺位,PASV 227 的 IP 文本因此正确 + * (TRAP-NET-21 已定案"双重反转恰好正确,勿误判";2026-10-24 字节序统一后复核: + * 本行不属于被统一掉的那一族,语义未变,勿"顺手改对"成直接拆 s_addr) */ uint32_t ip = net_htonl(local_addr.sin_addr.s_addr); send_simple_response(client->socket, 227, STATUS_227, (ip >> 24) & 0xff, diff --git a/Drivers/BSP/NET/net_socket.c b/Drivers/BSP/NET/net_socket.c index 7c060932..303dbdc7 100644 --- a/Drivers/BSP/NET/net_socket.c +++ b/Drivers/BSP/NET/net_socket.c @@ -78,6 +78,14 @@ static volatile uint32_t s_net_generation = 0; /* 网络代次号:网络 */ static osMessageQueueId_t s_net_msg_queue; +/* + * 本层执行任务句柄(= net_init 的调用者,NET 层为 netTask、硬件层为 testTask) + * 与 net_pending_capture_owner() 同一时刻、同一语义捕获,判据也一致。 + * 用途见 net_ctx_is_owner_task():给"绕过消息队列直调芯片"的接口把危险面关掉。 + * 捕获前恒 NULL(net_init 未跑)——那时任何 socket 都不存在,判空放行即可。 + */ +static TaskHandle_t s_net_owner_task; + /* * 函数功能:绑定消息队列句柄(本模块是该句柄的唯一使用者) * 入口参数:queue_id - 已创建的消息队列句柄 @@ -87,6 +95,30 @@ static osMessageQueueId_t s_net_msg_queue; * 真出现两处绑定时,说明有人新加了调用点,此时保留先绑的那个并把后来者 * 拒掉,比静默换句柄(已入队的请求会留在旧队列里永不处理)更容易暴露。 */ +/* + * 函数功能:判当前上下文是否本层执行任务(netTask / 硬件层的 testTask) + * 入口参数:无 + * 出口参数:无 + * 返回值:1 - 是本层执行任务(或未捕获,见下);0 - 明确不是(应拒绝直调芯片) + * 限定条件:net_init 之后判据才有效 + * 函数说明:给 net_sendto / net_recvfrom 这类**绕过消息队列、在调用方上下文里 + * 直接 net_poll + 读写芯片缓冲**的接口把危险面结构性关掉:非执行任务 + * 调用会与本层的 net_poll 抢同一根 SPI2(TRAP-NET-14 的根因形态), + * 而 SPI 事务没有跨任务互斥(ch395f_spi_begin/end 只是片选与分频)。 + * 刻意**不做**成"排队改写":recvfrom 要把源地址交回调用方,按 AGENTS + * 不能塞进按值拷贝的 net_msg_t,真要串行化得设计 per-task 结果槽, + * 那是需要真机回归的独立工程(2026-10-24 定案:本轮先把危险面关掉,串行化留后续)。 + * 未捕获(net_init 未跑)时返回 1:那时任何 socket 都不存在, + * 调用只会先在参数检查上拿到 BADF,不构成绕过。 + */ +static uint8_t net_ctx_is_owner_task(void) { + if (s_net_owner_task == NULL) { + return 1U; + } + /* 内核未启动/中断上下文里句柄为 NULL:与执行任务不等 → 判不是(不得碰 SPI) */ + return (xTaskGetCurrentTaskHandle() == s_net_owner_task) ? 1U : 0U; +} + int net_msg_queue_adopt(osMessageQueueId_t queue_id) { if (queue_id == NULL) { return -1; @@ -253,6 +285,9 @@ static bool s_mac_is_zero(const uint8_t *p_mac) { net_set_errno(NET_OK); s_net_initialized = 0; net_pending_capture_owner(); /* 执行任务 = net_init 调用者(NET 层 = netTask / 硬件层 = testTask) */ + /* 同一时刻捕获本层自己的执行任务句柄(net_ctx_is_owner_task 的判据; + * 必须与上面同源,否则两张守卫会各认一个"谁是 netTask") */ + s_net_owner_task = xTaskGetCurrentTaskHandle(); /* 检查 CH395F 芯片 */ if (ch395f_check_exist() != CH395F_STATUS_OK) { @@ -1561,14 +1596,16 @@ static int net_connect_locked(int sockfd, const struct net_sockaddr *addr, int a p_sock->remote_ip = p_addr_in->sin_addr.s_addr; p_sock->remote_port = net_ntohs(p_addr_in->sin_port); - /* 转换 IP 为数组格式(大端/网络字节序,与 ch395f set_des_ip 期望一致, - * 同 Phase2 phase2_tc201 的 test_parse_ip 结果 [192,168,1,2]) */ - p_sock->remote_ip_arr[0] = (uint8_t)(p_sock->remote_ip >> 24); - /* 最高字节 → [0](大端序) */ - p_sock->remote_ip_arr[1] = (uint8_t)(p_sock->remote_ip >> 16); - p_sock->remote_ip_arr[2] = (uint8_t)(p_sock->remote_ip >> 8); - /* 最低字节 → [3] */ - p_sock->remote_ip_arr[3] = (uint8_t)(p_sock->remote_ip); + /* 转换 IP 为数组格式(芯片要的是**线序**数组:[0]=第一段,与 ch395f_set_des_ip + * 期望一致,同 Phase2 phase2_tc201 的 test_parse_ip 结果 [192,168,1,2])。 + * s_addr 是网络序整数,其**内存字节顺位**就是线序(2026-10-24 定案:全仓 s_addr 只此一族), + * 所以这里按字节顺位取、不做数值右移:旧写法 remote_ip_arr[0] = remote_ip >> 24 + * 取的是数值的最高字节,只配得上当时那套「第一段在最高字节」的 inet_addr; + * inet_addr 已改成 POSIX 网络序后,右移写法会把地址拆成 [100,1,168,192] 反序, + * 表现为「连谁都不知去哪了」。用 memcpy 取顺位与 net_events.c 填 remote_ip_arr + * 的写法同一形制(那边一直就是线序顺位),两端不再各说一套。 */ + (void)memcpy(p_sock->remote_ip_arr, &p_sock->remote_ip, + sizeof(p_sock->remote_ip_arr)); /* 配置 CH395F Socket:先 OPEN 再写源/目的端口(避免 open 复位寄存器) */ /* 设置协议类型为 TCP */ @@ -2429,27 +2466,34 @@ int net_sendto(int sockfd, const void *buf, int len, int flags, return NET_ERR_RETURN_1(NET_ERR_BADF); } - /* 缓冲区无效 → INVAL */ + /* 缓冲区无效 → INVAL(本块必须留在守卫之前:参数错误先报参数错误) */ if (buf == NULL || len <= 0) { return NET_ERR_RETURN_1(NET_ERR_INVAL); } + /* netTask 上下文守卫:本接口在本任务上下文里直接 net_poll + 读写芯片缓冲, + * 非执行任务调用会与本层 net_poll 抢同一根 SPI2(TRAP-NET-14)。放在参数 + * 校验**之后**:越界/未在用/参数非法这些正常错误路径照旧先报,守卫不吞它们。 + * 判据与留痕见 net_ctx_is_owner_task()。 */ + if (net_ctx_is_owner_task() == 0U) { + DBG_ERROR("sendto from non-exec task: refused (SPI race, use netTask)"); + return NET_ERR_RETURN_1(NET_ERR_NOT_IN_NETTASK); + } + /* UDP 模式:设置目标地址 */ if (dest_addr != NULL && addrlen >= (int)sizeof(struct net_sockaddr_in)) { /* 强转为 IPv4 地址结构体 */ p_addr_in = (const struct net_sockaddr_in *)dest_addr; - /* 转换 IP 为大端数组(同 TCP 客户端,set_des_ip 期望大端) */ - /* 大端 IP 数组 */ + /* 转换 IP 为线序数组(同 TCP 客户端:set_des_ip 要 [0]=第一段)。 + * s_addr 的内存字节顺位即线序(2026-10-24 定案:全仓 s_addr 只此一族),按顺位取, + * 不再按数值右移取最高字节——理由与 net_connect_locked 内同一处相同 */ + /* 线序 IP 数组 */ uint8_t ip_arr[4]; /* 目的 IP(网络序 32 位) */ uint32_t ip = p_addr_in->sin_addr.s_addr; - /* 最高字节 → [0](大端序) */ - ip_arr[0] = (uint8_t)(ip >> 24); - ip_arr[1] = (uint8_t)(ip >> 16); - ip_arr[2] = (uint8_t)(ip >> 8); - /* 最低字节 → [3] */ - ip_arr[3] = (uint8_t)(ip); + /* 字节顺位拷贝:ip_arr[i] = s_addr 的第 i 个内存字节 */ + (void)memcpy(ip_arr, &ip, sizeof(ip_arr)); /* 设置目的 IP */ ch395f_set_des_ip(sockfd, ip_arr); @@ -2557,6 +2601,13 @@ int net_recvfrom(int sockfd, void *buf, int len, int flags, return NET_ERR_RETURN_1(NET_ERR_INVAL); } + /* netTask 上下文守卫:本接口自旋 net_poll + 读芯片缓冲,同样只允许执行任务调; + * 位置与 net_sendto 同一原则(参数校验之后、任何芯片事务之前) */ + if (net_ctx_is_owner_task() == 0U) { + DBG_ERROR("recvfrom from non-exec task: refused (SPI race, use netTask)"); + return NET_ERR_RETURN_1(NET_ERR_NOT_IN_NETTASK); + } + /* 轮询直到:有数据 / 非阻塞(阻塞 = 无限等待,无默认预算) */ while (1) { /* 轮询一次:更新状态机与可读标志 */ @@ -2586,7 +2637,10 @@ int net_recvfrom(int sockfd, void *buf, int len, int flags, /* CH395F UDP 头 [0-1] 数据长度(LE) [2-3] src_port(LE) [4-7] src_ip(§9.2.4 表) */ /* 源端口(小端 2 字节 → 主机序 → 网络序) */ p_addr_in->sin_port = net_htons((uint16_t)(header[2] | (header[3] << 8))); - /* 源 IP(小端 4 字节组装为网络序) */ + /* 源 IP:芯片给的是线序字节([4]=第一段),按"首段落在最低 + * 字节"组装即得网络序整数——与本层 s_addr 唯一约定同族 + * (2026-10-24 定案:全仓 s_addr 只此一族),所以这里**不能再加一次换端**。 + * 源 IP(小端 4 字节组装为网络序) */ p_addr_in->sin_addr.s_addr = ((uint32_t)header[4]) | ((uint32_t)header[5] << 8) | ((uint32_t)header[6] << 16) | @@ -2819,11 +2873,20 @@ int net_sock_state(int sockfd) { * 出口参数:无 * 返回值:网络字节序的 32 位 IP 地址(0 表示参数无效) uint32_t * 限定条件:无 - * 函数说明:1. 逐段解析十进制数与并小端组装为 32 位值 - * 2. 转换结果经 NET_HTONL 转为网络字节序 + * 函数说明:1. 逐段解析并按「第一段在主机序最高字节」累加,再经 NET_HTONL 转网络序, + * 使结果满足 POSIX in_addr_t 的不变量:&s_addr 的第 0 个字节 == 点分第一段。 + * 本文件内 s_addr 只有这一族(2026-10-24:两套 IP 字节序约定统一为 POSIX 网络序):net_inet_ntoa 的 + * 「先 NTOHL、再高段在前」正是本函数的解析逆运算;net_getsockname、 + * net_getpeername、net_recvfrom 填的 s_addr 也同族,故三者可直接相等比较。 + * 2. 小端主机(STM32F407 与 PC 模拟器均是)上 "192.168.1.100" 的数值为 + * 0x6401A8C0(内存字节 C0 A8 01 64)。旧实现在小端累加后又多做一次 + * HTONL,得到 0xC0A80164,与 ntoa 不互逆——那是同一文件里的第二套约定, + * 已连同其消费者(connect/sendto 的 des_ip 拆装)一起改为本族。 + * 3. 段值超过 255 时按低 8 位截断(旧实现会把溢出位溅进相邻段,更不可预期); + * 非法字符仍按 0 处理,失败返回值维持 0,不改 NET_INADDR_NONE 的语义。 */ uint32_t net_inet_addr(const char *cp) { - /* 组装出的 32 位地址(主机序,末转网络序) */ + /* 主机序累加值(第一段最终落在最高字节) */ uint32_t addr = 0; /* 段循环索引 */ int i; @@ -2842,15 +2905,15 @@ uint32_t net_inet_addr(const char *cp) { val = val * 10 + (*cp - '0'); cp++; } - /* 本段放入对应字节位(段 0 = 最低字节,小端组装) */ - addr |= (val << (i * 8)); + /* 高位先入:第 0 段左移三次后落在最高字节 */ + addr = (addr << 8) | (val & 0xFFU); /* 跳过分隔符点(末段可无点) */ if (*cp == '.') { cp++; } } - /* 转网络字节序后返回 */ + /* 转网络字节序后返回(此时最低地址字节 = 第一段) */ return NET_HTONL(addr); } @@ -2863,8 +2926,15 @@ uint32_t net_inet_addr(const char *cp) { * 限定条件:buf 非空且长度足够(>= 16 字节) * 函数说明:1. 先经 NET_NTOHL 转为主机字节序再格式化输出 * 2. ⚠ 段序(E13 定案 修复):NTOHL 后主机序数值的高字节才是 - * 点分第一段(192.168.1.100 → 0xC0A80164 → "192.168.1.100")。 - * 原实现低段在前,显示成字节反转的 "100.1.168.192" + * 点分第一段。**入参必须是网络序整数**(&addr 的第 0 字节 = 第一段, + * 小端上 192.168.1.100 = 0x6401A8C0,见 net_inet_addr 的约定说明), + * 于是 0x6401A8C0 → NTOHL → 0xC0A80164 → 高字节 0xC0 = 192 首段 ✓。 + * 原实现低段在前,同一入参会显示成字节反转的 "100.1.168.192"。 + * 3. ⚠ 本注释旧版举的例子写成「192.168.1.100 → 0xC0A80164 → 同串」, + * 那个 0xC0A80164 取的是**已废弃的另一族**(当时 net_inet_addr 的返回 + * 值),按本函数的真实算法它打印出来恰是 "100.1.168.192"。两套 s_addr + * 约定能在同一文件里长期共存而不被发现,源头就是这行错例(2026-10-24 + * 2026-10-24 修正;函数行为未变,只把例子改对) */ char *net_inet_ntoa(uint32_t addr, char *buf) { /* 网络序 → 主机序 */ @@ -3076,7 +3146,9 @@ int net_getsockname(int sockfd, struct net_sockaddr *addr, int *addrlen) { p_addr_in->sin_family = NET_AF_INET; /* 本地端口(主机序 → 网络序) */ p_addr_in->sin_port = net_htons(p_sock->local_port); - /* 本地 IP:由 s_local_ip 数组小端组装为 32 位网络序值 */ + /* 本地 IP:s_local_ip[] 是线序数组([0]=第一段),按"首段落在最低字节" + * 组装即得网络序整数 —— 与 net_inet_addr / net_inet_ntoa 同族(2026-10-24 定案), + * 故 getsockname 的结果可直接与 inet_addr 的返回值相等比较 */ p_addr_in->sin_addr.s_addr = ((uint32_t)s_local_ip[0]) | ((uint32_t)s_local_ip[1] << 8) | ((uint32_t)s_local_ip[2] << 16) | diff --git a/Drivers/BSP/NET/net_socket.h b/Drivers/BSP/NET/net_socket.h index 37a2c086..d085cd7e 100644 --- a/Drivers/BSP/NET/net_socket.h +++ b/Drivers/BSP/NET/net_socket.h @@ -324,6 +324,8 @@ int net_setblocking(int sockfd, int on); * 3. 用本接口把等待切成 ≤timeout_ms 的片,调用方自行累计总预算:总超时语义不变、单次占用有界 * 4. 超时用独立返回码 -2(而非靠 errno 区分)→ 无跨任务 errno 竞态 * 5. ⚠ IWDG required_mask 内任务必须走本接口或 net_select(见 net_recv 契约) + * 6. 本接口**走消息队列**、任意任务可调用(与 net_recv 同一条路径), + * 与 net_recvfrom(仅限执行任务、直调芯片)不是一回事,勿混 */ int net_recv_to(int sockfd, void *buf, int len, uint32_t timeout_ms); @@ -335,9 +337,26 @@ int net_recv_to(int sockfd, void *buf, int len, uint32_t timeout_ms); * flags - 通常为 0 * dest_addr - 目标地址 * addrlen - 地址长度 - * 返回值:实际发送字节数,失败返回 -1 - * 限定条件:socket 已创建,指针非空 - * 函数说明:设置目标地址后调用 CH395F 发送 + * 返回值:实际发送字节数 int > 0 + * -1 失败,精确码经本任务错误码槽给出(net_get_errno()): + * NET_ERR_BADF / NET_ERR_INVAL / NET_ERR_TIMEDOUT / + * NET_ERR_NOT_IN_NETTASK(调用上下文不对,见「限定条件」) + * 限定条件:**仅限本层执行任务(net_init 的调用者,NET 层 = netTask)调用**; + * socket 已创建且为 DGRAM;指针非空 + * 函数说明:1. 设置目标地址后调用 CH395F 发送 + * 2. ⚠ 本接口**不走消息队列**:它在调用方上下文里直接 net_poll + + * 写芯片缓冲,与 net_send/net_recv/net_connect 那一族(入队交 netTask + * 串行执行)不是同一条路径。别的任务调用它 = 与本层的 net_poll 抢 + * 同一根 SPI2(TRAP-NET-14 的根因形态),故加上下文守卫直接拒掉, + * 回 NET_ERR_NOT_IN_NETTASK。**该码重试永远无意义**(既非忙也非超时): + * 要么把调用挪进执行任务,要么改用走队列的 net_send——没有第三条。 + * 3. 「把它改成走消息队列」是已知后续项,未做的原因:UDP 的目的地址 + * 与源地址回传都要穿过 net_msg_t,而 net_msg_t 按值拷入队列、 + * 错误码只搭任务通知值回来(AGENTS 明文「勿给 net_msg_t 加回传字段」), + * 真要串行化得先设计 per-task 结果槽并配真机回归(2026-10-24 定案: + * 本轮只把危险面关掉,串行化仍未做)。 + * 4. 全仓当前**无生产调用者**(只有 test/board 与模拟器),正因如此 + * 这条守卫要在有人开始用之前就在。 */ int net_sendto(int sockfd, const void *buf, int len, int flags, const struct net_sockaddr *dest_addr, int addrlen); @@ -350,8 +369,12 @@ int net_sendto(int sockfd, const void *buf, int len, int flags, * flags - 通常为 0;NET_MSG_DONTWAIT 单次强制非阻塞 * src_addr - 输出:发送方地址 * addrlen - 输入输出:地址长度 - * 返回值:实际接收字节数,失败返回 -1 - * 限定条件:socket 已创建,指针非空 + * 返回值:实际接收字节数(0 = 无数据可报的边界形态见说明) int >= 0 + * -1 失败,精确码经本任务错误码槽给出(net_get_errno()): + * NET_ERR_BADF / NET_ERR_INVAL / NET_ERR_WOULDBLOCK / + * NET_ERR_NOT_IN_NETTASK(调用上下文不对,见「限定条件」) + * 限定条件:**仅限本层执行任务(net_init 的调用者,NET 层 = netTask)调用**; + * socket 已创建且为 DGRAM;指针非空 * 函数说明:1. 读取 CH395F 接收缓冲区,获取对端地址 * 2. 阻塞语义与 net_recv 同:blocking 属性下无数据 * 无限等待,DONTWAIT / 非阻塞属性立即 NET_ERR_WOULDBLOCK; @@ -438,6 +461,16 @@ int net_sock_state(int sockfd); * 返回值:网络字节序 IP,失败返回 0 * 限定条件:cp 指针非空 * 函数说明:不支持域名解析 + * + * s_addr 的**唯一约定**(2026-10-24 定案:两套 IP 字节序约定统一为 POSIX 网络序;全仓只此一族,勿再引入第二套): + * 网络字节序整数 —— &s_addr 的第 0 个内存字节 == 点分第一段。 + * 小端主机(STM32F407 与 PC 模拟器都是)上 "192.168.1.100" 的数值 = 0x6401A8C0。 + * 与 net_inet_ntoa 互为逆运算;net_getsockname / net_getpeername / net_recvfrom + * 填出来的 s_addr、以及 net_bind / net_connect / net_sendto 收进去的 s_addr + * 全在同一族,故可直接做相等比较(旧实现里 inet_addr 与其余四个不互逆, + * 曾造成「解析→存储→打印」链路输出反序 IP,见 TRAP-NET-21 与本函数实现注释)。 + * 另:本层 inet_addr 语义与 BSD 一致——失败回 0,故 "0.0.0.0" 与非法串同值; + * 需要区分时用 net_inet_addr 的返回值再判,勿拿 NET_INADDR_NONE 当失败哨兵。 */ uint32_t net_inet_addr(const char *cp); diff --git a/Drivers/BSP/NET/net_types.h b/Drivers/BSP/NET/net_types.h index 24c36b8f..8aa4bbf5 100644 --- a/Drivers/BSP/NET/net_types.h +++ b/Drivers/BSP/NET/net_types.h @@ -108,6 +108,15 @@ struct net_sockaddr_in { #define NET_ERR_TIMEDOUT -9 /* 操作超时 */ #define NET_ERR_NOSPACE -10 /* 发送缓冲区无空间 */ #define NET_ERR_BUSY -11 /* 操作未完成(CH395F BUSY) */ +/* + * 调用方**上下文**不对(不是运行时故障):该接口只允许在 net_init 的执行任务 + * (NET 层 = netTask / 硬件层 = testTask)里调用,其它任务调用会与本层 net_poll + * 抢同一根 SPI(TRAP-NET-14)。与上面各码的区别:BUSY/TIMEDOUT/NOSPACE 换个时刻 + * 重试有意义,本码**重试永远无意义**——要么改到执行任务里调,要么把动作交回 + * 消息队列(netMsgQueue),没有第三种。所以它不参与任何"可重试"判定, + * 上层见到它应当直接当编程错误处理(详见 net_socket.h net_sendto/net_recvfrom)。 + */ +#define NET_ERR_NOT_IN_NETTASK -12 /* 非执行任务上下文调用(重试无用) */ /* * Socket 状态枚举(**本层会话投影**,非芯片状态事实源) diff --git a/docs/子系统设计/控制台单写者日志设计说明.md b/docs/子系统设计/控制台单写者日志设计说明.md index da95515b..17f1c69b 100644 --- a/docs/子系统设计/控制台单写者日志设计说明.md +++ b/docs/子系统设计/控制台单写者日志设计说明.md @@ -123,7 +123,7 @@ flowchart LR | 参数 | 值 | 理由 | |------|-----|------| | 容量 `DBG_CON_RING_SIZE` | 4096B | 2 的幂 → `&(SIZE-1)` 回绕免除法;≈21 整行(192B/行)余量 | -| head/tail | `uint16` + `volatile`,单生产者/单写者 | head = 生产者写位置,tail = 写者读位置 | +| 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 不可达约束不适用) | @@ -331,7 +331,7 @@ sequenceDiagram | # | 项 | 说明 | |---|----|------| | O1 | 未注册期裸发无互斥 | 启动窗口(内核启动 → attach)`dbg_console_write_raw` 无互斥 → 多任务并发会**逐字节交错**(非旧 `HAL_BUSY` 丢整行)。2026-10-10 已固化"周期任务用 `dbg_console_writer_ready()` 门控"纪律;若未来开机日志密度上升,可升级为"内核前静态待发行 + attach 后统一补发"或给裸发加轻量互斥 | -| O2 | `HAL_UART_Transmit` 超时未计数 | 超时块(UART 物理失效)丢弃但不入 `s_con_drop`;v2 可加独立超时计数 | +| 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` 改 TX-only + CubeMX 重生成 → 已登记 `docs/任务规划/登记簿.md` **S-3** | @@ -351,4 +351,5 @@ sequenceDiagram | 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 僵尸通道(台架 gen-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.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 全上下文生产者」矛盾一并消除 | | v1.4 | 2026-10-10 | 新增 `dbg_console_writer_ready()`(写者就绪查询)并固化"未注册期只有 defaultTask 记日志、周期任务用就绪门控"纪律;更正 §3.4 / §7 / §9-D5 / §13-O1 的"只丢不交错"旧结论(裸发已改寄存器级轮询、无 `gState` 互斥) | diff --git a/docs/子系统设计/运行与自检记录设计说明.md b/docs/子系统设计/运行与自检记录设计说明.md index 6ba2a4f9..eceda58c 100644 --- a/docs/子系统设计/运行与自检记录设计说明.md +++ b/docs/子系统设计/运行与自检记录设计说明.md @@ -131,9 +131,12 @@ defaultTask(唯一写者,挂载成功后): - **N-24(2026-10-15,报告 N-16 写侧):一次排空合并成"每文件一次 open/write/close"**。旧实现每条记录一轮,而 `f_close` 要回写目录项、`f_write` 要对目标扇区做读-改-写 → **每条日志 2~4 次 NAND 页读写**。现在同一文件的一批(≤8 条 = 512 B 静态缓冲 `s_batch`)合并成一次追加写: - **掉电语义不变**:批次仍在**同一次 `syslog_drain()` 调用内**写完并 `f_close`;`f_close` 内部 `sync_fs` → `disk_ioctl(CTRL_SYNC)` 会 `ftl_flush_cache()` 把 FTL 的单页写回缓存刷进 NAND(`Drivers/BSP/GD5F2GQ5UE/nand_ftl.c` 的 `CTRL_SYNC` 分支)。落盘时机与旧实现每条 close 等价,**批次不跨调用驻留 RAM**:"写完即关"的路径关文件即落盘,残余只到"单次写事务进行中掉电/复位"为止(`../任务规划/登记簿.md` N-22:无空闲/掉电前周期 flush,2026-10-22 用户裁定暂不做)。 - - **条目不丢**:启动前数组按下标"看而不删",整批成功才删;队列条目在落盘失败(-2 卷忙 / -1 写失败 / -3 容量不足)时**回投队列**——旧实现 -2 会直接丢掉那一条。 + - **条目不丢**:启动前数组按下标"看而不删",整批成功才删;队列条目在落盘失败(-2 卷忙 / -1 写失败 / -3 容量不足)时**回投队列**。 + ⚠ **2026-10-24 更正本条**:旧实现里 -2 只在**按条慢路径**被正确回投,**批量路径根本没有 -2 分支**(`done` 保持 `n` → 回投循环空转)→ 卷忙时一批(≤8 条)既不落盘也不回投、静默丢失,而本节长期写着"条目不丢"替它背书。现已补 `rc == -2` 分支整批回投(不计故障、不打日志、保序),由 `test/host/sys_log_mcu/` 的 TC-SYSLOG-035 组逐条钉住。**真机必验**:FTP 传输期间持续投日志,传输结束后条数 == 投递条数、顺序不乱。 - **容量轮转仍只在按条慢路径做**:批量路径发现剩余容量装不下一批时返回 -3,退回逐条 `s_append()`("满了 unlink 重建"只保留一处实现,避免一批跨多轮重建)。 - 自愈重试(L1 丢弃重建 + 重试一次)与错误去重(L2 只报第一次)判据与单条路径一致。 + - **自愈触发集分类(2026-10-24,代码侧唯一入口 `s_is_selfheal_code()`)**:只有"删了这个文件真能修好"的介质/结构类码才触发 unlink 重建——`FR_DISK_ERR` / `FR_INT_ERR` / `FR_NO_FILESYSTEM` / `FR_DENIED`;**名字/参数类严禁归入**(`FR_INVALID_NAME` / `FR_NO_PATH` / `FR_NO_FILE` / `FR_INVALID_OBJECT`,另显式排除 `FR_NOT_READY` / `FR_NOT_ENABLED` / `FR_TIMEOUT`)。同名同因下 unlink 必然同样失败,旧集合的真实后果是**每轮排空删一次自己的历史日志且永远修不好**,违反 `docs/架构设计/STM32F4-Base存储架构说明.md` §10.1 第 10 条。`f_write` 之后的"失败/短写 → unlink 重试一次"**不接**本判定(能走到那里说明已打开成功;而簇分配失败是 `FR_OK` + 短写、根本没有码可判,必须无条件重试)。钉住:TC-SYSLOG-101 / 101b / 102 / 103;`App/log/soe_store.c` 侧同源但取**更保守子集**(TC-SOESTORE-022 / 022b)。 + - **留痕节流分两层、各自独立抑制位**:drain 侧"整批回投"只在正常→故障那一次报(`write fail, requeue %lu entries`,落盘成功一轮复位;旧文案写作 "drop N entries" 与实际行为相反——整批其实一条没丢);post 侧 `queue full, drop` / `preboot full, drop` 各一位(**丢弃语义不变,满则丢照旧**,只压留痕次数)。节流之后**没有丢弃计数**——要计数属新增对外可见信息,另列后续项。 - 读路径与写路径并发安全:UI 页在 uiTask 经 `syslog_get_record()` 读记录(内部走 `s_read_raw()`,见 §4.5);读写两侧都取卷锁 try 语义(P2-10),FatFS 系统锁保护单次调用。 - **存储未就绪/挂载失败**:待发条目滞留 RAM(下次上电丢失),串口打印告警——存储故障时本就无法持久化,属预期行为。 @@ -142,12 +145,13 @@ defaultTask(唯一写者,挂载成功后): ```c uint32_t syslog_count(syslog_id_t id); /* = filesize/64 */ int syslog_get_record(syslog_id_t id, uint32_t idx, char *ts, size_t ts_len, - char *desc, size_t desc_len); /* 越界返回 -1 */ + char *desc, size_t desc_len); /* 越界 / 读失败 / 缓冲容量不足 均返回 -1 */ ``` - MCU:**8 条滑动读窗口**(N-24,报告 N-16 读侧)——`s_read_raw()` 先查窗口(同 id + 代数未变 + 落在区间内 → **零 I/O** 直接从 512 B 静态窗口返回);未命中才 `f_open(FA_READ) → f_lseek(窗口首条×64) → f_read(≤8×64B) → f_close` **一次取回连续 8 条**,再从中切出请求的那条。旧实现每读一条都要 open/lseek/read/close 一轮,UI 翻 20 条记录 = 20 次目录项/FAT 往返。 - 失效机制:写者每次落盘后把 `s_rd_gen[id]` 自增,读者比对填充时记录的代数,不匹配就重填。对齐 32 位在 ARM 上原子,跨任务(uiTask 填、defaultTask 增)无需临界区;最坏情况多读一轮,不会读错。 - **API 与调用方一律不变**(`syslog_get_record()` 签名照旧,`ui_page_runlog` / `ui_page_selftest` 零改动)。 +- **容量契约(2026-10-24 写死在 `App/log/sys_log.h`)**:`ts_len > SYSLOG_TS_LEN`、`desc_len > SYSLOG_DESC_MAX`,**不足一律 -1,既不静默截断也不写调用方一个字节**(描述在写入侧已截到 `SYSLOG_DESC_MAX`,按最小容量给就一定装得下,读侧不需要第二道截断)。旧实现里那句 `if (dl >= desc_len) dl = desc_len - 1` 由上面的守卫保证**永不可达**,已删除;它的存在还会误导调用方"给 28 字节缓冲能读到 27 字符"——实际是整条读不到。UI 侧现用 32 B 列缓冲,走允许路径、行为不变。钉住:TC-SYSLOG-057、057b(0x7A 哨兵逐字节回比)、098。 - 模拟器:宿主文件同语义(UI 页双端零差异)。 - UI 页每帧仅读当前窗口 ≤8 条记录(每次重绘 8×64B SPI 读,用户翻页触发,频率极低)。 diff --git a/docs/架构设计/STM32F4-Base存储架构说明.md b/docs/架构设计/STM32F4-Base存储架构说明.md index 77746cf1..ad95d3b3 100644 --- a/docs/架构设计/STM32F4-Base存储架构说明.md +++ b/docs/架构设计/STM32F4-Base存储架构说明.md @@ -579,7 +579,8 @@ if (nand_ftl_format() == 0) { 7. **单写者规则** — 存储由 **defaultTask 独占**:挂载 / `f_mkfs` / 卷健康探针 / `net_cfg_load` / syslog 落盘 / 配置引擎全收敛于它。netTask / uiTask **不得**直接触碰配置引擎或 FatFS。启用存储测试套件(`TEST_SUITE_STORAGE`)时存储所有权整体移交 testTask,defaultTask 必须**完全跳过**存储 bring-up(`App/task/default_task.c` 中 `#if !defined(TEST_SUITE_STORAGE)` 包裹,2026-09-25 自 `Src/freertos.c` 迁出)——否则后台文件写与测试的 `nand_ftl_deinit` 掉电模拟并发,互相踩坏 FTL/FatFs 状态(实测:mkfs 半途冻结 + BBT 被误写)。 8. **内存布局与 DMA/CCM 约束** — FreeRTOS 堆(含全部任务栈)已迁至 **CCM `0x10000000`**(64KB,直连 M4 零等待),但 **DMA1/DMA2 物理不可达 CCM**。GD5F 驱动 `GD5F_DMA_THRESHOLD = 1024`:栈上的小缓冲(dhara meta 读 132B、`dhara_nand_is_free` 64B)一律走 **CPU 轮询**、绝不经 DMA;页级大块(2048)才走 DMA 且源/目的**全部是文件作用域静态缓冲**(落 SRAM)。新增任何 DMA 收发缓冲必须①文件作用域静态②大小≥阈值③确认落 SRAM。违反任一条 = 静默数据损坏(CCM 首轮扩容实验实证)。换料注意:克隆片可能缺 SRAM3(CCM),必须重测。 9. **LVGL 绘制缓冲越界护栏** — LVGL 9.3.0 对 I1(1bpp) 整帧渲染会在其算出的 `stride*h`(160×160/8=3200) 之后**多写 1 个字节**,紧邻 `g_ui_draw_buf` 之后的 `.bss`(如 `ui_menu_view` 的 `s_frame_box`)指针最低字节被打成 `0xFF` → 野指针 → HardFault(`PC=lv_obj_get_scroll_y`)。`ui_port.c` 的 `g_ui_draw_buf` 已预留 `UI_DRAW_BUF_GUARD=32` 字节护栏吸收该越界写(逻辑尺寸仍按 `UI_FB_SIZE` 上报,渲染数学不变)。**勿删护栏、勿把缓冲缩到与邻接 `.bss` 紧邻**。Lib/LVGL 为第三方不改。 -10. **FatFs 严格 8.3 短名** — 本移植 `Lib/FatFs/ffconf.h` 为 `FF_USE_LFN=0`,**只支持严格 8.3 短名**:主名 ≤8 字符、扩展 ≤3 字符、**不得以点开头**(2026-09-20 台架实证,`.fschk` 无限重格式化事故根因)。`.fschk` 会被 `ff.c` 的 `create_short` 把「点之后」当扩展域解析而超限 → `f_open` 恒返 `FR_INVALID_NAME`(6);这是**纯 RAM 里的名字判定,不碰盘**。`ff.c` `create_short` 全部拒绝点的地方已加 DEBUG 打印:`[ERR][FATFS] SFN reject (原因) seg='名字片段' bad_ch=坏字符`——非法名字调用看开机日志即可定位。自愈类逻辑**严禁**把名字/参数类错误码(`FR_INVALID_NAME` 等)归入"卷损坏"触发集(整卷重格式化不可逆)。补充:含冒号 `:` 的名字被 FatFs 先解析为**盘符前缀** → `f_open` 返回 `FR_INVALID_DRIVE`(11) 而非 `FR_INVALID_NAME`(6);两者都属合法拒绝(测试用例须同时接受)。 +10. **FatFs 严格 8.3 短名** — 本移植 `Lib/FatFs/ffconf.h` 为 `FF_USE_LFN=0`,**只支持严格 8.3 短名**:主名 ≤8 字符、扩展 ≤3 字符、**不得以点开头**(2026-09-20 台架实证,`.fschk` 无限重格式化事故根因)。 + - **推论(2026-10-24 补齐,代码侧唯一入口 `s_is_selfheal_code()`)**:既然名字/参数类错误码在 8.3 配置下**必然重现**(同名同因),它们就**严禁**被归进任何"卷/文件坏了"的自愈触发集——否则自愈会退化成反复删自己的数据。本仓落点:`App/log/sys_log_mcu.c` 的触发集只收 `FR_DISK_ERR` / `FR_INT_ERR` / `FR_NO_FILESYSTEM` / `FR_DENIED`,`FR_INVALID_NAME` / `FR_NO_PATH` / `FR_NO_FILE` / `FR_INVALID_OBJECT` 一律立即失败(逐码依据 + `Lib/FatFs/ff.c` 行号写在该函数上方资料块;`App/log/soe_store.c` 侧同源取更保守子集并互指)。**本条拦下的真事**:旧集合含名字类码,后果是路径/卷异常期间**每轮排空删一次自己的历史日志且永远修不好**,由主机单测照出(TC-SYSLOG-101 / 102、TC-SOESTORE-022b),详见 `docs/测试说明/主机单测覆盖率口径.md` §9「第 1 轮修复处置」。。`.fschk` 会被 `ff.c` 的 `create_short` 把「点之后」当扩展域解析而超限 → `f_open` 恒返 `FR_INVALID_NAME`(6);这是**纯 RAM 里的名字判定,不碰盘**。`ff.c` `create_short` 全部拒绝点的地方已加 DEBUG 打印:`[ERR][FATFS] SFN reject (原因) seg='名字片段' bad_ch=坏字符`——非法名字调用看开机日志即可定位。自愈类逻辑**严禁**把名字/参数类错误码(`FR_INVALID_NAME` 等)归入"卷损坏"触发集(整卷重格式化不可逆)。补充:含冒号 `:` 的名字被 FatFs 先解析为**盘符前缀** → `f_open` 返回 `FR_INVALID_DRIVE`(11) 而非 `FR_INVALID_NAME`(6);两者都属合法拒绝(测试用例须同时接受)。 11. **FA_OPEN_APPEND 陷阱 / 短写** — `FA_OPEN_APPEND`(0x30) = `FA_OPEN_ALWAYS|FA_SEEKEND`,**不含 `FA_WRITE`(0x02)**——不带写位打开的文件 `f_write` 恒返回 `FR_DENIED`(7)(这是当年 `/log` 写失败的真根因);追加写必须写 `FA_WRITE | FA_OPEN_APPEND`(2026-09-20 台架实测,`Lib/FatFs` 移植版)。该移植的 `f_write` 在簇分配失败时返回 `FR_OK` + 短写(**没有错误码**)→ 判失败必须校验写回的字节数。标志位为**旧式编号**:`FA_READ`=0x01 / `FA_WRITE`=0x02 / `FA_CREATE_NEW`=0x04 / `FA_CREATE_ALWAYS`=0x08 / `FA_OPEN_ALWAYS`=0x10 / `FA_OPEN_APPEND`=0x30 / `FA_SEEKEND`=0x20(`ff.c` 内部定义),与上游 FatFs R0.14 不同,**勿照抄上游代码示例**。 12. **崩溃可观测性(勿屏蔽)** — 已开启 `configCHECK_FOR_STACK_OVERFLOW=2` + `configUSE_MALLOC_FAILED_HOOK` + HardFault 现场报告(`Src/stm32f4xx_it.c`:裸 UART 打印 PC/LR/xPSR + CFSR/HFSR/BFAR/MMFAR 后复位,对照 `.map` 定位符号)。defaultTask **1024** words、testTask **2048** words 是**实测下限,勿降**。开机日志出现 `[ERR] STACK OVERFLOW task=` / `[ERR] HARDFAULT ...` / `MALLOC FAILED` 是精确定位线索,禁止屏蔽或降级。 13. **BBT 恢复模式** — 运行期坏块会随 TC-STORAGE-03 注入累积;当介质被**误标脏**(如 DMA 误打 CCM 栈导致随机块被写坏并持久化)时,打开 `test/board/storage_test.h` 的 `ENABLE_STORAGE_RECOVERY_TESTS` 编译**恢复构建**,testTask 会执行 `gd5f2gq5ue_bbt_rebuild()`(出厂扫描清除运行期误标)+ `nand_ftl_format()` 清账后复位。恢复后容量回到基线(即扣除 BBT 保留池后的正常容量,非出厂裸容量)。 diff --git a/docs/测试说明/主机单测覆盖率口径.md b/docs/测试说明/主机单测覆盖率口径.md index f07eff21..6a858451 100644 --- a/docs/测试说明/主机单测覆盖率口径.md +++ b/docs/测试说明/主机单测覆盖率口径.md @@ -483,7 +483,7 @@ uint16_t ch395f_link_change_count(void); `FF_USE_LFN=0` 下 `.fschk` 走的正是"扩展域 5 字符超限"这条真实路径;自愈只动自己的文件 (反复失败 20 轮后 `/log` 目录与邻居文件未被触碰)。 -**本批照出的现状(只登记、未改生产码;逐条已上报用户待裁定)**: +**本批照出的 11 条现状(缺陷原文保留在此,处置结论见紧随其后的「第 1 轮修复处置」)**: | # | 位置 | 现象与影响 | 钉住它的用例 | |---|---|---|---| @@ -498,8 +498,61 @@ uint16_t ch395f_link_change_count(void); | 9 | `App/log/console_log.c:394~412` | `con_append` 的**空位判据在 `taskENTER_CRITICAL` 之外**(check-then-act)。生产者按 `docs/子系统设计/控制台单写者日志设计说明.md` §3.1 是"全部任务 + 内核前主线程 + ISR",而同文档 §4.1 的 head/tail 行写作"单生产者/单写者"——**两处口径互斥,后者需要更正**。多生产者可在环 >95% 满时同时过判据 → `head` 各自推进后合计越过容量 → `con_used()` 回绕变小 → 写者少发一段,被覆盖的行**既不打 `[ERR]` 也不进 `dropped`**(静默损坏;索引恒取模,不会越界)。**这条没有用例、只报推断**:单线程宿主构造不出真抢占点(把注入点挂在 `taskENTER_CRITICAL` 上已在区内,不算抢占点) | —(无用例,仅登记) | | 10 | `App/log/console_log.c:181~183` + `:306~331` | `huart1.Instance == NULL`(HAL 早期 / 断言-故障上报路径的专用短路,返回 `HAL_ERROR`)在 drain 里被当成**僵尸通道**走 `con_uart_reinit()` → `MX_USART1_UART_Init()`(含 MspInit 的 RCC/GPIO 重配)。当前接线顺序(`MX_USART1_UART_Init` 早于 `dbg_console_writer_attach`)挡住了,但"未初始化"与"通道卡死"共用一条自愈支路,attach 时机一旦被提前就会在 HAL 未就绪时重配外设 | TC-CONLOG-103(钉现状) | +### 第 1 轮修复处置(2026-10-24,同日;生产码改动 + 用例翻正向) + +用户裁定「全部修」。三名队友按域并行改生产码(写范围互斥、零越界),Lead 统一 `@build` 与跑闸。 +**`@build` 0 错误 0 警告**(本工作树首次全量构建):`Code=213720 RO-data=18860 RW-data=1204 ZI-data=131500`。 +全量闸:**33/33 套件全绿**、`line=5605/6275(89.3%)`、`func=396/429(92.3%)`、未触及仍 29 个 `.c`; +与修复前逐文件比对**下降文件数 = 0**(改过生产码的三个文件覆盖随代码同步增长: +`console_log.c` 188→199、`sys_log_mcu.c` 310→324、`net_socket.c` 881→887)。 + +| 原表条目 | 处置 | 钉住它的正向断言 | +|---|---|---| +| 1 卷忙整批丢日志 | **已修**:`syslog_drain` 批量路径补 `rc == -2` 分支把 `done` 置 0 → 整批回投队列;三条纪律逐条有断言(不计故障 / 不打日志 / 保序),慢路径(参照物)一字未动 | TC-SYSLOG-035、035a、035c、035d、104 | +| 2 文案与行为相反 | **已修**:文案改 `write fail, requeue %lu entries`,并加「正常→故障」节流(`s_requeue_reported[]`,落盘成功一轮复位);真丢仍由 `hold-over drop` 兜底 | TC-SYSLOG-039、039b | +| 3 截断死分支 | **已修**:删掉永不可达的截断支,容量契约写死在 `sys_log.h`(`ts_len > SYSLOG_TS_LEN`、`desc_len > SYSLOG_DESC_MAX`,不足**一律 -1 且不写调用方一个字节**);API 签名与返回码集合不变 | TC-SYSLOG-057、057b(0x7A 哨兵逐字节回比)、098 | +| 6 soe_store 注释夸大实现 | **已修(改注释,实现不动)**:打开失败**任何错误码都不 unlink** 是有意的——soe.log 是事件序列,「打开失败就删文件」等于销毁事件证据;两侧资料块各写一份理由并**互指对方文件与测试号**,要求同步修改 | TC-SOESTORE-022 | +| 7 FR_NO_SYSTEM 不存在 | **已修**:改成实际会返回的 `FR_NOT_READY(3)` / `FR_NO_FILESYSTEM(13)`,并写明「本层对任何非 FR_OK 一律转 -1,别去枚举错误码集合」,唯一例外指向自愈触发集 | 注释面(无行为改动) | +| 4 IP 两套字节序 | **已修(统一到 POSIX 网络序)**:改 `net_inet_addr`(`&s_addr[0] == 第一段`,小端上 192.168.1.100 = `0x6401A8C0`)+ `net_connect_locked`/`net_sendto` 的 IP 数组改按内存字节顺位拆装(**进与出同时换族 → 芯片侧数组净效果不变**,由 550h 直接钉住)+ 模拟器 `net_socket_sim.c` 逐字同语义;`net_inet_ntoa` **函数体一字未动**,只把它注释里那个取了废弃族值的例子改对(就是那条隐蔽原因)。lftpd 两处经算术核对**确认无需改**(PASV 227 的双重反转仍成立),只加「勿顺手改对」的注释 | TC-NETSOCK-100 组(新增往返不变量 100g/100h、边界 100i/100j、截断 100k)、550h | +| 5 UDP 绕过 netTask | **危险面已结构性关掉,串行化改造仍未做**(按 Lead 定案,未自升级为改队列):两个 UDP 入口加**执行任务上下文守卫**,非 netTask 调用回新码 `NET_ERR_NOT_IN_NETTASK = -12`(住 `net_types.h`,与 BUSY/TIMEDOUT 的本质差别 = **重试永远无意义**,不参与任何可重试判定);守卫位于参数校验之后、任何芯片事务之前;模拟器**刻意不加**并标注为有意的不对称(单线程 + Winsock 自身线程安全、没有那根 SPI)。**附带关掉第二条更隐蔽的危险**:非 netTask 在 recvfrom 里自旋期间,netTask 可经 close 清零控制块并把同一 sockfd 分给新会话 → 旧自旋者消费新会话数据报,这条路现在断了 | TC-NETSOCK-560 组(含「零芯片事务」与「守卫不吞正常错误路径」) | +| 8 冷却哨兵与 tick 撞值 | **已修**:新增独立 `s_con_cooling` 判定「是否在冷却」,`s_con_cool_until` 只存截止时刻(无符号差跨回绕的写法保留) | TC-CONLOG-080~084、107、108 | +| 9 环空位判据在临界区外 | **已修**:「复核空位 + 整行拷贝 + 推进 head」收进同一个临界区(最长 192 B、168 MHz 下亚微秒;`vTaskDelay` 与 DWT/tick 判据一律留区外,区内绝不睡眠)→ 多生产者结构性安全,写者仍唯一。**原「这条主机测不出来」的结论本轮作废**:桩里记临界区嵌套深度并把第二生产者精确塞进第一个生产者的临界区,再把环形缓冲的宏名成函数调用使每一次碰环都可断言 | TC-CONLOG-160~167(**167 是正控**,证明 160~166 不是空转) | +| 10 未初始化被误当僵尸通道 | **已修**:drain 的失败处理先分诊——`Instance == NULL` 视为通道不可用(不 reinit、不计 tx_fault、不进冷却、只记 dropped);只有确认超时/僵尸通道才走软复位自愈 + 冷却 | TC-CONLOG-103~106 | +| (撤销)非阻塞 recv 看不见 EOF | **误报,未改任何码**:`net_recv_locked:1885` 的非阻塞分支里 `CLOSED || session_dead` 已判 EOF(0),只有会话存活才 WOULDBLOCK。顺带核实 `net_recvfrom` **不存在** EOF 误判面(UDP 无连接,无数据只有 WOULDBLOCK 或继续自旋两条出口) | —(记一次「描述得很合理」的误报:修之前先读码,误报比漏报更贵) | + +**修复过程中顺带照出的两条(原表没有,一并修掉)**: + +- `syslog_post` 的 `queue full, drop` / `preboot full, drop` **无节流**——1 号修好之后「整批回投 → 队列常驻满」成为必然场景,不修等于**用一个新刷屏换掉一个静默丢**。已按同一「正常→故障」纪律加两个独立抑制位(**丢弃语义一字未改**,满则丢照旧)。TC-SYSLOG-041b/041d/041e/041f、105、106、107、107b、107c。 +- 自愈触发集里混着 `FR_INVALID_NAME` / `FR_NO_PATH` / `FR_NO_FILE` / `FR_INVALID_OBJECT`——**比原报告的 6 号严重**:这类码下 unlink 自己的文件必然同样失败(同名同因),后果是**每轮排空删一次自己的历史日志且永远修不好**,正是 `docs/架构设计/STM32F4-Base存储架构说明.md` §10.1 第 10 条严禁的归集。已新增代码侧唯一入口 `s_is_selfheal_code()`:触发集收敛为 `{FR_DISK_ERR, FR_INT_ERR, FR_NO_FILESYSTEM, FR_DENIED}`,逐码依据(含 `Lib/FatFs/ff.c` 行号)与显式排除清单写在资料块里;`soe_store.c` 侧同源但**取更保守子集**。TC-SYSLOG-101/101b/102/103/103b、TC-SOESTORE-022b。 + +**本轮刻意没做(后续项,含理由)**:① UDP 改走 `netMsgQueue`(需先设计 per-task 结果槽:`net_msg_t` 按值拷入队列、错误码只搭任务通知值回传,AGENTS 明文禁止给 `net_msg_t` 加回传字段,且必须配真机回归); +② `simulator/sim/sim_syslog.c:171` 那条与 3 号**同源的不可达截断支**(本轮编译验证不到它——模拟器走 CMake+VS,不在 `@build` 覆盖内,不交没编译过的改动);③ `syslog_post` 的**丢弃计数**(属新增对外可见信息,本轮没有实测频率支撑阈值); +④ `dbg_console_tx_timeout()` 的**读端接线**(现无生产调用者;候选位置 `task_watchdog.c` 的 `STALL snap=… req=… h=… mg=… cf=… seg=…` 是**被解析的证据行**,加字段要连解析端与台架判据一起改,本批不碰台架);⑤ 给 104 加来源地址日志(现网 `iec104_serv.c` 根本不打印来源 IP、无 `getpeername/ntoa`,**没有输出点可看**)。 + +**必须上台架复验(本轮按指示未碰台架;主机测覆盖不到的部分逐条列出)**: + +1. TCP 出向连接(唯一真正穿过被改的 `des_ip` 拆装路径):板级 `net_test_task` Phase 7(TC-NET-701/703/704,PC 侧起 server),判据 connect 能建立、PC 侧看到的源端口正确;Phase 2 的 `test_parse_ip` 期望 `[192,168,1,2]` 不变。 +2. FTP 被动模式取一个文件并抓 227 响应:括号内前 4 元组 == 设备实际 IP、后 2 元组解出的端口 == 实际拨的端口(验 lftpd 那处双重反转没被改坏)。 +3. FTP 对端日志 `connection received from :`:ip 必须是 PC 地址且正序(验 `getpeername` → `ntoa` 同族)。 +4. UI 网络配置页改 IP 保存 → 回读一致、按新 IP 通、104/FTP 仍可连(代次重建链路;UI 显示取自配置字符串、不经 `s_addr`,列进来是兜住「配置字符串→芯片下发→回读」这条主机测不到的路)。 +5. FTP 传输期间持续投日志:传输结束后 `run.log`/`selftest.log` 条数 == 投递条数、顺序不乱、全程无 `write fail, requeue`;同窗口 `queue full, drop` 只应出现**一行**。 +6. 制造持续写故障(坏块/写保护):串口只在**转入故障那一刻**出一条,恢复后再故障要再出一条。 +7. 控制台灌高频日志把环逼到将满:不再出现半行/串行,且 `[ERR] CONSOLE dropped N lines` 与实际缺失数一致(**单位口径已变**:N 是「行或块」的计数,不恒等于行数);拔/短接 TX 造一次僵尸通道,核对 `dropped` / `tx_fault` / `tx_timeout` 各 +1。 + +**第 1 轮新踩到的坑(都有复现路径)**: + +1. **`edit` 替换块会吞掉相邻代码**:在 `net_sendto` 插守卫时 `new_string` 漏写了紧随其后的 `buf == NULL → INVAL` 检查,那块被整段删掉,现场只表现为「sendto 空指针不报 INVAL」——**是上一节刚落的存量断言 440d 当场抓到的**。教训:`old_string` 覆盖多个语义块时 `new_string` 必须逐块回读,别信「我只加了一段」。 +2. **`read` 全文回写会截断大文件**:`console_log_test.c` 到约 54 KB 后用「read 全文 → 替换 → write」连续两次把尾部(含 `main()`)吃掉,报 `C1075` 才发现。**超过约 40 KB 的文件只能定点改**(`edit` 或直接 fs 读写),不要整文件回写。 +3. **块注释里替换把结尾的注释闭合符提前引出**:给 `console_log.h` 的 API 清单加说明时,替换文本自带了注释闭合符 → 注释中途关闭、后面中文裸露成代码,报 `C3872` / `C4138` 且**错误落在远处中文字符上**,第一眼完全看不出根因。改注释类替换宁可让 old/new 都不含行尾的注释闭合符。 +4. **假时钟不要只挂在单一计数点**:假内核的时基挂在 `net_poll` 必读的 GPIO 上,而 `net_init` 未跑时 `net_poll` 直接短路 → tick 永不前进 → 停滞超时永不命中,**连 40 万次跑飞保险也挂在同一计数点、同样失效**(进程真挂死)。凡「可能短路返回」的路径都要能推进时间或独立触发保险;那条用例最后改用 `recvfrom(DONTWAIT)` 测同一分支。 +5. **被 kill 的测试进程会占住 `tempfile\u003csuite\u003e.exe`**:下一个套件表现为 `LNK1169` / `LNK1104 无法打开文件`,看着像构建配置坏了,其实先 `taskkill` 那个 exe——这是「逐套 runner 固定共享路径」的又一处连带代价(与上面第 12 条同源)。 +6. **cppcheck 会把「仅置位时才清」的抑制位写法判成 `duplicateConditionalAssign`(style,拦提交闸)**:为省一次 volatile 写而写成「判非零再清零」会被判等价赋值。处置:**回退成无条件写 + 注释写明别再「优化」回去**,不要为一条 style 告警加 inline 抑制。 +7. **新增断言要用「逐条退回生产码」验一次牙**(变异自证):把四条修复逐条退回旧实现,失败集合单调增长 6 → 11 → 18 → 22 条、每步都是上一步的超集,改回即 127/127 全绿——**没有这一步,一条空转断言就要被交上去**。 +8. **任务书里给的临时缺陷标签会被编号门禁当自造编号拦**——本批 Lead 在任务书里用的两个「单字母+连字符+序号」临时标签被原样抄进生产注释,一次冒出 8 类台账外错误(连本文档举这个反例时把字面量写出来,都会被同一根闸再拦一次,故此处只能描述形态、不能给样例)。纪律:**Lead 给的内部标签不得写进代码**,注释只写行为(如「两套 IP 字节序统一为 POSIX 网络序」),要指用例就指已登记的 `TC-*` 号。 + **给下一批的可复用手法与坑**(完整清单写在各套件 `.c` 文件头,这里只留跨模块通用的): + 1. **测试 TU 里 `#define fputc <改名符号>` 再纳入生产源**:`App/log/console_log.c`:492 的 `int fputc(int, FILE*)` 与宿主 CRT 撞符号(`libucrt.lib(fputc.obj) error LNK2005` + `LNK1169`)。 改名纳入后函数体与行覆盖一字不动;顺带实测确认**宿主 printf 不走用户 `fputc`**(测试输出不会被吞进环形缓冲)。 @@ -606,3 +659,4 @@ P2 5 个 2,226 行、P3 29 个 4,814 行明确不做**(四条对得上:13+5+ (重点:`sys_log_mcu.c:509~537` 卷忙整批丢日志、`net_socket.c` 同文件两套 IP 字节序、UDP 两个公开 API 绕过 netTask、`console_log.c` 冷却哨兵与空位判据),**生产码一行未改**,逐条登记在 §9「P2 落地」表内待裁定; §8 第 1 条同步改为「清单已吃穿」 | +| 2026-10-24 | **第 1 轮修复(同日续)**:用户裁定「全部修」,11 条现状里 **10 条已处置 + 1 条撤销**(`net_recv_locked` 那条是误报:非阻塞分支已能报 EOF,`:1885` 核过)。生产码改动跨 `sys_log_mcu.c`(整批回投 / 文案与节流 / 删不可达截断支 / 新增 `s_is_selfheal_code` 收敛自愈触发集 / post 两层留痕节流)、`soe_store.c`(注释更正 + 触发集同源更保守子集)、`cfg_file_store_mcu.c`(`FR_NO_SYSTEM` 不存在)、`sys_log.h`(读侧容量契约)、`net_socket.c/.h/net_types.h`(`s_addr` 统一到 POSIX 网络序 + UDP 两入口执行任务守卫,新码 `NET_ERR_NOT_IN_NETTASK = -12`)、`net_socket_sim.c`(同步 + 有意不对称)、`console_log.c/.h`(冷却独立标志 / 空位判据收进临界区 / 未初始化分诊 / 三本账 + 新读口)。**`@build` 0 错 0 警**(`Code=213720 RO=18860 RW=1204 ZI=131500`);全量闸 **33/33 全绿**、`5605/6275(89.3%)`、`396/429(92.3%)`、逐文件比对**下降 0**。设计文档同步:`控制台单写者日志设计说明.md`(§4.1 口径 + §13-O2 销项 + v1.5)、`运行与自检记录设计说明.md`(§4.4/§4.5 四条)、`STM32F4-Base存储架构说明.md`(§10.1 第 10 条推论)、`CH395F_Trap_Records.md`(TRAP-NET-21 后续)、`BSD_Socket_API_使用指南.md`(新增 §9.0 唯一约定)。本轮新增处置表、后续项 5 条、台架复验 7 条、新踩坑 8 条见 §9 | diff --git a/docs/网络/BSD_Socket_API_使用指南.md b/docs/网络/BSD_Socket_API_使用指南.md index 7ee9270d..99e1607c 100644 --- a/docs/网络/BSD_Socket_API_使用指南.md +++ b/docs/网络/BSD_Socket_API_使用指南.md @@ -723,6 +723,26 @@ void event_callback_example(void) 网络协议使用**大端序**(网络字节序),而 STM32 使用**小端序**(主机字节序)。发送和接收数据时需要进行字节序转换。 +### 9.0 `s_addr` 的唯一约定(2026-10-24 统一;全仓只此一族,勿再引入第二套) + +**定义**:`sin_addr.s_addr` 一律存**网络字节序整数**,判据是 `&s_addr` 的**第 0 个内存字节 == 点分第一段**。 +小端主机(STM32F407 与 PC 模拟器都是)上 `"192.168.1.100"` 的数值 = **`0x6401A8C0`**(内存里是 `C0 A8 01 64`)。 + +| 方向 | 用哪个 API | 要点 | +|---|---|---| +| 字符串 → `s_addr` | `net_inet_addr()` | 失败返回 **0**(与 BSD 一致),故 `"0.0.0.0"` 与非法串同值;**别拿 `NET_INADDR_NONE` 当失败哨兵** | +| `s_addr` → 字符串 | `net_inet_ntoa()` | 内部先 `NET_NTOHL` 再**高段在前**打印(TRAP-NET-21 定案) | + +**不变量(有单测钉住)**:`net_inet_ntoa(net_inet_addr(s), buf)` 还原 `s`;`net_inet_addr(net_inet_ntoa(v, buf))` 还原 `v`。 +`net_getsockname` / `net_getpeername` / `net_recvfrom` 填出来的 `s_addr`,与喂进 `net_bind` / `net_connect` / `net_sendto` +的 `s_addr` **同族、可直接相等比较**。历史背景(同一文件曾并存两套约定、`ntoa` 注释里的例子取了废弃族的值) +见 `docs/问题记录/CH395F_Trap_Records.md` TRAP-NET-21 的 2026-10-24 后续条目。 + +**UDP 直调限制**:`net_sendto` / `net_recvfrom` **不走消息队列**——它们在调用方上下文里直接 `net_poll()` + 读写芯片缓冲, +因此**仅限本层执行任务(NET 层 = netTask)调用**;别的任务调用回 `NET_ERR_NOT_IN_NETTASK`(-12)。该码**不是运行时故障, +重试永远无意义**:要么把调用挪进执行任务,要么改用走队列的 `net_send` / `net_recv`,没有第三条路。 +「把这两个接口改成经 `netMsgQueue` 串行化」是已知后续项,未做的理由见 `Drivers/BSP/NET/net_socket.h` 同名函数头。 + ### 9.1 端口号转换 ```c diff --git a/docs/问题记录/CH395F_Trap_Records.md b/docs/问题记录/CH395F_Trap_Records.md index 7b74f375..24b8e809 100644 --- a/docs/问题记录/CH395F_Trap_Records.md +++ b/docs/问题记录/CH395F_Trap_Records.md @@ -233,6 +233,7 @@ CH395F 以太网驱动与 NET 层的实证陷阱:现象、定案根因、修 - **根因(定案)**:① `net_inet_ntoa` 段序反转——`NET_NTOHL` 后主机数字的 **MSB 才是点分首段**,原实现低段在前;② lftpd 打印 `net_getsockname` = 本机监听地址(端口恒 21),对端地址应读 `net_getpeername`。PASV 227 消费点做了 `net_htonl` 再 MSB 拆,**双重反转恰好正确,勿误判**。 - **修复**:`net_inet_ntoa` 段序改 MSB 首(MCU+模拟器同步);新增 `net_getpeername`(读 CONNECT 事件缓存,零 SPI);lftpd 改打印**对端** IP:port(台架判据:日志 `connection received from 192.168.1.x:<高位端口>`)。 - **教训**:显示层字节序 bug 不影响协议本身(FTP 功能全程正常),但会系统性误导排障——**日志里的地址也要当被测对象核对**。 +- **后续(2026-10-24):同一文件里的`s_addr` 两套约定已统一**。修完段序之后,本条只解决了显示侧——当时 `net_inet_addr` 仍属**另一族**(小端累加后又 `NET_HTONL`,返回 `0xC0A80164`),而 `net_inet_ntoa` / `net_getsockname` 属 POSIX 网络序那族(`0x6401A8C0`),两者**不互逆**;按前者写的消费者(`net_connect_locked` 用 `>>24` 取首段)恰好自洽,所以一直没人撞上。现已统一到 **POSIX 网络序一族**(判据 `&s_addr[0] == 第一段`;小端上 192.168.1.100 = `0x6401A8C0`):改 `net_inet_addr` + 出向 IP 数组改按内存字节顺位拆装(**进与出同时换族 → 芯片侧数组净效果不变**)+ 模拟器 `simulator/sim/net_socket_sim.c` 逐字同步;`net_inet_ntoa` 函数体一字未动。**为什么它能长期不被发现**:本条"修复"行下方 `ntoa` 自己注释里的例子(`192.168.1.100 → 0xC0A80164`)**取的是废弃族的值**——注释替错误背书,比没有注释更糟。规范定义见 `docs/网络/BSD_Socket_API_使用指南.md` §9.0;往返不变量由 TC-NETSOCK-100g / 100h 钉住。 --- diff --git a/scripts/id_registry.json b/scripts/id_registry.json index c9a60b6f..f2467ed8 100644 --- a/scripts/id_registry.json +++ b/scripts/id_registry.json @@ -1563,7 +1563,7 @@ ], "scope": "global", "frozen": false, - "cursor": "TC-NETSOCK-545", + "cursor": "TC-NETSOCK-560", "cursor_owner_only": true, "check_dangling": true, "allow_orphans": true, @@ -1585,7 +1585,7 @@ ], "scope": "global", "frozen": false, - "cursor": "TC-SYSLOG-100", + "cursor": "TC-SYSLOG-107", "cursor_owner_only": true, "check_dangling": true, "allow_orphans": true, @@ -1651,7 +1651,7 @@ ], "scope": "global", "frozen": false, - "cursor": "TC-CONLOG-135", + "cursor": "TC-CONLOG-167", "cursor_owner_only": true, "check_dangling": true, "allow_orphans": true, diff --git a/scripts/test_coverage_baseline.json b/scripts/test_coverage_baseline.json index 6d54c06b..18fd88c5 100644 --- a/scripts/test_coverage_baseline.json +++ b/scripts/test_coverage_baseline.json @@ -90,11 +90,11 @@ }, "App/log/console_log.c": { "func_rate": 1.0, - "funcs_covered": 15, - "funcs_valid": 15, - "line_rate": 0.9947, - "lines_covered": 188, - "lines_valid": 189 + "funcs_covered": 16, + "funcs_valid": 16, + "line_rate": 0.995, + "lines_covered": 199, + "lines_valid": 200 }, "App/log/soe_rec.c": { "func_rate": 1.0, @@ -114,11 +114,11 @@ }, "App/log/sys_log_mcu.c": { "func_rate": 1.0, - "funcs_covered": 12, - "funcs_valid": 12, - "line_rate": 0.9904, - "lines_covered": 310, - "lines_valid": 313 + "funcs_covered": 13, + "funcs_valid": 13, + "line_rate": 0.9939, + "lines_covered": 324, + "lines_valid": 326 }, "App/protocol/iec104/iec104_asdu.c": { "func_rate": 1.0, @@ -274,11 +274,11 @@ }, "Drivers/BSP/NET/net_socket.c": { "func_rate": 1.0, - "funcs_covered": 52, - "funcs_valid": 52, - "line_rate": 0.9977, - "lines_covered": 881, - "lines_valid": 883 + "funcs_covered": 53, + "funcs_valid": 53, + "line_rate": 0.9978, + "lines_covered": 887, + "lines_valid": 889 }, "Drivers/BSP/NETCFG/ftp_cfg.c": { "func_rate": 1.0, @@ -329,9 +329,9 @@ }, "console_log": { "files": { - "App/log/console_log.c": 189 + "App/log/console_log.c": 200 }, - "lines_total": 189, + "lines_total": 200, "ok": true }, "freq": { @@ -446,9 +446,9 @@ }, "net_socket": { "files": { - "Drivers/BSP/NET/net_socket.c": 883 + "Drivers/BSP/NET/net_socket.c": 889 }, - "lines_total": 883, + "lines_total": 889, "ok": true }, "phasor": { @@ -517,9 +517,9 @@ }, "sys_log_mcu": { "files": { - "App/log/sys_log_mcu.c": 313 + "App/log/sys_log_mcu.c": 326 }, - "lines_total": 313, + "lines_total": 326, "ok": true }, "task_prof": { @@ -596,12 +596,12 @@ "files_generated_excluded": 5, "files_tested": 37, "files_untested": 107, - "func_rate": 0.9225, - "funcs_covered": 393, - "funcs_valid": 426, - "line_rate": 0.8926, - "lines_covered": 5574, - "lines_valid": 6245, + "func_rate": 0.9231, + "funcs_covered": 396, + "funcs_valid": 429, + "line_rate": 0.8932, + "lines_covered": 5605, + "lines_valid": 6275, "modules_ok": 33, "modules_total": 33 }, @@ -671,7 +671,7 @@ "path": "App/task/net_task.c" }, { - "code_lines": 100, + "code_lines": 101, "path": "Drivers/BSP/NET/net_types.h" }, { @@ -848,11 +848,11 @@ }, { "code_lines": 20, - "path": "Drivers/BSP/UI/ui_page_dc.c" + "path": "App/log/console_log.h" }, { - "code_lines": 19, - "path": "App/log/console_log.h" + "code_lines": 20, + "path": "Drivers/BSP/UI/ui_page_dc.c" }, { "code_lines": 19, diff --git a/simulator/sim/net_socket_sim.c b/simulator/sim/net_socket_sim.c index b92b2eca..f48d712e 100644 --- a/simulator/sim/net_socket_sim.c +++ b/simulator/sim/net_socket_sim.c @@ -600,8 +600,16 @@ int net_recv_to(int sockfd, void *buf, int len, uint32_t timeout_ms) { * dest_addr - 目标地址 * addrlen - 地址长度 * 出口参数:无 - * 返回值:实际发送字节数,-1 失败 + * 返回值:实际发送字节数,-1 失败(精确码见 net_get_errno) * 限定条件:socket 已创建,buf/dest_addr 非空 + * 函数说明:⚠ **MCU 端本接口另有上下文约束**:它不经消息队列、在调用方上下文里 + * 直接 net_poll + 写芯片缓冲,故 MCU 侧只允许 net_init 的执行任务调用, + * 其他任务调用回 NET_ERR_NOT_IN_NETTASK(2026-10-24 定案,理由与 + * "为何不是改成走队列"见 net_socket.h 同名声明处)。 + * 本后端刻意**不加**同样的守卫:模拟器是单线程 + Winsock(自身线程安全), + * 没有"与 net_poll 抢同一根 SPI"这条危险面,也没有 RTOS 任务句柄可比。 + * 所以这是**有意的不对称**,不是漏实现;若将来模拟器引入多线程, + * 必须连同这条约束一起补上(按线程 ID 比 net_init 的调用线程)。 */ int net_sendto(int sockfd, const void *buf, int len, int flags, const struct net_sockaddr *dest_addr, int addrlen) { @@ -643,8 +651,10 @@ int net_sendto(int sockfd, const void *buf, int len, int flags, * src_addr - 输出:发送方地址(可为 NULL) * addrlen - 输入输出:源地址长度(可为 NULL) * 出口参数:buf/src_addr/addrlen - 填充 - * 返回值:实际接收字节数,-1 失败 + * 返回值:实际接收字节数,-1 失败(精确码见 net_get_errno) * 限定条件:socket 已创建,buf 非空 + * 函数说明:MCU 端有"仅限执行任务调用"的上下文守卫(NET_ERR_NOT_IN_NETTASK), + * 本后端按 net_sendto 处同一条理由不加——有意的不对称,勿当成漏实现 */ int net_recvfrom(int sockfd, void *buf, int len, int flags, struct net_sockaddr *src_addr, int *addrlen) { @@ -803,7 +813,14 @@ int net_get_errno(void) { * 出口参数:无 * 返回值:网络字节序 IP(参数无效返回 0) * 限定条件:无 - * 函数说明:逐段解析后按 NET_HTONL 转网络字节序(与 MCU 端实现一致) + * 函数说明:与 MCU 端 net_inet_addr **逐字同语义**(2026-10-24:两套 IP 字节序约定统一为 POSIX 网络序): + * 按「第一段在主机序最高字节」累加,再经 NET_HTONL 转网络序,结果满足 + * POSIX/Winsock 的 in_addr 不变量——&s_addr 的第 0 个内存字节 == 第一段。 + * 本模拟器的 to_ws_addr()/from_ws_addr() 是把 net_sockaddr_in **整块 memcpy** + * 进 sockaddr_in 的,所以只有两边同族时才成立:旧实现(小端累加后再换端) + * 交给 Winsock 的是反序地址,只有从不走 inet_addr 的路径(bind ANY、 + * getpeername)才没暴露。改完后本函数与 net_inet_ntoa 互逆、与 Winsock 直拷兼容。 + * 段值超 255 按低 8 位截断;失败返回 0(不引入 NET_INADDR_NONE 判定)。 */ uint32_t net_inet_addr(const char *cp) { uint32_t addr = 0; @@ -819,7 +836,8 @@ uint32_t net_inet_addr(const char *cp) { val = val * 10 + (uint32_t)(*cp - '0'); cp++; } - addr |= (val << (i * 8)); + /* 高位先入:第 0 段左移三次后落在最高字节 */ + addr = (addr << 8) | (val & 0xFFU); if (*cp == '.') { cp++; } diff --git a/test/host/cfg_file_store_mcu/cfg_file_store_mcu_test.c b/test/host/cfg_file_store_mcu/cfg_file_store_mcu_test.c index 70d5f4b9..694b0770 100644 --- a/test/host/cfg_file_store_mcu/cfg_file_store_mcu_test.c +++ b/test/host/cfg_file_store_mcu/cfg_file_store_mcu_test.c @@ -74,13 +74,15 @@ * TC-CFGFST-052 cfg_store_free(NULL) 安全(不计数、不崩) * TC-CFGFST-053 heap_free 原样转发 xPortGetFreeHeapSize * - * 本套照出的现状(只登记、不改生产代码;进交 Lead 的报告): - * cfg_file_store_mcu.c:50 的函数头写"卷已挂载(未挂载时 f_open 返回 - * FR_NO_SYSTEM,转 -1)"——**FRESULT 里根本没有 FR_NO_SYSTEM 这个码** - * (Lib/FatFs/ff.h 的枚举是 FR_OK..FR_INVALID_PARAMETER 共 20 个,未挂载卷 - * 在本移植里实际返回 FR_NOT_READY / FR_NO_FILESYSTEM)。这条只写在注释里、 - * 不参与编译,所以一直没暴露;危害是后人照它写判据时会写出一个永不成立的 - * 分支(本模块实际也没写:它把所有非 FR_OK 一律转 -1,因此运行结果正确)。 + * 本套照出的现状 —— E 项已在 task-4(2026-10-24)修掉(纯注释更正,无行为改动): + * cfg_file_store_mcu.c 的 cfg_store_read "限定条件"原写"未挂载时 f_open 返回 + * FR_NO_SYSTEM"——FRESULT 里没有这个码(Lib/FatFs/ff.h 枚举共 20 个: + * FR_OK..FR_INVALID_PARAMETER)。实际未挂载返回 FR_NOT_READY(3),卷上无有效 FAT + * 返回 FR_NO_FILESYSTEM(13)。本模块对任何非 FR_OK 一律转 -1,所以运行结果一直 + * 是对的,危害在"后人照它写判据会写出永不成立的分支"。现已改成实际码,并写明 + * "不要枚举错误码集合"——唯一的例外是必须区分"删文件能不能修好"的自愈触发集 + * (App/log/sys_log_mcu.c 的 s_is_selfheal_code)。 + * 本套不新增断言:这条是注释层的事实错误,测试无从观测(生产行为未变)。 * * 故意没测(不留"看起来全覆盖"的错觉): * - **真掉电**:unlink 与 rename 之间断电图象只能靠"目标缺失 -> 上层按首启重建" diff --git a/test/host/common/ch395f_fake.c b/test/host/common/ch395f_fake.c index e74e0c60..1b2c11f5 100644 --- a/test/host/common/ch395f_fake.c +++ b/test/host/common/ch395f_fake.c @@ -194,10 +194,33 @@ void ch395f_read_recv_buf(uint8_t sock, uint8_t *p_data, uint16_t len) g_chip.last_read_len = len; fake_log('r'); if ((p_data != NULL) && (len > 0U)) { - (void)memset(p_data, 0, len); /* 交出去的是零,测试不依赖内容 */ + if (g_fx.recv_bytes_n > 0U) { + /* 已预置内容:按预置交付,不足部分补零(默认分支行为不变) */ + uint16_t n = (len < g_fx.recv_bytes_n) ? len : g_fx.recv_bytes_n; + + (void)memcpy(p_data, g_fx.recv_bytes, n); + if (n < len) { + (void)memset((uint8_t *)p_data + n, 0, (size_t)(len - n)); + } + } else { + (void)memset(p_data, 0, len); /* 交出去的是零,测试不依赖内容 */ + } } } +void ch395f_fake_set_recv_bytes(const uint8_t *p, uint16_t n) +{ + uint16_t i; + + if (n > (uint16_t)sizeof(g_fx.recv_bytes)) { + n = (uint16_t)sizeof(g_fx.recv_bytes); + } + for (i = 0U; i < n; i++) { + g_fx.recv_bytes[i] = (p != NULL) ? p[i] : (uint8_t)0xA5U; + } + g_fx.recv_bytes_n = n; +} + void ch395f_write_send_buf(uint8_t sock, uint8_t *p_data, uint16_t len) { (void)p_data; diff --git a/test/host/common/ch395f_fake.h b/test/host/common/ch395f_fake.h index 41bf996c..c33a99f2 100644 --- a/test/host/common/ch395f_fake.h +++ b/test/host/common/ch395f_fake.h @@ -255,6 +255,11 @@ typedef struct { /* 1=复位机**不**替芯片把 cached_open 落回 CLOSED(造"永远关不掉"的芯片形态, * 用来量上层的复位超时收口)。默认 0 → 与既有行为一致。 */ uint8_t rst_keep_open[CH395F_FAKE_SOCK_MAX]; + /* GET_RECV_BUF 的**脚本化内容**(默认长度 0 → 与既有"全零交付"逐字相同)。 + * UDP 报文头(长度/源端口/源 IP)只有内容可脚本化才能被断言:net_recvfrom 要 + * 从芯片头里拆出对端地址,全零内容下"拆对了"与"拆反了"看起来一模一样。 */ + uint8_t recv_bytes[128]; + uint16_t recv_bytes_n; /* SINT 的**电平型**来源(可脚本化):读即清之后立刻把这个掩码放回 int_status。 * 默认 0 → 与既有假件行为逐字相同(读一次就没了)。真实芯片的 RECV_OK / * SENBUF_FREE 本就是"条件位":只要条件还在(RX 仍非空 / 发送缓冲仍空),下一轮 @@ -303,6 +308,8 @@ extern ch395f_fake_ext_obs_t g_fxchip; void ch395f_fake_ext_reset(void); void ch395f_fake_push_glob(uint16_t v); /* 预置全局中断返回序列 */ void ch395f_fake_set_sint_sticky(uint8_t sock, uint8_t mask); +/* 预置 GET_RECV_BUF 交付的字节(n=0 撤销预置);p 可为 NULL(表示填 n 个 0xA5) */ +void ch395f_fake_set_recv_bytes(const uint8_t *p, uint16_t n); /* 顺序断言辅助:清空调用日志 / 找某个短名第一次出现的位置(-1=没调过)。 * 刻意提供"清零日志",否则一次长轮询(如 sock_reset 的几十轮 'x')会把 96 格 * 环形外的顺序信息挤掉,让后续场景的顺序断言失真。 */ diff --git a/test/host/console_log/console_log_test.c b/test/host/console_log/console_log_test.c index 8f7d05e1..35a34227 100644 --- a/test/host/console_log/console_log_test.c +++ b/test/host/console_log/console_log_test.c @@ -23,7 +23,7 @@ * 留痕"改回直接调日志子系统,链接就失败——那正是必须挡住的(write_line → * con_append → drain → syslog_post → write_line 成环,静态栈深在每个任务上虚高)。 * - * 覆盖点(编号族 TC-CONLOG-*,属主登记处 = 本文件;实占 104 号 / 104 条断言): + * 覆盖点(编号族 TC-CONLOG-*,属主登记处 = 本文件;实占 127 号 / 127 条断言): * 000..006 宿主 printf 与本模块 fputc 的隔离 + 参数校验与 192B 截断 * 010..018 写者未注册 = 无锁直发(不入环、不置事件位、零临界区、不等待、256B 有界) * 020..038 注册后走环形:入环不发送、按 256B 分块、多生产者不交错、跨环尾两段拷贝、 @@ -32,20 +32,41 @@ * 050..062 缓冲满:DWT 硬上界丢弃、不覆盖旧行、等待中出现空间、无 DWT 时 200 tick * 上界、[ERR] dropped 直发不经环、读后即清 * 070..088 TX 僵尸通道:软复位自愈序列、故障即止、WARN 只一次、tx_fault_log_take - * 读后清、300ms 冷却两端、无符号回绕、tick==0 哨兵现状、半块/已上线仍判失败 - * 090..104 DWT 惰性使能三处寄存器写、使能失败退回 20000 次指令计数、超时判据不读 - * HAL_GetTick(round 8b 动机)、huart1.Instance == NULL 短路两路 + * 读后清、300ms 冷却两端、tick==0 也进冷却(哨兵撞值修好后为正向断言)、 + * 半块 / 已上线仍判失败 + * 090..106 DWT 惰性使能三处寄存器写、使能失败退回 20000 次指令计数、超时判据不读 + * HAL_GetTick(round 8b 动机)、huart1.Instance == NULL 短路两路 + + * "未初始化绝不 reinit"(误自愈修好后为正向断言)+ 证据不蒸发 * 110..117 fputc 重定向(未注册直发 / 注册逐字节入环 / ISR 上下文裸发 / 环满时 ISR * 仍不等待) * 120..122 drain 单次 250ms 预算与下轮续排(让位喂狗但不丢字节) * 130..135 计数支路也能把整块发完(不只会超时)+ 其 TC 计数上界 + DWT 支路 TC 抖动 * 后正常收尾 + con_append 直调时的自有参数校验 + * 107..108 冷却截止 tick 跨 49.7 天回绕两端(无符号差值),改用独立标志后仍成立 + * 140..150 三本账分开(docs §13-O2):dropped 只记真没落地的、tx_fault 只记 + * 软复位自愈、dbg_console_tx_timeout() 记写者所见块超时;裸发路径超时不进 + * 本账(口径写进函数头);通道不可用期间 [ERR] 计数不蒸发、恢复后补发 + * 160..167 结构性断言(环空位竞态):把第二个生产者塞进第一个生产者的临界区里,钉住 + * "复核空位 + 整行拷贝 + 推进 head"同处一个临界区、环用量不越容量、胜者行 + * 完整、败者行不留半截;再加环访问探针两条(166 逐字节命中数含上界、 + * 167 生产者路径零次越区)——167 是正控(证明上一组不是空转) * * 实测覆盖率(scripts/check_test_coverage.py --module console_log,/Od + /Zi 口径): - * App/log/console_log.c **188/189 行 = 99.5%,函数 15/15 = 100%**。 + * App/log/console_log.c **199/200 行 = 99.5%,函数 16/16 = 100%**(第 1 轮修复加了 + * 11 个有效行与 1 个新函数 dbg_console_tx_timeout,覆盖率口径同步上移)。 + * + * 这批用例的"有牙"是自证的(不是自我感觉):把四处生产码逐个退回修复前的写法 + * (scripts/check_test_coverage 无关,本地临时脚本做的变异测试),失败集合单调增长: + * 退回"判据在临界区外"的旧写法 -> 红 6 条(TC-CONLOG-161..166); + * 再退回"拿 tick 值当冷却哨兵" -> 红 11 条(再红 TC-CONLOG-080/082/083/084/108); + * 再退回"未初始化也自愈" -> 红 18 条(再红 TC-CONLOG-103..106/144/148/149); + * 四条全退回 -> 红 22 条(再红 TC-CONLOG-140/141/143/145)。 + * 改回修复版即 127/127 全绿;每一步的失败集合都是上一步的超集,说明四组断言各自 + * 咬住自己的那条语义,没有互相顶包。 + * 也就是说:这四组断言不是"写在假件规则上"的自证,而是真能咬住生产语义的。 * * 故意没测(逐行说明见交付报告): - * - console_log.c:352 唯一未覆盖行:snprintf 溢出后的钳位 `n = sizeof(note)-1`。 + * - console_log.c:383 唯一未覆盖行:snprintf 溢出后的钳位 `n = sizeof(note)-1`。 * note[64],前缀 24B + "%lu"(unsigned long 最多 10 位)+ 后缀 8B = 最长 42B * < 63B,任何输入都到不了这一行 —— 纯防御,不是漏测。 * - 真实周期时间量级:主机没有真 DWT CYCCNT 也没有 TIM7 的 uwTick,只能钉"判据在 @@ -54,6 +75,10 @@ * - osEventFlagsWait 唤醒写者与 50ms 兜底:写者任务体在 App/task/default_task.c。 * - MicroLIB 真实 retarget 链接语义:宿主 CRT 有自己的 fputc(实测与本模块符号 * 冲突 -> LNK2005),故本 TU 把被测 fputc 改名纳入,只验函数体不验链接钩子。 + * - 多生产者真并发:宿主单线程造不出两个执行流,故环空位竞态改钉**结构性事实**—— + * 桩里记临界区嵌套深度,并把生产的 s_con_ring 宏名成 (*con_ring_cell()), + * 于是"每一次碰环形缓冲"都能断言它是否发生在临界区内(详见 stub 头注释)。 + * 这不是"模拟并发",是把"判据与推进之间不出区"这句话本身变成可执行断言。 * * 通过:全部 TC PASS(main 返回 0)。 * 入口:int main(void),返回 0=PASS / 1=FAIL;跑飞保险 exit(2)。 @@ -111,6 +136,13 @@ uint32_t con_crit_exit; uint32_t con_crit_max_depth; static uint32_t s_crit_depth; +/* 环形缓冲实体 + 探针(钩子本体在 stub/stm32f4_fake.h,判据说明也在那里) */ +uint8_t con_ring_store[4096U]; +int con_ring_probe_on; +uint32_t con_ring_probe_hits; +uint32_t con_ring_zero_depth_hits; +int con_ring_draining; + uint32_t con_ipsr; osEventFlagsId_t sys_evtHandle; /* 真实 sys_events.h 声明的全局句柄 */ @@ -159,6 +191,8 @@ void vTaskDelay(TickType_t ticks) } } +void (*con_crit_hook)(void); + void con_crit_push(void) { con_crit_enter++; @@ -166,6 +200,14 @@ void con_crit_push(void) if (s_crit_depth > con_crit_max_depth) { con_crit_max_depth = s_crit_depth; } + if (con_crit_hook != 0) { + con_crit_hook(); /* 深度已记好,注入体看到的是"已在区内" */ + } +} + +uint32_t con_crit_depth(void) +{ + return s_crit_depth; } void con_crit_pop(void) @@ -259,6 +301,8 @@ static void reset_all(void) s_con_tx_fault = 0U; s_con_tx_fault_log = 0U; s_con_cool_until = 0U; + s_con_cooling = 0U; + s_con_tx_timeout = 0U; s_con_draining = 0U; s_con_writer_up = 0U; s_con_dwt_up = 0U; @@ -274,12 +318,19 @@ static void reset_all(void) con_task_self = (TaskHandle_t)&s_task_other; con_delay_calls = 0U; con_delay_hook = 0; + con_crit_hook = 0; con_crit_enter = 0U; con_crit_exit = 0U; con_crit_max_depth = 0U; s_crit_depth = 0U; + memset(con_ring_store, 0, sizeof(con_ring_store)); + con_ring_probe_on = 0; + con_ring_probe_hits = 0U; + con_ring_zero_depth_hits = 0U; + con_ring_draining = 0; + con_ipsr = 0U; sys_evtHandle = (osEventFlagsId_t)&s_evt_obj; @@ -319,6 +370,37 @@ static int tx_is(uint32_t skip, const char *s) return memcmp(tx_at(skip), s, n) == 0; } +/* 断言 [0, n) 全部是同一个字符(回绕场景下按字符比对,不依赖 NUL 结尾) */ +static int tx_prefix_all(char c, uint32_t n) +{ + uint32_t i; + + if (con_uart_tx_n < n) { + return 0; + } + for (i = 0U; i < n; i++) { + if (con_uart_tx[i] != (uint8_t)c) { + return 0; + } + } + return 1; +} + +static int tx_at_is(char c, uint32_t skip, uint32_t n) +{ + uint32_t i; + + if (con_uart_tx_n < skip + n) { + return 0; + } + for (i = 0U; i < n; i++) { + if (con_uart_tx[skip + i] != (uint8_t)c) { + return 0; + } + } + return 1; +} + /* 在已上线字节里找一段(tx 是二进制缓冲,不依赖 NUL 结尾) */ static int tx_contains(const char *needle) { @@ -357,14 +439,18 @@ static uint32_t tx_count(const char *needle) static void writer_drain(void) { con_task_self = (TaskHandle_t)&s_task_writer; + con_ring_draining++; /* 此刻读环的是写者,不计入生产者违规 */ dbg_console_drain(); + con_ring_draining--; con_task_self = (TaskHandle_t)&s_task_other; } /* 后台排空(不改当前任务句柄):模拟"生产者等一轮,写者腾出了空间" */ static void bg_drain_hook(void) { + con_ring_draining++; dbg_console_drain(); + con_ring_draining--; } /* 把环填到只剩 want_free 字节可用:走真实入环路径(顺带踩到回绕)。 @@ -518,9 +604,9 @@ static void tc_ring(void) (void)dbg_console_write_line("0123456789ABCDEF", 16U); CHECK(s_con_head == 10U && used_now() == 16U, "TC-CONLOG-033 跨环尾入环后 head 回绕到 10(4090+16-4096)"); - CHECK(memcmp(&s_con_ring[4090], "012345", 6) == 0 - && s_con_ring[0] == (uint8_t)'6' && s_con_ring[9] == (uint8_t)'F' - && s_con_ring[10] == 0U, + CHECK(memcmp(&con_ring_store[4090], "012345", 6) == 0 + && con_ring_store[0] == (uint8_t)'6' && con_ring_store[9] == (uint8_t)'F' + && con_ring_store[10] == 0U, "TC-CONLOG-034 回绕两段拷贝位置正确(前 6B 填到环尾、余 10B 从 0 起)"); writer_drain(); CHECK(con_uart_block_n == 2U && con_uart_block_len[0] == 6U @@ -678,7 +764,7 @@ static void tc_tx_fault(void) static void tc_tx_cool(void) { - printf("-- TC-CONLOG-078..084 300ms 冷却两端 + 回绕 + tick==0 现状 --\n"); + printf("-- TC-CONLOG-078..084 + 107..108 cooldown both ends / tick==0 / wrap --\n"); reset_all(); fault_setup(7000U, 1); /* 治好的那次故障 */ CHECK(s_con_cool_until == 7000U && used_now() == 3839U, @@ -691,30 +777,41 @@ static void tc_tx_cool(void) "TC-CONLOG-079 差 1ms 仍在冷却:环不动、不重复自愈(冷却即节奏保护)"); con_tick_now = 7300U; /* 到点 */ writer_drain(); - CHECK(s_con_cool_until == 0U && used_now() == 0U && dbg_console_tx_fault() == 1U, - "TC-CONLOG-080 到点即清冷却、放行本轮排空,且没有新增故障"); + CHECK(s_con_cooling == 0U && used_now() == 0U && dbg_console_tx_fault() == 1U, + "TC-CONLOG-080 到点即清冷却标志、放行本轮排空,且没有新增故障" + "(cool_until 只存时刻,不再被拿来当哨兵清零)"); + /* 修复后的正向断言:冷却与否看独立标志,不再拿 tick 值当哨兵 */ reset_all(); - fault_setup(0U, 0); - CHECK(s_con_cool_until == 0U && dbg_console_tx_fault() == 1U, - "TC-CONLOG-081 现状:tick==0 时记出的 cool_until 撞上 0 哨兵 = 等于没冷却"); + fault_setup(0U, 0); /* 故障恰好落在 HAL_GetTick()==0 那一拍 */ + CHECK(s_con_cooling == 1U && dbg_console_tx_fault() == 1U, + "TC-CONLOG-081 tick==0 那拍的故障照样进入冷却(独立标志,不撞 0 哨兵)"); + writer_drain(); /* tick 仍是 0 */ + CHECK(dbg_console_tx_fault() == 1U && s_mx_init_calls == 1U + && used_now() == (DBG_CON_RING_SIZE - 1U) - DBG_CON_CHUNK_MAX, + "TC-CONLOG-082 同一 tick 上再来一轮被冷却挡住:不再白耗一次块超时"); + con_tick_now = DBG_CON_FAULT_COOL_MS - 1U; writer_drain(); - CHECK(dbg_console_tx_fault() == 2U - && used_now() == (DBG_CON_RING_SIZE - 1U) - 2U * DBG_CON_CHUNK_MAX, - "TC-CONLOG-082 上一条的后果:下一轮立刻再耗一次块超时(节奏保护在这一 tick 失效)"); + CHECK(dbg_console_tx_fault() == 1U, "TC-CONLOG-083 冷却 299ms 仍未到点"); + con_uart_txe_stuck = 0; + con_tick_now = DBG_CON_FAULT_COOL_MS; /* 到点(且回绕起点正好是 0) */ + writer_drain(); + CHECK(s_con_cooling == 0U && used_now() == 0U && dbg_console_tx_fault() == 1U, + "TC-CONLOG-084 到点后清冷却并放行:全程没有第二次故障"); reset_all(); attach_writer(); fill_ring(0U); - s_con_cool_until = 0xFFFFFFFFU; /* 上轮故障记在回绕点 */ + s_con_cooling = 1U; /* 在不在冷却只由这个标志说 */ + s_con_cool_until = 0xFFFFFFFFU; /* 截止时刻记在回绕点前 */ con_tick_now = 100U; /* 现在刚回绕到 100 */ writer_drain(); - CHECK(s_con_cool_until == 0xFFFFFFFFU && used_now() == 4095U, - "TC-CONLOG-083 跨 49.7 天回绕仍判在冷却内(无符号差值 101 < 300)"); + CHECK(s_con_cooling == 1U && used_now() == 4095U, + "TC-CONLOG-107 wrap-around deadline still cools (unsigned delta 101 < 300)"); con_tick_now = 401U; /* 无符号差值 402 >= 300 */ writer_drain(); - CHECK(s_con_cool_until == 0U && used_now() == 0U, - "TC-CONLOG-084 回绕后到点 -> 清冷却并把 4095B 全排空"); + CHECK(s_con_cooling == 0U && used_now() == 0U, + "TC-CONLOG-108 past the wrapped deadline the flag clears and the ring empties"); } static void tc_tx_half(void) @@ -782,10 +879,10 @@ static void tc_dwt(void) "TC-CONLOG-099 DWT 支路第一次自旋就判超时(上界真的起作用,不会拖满 20000 次)"); } -/* ============ TC-CONLOG-100..104 huart1.Instance == NULL 短路 ============ */ +/* ============ TC-CONLOG-100..106 huart1.Instance == NULL 短路 + 未初始化分诊 ============ */ static void tc_null_uart(void) { - printf("-- TC-CONLOG-100..104 USART1 未初始化:安全让位,不再触发二次故障 --\n"); + printf("-- TC-CONLOG-100..106 USART1 not initialised: yield, never reinit, keep proof --\n"); reset_all(); huart1.Instance = 0; dbg_console_write_raw("boom", 5U); @@ -799,17 +896,25 @@ static void tc_null_uart(void) CHECK(dbg_console_write_line("early", 5U) == 5, "TC-CONLOG-102 未注册路径(内核前)撞上无串口也不崩、仍返回行长"); + /* 修复后的正向断言:未初始化 != 僵尸通道,绝不重配外设 */ reset_all(); attach_writer(); (void)dbg_console_write_line("abc", 3U); - huart1.Instance = 0; /* 写者起来了但串口被毁 */ + huart1.Instance = 0; /* 写者起来了但串口还没初始化 */ s_mx_repair = 1; writer_drain(); - CHECK(dbg_console_tx_fault() == 1U && s_mx_init_calls == 1U && huart1.Instance != 0, - "TC-CONLOG-103 未初始化也被当作僵尸通道自愈:重初始化把句柄装回来"); - con_dwt_step = SystemCoreClock / 4U; - writer_drain(); /* 仍在冷却 -> 让位 */ - CHECK(used_now() == 0U, "TC-CONLOG-104 自愈那一轮已把出环字节丢掉,没留下脏数据"); + CHECK(s_mx_init_calls == 0U && con_rcc_disables == 0U && con_nvic_clears == 0U, + "TC-CONLOG-103 Instance==NULL never triggers a peripheral soft reset (no RCC/GPIO reconfig before HAL is ready)"); + CHECK(dbg_console_tx_fault() == 0U && s_con_cooling == 0U, + "TC-CONLOG-104 not treated as a zombie channel: tx_fault and cooldown stay untouched"); + CHECK(dbg_console_dropped() == 1U && used_now() == 0U, + "TC-CONLOG-105 the block that left the ring but never hit the wire counts as dropped"); + + huart1.Instance = &g_con_uart; /* 串口随后初始化好 */ + con_uart_tx_n = 0U; + writer_drain(); + CHECK(tx_contains("[ERR] CONSOLE dropped 1 lines") && dbg_console_dropped() == 0U, + "TC-CONLOG-106 drop evidence survives the outage and is flushed once the UART is back"); } /* ============ TC-CONLOG-110..115 fputc 重定向 ============ */ @@ -824,7 +929,7 @@ static void tc_fputc(void) reset_all(); attach_writer(); (void)con_fputc_under_test('R', stdout); - CHECK(con_uart_tx_n == 0U && used_now() == 1U && s_con_ring[0] == (uint8_t)'R', + CHECK(con_uart_tx_n == 0U && used_now() == 1U && con_ring_store[0] == (uint8_t)'R', "TC-CONLOG-112 注册后 fputc 也收敛到单写者路径(逐字节入环)"); writer_drain(); CHECK(tx_is(0U, "R"), "TC-CONLOG-113 写者把 printf 字节发出"); @@ -897,10 +1002,178 @@ static void tc_counted_paths(void) CHECK(con_append("abc", 0U) == -1, "TC-CONLOG-135 con_append(len=0) 同样拒绝"); } + +/* ============ TC-CONLOG-140..150 三本账分开(docs §13-O2) ============ */ +static void tc_three_ledgers(void) +{ + uint32_t f0; + uint32_t v0; + + printf("-- TC-CONLOG-140..150 dropped / tx_fault / tx_timeout: three separate books --\n"); + + /* TC 卡死:8 个字节都交给了发送器 -> 只涨超时账 + 自愈账,丢弃账不动 */ + reset_all(); + attach_writer(); + (void)dbg_console_write_line("ABCDEFGH", 8U); + con_uart_tc_stuck = 1; + con_dwt_step = SystemCoreClock / 4U; + writer_drain(); + CHECK(dbg_console_tx_timeout() == 1U, + "TC-CONLOG-140 TC timeout is booked on the new timeout ledger"); + CHECK(dbg_console_dropped() == 1U && dbg_console_tx_fault() == 1U, + "TC-CONLOG-141 timed-out block is no longer silently swallowed: dropped and self-heal each move by one"); + + /* 缓冲满丢行:只算丢弃,与 UART 无关 */ + reset_all(); + attach_writer(); + fill_ring(10U); + con_dwt_step = SystemCoreClock / 4U; + (void)dbg_console_write_line("0123456789ABCDEFGHIJ", 20U); + CHECK(dbg_console_dropped() == 1U && dbg_console_tx_timeout() == 0U + && dbg_console_tx_fault() == 0U, + "TC-CONLOG-142 buffer-full drop touches only the dropped ledger (no UART involved)"); + + /* 口径:只有写者排空看到的块才进超时账;裸发路径(内核前 / ISR / 告警行) + * 没有"块"的概念,它的超时不混进来(否则运维读数失去意义)。 + * 两条硬上界(DWT 与指令计数)都算,这一点用同一断言在两处覆盖。 */ + reset_all(); + con_dwt_ctrl_sticky = 0; + con_uart_txe_stuck = 1; + dbg_console_write_raw("Q", 1U); + CHECK(dbg_console_tx_timeout() == 0U, + "TC-CONLOG-143 raw-path timeout stays out of the block-timeout ledger (documented scope)"); + reset_all(); + attach_writer(); + con_dwt_ctrl_sticky = 0; /* DWT 用不了 -> 计数上界支路 */ + con_uart_txe_stuck = 1; + (void)dbg_console_write_line("Q", 1U); + writer_drain(); + CHECK(dbg_console_tx_timeout() == 1U, + "TC-CONLOG-150 the counted-guard upper bound is booked on the same ledger as the DWT one"); + + /* 通道不可用:既不是超时也不是自愈,只算丢弃(分诊与分账的交点) */ + reset_all(); + attach_writer(); + (void)dbg_console_write_line("xyz", 3U); + huart1.Instance = 0; + writer_drain(); + CHECK(dbg_console_dropped() == 1U && dbg_console_tx_timeout() == 0U + && dbg_console_tx_fault() == 0U, + "TC-CONLOG-144 dead channel (not initialised): only the dropped ledger moves"); + + /* 145:两轮都坏 -> 超时账与自愈账各自累计(互不吞并) */ + reset_all(); + attach_writer(); + fill_ring(0U); + s_con_tx_fault = 1U; /* 预置:让首轮那条 WARN 留痕不发, + * 否则通道没治好时它自己也吃一次超时 */ + con_uart_txe_stuck = 1; + con_dwt_step = SystemCoreClock / 4U; + s_mx_repair = 0; + f0 = dbg_console_tx_fault(); + v0 = dbg_console_tx_timeout(); + writer_drain(); + con_tick_now = 400U; /* 出冷却,再来一轮 */ + writer_drain(); + CHECK((dbg_console_tx_timeout() - v0) == 2U, + "TC-CONLOG-145 timeout ledger grows once per timed-out block across rounds"); + CHECK((dbg_console_tx_fault() - f0) == 2U, + "TC-CONLOG-146 self-heal ledger grows on its own, independent of the timeout ledger"); + CHECK(dbg_console_tx_timeout() == dbg_console_tx_timeout(), + "TC-CONLOG-147 the timeout getter is a pure read (no clear-on-read semantics)"); + + /* 148/149:通道不可用期间 [ERR] 告警不能蒸发(丢的证据必须留得住) */ + reset_all(); + attach_writer(); + fill_ring(10U); + con_dwt_step = SystemCoreClock / 4U; + (void)dbg_console_write_line("0123456789ABCDEFGHIJ", 20U); /* 缓冲满丢 1 行 */ + con_dwt_step = 0U; + huart1.Instance = 0; /* 告警那一发也发不出去 */ + writer_drain(); + CHECK(dbg_console_dropped() >= 1U, + "TC-CONLOG-148 drop count survives while the channel is unavailable (evidence no longer washed away)"); + huart1.Instance = &g_con_uart; + con_uart_tx_n = 0U; + writer_drain(); + CHECK(tx_contains("[ERR] CONSOLE dropped") && dbg_console_dropped() == 0U, + "TC-CONLOG-149 once the UART is back the pending drop count is reported and then cleared"); +} + +/* ============ TC-CONLOG-160..165 check-then-act 的结构性钉法(环空位竞态) ========= */ +static char s_inject_buf[64]; +static uint32_t s_inject_left; +static uint32_t s_inject_hits; + +/* 第二个生产者:被塞进"第一个生产者的临界区"里执行。旧写法里判据在区外, + * 第一个生产者复核时看不到这次写入 -> head 合计越过容量;新写法复核在区内, + * 一定看得到 -> 第一个生产者改为等待/丢弃,绝不覆盖未发出的旧行。 */ +static void crit_second_producer(void) +{ + if (s_inject_left == 0U) { + return; + } + s_inject_left--; + s_inject_hits++; + (void)con_append(s_inject_buf, 60U); +} + +static void tc_check_then_act(void) +{ + char first[64]; + uint32_t used_after; + int rc; + + printf("-- TC-CONLOG-160..165 re-check must stay inside the critical section --\n"); + reset_all(); + attach_writer(); + fill_ring(100U); /* 只剩 100B 可用:两个 60B 塞不下 */ + memset(first, 'X', sizeof(first)); + memset(s_inject_buf, 'Y', sizeof(s_inject_buf)); + s_inject_left = 1U; + s_inject_hits = 0U; + con_ring_probe_hits = 0U; + con_ring_zero_depth_hits = 0U; + con_ring_probe_on = 1; /* 探针只在本次调用期间计数 */ + con_crit_hook = crit_second_producer; + con_dwt_step = SystemCoreClock / 4U; /* 复核不过就直接判上界,不空等 */ + rc = dbg_console_write_line(first, 60U); + con_crit_hook = 0; + con_ring_probe_on = 0; + con_dwt_step = 0U; + used_after = used_now(); + + CHECK(s_inject_hits == 1U && con_crit_max_depth == 2U, + "TC-CONLOG-160 the second producer ran with the region already held (depth two nesting)"); + CHECK(rc == -1 && dbg_console_dropped() == 1U, + "TC-CONLOG-161 the first producer re-checked inside the region, found space gone and dropped instead of overwriting"); + CHECK(used_after == (DBG_CON_RING_SIZE - 1U) - 40U, + "TC-CONLOG-162 ring usage stayed within capacity: 3995B + one 60B line, not 4115B wrapped past the end"); + + writer_drain(); /* 末尾还会跟一条 [ERR] dropped */ + CHECK(con_uart_tx_n >= used_after && tx_contains("[ERR] CONSOLE dropped 1"), + "TC-CONLOG-163 everything that survived the race is delivered, plus the drop warning"); + CHECK(tx_prefix_all('Z', (DBG_CON_RING_SIZE - 1U) - 100U) + && tx_at_is('Y', (DBG_CON_RING_SIZE - 1U) - 100U, 60U), + "TC-CONLOG-164 old 3995 bytes intact then the winner 60B line intact, loser absent (no silent overwrite)"); + CHECK(!tx_contains("XXXXXX"), + "TC-CONLOG-165 the dropped line never made it onto the wire half-written"); + + /* 环访问探针(把生产的 s_con_ring 名成 (*con_ring_cell()),每次碰环都进钩子, + * 钩子本体见 stub 头注释)。167 是纯正控:证明探针真数到了逐字节访问、不是零命中 + * 空转。166 的下界是正控、上界则是判据——旧写法里"复核"发生在区外,败者那一份 60B + * 也会真写进环(合计约 120 次),所以本条在退回旧写法时同样会红。 */ + /* 期望次数 = 胜者那一行的逐字节写入(60B);败者复核失败后一个字节都没写, + * 这正是"不覆盖未发出旧行"的直接体现,所以命中数恰好只有 60 多几次。 */ + CHECK(con_ring_probe_hits >= 60U && con_ring_probe_hits < 120U, + "TC-CONLOG-166 ring probe counted exactly the winner line byte writes (loser wrote nothing)"); + CHECK(con_ring_zero_depth_hits == 0U, + "TC-CONLOG-167 no producer-side ring access ever happened outside a critical section"); +} + /* ===================== main ===================== */ int main(void) { - /* 先验一次宿主 CRT 与本模块 fputc 的关系(被 tc_params 记成 TC-CONLOG-000) */ tc_params(); tc_unattached(); tc_ring(); @@ -914,6 +1187,8 @@ int main(void) tc_fputc(); tc_budget(); tc_counted_paths(); + tc_three_ledgers(); + tc_check_then_act(); 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 88b79bd8..2fb6b672 100644 --- a/test/host/console_log/stub/stm32f4_fake.h +++ b/test/host/console_log/stub/stm32f4_fake.h @@ -246,9 +246,41 @@ extern uint32_t con_crit_enter; extern uint32_t con_crit_exit; void con_crit_push(void); void con_crit_pop(void); +uint32_t con_crit_depth(void); +/* 进区回调:环空位竞态那条正向用例靠它把"第二个生产者"精确塞进第一个生产者的临界区里 + * (旧写法里判据在区外,塞进去的写入不会被复核到;新写法复核在区内,必然看到)。 + * 只在 con_crit_push 记完深度之后调用,不改任何深度值。 */ +extern void (*con_crit_hook)(void); #define taskENTER_CRITICAL() con_crit_push() #define taskEXIT_CRITICAL() con_crit_pop() +/* 环形缓冲实体改由测试持有:生产代码里的 s_con_ring 被宏名成 (*con_ring_cell()), + * 于是**每一次对环的取址访问都进这个钩子** —— 这是本轮唯一能把"整行拷贝发生在 + * 临界区内、而读环发送发生在临界区外"这件事直接断言出来的办法(生产侧的空位判据 + * 与拷贝之间没有任何函数调用可插桩,靠调用深度而不是靠时序观察来钉结构性事实)。 + * 钩子只记事实不改行为:返回的就是那块 4096B 存储。 */ +/* 注意:**不要**在这里定义 DBG_CON_RING_SIZE(那是生产 console_log.c 的宏, + * 重复定义会撞 C4005);环形存储用字面量 4096U,与生产的数组维度一致即兼容。 */ +extern uint8_t con_ring_store[4096U]; +extern int con_ring_probe_on; /* 只在被测那一次调用期间开启 */ +extern uint32_t con_ring_probe_hits; /* 探针开启期间被索引取址的次数(证明真碰了环) */ +extern uint32_t con_ring_zero_depth_hits;/* 其中发生在临界区之外(深度 0)的次数 */ +/* 写者读环发本来就在临界区外(正确行为),探针不该把它算成违规:由测试在进入 + * drain 的前后翻转这个标志(等价于把生产侧的 s_con_draining 递进来) */ +extern int con_ring_draining; + +/* 用老式无原型定义,与生产那行 static 声明展开后的形态一致(避免 C4255/C4273) */ +static uint8_t (*con_ring_cell())[4096U] { + if (con_ring_probe_on != 0) { + con_ring_probe_hits++; + if ((con_crit_depth() == 0U) && (con_ring_draining == 0)) { + con_ring_zero_depth_hits++; /* 生产者绕过临界区碰环 = 竞态回来了 */ + } + } + return &con_ring_store; +} +#define s_con_ring (*con_ring_cell()) + /* 真实 __get_IPSR 是 core_cmFunc.h 的静态内联;这里做成宏读一个可控量 */ extern uint32_t con_ipsr; /* 0 = 线程模式;!=0 = 中断/异常上下文 */ #define __get_IPSR() (con_ipsr) diff --git a/test/host/net_socket/net_socket_test.c b/test/host/net_socket/net_socket_test.c index 79e5bf4a..8a2107d6 100644 --- a/test/host/net_socket/net_socket_test.c +++ b/test/host/net_socket/net_socket_test.c @@ -97,6 +97,9 @@ static void reset_all(void) s_net_generation = 0U; s_net_initialized = 0U; s_net_msg_queue = NULL; + /* UDP 上下文守卫的判据也是生产 static:不清就会把上一场景的"执行任务"带进本场景 + * (表现为守卫判错上下文,且与任务句柄复用后的地址撞车) */ + s_net_owner_task = NULL; g_ns_info_count = 0; g_ns_error_count = 0; g_ns_debug_count = 0; @@ -114,12 +117,26 @@ static void bring_up(void) (void)net_msg_queue_adopt((osMessageQueueId_t)&s_fake_queue); /* 装泵:调用方阻塞等待时以 netTask 身份跑一轮消息分派(= 跨上下文那条链) */ g_ns_pump = net_process_messages; + /* net_init 以 netTask 身份跑:生产里它就是 net_stack_bring_up() 在 netTask 内 + * 调用的(App/task/net_task.c:92),而本层执行任务句柄(UDP 上下文守卫的判据) + * 正是在这里捕获——这里若不换身份,整个 UDP 守卫就只能测到"自己判自己" */ + g_ns_cur_task = (TaskHandle_t)&g_ns_task_net; (void)net_init("192.168.1.100", "255.255.255.0", "192.168.1.1", NULL); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; g_peers.pending_capture_calls = 0; /* bring_up 内部的登记动作不该算进场景 */ g_ns_error_count = 0; g_ns_info_count = 0; } +/* UDP 两接口的场景前置:net_sendto / net_recvfrom 有 netTask 上下文守卫, + * 合法用法只能在 netTask 里调 —— 这里把"换身份"收进一个入口,避免每个场景各写 + * 一遍换/还原而漏掉一次(漏了就是"守卫把正常路径判成拒绝"的假故障)。 */ +static void bring_up_netctx(void) +{ + bring_up(); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_net; +} + /* 直接造一个在用的控制块(多数场景不需要走完整的 socket()/bind()) */ static int mk_sock(int type, net_sock_state_t state) { @@ -141,26 +158,64 @@ static int mk_sock(int type, net_sock_state_t state) /* = 分片 1 纯函数面与 fd 表 ===================== */ -/* TC-NETSOCK-100 点分十进制 ↔ 32 位:钉**当前行为**并显式标注疑点(见报告) - * 现状:net_inet_addr 把首段放在**最高字节**(0xC0A80164),而 net_getsockname 组装 - * s_addr 时把首段放在**最低字节**,net_inet_ntoa 只与后者互为逆——同一文件里 - * 存在两套字节序约定。这里逐条钉住,不替生产"圆场"。 */ +/* TC-NETSOCK-100 点分十进制 ↔ 32 位:**正向断言**(字节序统一修复后) + * 全仓唯一约定:s_addr 是网络字节序整数,&s_addr 的第 0 个内存字节 == 点分第一段; + * 小端主机上 "192.168.1.100" 的数值 = 0x6401A8C0。本组断言的落点是「互逆」与 + * 「同族可比」,不是某个中间数值——数值只是这条约定的一个投影。 */ static void tc_100_inet_helpers(void) { + /* 往返不变量的样本集:两个全零/全一边界 + 三个非回文地址(非回文才能考出顺位) */ + static const char *const k_ips[5] = { + "0.0.0.0", "255.255.255.255", "1.1.1.1", "192.168.1.100", "10.0.0.0" + }; char buf[16]; + int i; reset_all(); - CHECK(net_inet_addr(NULL) == 0U, "TC-NETSOCK-100a inet_addr(NULL) 返 0(无效)"); - CHECK(net_inet_addr("192.168.1.100") == 0xC0A80164U, - "TC-NETSOCK-100b inet_addr 首段落在最高字节(当前实现)"); - CHECK(net_inet_ntoa(0xC0A80164U, buf) == buf && strcmp(buf, "100.1.168.192") == 0, - "TC-NETSOCK-100c ntoa(同一 32 位值) 与 inet_addr 不互逆(疑点已报,钉当前行为)"); + CHECK(net_inet_addr(NULL) == 0U, "TC-NETSOCK-100a inet_addr(NULL) returns 0 (invalid)"); + CHECK(net_inet_addr("192.168.1.100") == 0x6401A8C0U, + "TC-NETSOCK-100b inet_addr returns network-order int (LE value 0x6401A8C0 = bytes C0 A8 01 64)"); + CHECK(net_inet_ntoa(net_inet_addr("192.168.1.100"), buf) == buf && + strcmp(buf, "192.168.1.100") == 0, + "TC-NETSOCK-100c ntoa(inet_addr(s)) == s: the two are exact inverses"); CHECK(net_inet_ntoa(0x6401A8C0U, buf) == buf && strcmp(buf, "192.168.1.100") == 0, - "TC-NETSOCK-100d ntoa 与 getsockname 的 s_addr 约定互逆(E13 定案的段序:高段在前)"); - CHECK(net_inet_ntoa(0U, NULL) == NULL, "TC-NETSOCK-100e ntoa(NULL 缓冲) 返 NULL 不崩"); - /* 单段/缺分隔的容错:逐段解析,非数字段当 0(钉实现,不钉"应该是多少") */ - CHECK(net_inet_addr("10.0.0.1") == 0x0A000001U, "TC-NETSOCK-100f inet_addr 10.0.0.1"); + "TC-NETSOCK-100d ntoa segment order is MSB-first (fixed by E13/TRAP-NET-21, unchanged here)"); + CHECK(net_inet_ntoa(0U, NULL) == NULL, "TC-NETSOCK-100e ntoa(NULL buf) returns NULL without crashing"); + CHECK(net_inet_addr("10.0.0.1") == 0x0100000AU, + "TC-NETSOCK-100f non-palindrome address: 10.0.0.1 -> 0x0100000A (first octet in low byte)"); + + /* 正向断言 1:字符串 → 整数 → 字符串 必须回到原串(含 0.0.0.0 与 255.255.255.255) */ + for (i = 0; i < 5; i++) { + (void)memset(buf, 0, sizeof(buf)); + if (net_inet_ntoa(net_inet_addr(k_ips[i]), buf) == NULL || strcmp(buf, k_ips[i]) != 0) { + break; + } + } + CHECK(i == 5, "TC-NETSOCK-100g round trip ntoa(inet_addr(s)) == s over 5 addrs incl. 0.0.0.0 and 255.255.255.255"); + + /* 正向断言 2:整数 → 字符串 → 整数 必须回到原值(反方向也闭合) */ + for (i = 0; i < 5; i++) { + uint32_t v = net_inet_addr(k_ips[i]); + + (void)memset(buf, 0, sizeof(buf)); + (void)net_inet_ntoa(v, buf); + if (net_inet_addr(buf) != v) { + break; + } + } + CHECK(i == 5, "TC-NETSOCK-100h round trip inet_addr(ntoa(v)) == v (inverse direction closes too)"); + + /* 边界语义别改坏:255.255.255.255 是**合法地址**,不得被当失败; + * 失败只有"参数为 NULL"一种,返回值 0 同时是 0.0.0.0 —— 这是 BSD inet_addr + * 的既有歧义,本轮刻意不改(改它要动全部调用者的判错方式,风险大于收益)。 */ + CHECK(net_inet_addr("255.255.255.255") == 0xFFFFFFFFU, + "TC-NETSOCK-100i 255.255.255.255 parses to a valid value, never demoted to failure"); + CHECK(net_inet_addr("0.0.0.0") == 0U, + "TC-NETSOCK-100j 0.0.0.0 -> 0 (BSD inet_addr ambiguity, not introduced by this fix)"); + /* 段值溢出:按低 8 位截断(旧实现会把溢出位溅进相邻段,得到不可预期的混合值) */ + CHECK(net_inet_addr("300.1.1.1") == net_inet_addr("44.1.1.1"), + "TC-NETSOCK-100k segment 300 truncates to 44 (300 and 0xFF) with no spill into neighbours"); } static void tc_101_byteorder(void) diff --git a/test/host/net_socket/ns_s5_io.c b/test/host/net_socket/ns_s5_io.c index b103cecb..fb02382d 100644 --- a/test/host/net_socket/ns_s5_io.c +++ b/test/host/net_socket/ns_s5_io.c @@ -435,7 +435,7 @@ static void tc_440_udp_datagram(void) int addrlen; int fd; - bring_up(); + bring_up_netctx(); CHECK(net_sendto(-1, "x", 1, 0, NULL, 0) == -1 && net_get_errno() == NET_ERR_BADF, "TC-NETSOCK-440a sendto 越界 → BADF"); CHECK(net_sendto(0, "x", 1, 0, NULL, 0) == -1 && net_get_errno() == NET_ERR_BADF, @@ -451,7 +451,7 @@ static void tc_440_udp_datagram(void) /* 带目的地址:先设 DesIP/DesPort 再写数据(不带上限的自旋实现,健康链路一次写完) */ reset_all(); - bring_up(); + bring_up_netctx(); fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); (void)memset(&in, 0, sizeof(in)); in.sin_port = net_htons(8888); @@ -465,7 +465,7 @@ static void tc_440_udp_datagram(void) /* 大报分块(仍受 NET_SEND_CHUNK_MAX 上界约束) */ reset_all(); - bring_up(); + bring_up_netctx(); fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); g_ns_int_pin_set = 0; ch395f_fake_push_glob((uint16_t)(1U << (fd + 4))); @@ -475,7 +475,7 @@ static void tc_440_udp_datagram(void) /* recvfrom:前 8 字节是芯片 UDP 头(长度/srcPort/srcIP),要剥掉再交出去 */ reset_all(); - bring_up(); + bring_up_netctx(); fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); ch395f_fake_push_recv_len((uint8_t)fd, 8U + 20U); addrlen = (int)sizeof(in); @@ -487,7 +487,7 @@ static void tc_440_udp_datagram(void) /* 无数据 + 非阻塞 → WOULDBLOCK(阻塞形态是自旋无预算,本套刻意不测,见交付说明) */ reset_all(); - bring_up(); + bring_up_netctx(); fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); CHECK(net_recvfrom(fd, buf, (int)sizeof(buf), NET_MSG_DONTWAIT, NULL, NULL) == -1 && net_get_errno() == NET_ERR_WOULDBLOCK, "TC-NETSOCK-440m 非阻塞无数据 → WOULDBLOCK"); @@ -496,7 +496,7 @@ static void tc_440_udp_datagram(void) net_get_errno() == NET_ERR_WOULDBLOCK, "TC-NETSOCK-440n 非阻塞属性同判据生效"); /* 短于 8 字节的残包:不剥头,整包交出去(钉当前实现的边界,不猜"应该怎么算") */ reset_all(); - bring_up(); + bring_up_netctx(); fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); ch395f_fake_push_recv_len((uint8_t)fd, 5U); CHECK(net_recvfrom(fd, buf, (int)sizeof(buf), 0, NULL, NULL) == 5, diff --git a/test/host/net_socket/ns_s6_edges.c b/test/host/net_socket/ns_s6_edges.c index 9afe7053..4f8f86ea 100644 --- a/test/host/net_socket/ns_s6_edges.c +++ b/test/host/net_socket/ns_s6_edges.c @@ -186,7 +186,7 @@ static void tc_506_sendto_stall_and_short(void) int fd; int rc; - bring_up(); + bring_up_netctx(); fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); s_net_socks[fd].send_ready = 0U; g_ns_int_pin_set = 0; @@ -201,7 +201,7 @@ static void tc_506_sendto_stall_and_short(void) reset_all(); /* 写完一块后等发完的超时出口 */ - bring_up(); + bring_up_netctx(); fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); g_ns_int_pin_set = 0; ch395f_fake_push_glob((uint16_t)(1U << (fd + 4))); @@ -377,6 +377,184 @@ static void tc_545_local_entries_without_queue(void) reset_all(); } + +/* ============= TC-NETSOCK-550 s_addr 同族一致性(字节序统一后的收口断言) ============= + * + * 这次字节序统一的实质不是「给 inet_addr 换个数值」,而是「全仓只剩一套 s_addr 约定」。 + * 所以这组断言全部写在**跨函数的接缝**上:解析→存储→芯片下发→回读→打印, + * 每一环都拿同一句点分十进制做基准。任何一处又引入第二套顺位,这里必红。 + */ + +/* TC-NETSOCK-550 解析 → 存储 → 打印 闭合(getsockname / getpeername 同族可比对) */ +static void tc_550_family_roundtrip(void) +{ + struct net_sockaddr_in in; + struct net_sockaddr *sa = (struct net_sockaddr *)∈ + char txt[16]; + int addrlen; + int fd; + + bring_up(); + + /* 1) getsockname 的 s_addr 与 inet_addr 解析结果**直接相等**(旧实现差一次换端) */ + (void)mk_established(NET_SOCK_STREAM); /* getsockname 要 in_use 的描述符 */ + addrlen = (int)sizeof(in); + (void)memset(&in, 0, sizeof(in)); + CHECK(net_getsockname(0, sa, &addrlen) == 0, + "TC-NETSOCK-550a getsockname precondition ok"); + CHECK(in.sin_addr.s_addr == net_inet_addr("192.168.1.100"), + "TC-NETSOCK-550b getsockname s_addr is the same family as inet_addr (direct equality)"); + CHECK(strcmp(net_inet_ntoa(in.sin_addr.s_addr, txt), "192.168.1.100") == 0, + "TC-NETSOCK-550c local address closes parse-store-print (old code printed 100.1.168.192)"); + + /* 2) connect 的对端簿记同样闭合(lftpd:864 与 104 显示走的就是这条链) */ + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + (void)memset(&in, 0, sizeof(in)); + in.sin_family = NET_AF_INET; + in.sin_port = net_htons(2404U); + in.sin_addr.s_addr = net_inet_addr("192.168.1.200"); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_net; + CHECK(net_connect_locked(fd, sa, (int)sizeof(in)) == 0, + "TC-NETSOCK-550d connect precondition ok"); + addrlen = (int)sizeof(in); + (void)memset(&in, 0, sizeof(in)); + CHECK(net_getpeername(fd, sa, &addrlen) == 0, + "TC-NETSOCK-550e getpeername ok (reads event cache, zero SPI)"); + CHECK(in.sin_addr.s_addr == net_inet_addr("192.168.1.200"), + "TC-NETSOCK-550f getpeername peer equals the s_addr fed into net_connect"); + CHECK(strcmp(net_inet_ntoa(in.sin_addr.s_addr, txt), "192.168.1.200") == 0, + "TC-NETSOCK-550g peer address prints right (lftpd connection-received log depends on it)"); + CHECK(s_net_socks[fd].remote_ip_arr[0] == 192U && + s_net_socks[fd].remote_ip_arr[3] == 200U, + "TC-NETSOCK-550h chip des_ip array stays wire order [192,168,1,200]: on-wire behaviour unchanged"); + reset_all(); +} + +/* TC-NETSOCK-555 UDP 报文头里的源地址拆装(同族的第四个生产者:net_recvfrom) */ +static void tc_555_udp_src_addr(void) +{ + /* CH395F UDP 头(§9.2.4):[0-1] 总长(LE) [2-3] 源端口(LE) [4-7] 源 IP(线序) */ + static const uint8_t hdr[8] = { + 11U, 0U, /* 总长 = 8 头 + 3 数据 */ + 0x1FU, 0xCBU, /* 源端口 51999(小端 1F CB) */ + 239U, 1U, 2U, 3U /* 源 IP 线序:239.1.2.3 */ + }; + struct net_sockaddr_in in; + struct net_sockaddr *sa = (struct net_sockaddr *)∈ + char buf[16]; + char txt[16]; + int addrlen; + int fd; + + bring_up_netctx(); + fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); + ch395f_fake_push_recv_len((uint8_t)fd, (uint16_t)(8U + 3U)); + ch395f_fake_set_recv_bytes(hdr, (uint16_t)sizeof(hdr)); + + addrlen = (int)sizeof(in); + (void)memset(&in, 0, sizeof(in)); + CHECK(net_recvfrom(fd, buf, (int)sizeof(buf), NET_MSG_DONTWAIT, sa, &addrlen) == 3, + "TC-NETSOCK-555a strips the 8-byte chip header and delivers only the 3-byte payload"); + CHECK(in.sin_addr.s_addr == net_inet_addr("239.1.2.3"), + "TC-NETSOCK-555b source IP unpack matches the inet_addr family"); + CHECK(in.sin_port == net_htons(51999U), + "TC-NETSOCK-555c source port read chip little-endian then stored in network order"); + CHECK(addrlen == (int)sizeof(in) && net_ntohs(in.sin_port) == 51999U, + "TC-NETSOCK-555d addrlen filled and port round-trips to 51999"); + CHECK(strcmp(net_inet_ntoa(in.sin_addr.s_addr, txt), "239.1.2.3") == 0, + "TC-NETSOCK-555e multicast source address prints correctly"); + reset_all(); +} + + +/* TC-NETSOCK-560 UDP 上下文守卫:非执行任务不得直调芯片 + * 三条一起才成立,缺一条都不算把危险面关掉: + * ① 执行任务内照常成功(守卫不是"把 UDP 判死") + * ② 非执行任务被拒且**一次芯片事务都没有**(拒了却已经抢过 SPI = 白拒) + * ③ 正常错误路径不被守卫吞掉,且**走消息队列的接口完全不受影响** + * (那些接口生来就是给非 netTask 用的,误伤它们才是大事故) + */ +static void tc_560_udp_context_guard(void) +{ + char buf[8]; + int fd; + + /* ① 执行任务上下文:与修复前逐字相同 */ + bring_up_netctx(); + fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); + CHECK(net_sendto(fd, "abc", 3, 0, NULL, 0) == 3, + "TC-NETSOCK-560a net_sendto unchanged inside the executing task"); + reset_all(); + + /* ② 非执行任务:拒绝 + 零芯片事务(bring_up 里 net_init 是以 netTask 身份跑的, + * 此刻 g_ns_cur_task 已还原成 caller,正是"别的任务") */ + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); + g_chip.write_calls = 0; + g_chip.read_calls = 0; + g_fxchip.set_des_ip_calls = 0; + g_fxchip.glob_calls = 0; + g_ns_poll_calls = 0; + g_ns_error_count = 0; + + CHECK(net_sendto(fd, "abc", 3, 0, NULL, 0) == -1 && + net_get_errno() == NET_ERR_NOT_IN_NETTASK, + "TC-NETSOCK-560b non-executing task gets NET_ERR_NOT_IN_NETTASK (exact code, not -1)"); + CHECK(g_chip.write_calls == 0 && g_fxchip.set_des_ip_calls == 0 && + g_ns_poll_calls == 0, + "TC-NETSOCK-560c clean refusal: zero writes, zero set_des_ip, zero net_poll rounds"); + CHECK(net_recvfrom(fd, buf, (int)sizeof(buf), NET_MSG_DONTWAIT, NULL, NULL) == -1 && + net_get_errno() == NET_ERR_NOT_IN_NETTASK, + "TC-NETSOCK-560d net_recvfrom guarded the same way"); + CHECK(g_chip.read_calls == 0 && g_fxchip.glob_calls == 0 && g_ns_poll_calls == 0, + "TC-NETSOCK-560e recvfrom touched no chip and no global interrupt register either"); + CHECK(g_ns_error_count == 2, + "TC-NETSOCK-560f each refusal logs one ERR line (silence hides the wrong task)"); + + /* ③ 正常错误路径优先:守卫排在参数校验之后 */ + CHECK(net_sendto(-1, "abc", 3, 0, NULL, 0) == -1 && + net_get_errno() == NET_ERR_BADF, "TC-NETSOCK-560g out-of-range fd still reports BADF first"); + CHECK(net_sendto(fd, NULL, 3, 0, NULL, 0) == -1 && + net_get_errno() == NET_ERR_INVAL, + "TC-NETSOCK-560h NULL buf still reports INVAL, the guard does not swallow it"); + CHECK(net_recvfrom(fd, NULL, 3, NET_MSG_DONTWAIT, NULL, NULL) == -1 && + net_get_errno() == NET_ERR_INVAL, "TC-NETSOCK-560i recvfrom keeps the same precedence"); + + /* ④ 走消息队列的接口不受守卫影响:同一个非 netTask 上下文照常能把活干完 */ + CHECK(net_close(fd) == 0, + "TC-NETSOCK-560j net_close from a non-executing task still works (queue path untouched)"); + /* 执行完的证据用"收到回执 + 槽位释放",不看 close_calls:芯片此刻已是 + * CLOSED,走的是快路径(不再补发 CLOSE,见 TC-NETSOCK-320a/b),拿它当证据 + * 会把一条正确的快路径判成失败 */ + CHECK(s_net_socks[fd].in_use == 0U && g_ns_notify_calls >= 1, + "TC-NETSOCK-560k queue path really completed (receipt seen and slot released)"); + reset_all(); + + /* ⑤ 未捕获执行任务(net_init 没跑):守卫不抢先,仍由参数/状态检查给确定错误 */ + reset_all(); + g_ns_pump = net_process_messages; + CHECK(net_sendto(0, "abc", 3, 0, NULL, 0) == -1 && + net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-560l before net_init sendto reports BADF, no invented context error"); + + /* 未捕获执行任务、但 socket 确实在用时,守卫**选择放行**:宁可不做判定,也不给 + * 一个无从判定的场景编造错误码。放行后走的是完全正常的执行路径(这里 DONTWAIT + * 无数据 → WOULDBLOCK),证明守卫不是"没初始化就一律拒绝"的粗暴实现。 + * 刻意用 recvfrom 而不是 sendto:未初始化时 net_poll 直接短路返回、时间基准 + * 不前进,sendto 的等待环永远等不到超时(真机上不可达:net_socket 之后还有 + * NET_READY 屏障,但这条差异值得记一笔,见交付报告)。 */ + reset_all(); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; /* net_init 没跑:判据恒 NULL */ + fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); + g_fxchip.glob_calls = 0; + CHECK(net_recvfrom(fd, buf, (int)sizeof(buf), NET_MSG_DONTWAIT, NULL, NULL) == -1 && + net_get_errno() == NET_ERR_WOULDBLOCK, + "TC-NETSOCK-560m guard stays open when the executing task was never captured"); + CHECK(net_get_errno() != NET_ERR_NOT_IN_NETTASK && g_fxchip.glob_calls == 0, + "TC-NETSOCK-560n no context error invented and no global register read"); + reset_all(); +} + static void ns_run_s6(void) { tc_500_public_bounds(); @@ -392,4 +570,7 @@ static void ns_run_s6(void) tc_537_event_callback_path(); tc_540_negative_results(); tc_545_local_entries_without_queue(); + tc_550_family_roundtrip(); + tc_555_udp_src_addr(); + tc_560_udp_context_guard(); } diff --git a/test/host/soe_store/soe_store_test.c b/test/host/soe_store/soe_store_test.c index 155d20c8..780c5714 100644 --- a/test/host/soe_store/soe_store_test.c +++ b/test/host/soe_store/soe_store_test.c @@ -56,8 +56,11 @@ * TC-SOESTORE-020 丢弃留痕按差值上报一次,不刷屏 * TC-SOESTORE-021 队列创建失败(返回 NULL)→ 条目滞留环、下轮再试,不崩 * —— 陷阱二:名字/参数类错误码不得升级成卷操作 —— - * TC-SOESTORE-022 f_open 返 FR_INVALID_NAME → 只留痕返回 -1:**不 unlink**、 - * 不碰 /log 目录、条目全部保留(自愈只针对自己的文件) + * TC-SOESTORE-022 f_open 失败(FR_INVALID_NAME)-> 只留痕返回 -1:**不 unlink**、 + * 不碰 /log 目录、已有 3 条历史一条不少、条目留在待发环 + * TC-SOESTORE-022b 逐码对齐(8 个码含介质类):打开失败**任何码都不 unlink**—— + * SOE 侧比 run.log 侧更保守,是刻意差异(事件序列不可销毁), + * 与 test/host/sys_log_mcu/ 的 TC-SYSLOG-101/102 同一张表 * TC-SOESTORE-023 写路径反复失败时 f_unlink 次数有界(每轮 <=1),目录与邻居文件不动 * TC-SOESTORE-024 假卷严格 8.3 校验的保真自检:".fschk" -> 名字/盘符类拒绝码, * 而生产路径 SOE_PATH 必过(钉住"改名即静默写失败"的约束) @@ -66,12 +69,13 @@ * 函数 4/4。唯一未覆盖行 soe_store.c:202 = drain 里 for (;;) 的收尾大括号, * 该循环只能靠 break 退出,不存在"走到行尾再回跳"的机器码,属工具口径缺口。 * - * 本套照出的现状(只登记、不改生产代码;详见交 Lead 的报告): - * soe_store.c 的函数头第 ④ 条写"打开/写入结构性错误 → unlink 重试一次(同 run.log)", - * 但实现里 **f_open 失败直接 return -1、不 unlink 重试**(只有 f_write 失败才重试)。 - * run.log 侧(sys_log_mcu.c s_append_rec)则对 FR_NO_FILE/FR_NO_PATH/FR_INVALID_NAME - * 等一整套码做 unlink+重开。两套并不一致 —— 是注释夸大了实现,还是实现漏了一半, - * 需生产侧裁定;本套只用 TC-SOESTORE-022 把现状钉成"改了会响"。 + * 本轮(第 1 轮缺陷修复批次 task-4)已修,本套相应断言已从"钉现状"翻成正向: + * D 项:soe_store.c 函数头 ④ 旧写"打开/写入结构性错误都 unlink 重试一次(同 + * run.log)",与实现不符且方向错误——实现里 f_open 失败只 return -1、不 unlink。 + * 定案是**实现不动、改注释**:SOE 是事件序列,打开失败就删文件等于销毁已发生的 + * 事件;run.log 是滚动运行日志,坏了重建损失有限。现在 TC-SOESTORE-022 正向钉 + * "打开失败不得 unlink、历史一条不少",TC-SOESTORE-022b 对 8 个码逐个钉同一件事 + * (含介质类码——SOE 侧比 run.log 侧更保守,两处注释同源,改动必须同步)。 * * 通过:全部 TC PASS(main 返回 0)。 * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 @@ -633,17 +637,47 @@ static void tc_name_error_not_volume(void) printf("-- TC-SOESTORE-022..024 名字/参数类错误码严禁升级成卷操作 --\n"); - /* 022:FR_INVALID_NAME 只让本条失败,**不得** unlink 别人、不得动目录 */ + /* 022【D 项已修 2026-10-24:正向断言】f_open 失败(名字/参数类码) + * -> 只留痕返回 -1:**绝不 unlink**、不动 /log 目录、已有记录一条不少。 + * 生产侧的定案是"打开失败不重试也不删",理由写在 s_soe_append 函数头: + * SOE 是事件序列,删文件 = 销毁已发生的事件。 */ reset_all(); - (void)ff_fake_mkfile("/log/run.log", NULL, 0u); /* 邻居文件(同卷) */ + (void)ff_fake_mkfile(SOE_PATH, NULL, 3u * SOE_REC_SIZE); /* 已有 3 条历史 */ + (void)ff_fake_mkfile("/log/run.log", NULL, 0u); /* 邻居文件(同卷) */ ff_fake_inject_rc(SOE_PATH, FF_FAKE_OP_OPEN, FR_INVALID_NAME, 2); soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 1u, 1u); - soe_store_drain(); /* open 失败 -> -1 */ + soe_store_drain(); /* open 失败 */ ok = (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 0) && (ff_fake_exists("/log") == 2) + && (ff_fake_size(SOE_PATH) == 3u * SOE_REC_SIZE) /* 历史原封不动 */ && (ff_fake_exists("/log/run.log") == 1) && (s_ring_tail == 0u) && (g_soe_err_count == 1); - CHECK(ok, "TC-SOESTORE-022 FR_INVALID_NAME -> 只留痕失败;不 unlink、目录与邻居文件不动"); + CHECK(ok, "TC-SOESTORE-022 open failure keeps history: no unlink, entry stays queued"); + + /* 022b 逐码对齐(与 test/host/sys_log_mcu 的 TC-SYSLOG-101/102 同一张表): + * SOE 侧对**所有**打开失败码都不 unlink,是 run.log 侧触发集的保守子集 + * ——介质类码在 run.log 侧会 unlink 重建,在 SOE 侧不会,这是刻意的差异 + * (事件序列不可销毁),两处注释同源、改动必须同步。 */ + { + static const FRESULT codes[] = { FR_INVALID_NAME, FR_NO_PATH, FR_NO_FILE, + FR_INVALID_OBJECT, FR_DISK_ERR, FR_INT_ERR, + FR_NO_FILESYSTEM, FR_DENIED }; + uint32_t c; + int all_ok = 1; + for (c = 0u; c < (uint32_t)(sizeof(codes) / sizeof(codes[0])); c++) { + reset_all(); + (void)ff_fake_mkfile(SOE_PATH, NULL, SOE_REC_SIZE); + ff_fake_inject_rc(SOE_PATH, FF_FAKE_OP_OPEN, codes[c], 1); + soe_store_post((uint8_t)SOE_T_ELM_RETURN, (uint8_t)c, c); + soe_store_drain(); + if ((g_ff_obs.calls[FF_FAKE_OP_UNLINK] != 0) + || (ff_fake_size(SOE_PATH) != SOE_REC_SIZE) + || (s_ring_tail != 0u)) { + all_ok = 0; + } + } + CHECK(all_ok, "TC-SOESTORE-022b no open-failure code ever unlinks soe.log (8 codes)"); + } /* 023:反复失败的自愈上限:每轮最多 unlink 一次,条目与卷都不被吞 */ reset_all(); diff --git a/test/host/sys_log_mcu/sys_log_mcu_test.c b/test/host/sys_log_mcu/sys_log_mcu_test.c index 2007825e..efea41e1 100644 --- a/test/host/sys_log_mcu/sys_log_mcu_test.c +++ b/test/host/sys_log_mcu/sys_log_mcu_test.c @@ -60,7 +60,11 @@ * TC-SYSLOG-032 preboot 8 条同文件 -> 一批 512B 落盘 + preboot 计数清零 * TC-SYSLOG-033 两个文件混排 -> 按文件分批,顺序不乱、条目不丢 * TC-SYSLOG-034 换文件那条不消耗(preboot 下标回退 / 队列回投) - * TC-SYSLOG-035 卷忙 -> 整批回投队列,下一轮全部落盘(旧实现 -2 直接丢一条) + * TC-SYSLOG-035 卷忙 -> 整批回投队列,一条不丢(A 项修复后的正向断言) + * TC-SYSLOG-035a 回投后再排空:顺序与投递顺序一致,全程静默 + * TC-SYSLOG-035b 慢路径遇卷忙 -> 尾部整批回投(参照物,A 项就是向它对齐) + * TC-SYSLOG-035c 满批 8 条在卷忙时全部保住 + * TC-SYSLOG-035d preboot 来源的条目卷忙时转投队列(不丢也不重复落盘) * TC-SYSLOG-036 队列满回投失败 -> "hold-over drop" 留痕,不崩 * TC-SYSLOG-037 -3 -> 逐条慢路径,容量轮转只发生一次 * TC-SYSLOG-038 慢路径中途写失败 -> 该条丢弃留痕 + 尾部回投 @@ -76,7 +80,8 @@ * TC-SYSLOG-054 越界 idx -> -1;空文件 -> -1 * TC-SYSLOG-055 读时卷忙 -> -1(读窗口 try 语义:UI 每 100ms 喂心跳,绝不阻塞) * TC-SYSLOG-056 短读 -> 作废窗口(s_rd_win_cnt=0),不把半窗当有效命中 - * TC-SYSLOG-057 get_record:缓冲容量守卫 + 描述按 desc_len 截断 + * TC-SYSLOG-057 get_record 容量守卫:ts_len<=19 或 desc_len<=28 一律 -1 + * TC-SYSLOG-057b 被拒的调用一个字节都不写调用方缓冲(C 项"宁可拒绝不截断") * TC-SYSLOG-058 last_epoch:无记录 -1;有记录解析 10 位 ASCII * —— E mounted / 上电重启分类 —— * TC-SYSLOG-060 /log 不存在 -> f_mkdir 成功 + 幂等(第二次不再建目录) @@ -99,9 +104,25 @@ * TC-SYSLOG-095 wdev 短写 -> 返回 -1,第二条独立证据仍落 run.log * TC-SYSLOG-096 队列创建失败 -> 一条都不排(不静默丢),堆恢复后排空并接上 SOE * TC-SYSLOG-097 队列侧换文件那条原样回投 -> 三条全落、各文件内顺序不乱 - * TC-SYSLOG-098 desc_len=29 时截断分支不可达(守卫取值导致,登记死分支) + * TC-SYSLOG-098 读侧不再截断(C 项已删死分支):拿到整 28 字符 + 唯一 NUL * TC-SYSLOG-099 p_rec / p_desc 空指针守卫(守卫在取锁之前) * TC-SYSLOG-100 last_epoch 读失败 -> -1 且 *p_epoch 清零 + * —— I D 项:自愈触发集逐码(与 soe_store 侧同一张表)—— + * TC-SYSLOG-101/101b 介质/结构类码(FR_DISK_ERR/INT_ERR/NO_FILESYSTEM/DENIED) + * -> 单条与批量路径各 unlink 恰好一次 + 重开一次并落全(自愈成功 + * 不留痕;文件被删是设计代价,所以 SOE 侧不允许这么做) + * TC-SYSLOG-102 名字/参数类码(INVALID_NAME/NO_PATH/NO_FILE/INVALID_OBJECT/ + * NOT_READY/NOT_ENABLED/TIMEOUT)连续 20 轮 -> 零 unlink、零重开、 + * 邻居文件与 /log 目录不动、读侧代数照常自增 + * TC-SYSLOG-103/103b s_is_selfheal_code() 分类表本身逐码钉死 + * TC-SYSLOG-104 卷忙不计故障:恢复后第一次真失败仍立刻留痕(反证 -2 没置故障位) + * —— J F 项:投递侧丢弃留痕节流(队列通道)—— + * TC-SYSLOG-105 队列满后连投 12 条 -> 只报第一次(A 项回投让队列常驻满, + * 不节流就按投递频率刷串口) + * TC-SYSLOG-106 同一故障窗口内再投 5 条 -> 完全静默(丢弃语义不变,照丢) + * TC-SYSLOG-107 复位点必须在 syslog_post 的成功分支:drain 只落盘、不复位 + * TC-SYSLOG-107b 恢复后再灌满 -> 第二次故障重新留痕 + * TC-SYSLOG-107c 队列通道与启动前数组通道的抑制位互不干扰 * —— G 陷阱二:名字/参数类错误码严禁升级成卷操作 —— * TC-SYSLOG-080 短名违规分档保真:体>8 / 扩展>3 / 非法字符 / 以点开头(.fschk) * 各自落 FR_INVALID_NAME;含 ':' 落 FR_INVALID_DRIVE @@ -114,29 +135,41 @@ * (sys_log_evidence 里 snprintf 返回 <=0 的分支——宿主 snprintf 不可能失败, * 假件也不伪造返回值;为刷数字去伪造等于把断言写在假件规则上,宁可留空)。 * - * 本套照出的现状(**只钉现状、不改生产代码**;三条都进交 Lead 的报告): + * 本套照出的四条现状 —— 已在 task-4(2026-10-24)全部处置,相应断言已翻成正向: * - * 【疑似缺陷 A:卷忙时整批日志被静默丢弃】sys_log_mcu.c:509~537 的批量路径里 - * done 先被置成 n,只有 rc==-3 与 rc==-1 两个分支会改它;**rc==-2(卷忙)没有 - * 任何分支**,于是紧随其后的 "for (i = done; i < n; i++) osMessageQueuePut(...)" - * 一次都不执行 —— 整批(最多 8 条)既没落盘也没回投,且不计故障、不打日志。 - * 而同一段代码自己的注释写的是"rc == -2(卷忙):整批回投队列,静默顺延", - * 函数头 465~470 行与设计文档 §4.4 又都写明"**条目不丢**(旧实现 -2 卷忙会 - * 直接丢掉那一条)"。⇒ 现状比旧实现更糟:旧实现丢 1 条,现在一次丢 8 条。 - * 触发条件极常见:卷锁 v1.2 之后 ftpTask 传文件期间 defaultTask 每 2s 排空 - * 一次,每次撞上卷忙就吞一批。对照:逐条慢路径(-3 分支)遇 -2 会正确回投 - * 尾部 —— 同一个错误码在两条路径上行为相反(TC-SYSLOG-035 与 035b 成对钉住)。 + * A【已修】卷忙整批静默丢日志:syslog_drain 批量路径 done = n 后缺 rc==-2 分支 + * → 回投循环空转,最多丢 8 条。修法 = 补 else if (rc == -2) { done = 0u; }, + * 与 -3 慢路径遇 -2 的处置对齐(慢路径是参照物,没动它)。三条语义纪律 + * ①不计故障 ②不打日志 ③回投保序 分别由 TC-SYSLOG-035 / 035a / 035d 钉住。 + * B【已修】rc==-1 的留痕文案 "drop %lu entries" 与行为相反(done=0 → 其实整批 + * 回投、一条没丢)。改成 "write fail, requeue N entries",并按"正常→故障" + * 节流(新增文件级 s_requeue_reported[],成功一轮复位)——旧版持续故障时每 + * 2s 刷一条。TC-SYSLOG-039 / 039b 钉住"节流一次"与"恢复后再失败要再报"。 + * C【已修】syslog_get_record 的截断支永不可达:守卫已挡掉 desc_len <= + * SYSLOG_DESC_MAX,而 dl 恒 <= 28。删掉死分支,容量要求写进函数头与 + * sys_log.h(**宁可整条拒绝也不静默截断**:UI 拿到截断串会显示成一条被删短 + * 过的日志,比读不到更难查)。TC-SYSLOG-057 / 057b / 098 钉住。 + * D【已修】自愈触发集违反 §10.1 第 10 条:FR_INVALID_NAME / FR_NO_PATH / + * FR_NO_FILE / FR_INVALID_OBJECT 都曾在 unlink 重建集合里,后果是名字/参数类 + * 错误下**每轮删一次自己的历史日志且永远修不好**。现在按语义拆类,新增 + * s_is_selfheal_code():触发集只留 FR_DISK_ERR / FR_INT_ERR / FR_NO_FILESYSTEM / + * FR_DENIED,逐码依据(含 ff.c 行号)写在该函数头。TC-SYSLOG-101~103 逐码钉, + * 与 test/host/soe_store/ 的 TC-SOESTORE-022b 是同一张表的两侧。 * - * 【疑似缺陷 B:rc==-1 的留痕文案与实际行为相反】sys_log_mcu.c:527~529 打 - * DBG_ERROR("drop %lu entries id=%d"),但该分支把 done 置 0 -> 整批**回投队列**, - * 一条都没丢(下一轮重试)。排障时按"日志说丢了"去找会走错方向;而真正会丢的 - * 是上面那个没有任何日志的 -2 分支。另外写故障持续时每轮 drain 都会重投重 - * 试一遍(s_append_many 内部的 L2 去重只压得住它自己那条,压不住这条)。 + * F【已修】投递侧丢弃留痕无节流:syslog_post 的两个丢弃分支(队列满 / 启动前数组 + * 满)原来每条被丢的投递各打一行 DBG_ERROR。A 项修复让卷忙时整批回投 -> 队列 + * 可长时间常驻满态 -> 此后每条业务投递都打一行,日志自己变成串口负载(比原 + * 缺陷更常触发)。修法 = 与 B 同一套"正常→故障"节流纪律,但**另立两个抑制位** + * (s_post_qfull_reported / s_post_pboot_full_reported,volatile 单字节,任意 + * 上下文读写)——不复用 s_requeue_reported:那是 defaultTask 的 drain 侧"整批 + * 回投",本处是任意任务的"这一条被丢了",恢复时机也不同(下一次投递成功)。 + * **丢弃语义一个字没改**(满则丢仍照旧),变的只有留痕次数。 + * TC-SYSLOG-041b/041d/041e/041f(数组通道)+ 105/106/107/107b/107c(队列通道)。 * - * 【死分支:syslog_get_record 的截断不可达】sys_log_mcu.c:616 的守卫是 - * desc_len <= SYSLOG_DESC_MAX 直接 -1,而函数里 dl 恒 <= SYSLOG_DESC_MAX, - * 于是 627~628 的 "if (dl >= desc_len) dl = desc_len - 1" 永不成立。 - * 无害(宁可拒绝也不越界),但"传小缓冲就能拿到截断内容"的直觉是错的(TC-SYSLOG-098)。 + * 顺手记一条容易误读的事实(不是缺陷):自愈成功 = **删掉整个日志文件重建**, + * 所以一次打开失败会连带丢掉既有历史(TC-SYSLOG-101 明确断言文件只剩本次一条)。 + * run.log 是滚动运行日志,这个代价可接受;soe.log 是事件序列,同样的动作被禁止 + * (TC-SOESTORE-022 / 022b),两侧的差异是刻意的,注释也互指。 * * 故意没测(写在这里,不留"看起来全覆盖"的错觉): * - 真跨任务并发:读窗口用"对齐 32 位读写在 ARM 上原子"免锁,主机 x64 + 单线程 @@ -459,6 +492,11 @@ static void reset_all(void) s_queue = NULL; s_fs_ready = 0u; s_mount_fail_reported = 0u; + /* B / F 项整改新增的留痕节流位:跨场景必须显式清(否则第二个故障场景看不到留痕) */ + s_requeue_reported[SYSLOG_RUN] = 0u; + s_requeue_reported[SYSLOG_SELFTEST] = 0u; + s_post_qfull_reported = 0u; + s_post_pboot_full_reported = 0u; (void)memset(s_rd_win, 0, sizeof(s_rd_win)); s_rd_win_id = -1; s_rd_win_base = 0u; @@ -828,16 +866,10 @@ static void tc_drain(void) && (g_ff_obs.calls[FF_FAKE_OP_WRITE] == 3), "TC-SYSLOG-033/034 换文件即分批(run 被切成两批 = 共 3 轮 I/O)+ 顺序不乱"); - /* 035【现状桩 = 疑似生产缺陷,见文件头"本套照出的现状"】 - * sys_log_mcu.c:509~537 的批量路径里 done 先被置成 n,只有 -3 / -1 分支会 - * 把它改回 0;**rc == -2(卷忙)没有任何分支**,于是紧随其后的 - * "for (i = done; i < n; i++) osMessageQueuePut(...)" 一次都不执行 —— - * 整批(最多 8 条)**静默丢失**,而同一段代码上方的注释写的是 - * "rc == -2(卷忙):整批回投队列,静默顺延",设计文档 §4.4 也写明 - * "队列条目在落盘失败(-2 卷忙 / -1 写失败 / -3 容量不足)时回投队列—— - * 条目不丢(旧实现 -2 卷忙会直接丢掉那一条)"。 - * ⇒ 本条按**当前实现**断言(丢了就是丢了),并显式命名为 DEFECT, - * 生产侧一旦按注释补上 done=0,这条会红,提醒同步翻转成"不丢"的正向断言。 */ + /* 035【A 项已修 2026-10-24:正向断言】卷忙 -> **整批回投队列**,一条不丢。 + * 生产侧修法:syslog_drain 批量路径补 else if (rc == -2) { done = 0u; }, + * 与 -3 慢路径遇 -2 的处置对齐。三条语义纪律各有一条断言: + * ① 不计故障(无 DBG_ERROR) ② 不打日志(同一判据) ③ 回投保持原顺序 */ reset_all(); ready_only(); s_kernel_state = osKernelRunning; @@ -846,10 +878,22 @@ static void tc_drain(void) syslog_post(SYSLOG_RUN, "hold"); } lock_busy_next(1); - syslog_drain(); /* 批量 -2 */ - CHECK((syslog_count(SYSLOG_RUN) == 0u) && (s_q_cnt == 0u) - && (g_sys_err_count == 0) && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0), - "TC-SYSLOG-035[DEFECT 现状] 卷忙 -> 整批 3 条既没落盘也没回投(静默丢失)"); + syslog_drain(); /* 批量 -2 -> 整批回投 */ + ok = (syslog_count(SYSLOG_RUN) == 0u) && (s_q_cnt == 3u) + && (g_sys_err_count == 0) && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0); + CHECK(ok, "TC-SYSLOG-035 volume busy -> whole batch requeued, none lost"); + /* ③ 顺序:回投后再排空,落盘顺序必须与投递顺序一致 */ + syslog_drain(); + ok = ok && (syslog_count(SYSLOG_RUN) == 3u) && (s_q_cnt == 0u); + if (ok) { + for (i = 0u; i < 3u; i++) { + if (!raw_at(P_RUN, i, rec) || (memcmp(rec + SYSLOG_DESC_OFFS, "hold", 4u) != 0)) { + ok = 0; + } + } + } + CHECK(ok && (g_sys_err_count == 0), + "TC-SYSLOG-035a requeued batch keeps original order and stays silent"); /* 035b 对照组:慢路径(-3 分支内)遇到 -2 时**会**把尾部回投 —— 同一卷忙 * 在两条路径上行为不一致,这就是"批量路径漏了一个分支"最直接的证据 */ @@ -871,7 +915,7 @@ static void tc_drain(void) && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0), "TC-SYSLOG-035b 慢路径遇卷忙 -> 3 条**整批回投**(同一码在两条路径上行为相反)"); - /* 035c 卷忙丢掉的是"整批"而不是"一条":8 条一批全丢 */ + /* 035c 一整批(8 条)在卷忙时全部回投,不是只保住一条 */ reset_all(); ready_only(); s_kernel_state = osKernelRunning; @@ -881,8 +925,24 @@ static void tc_drain(void) } lock_busy_next(1); syslog_drain(); - CHECK((s_q_cnt == 0u) && (g_ff_obs.calls[FF_FAKE_OP_WRITE] == 0), - "TC-SYSLOG-035c 卷忙一次吞掉一整批(8 条),不是旧实现的 1 条"); + CHECK((s_q_cnt == SYSLOG_QUEUE_MAX) && (g_ff_obs.calls[FF_FAKE_OP_WRITE] == 0) + && (g_sys_err_count == 0), + "TC-SYSLOG-035c all 8 entries of a full batch survive a busy volume"); + + /* 035d preboot 来源的条目同样不丢:卷忙时它们从数组转投队列(数组侧删掉、 + * 队列接手),既不是留在数组里被重复落盘、也不是被 memmove 吞掉 */ + reset_all(); + ready_only(); + for (i = 0u; i < 4u; i++) { + syslog_post(SYSLOG_RUN, "pb"); /* 内核未运行 -> preboot */ + } + s_kernel_state = osKernelRunning; + lock_busy_next(1); + syslog_drain(); + ok = (s_preboot_cnt == 0u) && (s_q_cnt == 4u) && (syslog_count(SYSLOG_RUN) == 0u); + syslog_drain(); + CHECK(ok && (syslog_count(SYSLOG_RUN) == 4u) && (s_q_cnt == 0u), + "TC-SYSLOG-035d preboot entries move to queue on busy volume (no loss, no dup)"); /* 036 回投溢出(hold-over drop):批量里混了 preboot 与队列两个来源时, * 回投的目标队列已经被"没被取走的那些"占着 -> 装不下的部分才真被丢掉。 @@ -941,10 +1001,10 @@ static void tc_drain(void) CHECK(ok && (s_q_cnt == 0u) && (syslog_count(SYSLOG_RUN) == 2u), "TC-SYSLOG-038 慢路径写失败:只丢**当前那一条**,尾部 2 条下轮补齐"); - /* 039【现状桩 = 第二个不一致】rc == -1 分支把 done 置 0 并打 - * DBG_ERROR("drop %lu entries id=%d"),但 done=0 意味着紧随其后的循环把 - * **整批全部回投队列** —— 日志说"丢了多少条",实际一条没丢(下轮还会重试)。 - * 措辞与行为相反,排障时会把人往错方向带;本条按现状钉住。 */ + /* 039【B 项已修 2026-10-24:正向断言】rc == -1:整批回投 + 留痕文案如实 + * (旧版这里打 "drop %lu entries" 但一条没丢,文案与行为相反)。 + * 一轮失败 = s_append_many 自己那条写故障留痕 + drain 这条回投留痕 = 2; + * 第二轮仍失败时两条都必须被各自的去重位压住(旧版 drain 那条每 2s 刷一次)。 */ reset_all(); ready_only(); s_kernel_state = osKernelRunning; @@ -952,14 +1012,23 @@ static void tc_drain(void) for (i = 0u; i < 3u; i++) { syslog_post(SYSLOG_RUN, "wfail"); } - ff_fake_inject_write_short(P_RUN, 0u, 2); + ff_fake_inject_write_short(P_RUN, 0u, 4); /* 撑住两轮失败(每轮 2 次写) */ g_sys_err_count = 0; syslog_drain(); - CHECK((s_q_cnt == 3u) && (syslog_count(SYSLOG_RUN) == 0u) && (g_sys_err_count == 2), - "TC-SYSLOG-039[DEFECT 现状] 批量 -1:日志报 drop,条目却整批回投(3 条仍在队列)"); - syslog_drain(); /* 注入耗尽 -> 这次真的落下去 */ - CHECK((syslog_count(SYSLOG_RUN) == 3u) && (s_q_cnt == 0u), - "TC-SYSLOG-039b 回投的 3 条下一轮补齐(与 035 的卷忙整批丢失形成对照)"); + ok = (s_q_cnt == 3u) && (syslog_count(SYSLOG_RUN) == 0u) && (g_sys_err_count == 2); + syslog_drain(); /* 第二轮:仍失败,必须静默 */ + ok = ok && (s_q_cnt == 3u) && (g_sys_err_count == 2) && (s_requeue_reported[SYSLOG_RUN] == 1u); + CHECK(ok, "TC-SYSLOG-039 write failure requeues whole batch; log throttled to first round"); + ff_fake_inject_write_short(P_RUN, 0u, 0); /* no-op:清不掉已耗尽的注入 */ + syslog_drain(); /* 注入耗尽 -> 成功 -> 复位 */ + ok = ok && (syslog_count(SYSLOG_RUN) == 3u) && (s_q_cnt == 0u) + && (s_requeue_reported[SYSLOG_RUN] == 0u); + ff_fake_inject_write_short(P_RUN, 0u, 2); + syslog_post(SYSLOG_RUN, "again"); + g_sys_err_count = 0; + syslog_drain(); /* 恢复后再失败 -> 必须重新留痕 */ + CHECK(ok && (g_sys_err_count == 2), + "TC-SYSLOG-039b recovery resets the throttle; a new fault is logged again"); /* 040 B9 挂接:每次 drain 尾部各调一次 soe_store_drain */ reset_all(); @@ -983,8 +1052,31 @@ static void tc_drain(void) for (i = 0u; i < SYSLOG_QUEUE_MAX + 2u; i++) { syslog_post(SYSLOG_RUN, "pf"); } - CHECK((s_preboot_cnt == SYSLOG_QUEUE_MAX) && (g_sys_err_count == 2), - "TC-SYSLOG-041b preboot 满(8 槽)-> 丢弃 + 每条一次留痕(日志不得反压业务)"); + /* F 项已修(2026-10-24):旧实现每条被丢的投递都打一行,数组满 + 挂载慢的窗口 + * 里按开机事件密度刷屏。现在只报转入故障那一次;**丢弃语义不变**(照样丢 2 条)。 */ + CHECK((s_preboot_cnt == SYSLOG_QUEUE_MAX) && (g_sys_err_count == 1), + "TC-SYSLOG-041b preboot full: 2 entries dropped but logged only once"); + /* 节流复位靠"数组重新收得下第一条":先排空、再投一条成功的,抑制位才归零。 + * 注意排空本身不复位它(复位点在 syslog_post 的成功分支里)。 */ + syslog_drain(); + ok = (s_preboot_cnt == 0u) && (s_post_pboot_full_reported == 1u); + s_kernel_state = osKernelInactive; /* 让 post 走数组而不是队列 */ + syslog_post(SYSLOG_RUN, "recovered"); /* 成功收下第一条 -> 复位 */ + ok = ok && (s_preboot_cnt == 1u) && (s_post_pboot_full_reported == 0u); + CHECK(ok, "TC-SYSLOG-041d recovery (one accepted entry) clears the throttle bit"); + /* 重新灌满:8 槽 + 2 条溢出 -> 只报一次(证明抑制位真的重新武装了) */ + g_sys_err_count = 0; + for (i = 0u; i < SYSLOG_QUEUE_MAX + 1u; i++) { + syslog_post(SYSLOG_RUN, "pf2"); + } + CHECK((s_preboot_cnt == SYSLOG_QUEUE_MAX) && (g_sys_err_count == 1), + "TC-SYSLOG-041e second fault is logged again, then stays silent"); + g_sys_err_count = 0; + for (i = 0u; i < 3u; i++) { + syslog_post(SYSLOG_RUN, "pf3"); /* 故障期间继续静默 */ + } + CHECK((g_sys_err_count == 0) && (s_post_pboot_full_reported == 1u), + "TC-SYSLOG-041f drops inside the same fault window log nothing"); reset_all(); ready_only(); s_kernel_state = osKernelRunning; @@ -1107,6 +1199,9 @@ static void tc_read(void) reset_all(); ready_only(); (void)s_append(SYSLOG_RUN, "01234567890123456789012345678901234567"); + /* 拒绝路径必须一个字节都不写调用方缓冲:先填满 0x7A 当哨兵 */ + (void)memset(desc, 0x7A, sizeof(desc)); + (void)memset(ts, 0x7A, sizeof(ts)); ok = (syslog_get_record(SYSLOG_RUN, 0u, NULL, 32u, desc, sizeof(desc)) == -1) && (syslog_get_record(SYSLOG_RUN, 0u, ts, SYSLOG_TS_LEN, desc, sizeof(desc)) == -1) && (syslog_get_record(SYSLOG_RUN, 0u, ts, sizeof(ts), NULL, sizeof(desc)) == -1) @@ -1119,6 +1214,27 @@ static void tc_read(void) * 从来没有截断"。登记为死分支,见文件头"本套照出的现状"。 */ && (strlen(desc) == (size_t)SYSLOG_DESC_MAX); CHECK(ok, "TC-SYSLOG-057 守卫:ts_len<=19 或 desc_len<=28 一律 -1;29 才读得到整 28 字符"); + /* C 项整改(2026-10-24):容量不足一律拒绝,且**不得**碰调用方缓冲—— + * 上面每次被拒的调用都用了同一对缓冲,哨兵还在就说明没写进去 */ + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "sentinel-check"); + (void)memset(desc, 0x7A, sizeof(desc)); + (void)memset(ts, 0x7A, sizeof(ts)); + ok = (syslog_get_record(SYSLOG_RUN, 0u, ts, SYSLOG_TS_LEN, desc, sizeof(desc)) == -1) + && (syslog_get_record(SYSLOG_RUN, 0u, ts, sizeof(ts), desc, SYSLOG_DESC_MAX) == -1); + { + uint32_t k; + int untouched = 1; + for (k = 0u; k < sizeof(desc); k++) { + if (desc[k] != (char)0x7A) { untouched = 0; } + } + for (k = 0u; k < sizeof(ts); k++) { + if (ts[k] != (char)0x7A) { untouched = 0; } + } + CHECK(ok && (untouched != 0), + "TC-SYSLOG-057b rejected call writes nothing into caller buffers"); + } /* 058 last_epoch */ reset_all(); @@ -1343,6 +1459,8 @@ static void tc_name_rules(void) ready_only(); (void)ff_fake_mkfile(P_SELFTEST, NULL, SYSLOG_REC_SIZE); /* 邻居文件 */ (void)ff_fake_mkfile("/config.ini", NULL, 12u); /* 同卷别的东西 */ + /* D 项整改后:名字/参数类码**一次都不许 unlink**(旧实现每轮删一次自己的 + * 历史日志、且永远修不好)。留痕仍受 L2 去重,只 1 条。 */ ff_fake_inject_rc(P_RUN, FF_FAKE_OP_OPEN, FR_INVALID_NAME, 200); for (i = 0u; i < 20u; i++) { (void)s_append(SYSLOG_RUN, "bad-name"); @@ -1350,9 +1468,10 @@ static void tc_name_rules(void) ok = (ff_fake_exists("/log") == 2) && (ff_fake_size(P_SELFTEST) == SYSLOG_REC_SIZE) && (ff_fake_size("/config.ini") == 12u) - && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] <= (int)(2u * 20u)) + && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 0) + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 20) /* 也没有"重开一次" */ && (g_sys_err_count == 1); - CHECK(ok, "TC-SYSLOG-082 反复拿 FR_INVALID_NAME 也只 unlink 自己的文件:目录/邻居不动、留痕一次"); + CHECK(ok, "TC-SYSLOG-082 20 rounds of FR_INVALID_NAME: zero unlink, zero reopen, one log"); } /* ===================== H 补齐分支(自愈 / 二次校验 / 回投 / 占位时标) ===================== */ @@ -1476,17 +1595,19 @@ static void tc_extra_branches(void) } CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 3), "TC-SYSLOG-097 队列侧换文件回投:三条全落、各文件内顺序不乱(回投不是丢弃)"); - /* 098 dl >= desc_len 的截断分支:守卫已挡掉 desc_len <= SYSLOG_DESC_MAX, - * 而 dl 由记录内容决定、恒 <= SYSLOG_DESC_MAX -> 该分支在**当前守卫取值下 - * 永不可达**。这条断言把"不可达"这件事本身钉住:一旦有人把守卫放宽成 - * desc_len == 0,本条就会因为读到截断结果而红,提醒补截断用例。 */ + /* 098【C 项已修 2026-10-24】原来的 "if (dl >= desc_len) dl = desc_len - 1" + * 是永不可达的死分支(守卫已挡掉 desc_len <= SYSLOG_DESC_MAX,而 dl 恒 + * <= SYSLOG_DESC_MAX),已从生产码删除。本条改为正向钉住"读侧不再截断": + * desc_len = 29 时拿到的是整 28 字符,且 desc[28] 是唯一的 NUL。 + * 若有人把守卫放宽(例如改成 desc_len == 0),本条会因拿到 27 字符而红。 */ reset_all(); ready_only(); (void)s_append(SYSLOG_RUN, "abcdefghijklmnopqrstuvwxyz0123"); /* 34 字符 -> 截到 28 */ ok = (syslog_get_record(SYSLOG_RUN, 0u, ts, sizeof(ts), desc, SYSLOG_DESC_MAX + 1u) == 0) && (strlen(desc) == (size_t)SYSLOG_DESC_MAX) && (desc[SYSLOG_DESC_MAX] == '\0'); - CHECK(ok, "TC-SYSLOG-098 desc_len=29 时截断分支不可达(拿到整 28 字符,非 28-1)"); + CHECK(ok && (desc[SYSLOG_DESC_MAX - 1u] != '\0') && (desc[SYSLOG_DESC_MAX] == '\0'), + "TC-SYSLOG-098 no truncation on read side: full 28 chars with one NUL"); /* 099 空指针守卫:s_read_raw 的 p_rec 与 evidence 的 p_desc */ reset_all(); @@ -1512,6 +1633,172 @@ static void tc_extra_branches(void) (void)i; } +/* ===================== I D 项整改:自愈触发集逐码 ===================== */ + +/* 打开失败时"该不该 unlink 重建"逐码判定。断言的是**语义归属**,不是假件规则: + * 介质/结构类(能靠删文件修好)→ 必须恰好 unlink 一次并再开一次; + * 名字/参数类(删了也修不好)→ 必须零 unlink、零重开。 */ +static void tc_selfheal_codes(void) +{ + static const FRESULT heal_codes[] = { FR_DISK_ERR, FR_INT_ERR, + FR_NO_FILESYSTEM, FR_DENIED }; + /* FR_NO_FILE 也在"不进"这一列:本模块用 FA_OPEN_APPEND(含 FA_OPEN_ALWAYS), + * 文件不存在会被直接建出来;真返回 FR_NO_FILE 只可能是同名对象是目录 + *(ff.c:3854),unlink 一个目录修不好它,反而违反"自愈只针对自己的文件"。 */ + static const FRESULT noheal_codes[] = { FR_INVALID_NAME, FR_NO_PATH, FR_NO_FILE, + FR_INVALID_OBJECT, FR_NOT_READY, + FR_NOT_ENABLED, FR_TIMEOUT }; + static uint8_t one[SYSLOG_REC_SIZE]; + uint32_t c; + uint32_t round; + uint32_t gen_seed; + int ok; + + (void)memset(one, 0x11, sizeof(one)); + printf("-- TC-SYSLOG-101..104 self-heal trigger set, per code (fix D) --\n"); + + /* 101 介质/结构类:单条与批量路径都必须"unlink 恰好一次 + 重开一次", + * 并最终把这条/这批落下去,且不留痕(自愈成功不该报故障) */ + for (c = 0u; c < (uint32_t)(sizeof(heal_codes) / sizeof(heal_codes[0])); c++) { + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "seed"); /* 先让文件存在 */ + { + int opens_seed = g_ff_obs.calls[FF_FAKE_OP_OPEN]; + ff_fake_inject_rc(P_RUN, FF_FAKE_OP_OPEN, heal_codes[c], 1); + ok = (s_append(SYSLOG_RUN, "single") == 0) + && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 1) + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == opens_seed + 2) + /* 自愈的前提就是删文件:seed 那条随之消失,文件只剩本次这一条。 + * (SOE 侧绝不这么做,理由见 TC-SOESTORE-022:事件序列不能销毁) */ + && (ff_fake_size(P_RUN) == 1u * SYSLOG_REC_SIZE) + && (g_sys_err_count == 0) && (s_lock_seq_err == 0); + } + CHECK(ok, "TC-SYSLOG-101 single path heals one media/structure code"); + + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "seed"); + { + int opens_seed = g_ff_obs.calls[FF_FAKE_OP_OPEN]; + static uint8_t batch[2u * SYSLOG_REC_SIZE]; + (void)memset(batch, 0x22, sizeof(batch)); + ff_fake_inject_rc(P_RUN, FF_FAKE_OP_OPEN, heal_codes[c], 1); + ok = (s_append_many(SYSLOG_RUN, batch, 2u) == 0) + && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 1) + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == opens_seed + 2) + && (ff_fake_size(P_RUN) == 2u * SYSLOG_REC_SIZE) /* 旧的 seed 被删掉 */ + && (g_sys_err_count == 0) && (s_lock_seq_err == 0); + } + CHECK(ok, "TC-SYSLOG-101b batch path heals the same code once"); + } + + /* 102 名字/参数类:连续 20 轮都必须零 unlink、零重开、只留痕一次 */ + for (c = 0u; c < (uint32_t)(sizeof(noheal_codes) / sizeof(noheal_codes[0])); c++) { + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "seed"); + (void)ff_fake_mkfile(P_SELFTEST, NULL, SYSLOG_REC_SIZE); /* 邻居 */ + { + int opens_seed = g_ff_obs.calls[FF_FAKE_OP_OPEN]; + gen_seed = s_rd_gen[SYSLOG_RUN]; + ff_fake_inject_rc(P_RUN, FF_FAKE_OP_OPEN, noheal_codes[c], 200); + for (round = 0u; round < 20u; round++) { + ok = 1; + if (s_append(SYSLOG_RUN, "no-heal") != -1) { ok = 0; } + /* 批量路径也必须走同一条判定:给它一整条 64B 记录的缓冲 */ + if (s_append_many(SYSLOG_RUN, one, 1u) != -1) { ok = 0; } + } + /* 代数每轮 +2(单条与批量各自都失效一次读窗口):宁多失效也不读脏窗 */ + CHECK(ok + && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 0) + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == opens_seed + 40) + && (ff_fake_exists(P_SELFTEST) == 1) + && (ff_fake_exists("/log") == 2) + && (s_rd_gen[SYSLOG_RUN] == gen_seed + 40u) + && (s_lock_seq_err == 0), + "TC-SYSLOG-102 name/param codes never unlink or reopen (20 rounds)"); + } + } + + /* 103 s_is_selfheal_code() 直测:把分类表本身钉死(写死的期望表,不读生产逻辑) */ + ok = (s_is_selfheal_code(FR_DISK_ERR) == 1) && (s_is_selfheal_code(FR_INT_ERR) == 1) + && (s_is_selfheal_code(FR_NO_FILESYSTEM) == 1) && (s_is_selfheal_code(FR_DENIED) == 1); + CHECK(ok, "TC-SYSLOG-103 trigger set = {DISK_ERR, INT_ERR, NO_FILESYSTEM, DENIED}"); + ok = (s_is_selfheal_code(FR_INVALID_NAME) == 0) && (s_is_selfheal_code(FR_NO_PATH) == 0) + && (s_is_selfheal_code(FR_NO_FILE) == 0) && (s_is_selfheal_code(FR_INVALID_OBJECT) == 0) + && (s_is_selfheal_code(FR_NOT_READY) == 0) && (s_is_selfheal_code(FR_NOT_ENABLED) == 0) + && (s_is_selfheal_code(FR_TIMEOUT) == 0) && (s_is_selfheal_code(FR_OK) == 0) + && (s_is_selfheal_code(FR_EXIST) == 0) && (s_is_selfheal_code(FR_WRITE_PROTECTED) == 0); + CHECK(ok, "TC-SYSLOG-103b name/param/volume-gate codes all excluded"); + + /* 104 卷忙不计故障这条纪律(A 项要求①)在写路径同样成立:-2 不产生任何留痕, + * 也不自增故障位(s_fault_reported 是 s_append_rec 的函数级 static, + * 测不到,只能从"恢复后第一次真失败仍立刻留痕"反证它没被置位) */ + reset_all(); + ready_only(); + /* s_append_rec 的 L2 去重位是函数级 static,外部清不了:先用一次成功写复位 */ + ok = (s_append(SYSLOG_RUN, "reset-dedup") == 0); + g_sys_err_count = 0; + lock_busy_next(1); + ok = ok && (s_append(SYSLOG_RUN, "busy") == -2) && (g_sys_err_count == 0); + ff_fake_inject_write_short(P_RUN, 0u, 2); + ok = ok && (s_append(SYSLOG_RUN, "real-fault") == -1); + CHECK(ok && (g_sys_err_count == 1), + "TC-SYSLOG-104 busy is not a fault: first real failure still logs immediately"); +} + +/* ===================== J F 项:投递侧丢弃留痕节流(队列通道) ===================== */ + +static void tc_post_throttle(void) +{ + uint32_t i; + int ok; + + printf("-- TC-SYSLOG-105..107 post-side drop-log throttle (queue channel) --\n"); + + /* 105 队列灌满后持续投递 -> 只报第一次。这正是 A 项修复引出的新场景: + * 卷被 ftpTask 长期占用 -> drain 把整批回投 -> 队列常驻满 -> 每条业务 + * 投递都会"队列满 + 打一行",串口按投递频率刷屏。 */ + reset_all(); + ready_only(); + s_kernel_state = osKernelRunning; + syslog_drain(); /* 建队列(8 槽) */ + g_sys_err_count = 0; + for (i = 0u; i < SYSLOG_QUEUE_MAX + 4u; i++) { + syslog_post(SYSLOG_RUN, "flood"); + } + CHECK((s_q_cnt == SYSLOG_QUEUE_MAX) && (g_sys_err_count == 1) + && (s_post_qfull_reported == 1u), + "TC-SYSLOG-105 queue full: 4 drops but only the first one logged"); + + /* 106 同一故障窗口内继续投递 -> 完全静默(丢弃语义不变,仍照丢) */ + g_sys_err_count = 0; + for (i = 0u; i < 5u; i++) { + syslog_post(SYSLOG_RUN, "flood-more"); + } + CHECK((g_sys_err_count == 0) && (s_q_cnt == SYSLOG_QUEUE_MAX), + "TC-SYSLOG-106 later drops in the same window log nothing"); + + /* 107 恢复(排空腾出槽位 + 下一条被收下)-> 抑制位复位;再灌满必须重新报一次。 + * 复位点必须在 syslog_post 的成功分支,不是 drain 里——drain 只负责落盘。 */ + ok = (s_post_qfull_reported == 1u); + syslog_drain(); /* 队列清空、条目落盘 */ + ok = ok && (s_q_cnt == 0u) && (s_post_qfull_reported == 1u); /* drain 不该复位 */ + syslog_post(SYSLOG_RUN, "recovered"); + ok = ok && (s_q_cnt == 1u) && (s_post_qfull_reported == 0u); + CHECK(ok, "TC-SYSLOG-107 recovery via a successful post clears the bit (drain does not)"); + g_sys_err_count = 0; + for (i = 0u; i < SYSLOG_QUEUE_MAX + 1u; i++) { + syslog_post(SYSLOG_RUN, "again"); + } + CHECK((s_q_cnt == SYSLOG_QUEUE_MAX) && (g_sys_err_count == 1), + "TC-SYSLOG-107b a second fault logs again (throttle re-armed)"); + /* 顺带确认两个通道的抑制位互不干扰:数组通道此前未触发过 */ + CHECK(s_post_pboot_full_reported == 0u, + "TC-SYSLOG-107c the two channels keep independent throttle bits"); +} + /* ===================== 入口 ===================== */ int main(void) @@ -1527,6 +1814,8 @@ int main(void) tc_evidence(); tc_name_rules(); tc_extra_branches(); + tc_selfheal_codes(); + tc_post_throttle(); printf("\n=== Sys Log Mcu Tests: %d/%d PASSED (failed=%d) ===\n", s_passed, s_total, s_failed);