Merge branch 'v2-on-ui' into master
CI / L0 静态门禁 (pre-commit) (push) Has been cancelled
CI / L0.5 主机侧单测 (MSVC) (push) Has been cancelled

把 v2-on-ui 开发主线(250 个提交)合并进 master:涵盖 B0 产品定义、
B1 ADC 采集链路、1 kHz TIM6 采样节拍、L1-3/L1-4 语义通道映射、
@build/@download 收敛到 .dsh/skills/ 下的 skill(删除 build.bat /
download.bat / reset.bat)等。

master 侧 3 个提交(.gitignore 忽略项 + simulator/sim_config.ini)
已保留,无冲突。
This commit is contained in:
2026-10-10 16:46:28 +08:00
1275 changed files with 210605 additions and 7628 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"snapshot": {
"iwdg-defer-start": "ca839ffc1cda439f",
"hardfault-handler": "f3afab8d2be18794",
"fault-handlers-3": "f3afab8d2be18794",
"fault-handler-enable": "be87fcd3a7926138",
"hardfault-report-impl": "f3afab8d2be18794",
"stack-overflow-check": "d4735e3a265e16ee",
"malloc-failed-hook": "6b86b273ff34fce1",
"assert-not-deadloop": "d4a00718ab2bd071",
"assert-impl": "5d862ceb21c77a86",
"stack-overflow-hook-impl": "a006edbd30d2c4d7",
"malloc-failed-hook-impl": "fcd7250e5cd6403b",
"stack-overflow-hook-helper": "cacd9fd028be9b5b",
"watchdog-module": "3d908285b092c402",
"reset-cause-latch": "52fc71acbb0f1aed",
"iwdg-hal-driver": "4b902875a84554e9",
"hal-iwdg-enabled": "1a8ed6cff34f7ba6",
"ioc-keepusercode": "b5bea41b6c623f7c",
"ioc-iwdg-present": "d3a6e667048c95db",
"ioc-task-stacks": "3262f737d92dcbee",
"adc1-hal-enabled": "1a8ed6cff34f7ba6"
},
"_note": "CubeMX 存活审计基线;改动用 --write 重建"
}
+57
View File
@@ -0,0 +1,57 @@
---
name: board-test-suite
description: 板级测试套件运行与恢复:test_config.h 套件互斥、破坏性开关的持久代价(NAND 坏块记账)、run_all_suites.py 一键回归(自动改宏+无条件还原)、跑后恢复产品构建、容量恢复动作 A/B/C 区分。嵌入式"编译期套件开关"模式的通用流程。
whenToUse: 要跑板级测试套件(CH395F/STORAGE/GD5F/RTC)回归、要开/关破坏性测试、怀疑仓库里混入了测试固件构建、或跑完套件要做容量/状态恢复时。
---
# board-test-suite — 板级测试套件运行与恢复(文档型 skill)
## 开关模型(编译期,`test/board/test_config.h`)
- `TEST_SUITE_CH395F / STORAGE / GD5F / RTC` **互斥**(同开 = `#error`);
**全部注释 = 产品构建 = 默认状态**。
- `ENABLE_DESTRUCTIVE_TESTS` 默认关:开着跑一轮的代价是**改设备持久状态**——
实测 GD5F 整轮破坏性跑一次即多记一个 NAND 坏块(容量 95248→95200),
计数持久化在 dhara journal checkpoint 头里,普通格式化恢复不回来。
- 提交门禁:pre-commit 钩子 `test-suite-switch` 默认拦截测试构建
(确需提交用 `ALLOW_TEST_BUILD=1` 显式放行)——**跑完务必改回全注释并 `@build`**。
## 一键回归(推荐路径,勿手工循环)
```powershell
& '.venv\Scripts\python.exe' test/run_all_suites.py # 全部套件:改宏→build→烧录→采集判读
& '.venv\Scripts\python.exe' test/run_all_suites.py --suites STORAGE,GD5F
& '.venv\Scripts\python.exe' test/run_all_suites.py --check-build # 只编译不烧录(无需硬件,防"某套件长期编不过")
& '.venv\Scripts\python.exe' test/run_all_suites.py --dry-run # 只看计划
```
- 脚本 try/finally **无条件还原 `test_config.h`**(含 Ctrl-C 路径)——这是它存在的核心
理由:跑完忘还原 = 测试固件进库。`--keep-config` 仅调试用。
- 退出码 0=全 PASS / 1=有 FAIL / 2=环境错误;4 套件真机一轮数分钟起,**仅回归窗口用,绝不挂 pre-commit**。
- 手工循环时的顺序(每轮):改宏(只开一个)→ `@build` 0错0警 → 烧录 →
`serial-assistant` 采集(open→clear→烧录→wait-idle)→ 判读套件汇总行。
## 跑后检查清单(必做)
1. `test_config.h` 恢复全注释(用 run_all_suites.py 则已自动);
2. `@build` 确认产品构建 0 错 0 警;
3. 若开过破坏性开关:核对开机日志容量数字(健康满值 96064 pages / 187 MB),
掉了 → 按 `docs/测试说明/存储维护与容量恢复.md` 走恢复动作;
4. 提交前 `test-suite-switch` 门禁会再兜一道。
## 三个维护动作别混用(存储类)
| 目标 | 开关 | 代价 |
|---|---|---|
| A 卷/BBT 恢复 | `TEST_SUITE_STORAGE` + `ENABLE_STORAGE_RECOVERY_TESTS` | 卷全失,**不恢复容量** |
| B 容量恢复 | A + `STORAGE_RECOVERY_FULL_WIPE`(擦净 2044 块 + 断电重启) | 卷全失,约 4s 擦除 |
| C 日常回归 | 只开 `TEST_SUITE_*`,不开破坏性 | 无(只读模式,横幅 `mode: READ-ONLY`) |
原理与完整步骤见 `docs/测试说明/存储维护与容量恢复.md`;各套件用例清单/判读见
`docs/测试说明/` 对应 `*_Test_Guide.md`;流程规范 `docs/测试说明/测试开发标准流程.md` §3。
## 其它项目怎么套
模式通用:套件选择收进单一编译期头文件 + 互斥 `#error` + 默认产品构建 +
提交期门禁 + 编排脚本"改宏→构建→烧录→判读→**无条件还原**"。破坏性用例必须
独立开关且默认关,跑前想清楚持久代价与恢复路径。
+61
View File
@@ -0,0 +1,61 @@
---
name: cubemx-regen
description: CubeMX 重生成存活工作流(L1 写进 .ioc → L2 USER CODE 区/宏改名 → L3 审计兜底):重生成前后各跑什么、哪些定制会被静默覆盖、审计脚本 check_cubemx_survival.py 的 --check/--write 语义。STM32CubeMX 项目通用方法论 + 本仓库清单。
whenToUse: 要用 CubeMX 重新生成工程(改 .ioc / 外设配置)、改动 Src/Inc 生成区附近代码、或排查"重生成后定制丢失/行为变了但编译不报错"时。
---
# cubemx-regen — CubeMX 重生成存活(文档型 skill)
**核心事实**:`.ioc` 的 `KeepUserCode=true` **只**保护 `USER CODE BEGIN/END` 标记之间;
标记之外的一切手工改动重生成即被模板覆盖,且**多数不报编译错**(崩溃采集被打回
`while(1)`、加固宏被整块删)——只在真出故障时暴露。
## 三层优先级(防丢失的唯一正确顺序)
1. **L1 写进 `.ioc`**(权限最高):凡 CubeMX 界面能表达的配置(任务栈/优先级/队列/
事件组/堆大小/IWDG 参数/FreeRTOS 钩子开关)必须在 GUI 里设——重生成只会照写。
⚠ **别凭印象断言"界面里没有该项"**,先翻标签页或查 `db/mcu/IP/*_Modes.xml`
(实测 `configCHECK_FOR_STACK_OVERFLOW=2` 在 FreeRTOS → Config parameters →
Hook function related definitions 里,自造宏区被抹两次后才找到)。
2. **L2 搬进 `USER CODE` 区**:只信 CubeMX **当前模板真实生成**的区名(优先
`USER CODE BEGIN 0` 这类从未变过的标准位)。**不要依赖"自造标记区"**——
模板换代时自造区连同内容一起消失(实测中断函数模板换代的教训)。
生成区内要**去掉一句调用** → 宏改名:`USER CODE BEGIN 0` 里放空实现 +
`#define HAL_IWDG_Init(a_h) iwdg_deferred_start(a_h)`,生成句自动变空操作
(`.map` 里 `Removing ... N bytes` 验证;生成区零 diff)。
要**替换生成实体定义**(如 4 个 Fault Handler 本体)→ 我们的定义放
`USER CODE BEGIN 0`,**必须写在 `#define X X_Gen` 之前**(写在之后会被自己的宏改名),
生成区恢复原样,链接器自动移除未引用的 `*_Gen`。
3. **L3 审计兜底**:清单 + 哈希基线漂移检测(见下)。
## 重生成前后固定动作
```powershell
# 重生成前:把能在 GUI 表达的都写进 .ioc(L1 检查)
# 重生成后【立刻】:
& '.venv\Scripts\python.exe' scripts/check_cubemx_survival.py # 人读报告
& '.venv\Scripts\python.exe' scripts/check_cubemx_survival.py --check # 门禁模式(pre-commit 同款)
# 确认 diff 全部是"想要的改动"后重建基线:
& '.venv\Scripts\python.exe' scripts/check_cubemx_survival.py --write
```
- 退出码 0=通过 / 1=有 FAIL 项;已挂 pre-commit `cubemx-survival` 钩子
(改 `.ioc`/`Src/`/`Inc/`/`.uvprojx` 触发)。
- **L3 清单已清空 = 重生成应当零 diff**;仍有 diff 就是被覆盖了,直接跑脚本定位。
- `uvprojx` 不参与漂移检测(每次构建被 UV4 改写),只保留"源文件仍挂载"断言。
## 实测陷阱清单
- CubeMX 对 System Exception(HardFault/MemManage/BusFault/UsageFault)**只生成优先级、
从不生成使能代码**;`.ioc` 里 `NVIC.MemoryManagement_IRQn=true` ≠ 已使能(只是向量可见)。
`SHCSR` 使能位只能手工写(L2)+ 审计项兜底。
- 启用 FreeRTOS 后模板会生成 `USER CODE BEGIN 4 / 5` 区(默认空桩)——**钩子实现必须
整体写进这两个区**,且 helper 函数放更早的区(同名两处定义 = 编译错)。
- 生成文件行尾/末尾空行按工具自身风格,**勿用改写型钩子去修剪**(已豁免,勿"顺手格式化")。
- 重生成后除审计脚本外,照常走 `@build`(0 错 0 警)+ 必要时 `@test` 闭环。
## 其它 CubeMX 项目怎么套
L1/L2/L3 方法论与"宏改名两招"完全通用;本仓库的清单/基线在
`scripts/check_cubemx_survival.py`(21 项 + `.cubemx_survival_baseline.json`),
新项目照它建自己的清单。方法论详见 `docs/子系统设计/看门狗设计说明.md` §10.1/§10.2。
+72
View File
@@ -0,0 +1,72 @@
---
name: draw-diagram
description: docs/ 文档技术配图绘制流程(精确布局图用 SVG 生成器 / 拓扑图用 Mermaid):挑脚本 → 改顶部数据 → 重跑生成 → 目检 → md 相对路径引用 → 脚本+产物一并提交,产物统一放 docs/images/。
whenToUse: 需要新增/修改 docs/ 文档的技术配图(协议帧、分层架构、依赖/就绪屏障、流程图、时序、状态机),或用户要求"画个图 / 生成图 / 更新架构图 / 帧格式图"时。
---
# 文档配图绘制(SVG / Mermaid)
`docs/` 文档技术配图的统一流程;完整选型依据 / 约定 / 检查清单见 `docs/架构设计/图形绘制流程.md`。**产物统一放 `docs/images/`**(专门配图目录,`.svg` 入库;PNG 预览属本地派生物、不入库)。
## 工具选型
| 图形类型 | 工具 |
|---|---|
| 协议帧 / 字节布局(字段宽 ∝ 字节数、偏移标尺) | SVG:`tools/arch_diagram/gen_frame_svg.py` |
| 分层架构 / 模块依赖 | SVG:`tools/arch_diagram/gen_arch_svg.py` |
| 就绪屏障 / 生产者-消费者 | SVG:`tools/arch_diagram/gen_barrier_svg.py` |
| 其他精确布局图 | **复制**最接近的生成器改顶部数据(`build()` 与数据解耦,通用) |
| 流程 / 时序 / 状态机 / ER(只表达拓扑关系) | Markdown 内嵌 ` ```mermaid ` 代码块(`flowchart` / `sequenceDiagram` / `stateDiagram-v2` / `erDiagram`),无需生成图片 |
> Mermaid 盒子按文本自适应宽、自动加有向边,**只适合"关系"类图**;凡需精确像素 / 比例的布局图一律走 SVG 生成器。
## SVG 生成流程(仓库根目录执行)
1. **挑 / 复制脚本**:新类型图复制最接近的生成器,只改顶部数据区。
2. **改顶部数据**:字段 / 节点 / 标题 / 配色。沿用既有调色板(主色 / 底色):
| 语义 | 主色 | 底色 |
|---|---|---|
| 蓝 | `#2563eb` | `#eff6ff` |
| 青 | `#0d9488` | `#f0fdfa` |
| 绿 | `#16a34a` | `#f0fdf4` |
| 琥珀 | `#d97706` | `#fffbeb` |
| 紫 | `#7c3aed` | `#f5f3ff` |
| 灰 | `#475569` | `#f1f5f9` |
| 玫红 | `#e11d48` | `#fff1f2` |
3. **重跑**(脚本内置 `ElementTree` 自检,XML 不合法直接抛错):
```powershell
& '.venv\Scripts\python.exe' tools/arch_diagram/gen_xxx.py
# 成功输出:OK <字节> bytes, canvas <W>x<H> -> <路径>
```
4. **目检**:浏览器打开 `docs/images/*.svg`——文字不溢出盒外、框 / 线 / 标签对齐、配色符合调色板。
5. **md 引用**(相对路径):`docs/` 根的 md 写 `![标题](images/xxx.svg)`;`docs/UI设计/` 等子目录 md 写 `![…](../images/xxx.svg)`。
6. **提交**:生成器脚本(源)+ 生成的 `.svg`(产物)**一并**提交。
## SVG 约定(手写 / 新增生成器必须遵循)
- **纯标准库**拼 SVG(只用 `html` / `os`(+ `math`)),不引第三方库(SVG 由浏览器用系统字体渲染)
- 所有文本经 `html.escape(s, quote=True)`,防止破坏 XML
- 字体栈:`Microsoft YaHei, Segoe UI, PingFang SC, sans-serif`
- 文本宽估算(自适应盒宽):CJK / 全角 / 圈码 / 箭头 ≈ `1.0em`,其余 ≈ `0.58em`(**宁大勿小**防溢出)
- 落盘:`open(OUT, "w", encoding="utf-8", newline="\n")`(UTF-8 无 BOM + LF)
- 写盘后 `xml.etree.ElementTree.fromstring(svg)` 自检
- 产物放 `docs/images/`,命名 `<主题>.svg`(可与同名 `.md` 对应)
## SVG → PNG(可选,本地派生物不入库)
- 最简单:浏览器打开 `.svg` → 另存为图片
- 脚本化:`& '.venv\Scripts\python.exe' -m pip install cairosvg` 后
`& '.venv\Scripts\python.exe' -c "import cairosvg; cairosvg.svg2png(url='docs/images/xxx.svg', write_to='x.png', scale=2)"`
- `.gitignore` 已含 `docs/images/*.png`
## 提交前检查清单
- [ ] 生成器输出 `OK … bytes`(无异常 = XML 合法)
- [ ] 浏览器目检:文字不溢出、框 / 线 / 标签对齐、配色符合调色板
- [ ] 引用它的 `.md` 里图片**相对路径**正确(能显示)
- [ ] 脚本 + 产物 `.svg` **一并**暂存
- [ ] `pre-commit run --files <脚本> <svg> <md>` 全过(无乱码 / 行尾空白 / 编码 OK)
+33
View File
@@ -0,0 +1,33 @@
---
name: font-regen
description: 重生成中文子集字体 ui_font_cn_12(extract_cn 提取 → make_font 生成 → diff 检查,固定 4 步,.venv 解释器)。
whenToUse: UI 代码新增中文文案、用户报告屏幕缺字/空白块、或提交任何含新汉字的 UI 字符串之前。
---
# 中文字体重生成
本工程**唯一字体** `ui_font_cn_12`(宋体 12px 1bpp 子集,MCU/模拟器双端共用)。**缺字渲染为空白**——新增汉字必须走以下固定顺序,不得跳过或调换。详细原理见 `docs/UI设计/UI_中文字体生成与配置.md`。
## 固定顺序(仓库根目录执行)
```powershell
# 1. 代码落库(新界面字符串已写入 ui_menu.c / ui_content_page.c 等 UI 源码)
# 2. 提取字符集(纯标准库,任意 python3 均可)
python tools/font_gen/extract_cn.py # 扫描 UI 源码字符串 → 仓库根 ui_cn_chars.txt
# 3. 生成字体(需 fontTools,一律用 .venv;宋体 .ttc 的 face0 抽取与 ▶ 箭头补回均自动完成,幂等)
.venv\Scripts\python.exe tools/font_gen/make_font.py
# 变体:--bpp 2(宋体细笔画发虚时)/ --font C:/path/to/font.ttf(换字体)
# 4. diff 检查:新增字在位、旧字不变、生成物 Opts 行字符集与预期一致
```
## 提交清单
`ui_cn_chars.txt`(仓库根)+ `Drivers/BSP/UI/ui_font_cn_12.c` **随代码一起提交**,缺一个都会让另一端/下次构建字体与字集不一致。
## 坑
- 不要用 PATH 默认 `python`(msys64,无 fonttools)跑 make_font.py;`.venv` 缺失时按 AGENTS.md「Python 环境」重建
- `requirements.txt` 必须**纯 ASCII**(Windows 下 pip 按 GBK 解码,中文注释 → UnicodeDecodeError)
- 生僻字(宋体可能无字形)→ **写码前先手动验证字形**,避免"代码写完才发现是空白"
- 生成成功后跑 `ui-verify` skill 全回归;黄金参考因字形变化而 FAIL 属预期 → `check_content_pages.py --update` 刷新
- 字数变化时同步更新文档中的字符集计数(当前 97 汉字 + ASCII 0x20-0x7E,共 193 字形):`UI_中文字体生成与配置.md`、`UI_界面制作流程.md` C3、`UI_设计架构.md`、`simulator/README.md` 已知坑 #4
+1
View File
@@ -0,0 +1 @@
__pycache__/
+52
View File
@@ -0,0 +1,52 @@
---
name: keil-build
description: Keil MDK-ARM 命令行编译闭环(自带独立脚本 keil_build.py,纯标准库,拷到任何 Keil 项目即用):UV4 -b 构建 → "0 错误 0 警告"判定 → build_log 定位报错。封装入口约定、退出码判读、UV4 改写工程文件的入库陷阱。
whenToUse: 需要编译 Keil MDK 工程、验证代码改动是否 0 错误 0 警告、或解读 UV4 构建日志时。
---
# keil-build — Keil 命令行编译(独立可运行 skill)
**执行体 = 本 skill 自带脚本** `.dsh/skills/keil-build/scripts/keil_build.py`
(纯标准库,任意 python3;拷整个 skill 目录到别的 Keil 项目即可用,不依赖仓库脚本)。
```powershell
# cwd = 项目根目录
& $PY ".dsh\skills\keil-build\scripts\keil_build.py"
# 覆盖:--project X.uvprojx --uv4 UV4.exe --target 名字 --log 路径 --timeout 600
```
解析顺序(命令行 > 项目档案 `.serialtest/config.json` > 环境 > 探测):
工程 `uvprojx`(档案缺省时自动搜 cwd 下唯一 `*.uvprojx`);UV4 `uv4` >
`$DSH_KEIL_UV4` > `$DTU_KEIL_UV4` > PATH(**显式设了但路径无效 = 立即报错,不静默回退**)。
退出码:`0`=PASS(0错0警) / `1`=构建失败(打印报错行)/ `2`=UV4 未找到/无效 / `3`=用法。
## 本仓库
**`@build` = 本 skill**(2026-10-10 起为仓库文档口径):在仓库根目录跑
`& '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py`。
判定口径(与原 `MDK-ARM/build.bat` 同款;该文件已于 2026-10-10 删除,本 skill 即 `@build`):
先删旧日志 → `-b -j0 -o` → 无 `error:` 且含 `0 Error(s), 0 Warning(s)`。
本 skill 另有超时保护、UV4 多级解析链(含 PATH 兜底)、失败只打报错行。
环境变量 `DTU_KEIL_UV4`(用户级,指向 UV4.exe)。
## 铁律与陷阱(实测教训)
- **任何代码改动后必须先编译验证(0 错误 0 警告)再谈烧录/提交**——编译不过就进
硬件闭环只会把编译错误伪装成运行问题。
- **警告也算失败**:判 PASS 只认退出码 0,别只看"有没有 error:"。
- **`MDK-ARM/*.uvprojx` 每次构建后被 UV4 自动改写,改动一律不入库**:UV4 会把工程
XML 归一化成本机风格(`<Tag />` → `<Tag></Tag>` 等)并把 `PackID` 降到本机 DFP 版本。
判断:`git diff` 里只见 XML 格式/`PackID` 差异而**无源文件挂载增删** →
`git checkout -- <uvprojx>` 丢弃;只有真的增删了源文件(grep 文件名计数变化)才提交。
- 报错定位读日志别只看终端回显:本仓库 = `MDK-ARM\build_log.txt`。
- **exit 2 先分清"真没设"还是"会话没继承"**(2026-10-10 实测):agent 的 shell 由
常驻服务派生,服务启动**之后**设置的用户级环境变量不会自动进来。查
`[Environment]::GetEnvironmentVariable('DTU_KEIL_UV4','User')`,有值就
`$env:DTU_KEIL_UV4 = <该值>` 注入本会话再跑,别急着让用户去设。
- 编码类报错(`#870-D: invalid multibyte character sequence` 等)先怀疑源文件
BOM/编码被改写,而不是代码本身(见仓库 AGENTS.md 编码铁律)。
## 与其它 skill 的衔接
- 烧录/回读 = `stlink-flash` skill;串口闭环 = `serial-boot-test` skill
(其档案 `flash_cmd`/`readback_cmd` 即 stlink-flash 的入口命令)。
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""keil_build.py - Keil MDK-ARM 命令行构建 + 判定(纯标准库,可移植 skill 版)。
用法(**项目根目录**下运行,任意 python3):
python <skill>/scripts/keil_build.py
[--project X.uvprojx] [--uv4 UV4.exe] [--target 名字]
[--log build_log.txt] [--timeout 600]
解析顺序(命令行 > 项目档案 > 环境 > 探测):
工程 --project > 档案 "uvprojx" > 自动搜 cwd 下唯一 *.uvprojx
UV4 --uv4 > 档案 "uv4" > $DSH_KEIL_UV4 > $DTU_KEIL_UV4 > PATH
(环境变量**显式设了但路径无效 = 立即报错,不静默回退**)
日志 --log > 档案 "build_log" > <工程目录>\\build_log.txt
判定口径(实测教训沉淀;原仓库 MDK-ARM/build.bat 已删除,本脚本即 @build):
1. 先删旧日志——UV4 -o 只在真跑了构建才写日志,残留旧日志会被当成本次结果
2. UV4 单实例:IDE 开着时 -b 可能被转发给 IDE(退出码丢失)→ 只 WARN 不杀进程
3. PASS = 日志存在 + 无 "error:"(大小写不敏感)+ 含 "0 Error(s), 0 Warning(s)"
(0E0W 精确匹配同时把"只有警告"的构建判 FAIL)
退出码: 0=PASS(0错0警) 1=构建失败(看打印的报错行/日志) 2=UV4 未找到/无效 3=用法错误
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
def find_config_file():
env = os.environ.get("DSH_SERIAL_CONFIG")
if env:
return Path(env)
cwd = Path.cwd()
candidates = [cwd / ".serialtest" / "config.json",
cwd / ".opencode" / "serial_config.json"]
for c in candidates:
if c.is_file():
return c
return candidates[0]
def load_config():
"""档案缺失不是错误;解析失败报错由调用方 exit 3。返回 (cfg, err)。"""
f = find_config_file()
if not f.is_file():
return {}, ""
try:
raw = json.loads(f.read_text(encoding="utf-8-sig"))
except (OSError, ValueError) as e:
return {}, "cannot parse {}: {}".format(f, e)
if not isinstance(raw, dict):
return {}, "config root is not an object: {}".format(f)
return raw, ""
def find_uv4(cli_arg, cfg):
"""UV4 解析。显式来源(--uv4/档案/环境变量)设了但无效 = 硬错误,不回退。"""
for label, val in (("--uv4", cli_arg),
("config 'uv4'", cfg.get("uv4")),
("$DSH_KEIL_UV4", os.environ.get("DSH_KEIL_UV4")),
("$DTU_KEIL_UV4", os.environ.get("DTU_KEIL_UV4"))):
if val:
if Path(val).is_file():
return Path(val), label
raise SystemExit(("BUILD_ENV_ERROR: {} is set but not a file: {} "
"(fix the value; no silent fallback)").format(label, val))
w = shutil.which("UV4.exe") or shutil.which("UV4")
if w:
return Path(w), "PATH"
raise SystemExit("BUILD_ENV_ERROR: UV4.exe not found "
"(--uv4 / config 'uv4' / $DSH_KEIL_UV4 / $DTU_KEIL_UV4 / PATH)")
def find_project(cli_arg, cfg):
if cli_arg:
p = Path(cli_arg)
if not p.is_file():
raise SystemExit("BUILD_ENV_ERROR: --project not found: {}".format(p))
return p
v = cfg.get("uvprojx")
if v:
p = Path(v)
if not p.is_file():
raise SystemExit("BUILD_ENV_ERROR: config 'uvprojx' not found: {}".format(p))
return p
hits = sorted(p for p in Path.cwd().rglob("*.uvprojx")
if not any(part.startswith(".") for part in p.parts))
if len(hits) == 1:
print("PROJ: auto-detected {}".format(hits[0]))
return hits[0]
if not hits:
raise SystemExit("BUILD_ENV_ERROR: no *.uvprojx under cwd "
"(--project or config 'uvprojx')")
raise SystemExit("BUILD_ENV_ERROR: multiple .uvprojx found, pick one "
"(--project or config 'uvprojx'):\n " +
"\n ".join(str(h) for h in hits))
def main(argv=None):
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
ap = argparse.ArgumentParser(description="Keil UV4 CLI build + verdict (pure stdlib)")
ap.add_argument("--project", default=None, help="*.uvprojx (default: config/auto-detect)")
ap.add_argument("--uv4", default=None, help="UV4.exe path")
ap.add_argument("--target", default=None, help="build target name (-t; default: project default)")
ap.add_argument("--log", default=None, help="build log path (default: <proj dir>/build_log.txt)")
ap.add_argument("--timeout", type=int, default=600, help="UV4 timeout seconds")
args = ap.parse_args(argv)
cfg, err = load_config()
if err:
print("BUILD_ENV_ERROR: {}".format(err))
return 3
proj = find_project(args.project, cfg)
uv4, src = find_uv4(args.uv4, cfg)
target = args.target or cfg.get("uv_target") or ""
log = Path(args.log or cfg.get("build_log") or (proj.parent / "build_log.txt"))
print("UV4: {} ({})".format(uv4, src))
print("PROJ: {} target={}".format(proj, target or "(default)"))
print("LOG: {}".format(log))
# UV4 单实例陷阱:IDE 开着时 -b 可能被转发(退出码丢失、GUI 弹出)——只 WARN
try:
tl = subprocess.run(["tasklist", "/fi", "imagename eq UV4.exe", "/nh"],
capture_output=True, timeout=10)
if b"UV4.exe" in (tl.stdout or b""):
print("WARN: UV4 IDE is running - this build may be forwarded to it. "
"Close the IDE for a deterministic CLI build.")
except (OSError, subprocess.TimeoutExpired):
pass
# 先删旧日志:UV4 -o 只在真跑构建时写日志,残留会被当成本次结果
try:
log.unlink()
except FileNotFoundError:
pass
cmd = [str(uv4), "-b", str(proj), "-j0", "-o", str(log)]
if target:
cmd.insert(3, "-t" + target)
try:
proc = subprocess.run(cmd, capture_output=True, timeout=args.timeout)
rc = proc.returncode
except subprocess.TimeoutExpired:
print("BUILD_FAIL: UV4 timed out after {}s (hung or forwarded to IDE?)".format(args.timeout))
return 1
print("UV4_RC: {}".format(rc))
if not log.is_file():
print("BUILD_FAIL: no build log produced - UV4 did not run or hung")
return 1
text = log.read_text(encoding="utf-8", errors="replace")
lines = text.splitlines()
errs = [l for l in lines if "error:" in l.lower()]
if errs:
print("BUILD_FAIL: {} error line(s) in log:".format(len(errs)))
for l in errs[:20]:
print(" | {}".format(l.strip()))
if len(errs) > 20:
print(" ... {} more".format(len(errs) - 20))
return 1
if "0 Error(s), 0 Warning(s)" not in text:
print("BUILD_FAIL: no '0 Error(s), 0 Warning(s)' marker in log "
"(warnings present or build incomplete). Tail:")
for l in lines[-15:]:
print(" | {}".format(l.strip()))
return 1
for l in lines:
if l.strip().startswith("Program Size:"):
print(l.strip())
print("BUILD_OK: 0 errors, 0 warnings")
return 0
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -0,0 +1 @@
__pycache__/
+49
View File
@@ -0,0 +1,49 @@
---
name: mem-budget
description: 嵌入式 RAM/栈预算核算工作流:Keil .map/.htm 静态报告(ram_report/stack_report)+ 串口运行时水位叠加 + DBG 日志成本模型(每条约 90B Flash/216B 栈)。"改任务/缓冲/栈之前必须核算"的固定动作与判读口径。
whenToUse: 新增任务/加大缓冲/改栈/接入日志之前之后、链接报 L6406E 或 RAM 溢出、串口 [STACK] 水位余量低于底线、或需要回答"这个改动花多少 RAM/Flash"时。
---
# mem-budget — RAM/栈预算核算
## 固定动作(改前、改后各跑一次)
```powershell
# cwd = 项目根;先 @build 保证 .htm/.map 是最新的
& '.venv\Scripts\python.exe' ".dsh\skills\mem-budget\scripts\mem_check.py"
# 等价于分别跑:
# scripts/ram_report.py (.map → RAM/Flash 分区与按文件占用表)
# scripts/stack_report.py (.htm 静态调用图 → 每任务最坏链 vs 配置栈)
# 叠加运行时水位(有真机日志时):
& '.venv\Scripts\python.exe' scripts/stack_report.py -s .dsh\skills\serial-assistant\scripts\state\serial.log
```
退出码:0=全部通过 / 1=有任务不达标 / 2=解析错误(.htm/.map 缺失先重跑构建)。
## 判读口径
- **静态**:`stack_report.py` 的 Max Depth(最坏调用链累计)vs `Src/freertos.c` 配置栈;
默认要求余量 ≥30%(`--min-free-pct`),ISR 另留 `--isr-margin`(默认 1024B)。
- **运行时**:开机日志 `[STACK] <task> peak_used=…/… free_min=…` 与静态值对照——
静态是上界、运行时是下界证据,**两者都要看**;余量 <1KB 底线要上报。
- **RAM 总量**:`ram_report.py` 对 Grand Totals 与 `docs/内存管理/` 预算表核对;
链接错 `L6406E` = 已经溢出,用按文件表找大头。
- 任务表一致性由 pre-commit `task-table` 钩子保证(改栈后 `gen_task_table.py` 同步 AGENTS.md)。
## 成本模型(实测,估算用)
- **`DBG_*` 日志不是免费的**:每条调用点约 **90 B Flash**(字符串常量另计)+ 深链上
每帧一份行缓冲进最坏栈(实测一条链 9 帧 ≈ 1.5 KB 纯日志缓冲)。
已覆盖步骤勿加重复锚点,只在原先静默返回的黑洞处补。
- **深调用链模块收紧行缓冲**:`#define DBG_LINE_BUF_MAX 128U` 写在 `#include "dbg_log.h"`
之前;取值必须 ≥ 该模块最长日志行(时间戳 20 + 标签 + 正文),否则截断。
- **加大局部缓冲的替代方案是静态化**(实测 lftpd 9 处大局部缓冲静态化:BSS +4.6 KB,
任务峰值 3480B→1184B)——栈紧张时先找大局部变量。
- **自研代码禁 C 库堆**(MicroLIB 堆仅 512B):只用 `pvPortMalloc/vPortFree`(FreeRTOS 堆),
模拟器 CRT 堆照不出 MCU 堆分配失败。
## 其它项目怎么套
模式通用:链接器 map + 调用图 htm 是**免硬件**的栈可观测源;固定"改前改后各跑一次
报告 + 运行时水位叠加 + 余量阈值"三件套。本仓库两个报告脚本按 Keil 输出格式解析,
换工具链(GCC newlib + `-fstack-usage`)需替换解析层,工作流不变。
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""mem_check.py - 跑项目的 RAM/栈报告(薄运行器,纯标准库)。
依次调用仓库的 ram_report.py 与 stack_report.py(路径可覆盖),打印各自退出码
与总结。用法(项目根目录):
python <skill>/scripts/mem_check.py [--ram-script scripts/ram_report.py]
[--stack-script scripts/stack_report.py] [--serial-log PATH] [--python PATH]
退出码: 0=两报告都过 / 1=任一 FAIL / 2=脚本缺失或用法错误。
"""
import argparse
import os
import subprocess
import sys
from pathlib import Path
def find_python(cli):
if cli:
return cli
for cand in (".venv/Scripts/python.exe", ".venv/bin/python"):
if Path(cand).is_file():
return cand
return sys.executable
def run(py, script, extra):
p = Path(script)
if not p.is_file():
print("MISSING: {}".format(p))
return 2
print("=== {} ===".format(p))
r = subprocess.run([py, str(p)] + extra)
return r.returncode
def main(argv=None):
for s in (sys.stdout, sys.stderr):
try:
s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
ap = argparse.ArgumentParser(description="run RAM/stack reports")
ap.add_argument("--ram-script", default="scripts/ram_report.py")
ap.add_argument("--stack-script", default="scripts/stack_report.py")
ap.add_argument("--serial-log", default=None,
help="serial log with [STACK] lines (overlay runtime watermarks)")
ap.add_argument("--python", default=None, help="interpreter (default .venv)")
args = ap.parse_args(argv)
py = find_python(args.python)
rc_ram = run(py, args.ram_script, [])
stack_extra = ["-s", args.serial_log] if args.serial_log else []
rc_stack = run(py, args.stack_script, stack_extra)
worst = max(rc_ram, rc_stack)
print("MEM_CHECK: ram={} stack={} -> {}".format(rc_ram, rc_stack,
"PASS" if worst == 0 else ("FAIL" if worst == 1 else "ERROR")))
return 0 if worst == 0 else (1 if worst == 1 else 2)
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -0,0 +1 @@
__pycache__/
+55
View File
@@ -0,0 +1,55 @@
---
name: new-machine-setup
description: 换机/新克隆环境自检与搭建:venv 重建、DTU_* 工具路径环境变量(用户级、无管理员)、驱动确认、验收三连(selftest/build/list)。自带独立自检脚本 env_check.py(纯标准库),含"会话不继承用户级变量"实测坑。任何嵌入式项目通用模式。
whenToUse: 换新机器/新克隆要把环境搭起来、构建或烧录报"环境变量未设/工具找不到"、CI 之外任何机器第一次跑本项目、或怀疑本机环境缺件时。
---
# new-machine-setup — 换机环境自检与搭建
## 先跑自检(只读,不改任何东西)
```powershell
# cwd = 项目根目录
& <任一 python ≥3.10> ".dsh\skills\new-machine-setup\scripts\env_check.py"
```
逐项 PASS/WARN/FAIL + 缺件时打印**可直接执行的修复命令**;退出码 0=可用 / 1=有 FAIL。
## 搭建顺序(自检 FAIL 时的完整路径)
1. **venv**:`& <任一 python ≥3.10> -m venv .venv` →
`& '.venv\Scripts\python.exe' -m pip install -r requirements.txt`
(含 pyserial/fontTools/pre-commit;`requirements.txt` 必须纯 ASCII)。
PATH 默认 `python` 可能是 msys64 的——依赖第三方库的脚本一律用 `.venv`。
2. **工具路径环境变量**(用户级即可,无需管理员;约定见
`docs/环境与部署/本地工具路径环境变量方案.md`):
```powershell
[Environment]::SetEnvironmentVariable('DTU_KEIL_UV4', '<本机>\UV4.exe', 'User')
[Environment]::SetEnvironmentVariable('DTU_STLINK_CLI', '<本机>\ST-LINK_CLI.exe', 'User') # 已入 PATH 可不设
```
语义统一:**显式设了但路径无效 = 立即报错不回退**;不设则走 PATH/自动发现。
3. **驱动**:CH340(调试串口)与 ST-Link 的 USB 驱动装好——
`Get-PnpDevice -Class Ports` 能看到 COMx、`stlink_flash.py list` 能看到探针 SN。
4. **验收三连**(全绿才算环境就绪):
```powershell
& '.venv\Scripts\python.exe' .dsh/skills/serial-assistant/scripts/serial_tool.py selftest # 23/23 PASS
& '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py # exit 0
& '.venv\Scripts\python.exe' .dsh\skills\stlink-flash\scripts\stlink_flash.py list # 列出探针
```
## 实测坑(本会话两次撞上)
- **agent/服务派生的 shell 不继承"服务启动之后"设置的用户级环境变量**:
`keil_build.py` 报 exit 2 先分清"真没设"还是"没继承"——查
`[Environment]::GetEnvironmentVariable('DTU_KEIL_UV4','User')`,有值就
`$env:DTU_KEIL_UV4 = <该值>` 注入当前会话再跑,别急着让用户重设。
(`env_check.py` 对这种情况报 WARN 并给注入命令。)
- 换机后 COM 口几乎必变:只改 `.serialtest/config.json` 的 `portName`,
然后 `serial_tool.py restart`(daemon 启动时缓存配置)。
- 串口"拒绝访问"先查残留旧 daemon(AGENTS.md 串口助手节),不是驱动问题。
## 其它项目怎么套
模式通用:本机差异全部走 `*_TOOL_PATH` 类环境变量(显式无效=硬错)、
仓库内路径全部相对解析、环境就绪判据 = 三条独立验收命令(工具链构建 /
烧录器在位 / 采集自检),自检脚本只读不修、FAIL 行自带修复命令。
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""env_check.py - 换机/新克隆环境自检(纯标准库,只读不修)。
用法(项目根目录,任一 python3 ≥3.8):
python <skill>/scripts/env_check.py [--venv .venv]
检查项: venv 与关键包 / DTU_* 工具路径变量(会话 vs 用户级)/ UV4 /
ST-LINK_CLI / COM 口在位 / pre-commit。每行 PASS|WARN|FAIL,FAIL 行附
可直接执行的修复命令。退出码: 0=可用(无 FAIL) / 1=有 FAIL。
"""
import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path
def line(status, name, detail=""):
print("{:4} {:24} {}".format(status, name, detail))
return status
def env_user_scope(name):
"""读用户级环境变量(Windows 注册表,标准库 winreg;其他平台回退 os.environ)。"""
if os.name != "nt":
return os.environ.get(name)
try:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as k:
v, _ = winreg.QueryValueEx(k, name)
return v
except OSError:
return None
def check_tool_env(name, venv_py):
"""工具路径变量:会话值 > 用户级值;区分'没设'与'设了没继承'。"""
sess = os.environ.get(name)
if sess:
if Path(sess).is_file():
return line("PASS", name, sess)
return line("FAIL", name, "已设但路径无效: {}(改正值本身)".format(sess))
user = env_user_scope(name)
if user:
return line("WARN", name, "用户级已设({}),本会话未继承——注入: $env:{} = '{}'".format(
user, name, user))
return None # 未设:由调用方决定 WARN/FAIL
def py_import_ok(py, mod):
if not py or not Path(py).is_file():
return False
r = subprocess.run([str(py), "-c", "import " + mod], capture_output=True, timeout=30)
return r.returncode == 0
def main(argv=None):
for s in (sys.stdout, sys.stderr):
try:
s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
ap = argparse.ArgumentParser(description="environment self-check (read-only)")
ap.add_argument("--venv", default=".venv", help="venv dir (default ./.venv)")
args = ap.parse_args(argv)
fails = 0
root = Path.cwd()
# 1) venv 与关键包
venv_py = root / args.venv / "Scripts" / "python.exe"
if not venv_py.is_file():
venv_py = root / args.venv / "bin" / "python" # *nix
if line("FAIL" if not venv_py.is_file() else "PASS", "venv",
str(venv_py) if venv_py.is_file() else
"缺失——& <任一 python ≥3.10> -m venv {}; & '{}\\Scripts\\python.exe' -m pip install -r requirements.txt".format(
args.venv, args.venv)) == "FAIL":
fails += 1
else:
for mod, pipname in (("serial", "pyserial"), ("fontTools", "fontTools"),
("pre_commit", "pre-commit")):
if line("PASS" if py_import_ok(venv_py, mod) else "FAIL",
"pkg:" + mod, "" if py_import_ok(venv_py, mod) else
"缺失——& '{}' -m pip install {}".format(venv_py, pipname)) == "FAIL":
fails += 1
# 2) 工具路径变量
r = check_tool_env("DTU_KEIL_UV4", venv_py)
if r is None:
w = shutil.which("UV4.exe") or shutil.which("UV4")
if line("FAIL" if not w else "WARN", "UV4 可达",
w or "DTU_KEIL_UV4 未设且 PATH 无 UV4——[Environment]::SetEnvironmentVariable('DTU_KEIL_UV4','<本机>\\UV4.exe','User')"):
fails += 1
r = check_tool_env("DTU_STLINK_CLI", venv_py)
if r is None:
w = shutil.which("ST-LINK_CLI.exe") or shutil.which("ST-LINK_CLI")
line("PASS" if w else "WARN", "ST-LINK_CLI 可达",
w or "未设 DTU_STLINK_CLI 且 PATH 无(烧录机需二者之一)")
# 3) COM 口在位(用 venv 的 pyserial,不碰 daemon)
if py_import_ok(venv_py, "serial.tools.list_ports"):
r = subprocess.run([str(venv_py), "-c",
"import serial.tools.list_ports as p;"
"print(';'.join(x.device for x in p.comports()))"],
capture_output=True, timeout=30)
ports = (r.stdout or b"").decode("utf-8", "replace").strip()
if line("PASS" if ports else "FAIL", "COM 口",
ports or "无任何 COM 设备——装 CH340 驱动 / 检查 USB"):
if not ports:
fails += 1
else:
line("WARN", "COM 口", "跳过(pyserial 不可用)")
# 4) 项目配置存在
cfg = root / ".serialtest" / "config.json"
if line("PASS" if cfg.is_file() else "FAIL", "串口配置档案",
str(cfg) if cfg.is_file() else
"缺失——建 .serialtest/config.json(portName/baudRate,见 .dsh/skills/serial-boot-test/config.example.json)"):
if not cfg.is_file():
fails += 1
print("SUMMARY: {}".format("OK - 环境可用(WARN 项按提示处理)" if fails == 0
else "{} 项 FAIL - 按上面修复命令处理".format(fails)))
return 0 if fails == 0 else 1
if __name__ == "__main__":
sys.exit(main())
+40
View File
@@ -0,0 +1,40 @@
---
name: new-source-file
description: 新增 .c 源文件的双端注册:MDK-ARM/STM32F407-Demo.uvprojx 与 simulator/CMakeLists.txt 均为显式文件列表,新文件必须手动加进两处再双端构建验证。
whenToUse: 新建 App/、Drivers/BSP/ 下的 .c 文件,或构建/运行报 undefined reference、"改了代码没生效" 而怀疑文件漏注册时。
---
# 新文件双端注册
本工程 Keil 工程与 PC 模拟器都使用**显式文件列表**(无通配扫描)——新 `.c` 文件不手动注册就不会被编译;只注册一端 = 另一端**静默**缺文件(不一定报错,功能悄悄不生效)。
## 注册步骤
1. **Keil MDK**:编辑 `MDK-ARM/STM32F407-Demo.uvprojx`,在对应 `<GroupName>` 的 `<Files>` 中按相邻条目格式插入(`..\` 开头反斜杠路径):
```xml
<File>
<FileName>ui_new_module.c</FileName>
<FileType>1</FileType>
<FilePath>..\Drivers\BSP\UI\ui_new_module.c</FilePath>
</File>
```
现有组:`App`、`Drivers/BSP`、`Drivers/BSP/UI`、`Test` 等;新文件放语义对应的组。
2. **模拟器**(仅当文件需 PC 端编译,如 `Drivers/BSP/UI/*.c`):在 `simulator/CMakeLists.txt` 的 `set(UI_SOURCES ...)` 列表加一行 `${UI_DIR}/ui_new_module.c`。非 UI 模块文件通常不需模拟器注册。
3. **双端构建验证**(两端都必须过):
```powershell
cmake --build simulator/build --config Debug # 0 警告
& '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py # BUILD_OK,0 错 0 警
```
## 注意
- **禁止**把源文件复制进 `simulator/`——两端共用 `Drivers/BSP/` 原件(UI_界面制作流程.md C7)
- 只注册 `.c`(含 `.s`);`.h` 靠 include 路径找到,不加工程
- 漏注册的典型症状:一端编译失败(undefined reference)或一端行为悄悄停留在旧逻辑
- 注册完成后跑回归:UI 文件 → `ui-verify` skill;其他模块 → `@test`(`.dsh/skills/serial-boot-test/scripts/run_boot_test.py`)
- 改过 `CMakeLists.txt` 后需重新 `cmake -B build` 配置
+4
View File
@@ -0,0 +1,4 @@
# 运行时产物不入库(daemon 日志/状态、专用 venv、字节码缓存)
scripts/state/
venv/
__pycache__/
+61
View File
@@ -0,0 +1,61 @@
---
name: serial-assistant
description: 常驻串口助手(采集层):daemon + 薄 CLI + 日志文件即接口。ports/open/send/log/clear/wait-idle/status 全套命令;RX/TX 全量历史落盘可直接 Read/Grep;一台机单 daemon 跨进程共享,自动拉起。serial-boot-test 等串口工作流的底座。
whenToUse: 需要打开/关闭串口、发送命令或字节、查询串口历史日志、等 RX 静默(抓开机日志)、排查串口占用或端口参数问题时。
---
# serial-assistant — 串口助手(项目级 skill,采集层)
单脚本 `.dsh/skills/serial-assistant/scripts/serial_tool.py`(Python + pyserial)。
架构:CLI(短命进程,硬超时)→ HTTP JSON → daemon(detached 单实例,127.0.0.1:18765);
daemon = 串口读线程(唯一 I/O 者)+ 日志写线程(唯一文件写者)。
**历史 = `scripts/state/serial.log`**(纯文本、只含完整行;绝对路径以 `status` 的
`log.path` 为准,直接 Read/Grep 即是历史,无采集窗口仪式)。
## 前置条件
解释器需装有 pyserial:优先项目自己的 venv;否则一次性建 skill 专用 venv:
```powershell
python -m venv ".dsh\skills\serial-assistant\venv"
& ".dsh\skills\serial-assistant\venv\Scripts\python.exe" -m pip install pyserial
```
下文 `$PY` = 选定解释器,`$TOOL` = `.dsh\skills\serial-assistant\scripts\serial_tool.py`。
**所有命令在项目根目录下运行**(cwd 决定配置源解析)。
## 命令(全部 JSON 输出;退出码 0=成功 1=领域错误 2=daemon 不可达/超时 3=用法)
```powershell
& $PY $TOOL ports # 枚举 COM 口 + configured 标注 + 唯一候选 hint
& $PY $TOOL open # 开串口(幂等;daemon 未跑自动拉起);--port/--baud 可覆盖
& $PY $TOOL send "help" # 发文本;send --hex 0D0A --nl 发字节并追加 CRLF
& $PY $TOOL log --tail 40 # 读日志;--grep P [-i] [--regex](先过滤后截尾)
& $PY $TOOL clear # 清空日志(代际 +1,会话边界)
& $PY $TOOL wait-idle # 等 RX 静默:--settle-ms 8000 --max-ms 30000 --first-timeout-ms 12000
& $PY $TOOL status # 全状态 JSON(daemon 版本/端口/计数/rx_age_ms/log.path)
& $PY $TOOL start|stop|restart # daemon 生命周期
& $PY $TOOL selftest # 无头自检(行拆分/解码/截头/wait 判据,无需硬件)
```
**抓开机日志铁律**:`open` → `clear` → 烧录/复位 → `wait-idle` → 读日志校验标记
(采集必须先于烧录,否则错过开机头几行)。
## 配置源(串口参数单一配置源)
解析顺序:`$DSH_SERIAL_CONFIG` > `<cwd>/.serialtest/config.json` >
`<cwd>/.opencode/serial_config.json`(旧位置)。用到的键:`portName`、`baudRate`、
`httpPort`(缺省 18765)。端口变化**只改这个文件**,改完必须 `restart`
(daemon 启动时缓存配置)。
## 陷阱(实测教训)
- **一台机只有一个 daemon**(HTTP 口 `SO_EXCLUSIVEADDRUSE` 独占绑定)。多个项目的
serial_tool 副本协议兼容、可互连:`status` 的 `daemon.version` 辨归属
(本 skill 版 = `1.1.0-skill`),`log.path` 辨日志落点。
- **`open` 报"拒绝访问/端口被占用"**:先 `status` 查是不是残留旧 daemon 占着口 →
`stop`(或 `taskkill /F /PID`)→ 重新 `open`。
- **`wait-idle` 判据**:`no_data` = 本次等待期间零字节;首字节到达前不启动静默计时;
返回的 `rx_bytes` 是本次增量。`settle-ms` 必须大于固件的自然静默间隙(常见 3~4s)。
- **`open` 不带 `--dtr/--rts` = 保持当前线路状态**;显式给才翻转。
- 日志 20MB 上限自动按行边界截头保尾 10MB;无换行残行 64KB 封顶。
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
# 运行时产物不入库(daemon 日志/状态、专用 venv、字节码缓存)
state/
venv/
__pycache__/
+88
View File
@@ -0,0 +1,88 @@
---
name: serial-boot-test
description: 嵌入式目标"烧录/复位 → 抓串口开机日志 → 标记校验"一键闭环。构建在 serial-assistant skill(串口采集层)之上,自带烧录测试编排器与日志标记校验器;项目差异全部收在 <项目根>/.serialtest/config.json,任何带调试串口的项目都能用。
whenToUse: 需要向目标板烧录或复位后,用串口日志自动判定运行是否正常(开机回归、固件身份校验、HardFault/FAIL 标记扫描、开机耗时测量),或需要交互式串口收发与日志回放时。
---
# serial-boot-test — 串口自动验证(判定层,构建在 serial-assistant 之上)
分层:**采集层 = 兄弟 skill `serial-assistant`**(常驻 daemon 串口助手),
本 skill 只负责**闭环编排 + 判定**。换项目时把 `serial-boot-test` 和
`serial-assistant` 两个文件夹一起拷进目标项目的 `.dsh/skills/`
(或 `.agents/skills/`),脚本不含任何仓库路径。
| 脚本 | 作用 |
|---|---|
| `.dsh/skills/serial-boot-test/scripts/run_boot_test.py` | 一键闭环:open+clear → 烧录 → wait-idle → 提取 boot log → 烧录回读校验 → 标记校验。串口助手按 `$DSH_SERIAL_TOOL` > 同目录 > `../serial-assistant/scripts/serial_tool.py` 解析 |
| `.dsh/skills/serial-boot-test/scripts/verify_log.py` | 对任意日志文件做标记校验(Required/Forbidden/锚点计数/顺序/耗时),可单独用 |
交互式串口操作(open/send/log/wait-idle/status 等)不属于本 skill——
**用 `serial-assistant` skill**。
## 前置条件
1. **解释器需装有 pyserial**(闭环经采集层脚本用串口)。优先用项目自己的 venv;
否则按 `serial-assistant` skill 的"前置条件"建专用 venv。
下文 `$PY` = 选定的解释器,`$SKILL` = `.dsh\skills\serial-boot-test`(项目根下相对路径)。
2. **所有命令都在项目根目录下运行**(cwd 决定配置源解析)。
## 第一步:项目档案(每个项目一次)
配置解析顺序:`$DSH_SERIAL_CONFIG` > `<cwd>/.serialtest/config.json` > `<cwd>/.opencode/serial_config.json`(旧位置,仅串口字段)。
若项目没有 `.serialtest/config.json`:**先从项目文档(AGENTS.md/README/测试文档)推断各字段,推断不了的问用户**,然后按 `config.example.json` 写一份。字段含义:
| 键 | 含义 | 缺省行为 |
|---|---|---|
| `portName` / `baudRate` | 串口 | 无 portName 直接 exit 3 |
| `flash_cmd` | 烧录命令(**必须含复位运行**,如 `-Rst -Run`;可带参数) | 空 = 不烧录,脚本提示人工复位板子 |
| `hex` | 刚构建的镜像(Intel HEX),供身份戳 + 回读比对 | 空 = 两项都跳过(WARN) |
| `fw_marker` | 固件身份戳前缀(固件里打印 `"<前缀>" __DATE__ " " __TIME__`) | 默认 `fw build: `;hex 里无此串只 WARN 不假判 |
| `readback_cmd` | 回读校验命令模板,`{hex}` = 镜像路径(引号自动加) | 空 = 跳过 |
| `readback_ok` | 回读成功判据(按行首匹配,大小写不敏感) | `no difference found` |
| `required` | 必须出现的标记(`;` 分隔,大小写敏感) | **空 = exit 3**(必须给) |
| `forbidden` | 禁止出现的模式(`;` 分隔,大小写不敏感) | `FAIL;ERROR;HardFault` |
| `boot_marker` | 一轮启动的锚点子串(如 `reset cause:`) | 空 = 锚点计数关闭 |
| `max_boot_count` | 锚点出现 0 次或 >N 次 = FAIL | 1(仅 boot_marker 非空时生效) |
| `done_marker` + `max_boot_ms` | 开机耗时口径与上限 | 空/0 = 只报告不判 |
| `settle_ms` / `max_wait_ms` / `first_wait_ms` | RX 静默判据 / 总超时 / 首字节超时 | 8000 / 30000 / 12000 |
| `require_order` / `expect_reset_cause` / `require_suite_summary` | 顺序校验 / 复位原因白名单 / 套件汇总行 | 空 = 关闭 |
| `require_selftest_pass` + `device_ip` | 设备侧自检台账联合判定(FTP 抓取) | 关闭 |
**先跑项目的构建**(本仓库 = `keil-build` skill,其它 Keil 项目照该 skill 套;
非 Keil 项目用项目自己的构建入口),确认 0 错误 0 警告后再进闭环。
档案里的 `flash_cmd`/`readback_cmd` 就是烧录层的入口命令
(本仓库 = `stlink-flash` skill 的 `stlink_flash.py flash` / `cmp {hex}`)。
## 第二步:跑闭环
```powershell
# cwd = 项目根目录
& $PY "$SKILL\scripts\run_boot_test.py"
# 临时覆盖:--port COM7 --required "..." --forbidden "..." --flash-cmd "..."
```
退出码:`0=PASS` / `1=FAIL`(标记缺失或命中)/ `2=采集或烧录错误`(含回读不一致,本次运行作废)/ `3=用法/预检错误`。
产物:boot log(每行带 `[+Nms]` 相对时基)写到 `%TEMP%\serial-boot-test\boot_log_<时间戳>.txt`(`--out-dir` 改,保留最近 20 份)。
## 第三步:判读与迭代
- **PASS** → 报告命中的标记与开机耗时。
- **FAIL** → `[MISS]`/`[HIT]` 行(Forbidden 命中带行号+内容)+ 日志尾部已给出;需要更多上下文直接 Read/Grep boot log 文件或 `status` 里 `log.path` 指向的 serial.log。修代码 → 重新构建 → 重跑闭环。
- **exit 2 `no_data`** → 板子全程零字节:查端口在位(`ports`)、板电、烧录命令是否真的复位了目标。
## 交互式串口调试(不跑闭环时)
用 **`serial-assistant` skill**(`ports/open/send/log/clear/wait-idle/status` 全套命令、
抓开机日志铁律、daemon 陷阱都在那边)。本 skill 不重复采集层用法。
## 陷阱(都是实测教训)
- **daemon 一台机只有一个**(HTTP 口 18765 独占绑定,由采集层 serial-assistant 管理)。改配置后必须 `restart`(daemon 启动时缓存配置)。若机器上还有旧 daemon,先 `status` 看 `daemon.version`(serial-assistant 版 = `1.1.0-skill`)确认归属,必要时 `stop` 后重开。
- **回读校验排在日志采集之后**:部分烧录工具(如 ST-LINK `-CmpFile`)连接时会复位目标,提前跑会截断开机日志。脚本已按此顺序,勿手工调换。
- **烧录命令必须让目标复位运行**(裸 `-P` 类写入在目标核死循环时不落地);写完镜像后 `fw build` 身份戳校验会揭穿"芯片还是旧镜像"。
- **`settle_ms` 必须大于固件的自然静默间隙**(常见 3~4s),默认 8000;日志被截断先调大它。
- **Required 标记宁少勿多**:先用启动锚点 + 少数里程碑标记;按用例临时覆盖 `--forbidden`(预期出现的 `[ERR]` 负路径用例)。
- **烧录与串口采集不冲突**:SWD 下载走探针、调试日志走 USB-serial(CH340 类),互不占用;烧录期间串口侧静默几秒属正常,不算 `no_data`(首字节门会兜住)。
- 中文/编码:脚本全程字节级处理,不受控制台代码页影响;配置文件存 UTF-8(BOM 容忍)。
@@ -0,0 +1,29 @@
{
"portName": "COM6",
"baudRate": 115200,
"flash_cmd": ".venv\\Scripts\\python.exe .dsh\\skills\\stlink-flash\\scripts\\stlink_flash.py flash",
"flash_timeout": 180,
"hex": "MDK-ARM\\STM32F407-Demo\\STM32F407-Demo.hex",
"fw_marker": "fw build: ",
"readback_cmd": ".venv\\Scripts\\python.exe .dsh\\skills\\stlink-flash\\scripts\\stlink_flash.py cmp {hex}",
"readback_ok": "no difference found",
"required": "App init OK;Network Init;net init: OK",
"forbidden": "FAIL;ERROR;HardFault;[ERR]",
"boot_marker": "reset cause:",
"max_boot_count": 1,
"done_marker": "net init: OK",
"max_boot_ms": 0,
"require_order": "",
"expect_reset_cause": "",
"settle_ms": 8000,
"max_wait_ms": 30000,
"first_wait_ms": 12000,
"require_selftest_pass": false,
"device_ip": "",
"httpPort": 18765
}
@@ -0,0 +1,624 @@
#!/usr/bin/env python3
"""run_boot_test.py - 一键开机日志抓取 + 校验(纯标准库;可移植 skill 版)。
调用串口助手(采集层 = 兄弟 skill serial-assistant 的 serial_tool.py,常驻 daemon,
子进程 CLI + JSON;解析顺序见 find_serial_tool):
1. open 串口 + clear 日志(本次运行独立代际)
2. 烧录(config "flash_cmd";未配置 = 跳过烧录,等人工复位板子)
2.5 固件身份戳:从待烧 hex(config "hex")里抠出固件戳(config "fw_marker",
默认 `fw build: <DATE> <TIME>`),追加成 Required 标记——
芯片里跑的必须就是这份构建(--no-fw-check 关闭)
3. wait-idle:RX 静默 >= settle-ms 收尾;首字节超过 --first-wait-ms 未到 → no_data
(快速失败);整体 max-wait-ms 超时
4. 从日志提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀),并给每行加 `[+Nms]` 相对
偏移(daemon 毫秒时基,本次第一行 = +0ms;--raw-log 出纯载荷)。固件侧时间戳往往
只有秒级、开机头几行还没时钟,量不了耗时——这个前缀就是耗时口径
4.5 烧录回读校验:config "readback_cmd" 模板({hex} = 镜像路径),成功判据 =
某行以 "readback_ok"(默认 No difference found)开头;未配置 = 跳过。
**必须排在采集之后**:ST-LINK -CmpFile 实测连接时会复位目标
5. verify_log.py 标记校验(含启动锚点计数 config "boot_marker" + --max-boot-count;
--expect-reset-cause 原因白名单;--require-order 标记顺序;
--done-marker + --max-boot-ms 开机耗时)
6. (仅当 --require-selftest-pass)自检记录联合判定(S7):
--selftest-log 提供本地副本则直接用它,否则从设备 FTP 抓取 /log/selftest.log
(需 config "device_ip");判定:文件缺失 / 0 字节 = 无故障 → PASS
采集在烧录**之前**武装(open + clear 先于烧录),daemon 常驻全量日志兜底,
无采集窗口/首字节等待仪式。
项目差异全部收在 <cwd>/.serialtest/config.json(见 config.example.json):
portName/baudRate/flash_cmd/hex/fw_marker/readback_cmd/readback_ok/
required/forbidden/boot_marker/done_marker/settle_ms/...
解析顺序:$DSH_SERIAL_CONFIG > <cwd>/.serialtest/config.json >
<cwd>/.opencode/serial_config.json(旧位置,仅串口字段)。
优先级:命令行 > 档案 > 内置通用默认。
加固口径(Windows 实测教训):
- 子进程输出按**字节**读取、显式 UTF-8 解码 —— 不受控制台代码页(GBK)影响
- 配置 JSON 按 UTF-8(容忍 BOM)解析
- 不经 PowerShell —— 无执行策略拦截
- 烧录/回读带超时 —— 坏连接不会永久挂起
用法(**项目根目录**下运行,python 需装有 pyserial):
python <skill>/scripts/run_boot_test.py
[--port COM7] [--baud-rate 115200] [--settle-ms 8000] [--max-wait-ms 30000]
[--first-wait-ms 12000]
[--flash-cmd "<烧录命令>"] [--flash-timeout 180]
[--required "m1;m2"] [--forbidden "FAIL;ERROR;HardFault"]
[--require-suite-summary "Storage Tests"]
[--require-selftest-pass] [--selftest-log <本地副本>]
[--device-ip 192.168.1.100] [--out-dir <目录>] [--show-tail-lines 15]
[--fw-hex <构建产物 hex>] [--no-fw-check]
[--no-verify-flash] [--readback-timeout 120]
[--max-boot-count 1] [--expect-reset-cause 'software;power-on']
[--require-order 'a>b>c'] [--done-marker 'ok'] [--max-boot-ms 0]
[--raw-log] [--keep-out 20]
未显式给的参数取 <cwd>/.serialtest/config.json(见 config.example.json)
退出码(与旧版一致):
0 = PASS(采集成功 + Required 全中 + Forbidden 零命中)
1 = FAIL(标记缺失/命中)
2 = 采集或烧录错误(含烧录回读不一致 = 芯片内容与刚编的镜像不同,本次运行作废)
3 = 用法/预检错误
"""
import argparse
import ftplib
import json
import os
import shlex
import subprocess
import sys
import tempfile
import time
from datetime import datetime
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
VERIFY_LOG = SCRIPT_DIR / "verify_log.py"
def find_serial_tool():
"""串口助手脚本解析顺序(分层 skill:采集层 = 兄弟 skill serial-assistant):
1. $DSH_SERIAL_TOOL 环境变量(显式指定)
2. 与本脚本同目录(兼容单独整包拷贝的场景)
3. 兄弟 skill:<skills 根>/serial-assistant/scripts/serial_tool.py
都不存在时返回第 3 候选(错误信息照它报)。"""
env = os.environ.get("DSH_SERIAL_TOOL")
if env:
return Path(env)
candidates = [SCRIPT_DIR / "serial_tool.py",
SCRIPT_DIR.parent.parent / "serial-assistant" / "scripts" / "serial_tool.py"]
for c in candidates:
if c.is_file():
return c
return candidates[1]
SERIAL_TOOL = find_serial_tool()
def find_config_file():
"""项目测试档案解析顺序(cwd = 项目根目录):
1. $DSH_SERIAL_CONFIG 环境变量(显式文件)
2. <cwd>/.serialtest/config.json (本 skill 约定)
3. <cwd>/.opencode/serial_config.json (旧位置,仅串口字段)
都不存在时返回第 1 候选(错误信息照它报)。"""
env = os.environ.get("DSH_SERIAL_CONFIG")
if env:
return Path(env)
cwd = Path.cwd()
candidates = [cwd / ".serialtest" / "config.json",
cwd / ".opencode" / "serial_config.json"]
for c in candidates:
if c.is_file():
return c
return candidates[0]
CFG_FILE = find_config_file()
# 通用默认(与具体项目无关;项目差异全部进 .serialtest/config.json)
FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault"
# 固件身份戳:固件里打印 `"<fw_marker>" __DATE__ " " __TIME__`(编译期展开成连续
# ASCII),脚本从待烧 hex 里抠出该串追加成 Required 标记——芯片里跑的镜像必须
# 就是刚编出来的这份。旧镜像/错镜像在纯固定串标记下不可见。
# 固件没有这类戳 → config 里 "fw_marker": "" 关闭(只 WARN 跳过,不假判)。
FW_MARKER_DEFAULT = "fw build: "
# 烧录回读校验的成功判据(按**行首**匹配,大小写不敏感):ST-LINK_CLI -CmpFile
# 输出 `No difference found`。其他烧录工具按其成功行改 config 里的 "readback_ok"。
READBACK_OK_DEFAULT = "no difference found"
def _split_cmd(cmd_str):
r"""把 `--flash-cmd` 这类命令串拆成 [exe, arg, ...](2026-10-10 实测坑)。
直接交给 subprocess、**不经 `cmd /c`**:CreateProcess 能直接跑 .bat,且 Python
会给含空格的路径正确加引号。旧写法 `['cmd','/c', 串]` 在本仓库(目录名
`DTU Code` 含空格)会被 cmd 的 /c 引号规则玩坏——实测报
`'D:\Code\DTU' is not recognized`,回读校验被假判成"芯片内容不一致"。
路径本身含空格时把该段用引号包住(`"D:\some dir\flash_tool.bat" flash`)。
posix=False 是必须的:posix=True 会吃掉 Windows 路径里的反斜杠。
"""
return [p.strip('"') for p in shlex.split(cmd_str, posix=False)]
def _run_cmd(argv, timeout):
"""跑一条外部命令,按字节取回输出并显式 UTF-8 解码(不受控制台代码页影响)。
stdin=DEVNULL 必须:烧录/回读走 ST-LINK_CLI,它编程完成后会停在
"run application to exit" 等按键——stdin 若是未关闭的管道会永久挂起
(2026-10-10 台架实测:编程成功但 CLI 180s 不退 → 误判 flash rc=-1)。
"""
try:
proc = subprocess.run(argv, capture_output=True, timeout=timeout,
stdin=subprocess.DEVNULL)
rc = proc.returncode
raw = (proc.stdout or b"") + (proc.stderr or b"")
except subprocess.TimeoutExpired as e:
rc = -1
raw = (e.stdout or b"") + (e.stderr or b"")
return rc, raw.decode("utf-8", errors="replace")
def extract_fw_stamp(hex_path, marker):
"""从 Intel HEX 里抠固件身份戳(只扫数据记录,不建整片镜像,内存 O(镜像体积))。
返回戳串(如 'Oct 10 2026 02:38:12');找不到(旧固件/未重编译)返回 None。
"""
data = bytearray()
for line in Path(hex_path).read_text(errors="replace").splitlines():
line = line.strip()
if not line.startswith(":"):
continue
try:
rec = bytes.fromhex(line[1:])
except ValueError:
continue
if len(rec) > 4 and rec[3] == 0:
data += rec[4:4 + rec[0]]
i = data.find(marker.encode("ascii"))
if i < 0:
return None
j = i + len(marker)
end = j
while end < len(data) and 0x20 <= data[end] < 0x7F:
end += 1
return data[j:end].decode("ascii", errors="replace")
def _cli(cmd_args, timeout):
"""调用串口助手 CLI,返回 (rc, stdout_text, parsed_json_or_None)。
全程字节级处理(capture_output 取 bytes 后显式 UTF-8 解码),
任何 Windows 控制台代码页都不参与数据路径。
"""
try:
proc = subprocess.run(cmd_args, capture_output=True, timeout=timeout)
except subprocess.TimeoutExpired:
return 2, "client timeout ({}s)".format(timeout), None
text = proc.stdout.decode("utf-8", errors="replace")
try:
return proc.returncode, text, json.loads(text)
except ValueError:
return proc.returncode, text, None
def _tool_cmd(py, cmd, *rest, timeout=15):
rc, out, data = _cli([py, str(SERIAL_TOOL), cmd, *rest], timeout)
if rc != 0 or data is None:
return None, out
return data, out
def _ftp_quit(ftp):
"""尽力关闭 FTP 控制连接(QUIT 失败不影响判定)。"""
try:
ftp.quit()
except (OSError, ftplib.Error):
pass
def _prune_out_dir(out_dir, keep):
"""只保留最近 N 份产物(`boot_log_*.txt` / `selftest_log_*.log`)。
这些文件按 `%Y%m%d_%H%M%S` 命名、每次 @test 各留一份,旧实现从不清理——
本机实测 out 目录已堆到 31 份 boot_log + 7 份 selftest_log(2026-10-10)。
文件名含时间戳 → 按名字排序即按时间排序。
"""
if keep <= 0:
return 0
removed = 0
for pat in ("boot_log_*.txt", "selftest_log_*.log"):
for p in sorted(out_dir.glob(pat), key=lambda q: q.name)[:-keep]:
try:
p.unlink()
removed += 1
except OSError:
pass
return removed
def _fetch_selftest_log(device_ip, out_dir, stamp):
"""从设备 FTP 抓取 /log/selftest.log(lftpd :21 匿名,根目录 = 卷根)。
成功返回本地路径字符串;失败(网络不可达 / FTP 错误 / 设备侧文件缺失)返回 ""
(按 S7 语义视为无故障记录;此处打 WARN,调用方不判整体 FAIL)。
2026-10-02 台架修复:原实现只 `except OSError`,而 ftplib 的 error_temp /
error_perm 继承自 Exception 而非 OSError —— 台账"仅故障才写",干净设备上
/log/selftest.log 根本不存在,RETR 返回 450 直接抛出未捕获异常,令整个
`@test --require-selftest-pass` 在最后一步崩掉(而它本该是 PASS 路径)。
现把"设备侧无此文件"单列为正常路径(INFO),其余 FTP 错误保留 WARN。
"""
local = Path(out_dir) / "selftest_log_{}.log".format(stamp)
ftp = ftplib.FTP()
try:
ftp.connect(device_ip, 21, timeout=10)
ftp.login() # 匿名(lftpd 接受 USER anonymous / 空 PASS)
except (OSError, ftplib.Error) as e:
print("RUN_TEST_WARN: selftest fetch failed: {} "
"(按 S7 语义视为无记录;设备网络/FTP 需另行排查)".format(e))
return ""
buf = bytearray()
try:
ftp.retrbinary("RETR /log/selftest.log", buf.extend)
except ftplib.all_errors as e:
# 450/550 = 设备侧无此文件:干净台账,S7 判 PASS(正常路径,非故障)
print("SELFTEST_FETCH: {} -> (ledger absent: {}) 按 S7 视为无故障记录"
.format(device_ip, str(e).strip()))
_ftp_quit(ftp)
return ""
_ftp_quit(ftp)
local.write_bytes(bytes(buf))
print("SELFTEST_FETCH: {} -> {} ({} bytes)".format(device_ip, local, len(buf)))
return str(local)
def main(argv=None):
# 控制台输出固定 UTF-8:Windows 代码页只影响显示,不会让脚本崩溃
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
ap = argparse.ArgumentParser(description="boot log capture + verify (pure stdlib, no PowerShell)")
ap.add_argument("--port", default=None,
help="serial port (default: portName from project config)")
ap.add_argument("--baud-rate", type=int, default=None)
ap.add_argument("--settle-ms", type=int, default=None,
help="RX silence that ends the capture")
ap.add_argument("--max-wait-ms", type=int, default=None,
help="overall wait-idle timeout")
ap.add_argument("--first-wait-ms", type=int, default=None,
help="give up as no_data if the board sent ZERO bytes this long "
"after flash started the wait (0 = same as --max-wait-ms)")
ap.add_argument("--flash-cmd", default=None,
help="flash command (from config 'flash_cmd'; empty = skip flashing, "
"reset the board manually right after the run starts)")
ap.add_argument("--flash-timeout", type=int, default=None,
help="flash subprocess timeout in seconds")
ap.add_argument("--required", default=None,
help="required markers, ';' separated (config 'required')")
ap.add_argument("--forbidden", default=None,
help="forbidden patterns, ';' separated (config 'forbidden')")
ap.add_argument("--require-suite-summary", default=None,
help="suite names that MUST appear as '=== <name>: N/M PASSED (failed=0) ===' "
"with N>0, ';' separated (T-2). Empty = off")
ap.add_argument("--require-selftest-pass", action="store_true", default=None,
help="jointly judge /log/selftest.log (S7): missing/empty = PASS, "
"any 64B record = FAIL")
ap.add_argument("--selftest-log", default=None,
help="local copy of /log/selftest.log (skips device-side fetch when given)")
ap.add_argument("--device-ip", default=None,
help="device IP for fetching /log/selftest.log via FTP (anonymous)")
ap.add_argument("--out-dir", default=None)
ap.add_argument("--show-tail-lines", type=int, default=None)
ap.add_argument("--fw-hex", default=None,
help="built image for the firmware-identity stamp and the readback "
"compare (config 'hex')")
ap.add_argument("--no-fw-check", action="store_true", default=None,
help="do not require the firmware build stamp in the boot log")
ap.add_argument("--no-verify-flash", action="store_true", default=None,
help="skip the readback compare (config 'readback_cmd')")
ap.add_argument("--readback-timeout", type=int, default=None,
help="timeout for the readback compare, seconds")
ap.add_argument("--max-boot-count", type=int, default=None,
help="fail if the boot banner (config 'boot_marker') appears 0 times or "
"more than N times (0 = check off)")
ap.add_argument("--expect-reset-cause", default=None,
help="';'-separated substrings the FIRST reset cause must match "
"(case-insensitive); empty = report only, no verdict")
ap.add_argument("--require-order", default=None,
help="'>'-separated markers that must appear in that line order "
"(catches out-of-order boot steps); empty = off")
ap.add_argument("--done-marker", default=None,
help="marker whose line marks boot completion (for --max-boot-ms); "
"empty = don't measure")
ap.add_argument("--max-boot-ms", type=int, default=None,
help="fail if time from first line to --done-marker exceeds N ms "
"(0 = measure and report only)")
ap.add_argument("--raw-log", action="store_true", default=None,
help="write payload-only boot log (no '[+Nms]' relative-time prefix)")
ap.add_argument("--keep-out", type=int, default=None,
help="keep the newest N boot_log_*/selftest_log_* files in --out-dir "
"(0 = keep everything)")
args = ap.parse_args(argv)
# 串口助手 CLI 用当前解释器(需装有 pyserial)
py = sys.executable
# 项目测试档案(.serialtest/config.json,UTF-8 容忍 BOM):
# 优先级 = 命令行 > 档案 > 内置通用默认。档案缺失不是错误(纯命令行也能跑)。
cfg = {}
if CFG_FILE.is_file():
try:
raw = json.loads(CFG_FILE.read_text(encoding="utf-8-sig"))
except (OSError, ValueError) as e:
print("RUN_TEST_ERROR: cannot parse {}: {}".format(CFG_FILE, e))
return 3
if not isinstance(raw, dict):
print("RUN_TEST_ERROR: config root is not an object: {}".format(CFG_FILE))
return 3
cfg = raw
print("CONFIG: {}".format(CFG_FILE if cfg else "(none, CLI-only)"))
def _pick(cli, key, default=None):
if cli is not None:
return cli
v = cfg.get(key)
return default if v is None else v
port = str(_pick(args.port, "portName", default="") or "")
args.baud_rate = int(_pick(args.baud_rate, "baudRate", default=115200))
args.settle_ms = int(_pick(args.settle_ms, "settle_ms", default=8000))
args.max_wait_ms = int(_pick(args.max_wait_ms, "max_wait_ms", default=30000))
args.first_wait_ms = int(_pick(args.first_wait_ms, "first_wait_ms", default=12000))
args.flash_cmd = str(_pick(args.flash_cmd, "flash_cmd", default="") or "")
args.flash_timeout = int(_pick(args.flash_timeout, "flash_timeout", default=180))
args.required = str(_pick(args.required, "required", default="") or "")
args.forbidden = str(_pick(args.forbidden, "forbidden", default=FORBIDDEN_DEFAULT))
args.require_suite_summary = str(_pick(args.require_suite_summary,
"require_suite_summary", default="") or "")
args.require_selftest_pass = bool(_pick(args.require_selftest_pass,
"require_selftest_pass", default=False))
args.selftest_log = str(_pick(args.selftest_log, "selftest_log", default="") or "")
args.device_ip = str(_pick(args.device_ip, "device_ip", default="") or "")
args.out_dir = str(_pick(args.out_dir, "out_dir",
default=str(Path(tempfile.gettempdir()) / "serial-boot-test")))
args.show_tail_lines = int(_pick(args.show_tail_lines, "show_tail_lines", default=15))
args.fw_hex = str(_pick(args.fw_hex, "hex", default="") or "")
args.no_fw_check = bool(_pick(args.no_fw_check, "no_fw_check", default=False))
args.fw_marker = str(cfg.get("fw_marker", FW_MARKER_DEFAULT) or "")
args.no_verify_flash = bool(_pick(args.no_verify_flash, "no_verify_flash", default=False))
args.readback_cmd = str(cfg.get("readback_cmd", "") or "")
args.readback_ok = str(cfg.get("readback_ok", READBACK_OK_DEFAULT) or READBACK_OK_DEFAULT)
args.readback_timeout = int(_pick(args.readback_timeout, "readback_timeout", default=120))
args.max_boot_count = int(_pick(args.max_boot_count, "max_boot_count", default=1))
args.boot_marker = str(cfg.get("boot_marker", "") or "")
args.expect_reset_cause = str(_pick(args.expect_reset_cause,
"expect_reset_cause", default="") or "")
args.require_order = str(_pick(args.require_order, "require_order", default="") or "")
args.done_marker = str(_pick(args.done_marker, "done_marker", default="") or "")
args.max_boot_ms = int(_pick(args.max_boot_ms, "max_boot_ms", default=0))
args.raw_log = bool(_pick(args.raw_log, "raw_log", default=False))
args.keep_out = int(_pick(args.keep_out, "keep_out", default=20))
if not port:
print("RUN_TEST_ERROR: no --port given and no portName in {} "
"(create .serialtest/config.json, see config.example.json)".format(CFG_FILE))
return 3
if not args.required:
print("RUN_TEST_ERROR: no required markers (--required or 'required' in {})".format(CFG_FILE))
return 3
# --flash-cmd 允许带参数(如 "python flash_tool.py flash"):拆成 argv,
# 存在性只查 exe 段(整串当文件名必然 missing,2026-10-10 实测)
flash_argv = _split_cmd(args.flash_cmd) if args.flash_cmd else None
# 预检(对应旧版 missing-file 检查)
preflight = [SERIAL_TOOL, VERIFY_LOG]
if flash_argv is not None:
preflight.append(Path(flash_argv[0]))
for p in preflight:
if not p.is_file():
print("RUN_TEST_ERROR: missing {}".format(p))
return 3
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
pruned = _prune_out_dir(out_dir, args.keep_out)
if pruned:
print("OUT: pruned {} old artifact file(s) in {} (keep={})".format(
pruned, out_dir, args.keep_out))
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
boot_txt = out_dir / "boot_log_{}.txt".format(stamp)
def tool(cmd, *rest, timeout=15):
return _tool_cmd(py, cmd, *rest, timeout=timeout)
print("=== Boot test {} ===".format(stamp))
print("Port: {} @ {} settle={}ms max={}ms".format(
port, args.baud_rate, args.settle_ms, args.max_wait_ms))
# 1. open(幂等;未运行则自动拉起常驻 daemon)
data, out = tool("open", "--port", port, "--baud", str(args.baud_rate), "--timeout", "15")
if data is None:
print("RUN_TEST_ERROR: open failed: {}".format(out))
return 2
# 2. clear(新代际;此后数据才属于本次运行)
data, out = tool("clear", "--timeout", "15")
if data is None:
print("RUN_TEST_ERROR: clear failed: {}".format(out))
return 2
# 3. flash(下载 + Reset & Run)。烧录失败不中断:板子可能已复位,
# 由采集 + 标记判定结果(与旧版一致)。flash_cmd 为空 = 不烧录,
# 采集已武装,等人工复位/上电目标板(纯串口验证项目常用)。
if flash_argv is None:
print("FLASH: skipped (no flash_cmd) -- reset/power-cycle the board NOW")
else:
print("FLASH_START: {}".format(" ".join(flash_argv)))
t0 = time.monotonic()
try:
proc = subprocess.run(flash_argv,
capture_output=True, timeout=args.flash_timeout)
flash_rc = proc.returncode
flash_bytes = (proc.stdout or b"") + (proc.stderr or b"")
except subprocess.TimeoutExpired as e:
flash_rc = -1
flash_bytes = (e.stdout or b"") + (e.stderr or b"")
elapsed_ms = int((time.monotonic() - t0) * 1000)
for line in flash_bytes.decode("utf-8", errors="replace").strip().splitlines()[-12:]:
print(line)
print("FLASH_RC: {} elapsed_ms={}".format(flash_rc, elapsed_ms))
if flash_rc != 0:
print("RUN_TEST_WARN: flash rc={} (continuing: board may still have reset)".format(flash_rc))
# 3.5 固件身份戳:从**刚烧进去的那份 hex** 里抠出固件戳(config "fw_marker",
# 默认 'fw build: '),追加成 Required 标记 → 芯片里跑的必须就是这份构建。
# hex 里没有该串(旧构建/无此机制)时只 WARN 跳过,不假判 FAIL。
required = args.required
fw_hex = Path(args.fw_hex) if args.fw_hex else None
if args.no_fw_check:
print("FW ID: skipped (--no-fw-check)")
elif not args.fw_marker:
print("FW ID: skipped (fw_marker empty)")
elif fw_hex is None:
print("RUN_TEST_WARN: no 'hex' configured -> identity + readback skipped")
elif not fw_hex.is_file():
print("RUN_TEST_WARN: fw hex not found {} -> identity + readback skipped".format(fw_hex))
else:
fw_stamp = extract_fw_stamp(fw_hex, args.fw_marker)
if fw_stamp is None:
print("RUN_TEST_WARN: no '{}' stamp in {} -> identity check skipped "
"(stale image: rebuild first)".format(args.fw_marker, fw_hex.name))
else:
marker = args.fw_marker + fw_stamp
required = (args.required + ";" + marker) if args.required else marker
print("FW ID: expected stamp '{}' (from {})".format(marker, fw_hex.name))
# 4. wait-idle(RX 静默收尾或整体超时)
# --first-timeout-ms:首字节门——烧录后板子全程不吐字节时按 no_data 快速失败
# (旧版 daemon 常驻累计 rx_bytes>0,no_data 永不触发,只能靠后面"日志里没有
# [RX] 行"兜底,报错信息误导成 device silent?)
wait_timeout_s = args.max_wait_ms // 1000 + 20
wait_args = ["--settle-ms", str(args.settle_ms),
"--max-ms", str(args.max_wait_ms),
"--timeout", str(wait_timeout_s)]
if args.first_wait_ms:
wait_args += ["--first-timeout-ms", str(args.first_wait_ms)]
data, out = tool("wait-idle", *wait_args, timeout=wait_timeout_s + 15)
if data is None:
print("RUN_TEST_ERROR: wait-idle failed: {}".format(out))
return 2
print("WAIT: reason={} rx_bytes={} waited_ms={}".format(
data.get("reason"), data.get("rx_bytes"), data.get("waited_ms")))
if data.get("reason") == "no_data":
print("RUN_TEST_ERROR: no RX data at all (check port / board power / flash)")
return 2
# 5. 提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀)
# 默认给每行加 `[+Nms]` 相对偏移(daemon 毫秒时基,本次采集第一行 = +0ms)。
# 为什么加:MCU 侧时间戳只有**秒级**,且开机头几行时钟还没起(`----/--/--`),
# 根本量不了耗时;有了它 verify_log 才能查"能开机但慢了几秒"(--max-boot-ms)。
# --raw-log 恢复旧格式(纯载荷)。
data, out = tool("log", "--tail", "0", "--timeout", "15")
if data is None:
print("RUN_TEST_ERROR: log read failed: {}".format(out))
return 2
rx = []
t0 = None
for l in data.get("lines", []):
if not (isinstance(l, str) and l.startswith("[RX] ")):
continue
rest = l[5:]
sp = rest.find(" ")
ts_txt = rest[:sp] if sp >= 0 else ""
payload = rest[sp + 1:] if sp >= 0 else rest
if args.raw_log:
rx.append(payload)
continue
ts = None
if ts_txt:
try:
ts = datetime.fromisoformat(ts_txt)
except ValueError:
ts = None
if ts is None:
rx.append(payload) # 时间戳读不出来就不加前缀(不猜)
continue
if t0 is None:
t0 = ts
rx.append("[+{}ms] {}".format(int((ts - t0).total_seconds() * 1000), payload))
if not rx:
print("RUN_TEST_ERROR: no [RX] lines in log (device silent?)")
return 2
boot_txt.write_bytes(("\r\n".join(rx) + "\r\n").encode("utf-8"))
print("Boot log: {} (lines={} gen={} ts={})".format(
boot_txt, len(rx), data.get("gen"), "raw" if args.raw_log else "+ms"))
# 5.6 烧录回读校验(config "readback_cmd" 模板,{hex} = 待比对镜像路径;
# 成功判据 = 某行以 "readback_ok"(默认 No difference found)开头)。
# 必须排在**采集之后**:实测 ST-LINK -CmpFile 连接时会复位目标(板子当场
# 重打一遍开机日志),排在采集前会截断开机日志并让启动锚点计数误判。
if args.no_verify_flash:
print("READBACK: skipped (--no-verify-flash)")
elif not args.readback_cmd:
print("READBACK: skipped (no 'readback_cmd' in config)")
elif fw_hex is None or not fw_hex.is_file():
print("READBACK: skipped (no fw hex: {})".format(args.fw_hex or "(unset)"))
else:
rb_cmd = args.readback_cmd.replace("{hex}", '"{}"'.format(fw_hex))
rb_rc, rb_text = _run_cmd(_split_cmd(rb_cmd), args.readback_timeout)
# 判定按**行首**匹配:进度条和帮助文本里也可能含成功字样,
# 只有工具自己那行才独占行首(实测 `...100%\r\nNo difference found.`)
rb_ok = any(ln.strip().lower().startswith(args.readback_ok.lower())
for ln in rb_text.splitlines())
if rb_ok:
print("READBACK: OK ({}) rc={}".format(args.readback_ok, rb_rc))
else:
print("READBACK: FAIL -- device flash does NOT match {}".format(fw_hex.name))
for line in rb_text.strip().splitlines()[-10:]:
print(" | {}".format(line))
print("RUN_TEST_ERROR: flash readback mismatch (chip content != built image)")
return 2
# 5.5 自检记录抓取(S7):启用且未提供本地副本时才走 FTP(需 config "device_ip")
selftest_local = args.selftest_log
if args.require_selftest_pass and not selftest_local and not args.device_ip:
print("RUN_TEST_WARN: require_selftest_pass but no device_ip/selftest_log "
"-> treated as no record (S7 PASS path)")
if args.require_selftest_pass and not selftest_local and args.device_ip:
selftest_local = _fetch_selftest_log(args.device_ip, out_dir, stamp)
# 6. 标记校验(含固件身份戳、启动锚点计数、可选的自检记录联合判定)
# 启动锚点(config "boot_marker",如本仓库的 'reset cause:')未配置 =
# 锚点计数关闭——不同固件的"一轮启动"锚点各不相同,宁关勿假判。
from verify_log import verify
boot_count = args.max_boot_count if args.boot_marker else 0
if not args.boot_marker:
print("BOOT ANCHOR: off (set 'boot_marker' in config to catch "
"mid-capture resets / stale captures)")
rc = verify(str(boot_txt), required, args.forbidden, args.show_tail_lines,
suite_summary=args.require_suite_summary,
selftest_pass=args.require_selftest_pass,
selftest_log=selftest_local,
boot_marker=args.boot_marker or "reset cause:",
max_boot_count=boot_count,
expect_reset_cause=args.expect_reset_cause,
require_order=args.require_order,
done_marker=args.done_marker,
max_boot_ms=args.max_boot_ms)
print("RUN_TEST_RC={}".format(rc))
return rc
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,448 @@
#!/usr/bin/env python3
"""verify_log.py - 对抓取的 MCU 调试日志做标记校验(纯标准库,2026-09-20 替代 verify_log.ps1)。
用于 @test 闭环:烧录后从串口助手日志提取的开机日志交给本脚本 grep 标记,
输出结构化判定(格式与旧 PowerShell 版逐字一致,下游文档/习惯不受影响)。
用法:
& '.venv\\Scripts\\python.exe' .dsh/skills/serial-boot-test/scripts/verify_log.py --log-path <file>
[--required 'marker1;marker2']
[--forbidden 'FAIL;ERROR;HardFault;assert;[ERR]']
[--require-suite-summary 'Storage Tests;GD5F2GQ5UE Tests']
[--require-selftest-pass] [--selftest-log <selftest.log 本地副本>]
[--show-tail-lines 40]
[--max-boot-count 1] [--boot-marker 'reset cause:']
[--expect-reset-cause 'software;power-on']
[--require-order 'App init OK>Network Init>net init: OK']
[--done-marker 'net init: OK'] [--max-boot-ms 8000] [--json]
退出码(与旧版一致):
0 = PASS(所有 Required 标记找到,Forbidden 零命中,汇总行要求满足)
1 = FAIL(缺 Required / 命中 Forbidden / 汇总行要求不满足)
2 = ERROR(日志文件不存在/不可读)
默认 Required 标记对应 STM32F4-Base 启动序列(见 App/app/app_main.c + App/task/net_task.c),
按测试用例覆盖,例如:
--required 'App init OK;net init: OK;PHY: 100M FULL'
--forbidden 'FAIL;ERROR;HardFault;assert;[ERR]'
语义约定(与旧 PowerShell 版对齐):
- Required:大小写敏感子串匹配(全文)
- Forbidden:大小写**不敏感**子串匹配、逐行计数(旧版 -like 语义;
如 Forbidden 'FAIL' 会命中 "write /log/run.log fail (7)" 这类行)
- 固件错误级日志前缀是 `[ERR]`(`DBG_ERROR`,形如 `[时间][ERR][TAG] 消息`),
与 `ERROR` 子串**不匹配**——默认 Forbidden 因此显式包含 `[ERR]`,驱动/模块
错误行不再漏检(2026-09-21 修复)。测试套件中**预期**出现 `[ERR]` 的用例
(如 GD5F DMA 守卫负路径、FatFs 非法名校验)须用 --forbidden 覆盖默认值。
- 读取编码:先按 UTF-8,解码失败回退系统 locale 编码(旧版 Get-Content 行为)
- 控制台输出重配置为 UTF-8(errors=replace):任何 Windows 控制台代码页
都只影响终端显示,不会导致本脚本崩溃
- 汇总行(2026-10-01 加,T-2):纯 Required 子串匹配有个盲区——套件启动横幅
命中即判 PASS,**套件是否跑到汇总行、断言数是否为 0 完全不可见**(套件中途
挂死/复位、或 Phase 宏全关时会被误判为通过)。`--require-suite-summary`
要求日志中出现 `=== <名称>: N/M PASSED (failed=0) ===` 且 **N > 0**。
- 自检记录联合判定(S7,2026-10 实机窗口):`--require-selftest-pass` 对 /log/selftest.log
的本地副本(定长 64B 记录,格式见 App/log/sys_log.h)判定。自检仅故障写盘(v1 不记
通过项):**文件缺失 / 0 字节 = 无故障 → PASS**;存在任何一条记录 = 有故障 → FAIL
(退出码 1),并逐条打印时间戳 + 描述。抓取失败(设备不可达 / FTP 错误等)由调用方
(run_boot_test.py)先打 WARN,本地文件按缺失处理 → 按上述规则不判整体 FAIL。
- 启动锚点计数(2026-10-10):`--max-boot-count N`(run_boot_test.py 默认传 1)统计
`reset cause:` 锚点出现次数——**0 次** = 采集没从复位开始(标记全中也可能只是上一轮
的残留日志);**>N 次** = 板子在采集窗口内自己复位过(IWDG 咬狗 / Fault 后
`NVIC_SystemReset` / 掉电重启),此前这类运行只要不命中 Forbidden 就判 PASS。
`--expect-reset-cause` 对第一个锚点做原因白名单(大小写不敏感子串),空 = 只报告不判。
- Forbidden 命中行(2026-10-10):命中时逐行打印**行号 + 内容**(最多 6 行,超出报剩余数)。
旧实现只说 `forbidden pattern: [ERR] (3 line(s))`,人还得自己再 grep 一遍才知道是哪几行
——而"是哪几行"恰恰是定位要的东西。
- 标记顺序(`--require-order 'a>b>c'`):按**首次出现的行序**校验启动步骤先后。标记全中
但顺序颠倒 = 某步在另一步之后补做(屏障失序、重试后补跑),旧实现完全看不见。
- 开机耗时(`--done-marker` + `--max-boot-ms`):依赖行首 `[+Nms]` 前缀(run_boot_test.py
2026-10-10 起按 daemon 毫秒时基给每行加,MCU 侧时间戳只有秒级且开机头几行根本没有)。
量"第一行 → done-marker"的真实耗时,抓"能开机但慢了几秒"(mount 重试 / PHY 重协商)。
日志里没有该前缀 → 只 WARN 跳过,不假判 FAIL。
- `--json`:同一套判定以 JSON 输出(供 CI / 上层脚本消费;退出码语义不变)。
"""
import argparse
import json
import locale
import re
import sys
from pathlib import Path
REQUIRED_DEFAULT = "App init OK;Network Init;net init: OK"
FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault;assert;[ERR]"
# Forbidden 命中时最多打印几条命中行(够定位又不刷屏;剩余条数会报出来)
HIT_LINES_MAX = 6
# 行首 `[+1234ms] `:run_boot_test.py 按 daemon 毫秒时基给每行加的相对偏移
# (MCU 侧时间戳只有秒级,且开机头几行时钟还没起 → 量不了耗时)
_TS_OFF_RE = re.compile(r"^\[\+(\d+)ms\]\s")
# 每轮启动的锚点行(App/app/app_main.c:`reset cause: <原因> (CSR=0x…)`)。
# 一次采集里出现 >1 次 = 板子在采集窗口内自己复位过(IWDG 咬狗 / Fault 后
# NVIC_SystemReset / 掉电重启),此前只有 Forbidden 命中时才被发现,
# "跑完 PASS 但中途重启过"完全不可见(2026-10-10 加)。
BOOT_MARKER_DEFAULT = "reset cause:"
# 套件汇总行:`=== Storage Tests: 3/3 PASSED (failed=0) ===`
# 名称可含空格;N/M 为十进制;failed 允许前导 0。宽松容忍前后空白与
# 日志时间戳前缀(`[2026-10-01 12:00:00.000]`)——故用 search 而非 match。
_SUMMARY_RE = re.compile(
r"===\s*(?P<name>[^:=]+?)\s*:\s*(?P<passed>\d+)\s*/\s*(?P<total>\d+)\s+"
r"PASSED\s*\(\s*failed\s*=\s*(?P<failed>\d+)\s*\)\s*==="
)
def _split_list(spec):
"""';' 分隔列表 → 去空白、去空项。"""
return [p.strip() for p in spec.split(";") if p.strip()]
def _read_lines(path):
raw = Path(path).read_bytes()
for enc in ("utf-8", locale.getpreferredencoding(False)):
try:
return raw.decode(enc).splitlines()
except UnicodeDecodeError:
continue
return raw.decode("utf-8", errors="replace").splitlines()
def _reset_cause_of(line, marker):
"""从 `reset cause: software (NVIC_SystemReset) (CSR=0x14000000)` 取原因文本。"""
i = line.find(marker)
if i < 0:
return ""
tail = line[i + len(marker):].strip()
j = tail.find(" (CSR=")
return tail[:j] if j > 0 else tail
def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
show_tail=40, out=print, suite_summary="",
selftest_pass=False, selftest_log="",
boot_marker=BOOT_MARKER_DEFAULT, max_boot_count=0,
expect_reset_cause="", require_order="", done_marker="",
max_boot_ms=0, as_json=False):
"""标记校验核心(可被 run_boot_test.py 复用)。返回退出码 0/1/2。
suite_summary: ';' 分隔的套件名列表。非空时,要求日志中每个套件都出现
`=== <名称>: N/M PASSED (failed=0) ===` 且 **N > 0**。
selftest_pass: True 时对 selftest_log(/log/selftest.log 本地副本)联合判定:
文件缺失 / 0 字节 = 无故障(PASS);存在任何 64B 记录 = FAIL(S7)。
selftest_log: 自检记录文件本地路径(空串 = 按文件缺失处理)。
boot_marker / max_boot_count(2026-10-10):统计启动锚点出现次数。
max_boot_count>0 时启用:0 次 = 采集没从复位开始;>N 次 = 板子
在采集窗口内自己复位过(IWDG / Fault 后 NVIC_SystemReset)。
expect_reset_cause: ';' 分隔的原因子串白名单,只对**第一个**锚点生效(大小写不敏感)。
空 = 只报告不判定。
require_order: '>' 分隔的标记序列(2026-10-10),按**首次出现的行序**校验先后;
顺序颠倒 = 某步在另一步之后补做(屏障失序 / 重试补跑)。空 = 不查。
done_marker / max_boot_ms(2026-10-10):量"第一行 → done_marker"的毫秒耗时
(靠行首 `[+Nms]` 前缀);超过 max_boot_ms 判 FAIL。
日志无该前缀 → WARN 跳过,不假判。
as_json: True = 不打印文本判定,最后输出一个 JSON 对象(退出码不变)。
"""
res = {"log_path": str(log_path), "lines": 0, "bytes": 0, "required_missing": [],
"forbidden_hits": [], "boot": None, "suites_bad": [], "selftest": None,
"order_bad": [], "timing": None, "tail": [], "verdict": None, "exit": None}
emit = out
def finish(code):
res["exit"] = code
res["verdict"] = {0: "PASS", 1: "FAIL", 2: "ERROR"}[code]
if as_json:
emit(json.dumps(res, ensure_ascii=False, indent=2))
return code
if as_json:
out = lambda *a, **k: None # 文本判定静默,结果走 JSON
p = Path(log_path)
if not p.is_file():
out("VERIFY_ERROR: log file not found: {}".format(log_path))
return finish(2)
try:
lines = _read_lines(log_path)
except OSError:
out("VERIFY_ERROR: cannot read log file: {}".format(log_path))
return finish(2)
full = "\n".join(lines)
req_list = _split_list(required)
forb_list = _split_list(forbidden)
summary_list = _split_list(suite_summary)
out("=== Log verdict: {} ===".format(log_path))
out("Lines: {}, bytes: {}".format(len(lines), p.stat().st_size))
res["lines"] = len(lines)
res["bytes"] = p.stat().st_size
missing = []
for m in req_list:
if m in full:
out("[OK] required marker found: {}".format(m))
else:
out("[MISS] required marker NOT found: {}".format(m))
missing.append(m)
res["required_missing"] = list(missing)
hits = []
for f in forb_list:
fl = f.lower()
matched = [(i + 1, ln) for i, ln in enumerate(lines) if fl in ln.lower()]
if matched:
out("[HIT] forbidden pattern: {} ({} line(s))".format(f, len(matched)))
# 命中行必须打出来(2026-10-10):旧实现只报 "(3 line(s))",人还得自己
# 再 grep 一遍才知道是哪几行——而"哪几行"正是定位所需的那一步
for ln_no, ln in matched[:HIT_LINES_MAX]:
out(" L{}: {}".format(ln_no, ln.strip()[:200]))
if len(matched) > HIT_LINES_MAX:
out(" ... {} more".format(len(matched) - HIT_LINES_MAX))
hits.append(f)
res["forbidden_hits"].append({
"pattern": f, "count": len(matched),
"lines": [{"no": n, "text": t.strip()[:200]}
for n, t in matched[:HIT_LINES_MAX]]})
else:
out("[OK] forbidden pattern absent: {}".format(f))
# 启动锚点计数(2026-10-10):抓"跑完但中途复位过"和"采集没从复位开始"
boot_issue = False
if max_boot_count > 0:
banner_lines = [ln for ln in lines if boot_marker in ln]
n_boot = len(banner_lines)
causes = [_reset_cause_of(ln, boot_marker) for ln in banner_lines]
if n_boot == 0:
out("[FAIL] no boot banner (marker '{}'): capture did not start at a reset".format(
boot_marker))
boot_issue = True
elif n_boot > max_boot_count:
out("[FAIL] boot banner x{} > allowed {} -> board reset itself during capture".format(
n_boot, max_boot_count))
for ln in banner_lines[:6]:
out(" {}".format(ln.strip()))
boot_issue = True
else:
out("[OK] boot banner x{} (marker '{}')".format(n_boot, boot_marker))
if causes:
out("[INFO] reset cause(s): {}".format("; ".join(causes)))
exp = _split_list(expect_reset_cause)
if exp:
got = causes[0].lower()
if any(e.lower() in got for e in exp):
out("[OK] reset cause '{}' matches expected {}".format(
causes[0], expect_reset_cause))
else:
out("[FAIL] reset cause '{}' not in expected: {}".format(
causes[0], expect_reset_cause))
boot_issue = True
res["boot"] = {"marker": boot_marker, "count": n_boot, "allowed": max_boot_count,
"causes": causes, "issue": boot_issue}
# 标记顺序(2026-10-10):标记全中但**顺序颠倒** = 某步在另一步之后补做
# (屏障失序 / 失败重试补跑)。旧实现只看子串存在性,这类失序完全看不见。
order_issue = False
if require_order:
seq = [s.strip() for s in require_order.split(">") if s.strip()]
pos = []
for m in seq:
idx = next((i for i, ln in enumerate(lines) if m in ln), -1)
pos.append((m, idx))
absent = [m for m, i in pos if i < 0]
if absent:
out("[FAIL] require-order: marker(s) not found: {}".format("; ".join(absent)))
order_issue = True
else:
bad = [(pos[k][0], pos[k + 1][0]) for k in range(len(pos) - 1)
if pos[k][1] > pos[k + 1][1]]
if bad:
out("[FAIL] marker order violated (expected {})".format(require_order))
for a, b in bad:
out(" '{}' first appeared AFTER '{}'".format(a, b))
for m, i in pos:
out(" L{}: {}".format(i + 1, m))
order_issue = True
else:
out("[OK] marker order ok: {}".format(require_order))
res["order_bad"] = [m for m, i in pos if i < 0] + [
"{}>{}".format(a, b) for a, b in
[(pos[k][0], pos[k + 1][0]) for k in range(len(pos) - 1) if pos[k][1] > pos[k + 1][1]]]
# 开机耗时(2026-10-10):行首 [+Nms] = daemon 毫秒时基的相对偏移
# (MCU 侧时间戳只有秒级,且开机头几行时钟还没起,量不出耗时)。
timing = None
timing_issue = False
offs = []
for i, ln in enumerate(lines):
m = _TS_OFF_RE.match(ln)
if m:
offs.append((i, int(m.group(1))))
if offs:
timing = {"ts_lines": len(offs), "first_ms": offs[0][1], "last_ms": offs[-1][1],
"span_ms": offs[-1][1] - offs[0][1]}
out("[INFO] capture span: +{}ms .. +{}ms ({} timestamped line(s))".format(
timing["first_ms"], timing["last_ms"], timing["ts_lines"]))
if done_marker:
di = next((i for i, ln in enumerate(lines) if done_marker in ln), -1)
if di < 0:
out("[WARN] done marker '{}' not found -> boot time not measured".format(
done_marker))
else:
doff = next((off for i, off in offs if i >= di), None)
if doff is None:
out("[WARN] done marker line has no [+Nms] prefix -> not measured")
else:
boot_ms = doff - offs[0][1]
timing["boot_ms"] = boot_ms
timing["done_marker"] = done_marker
out("[INFO] boot time to '{}': {} ms".format(done_marker, boot_ms))
if max_boot_ms > 0 and boot_ms > max_boot_ms:
out("[FAIL] boot time {} ms > limit {} ms".format(boot_ms, max_boot_ms))
timing_issue = True
elif done_marker or max_boot_ms > 0:
out("[WARN] no [+Nms] line prefix in this log -> timing checks skipped "
"(run_boot_test.py adds it; --raw-log turns it off)")
res["timing"] = timing
# 套件汇总行解析(T-2):抓"没跑到汇总行 / 断言数为 0"的盲区
bad_summary = []
if summary_list:
parsed = {}
for ln in lines:
m = _SUMMARY_RE.search(ln)
if m:
parsed.setdefault(m.group("name").strip(), []).append(m)
for name in summary_list:
cands = parsed.get(name)
if not cands:
out("[MISS] suite summary NOT found: '=== {}: N/M PASSED (failed=0) ==='".format(name))
bad_summary.append(name)
continue
ok = False
for m in cands:
passed = int(m.group("passed"))
total = int(m.group("total"))
failed = int(m.group("failed"))
if failed != 0:
out("[FAIL] suite summary reports failures: {} -> {}/{} PASSED (failed={})".format(
name, passed, total, failed))
continue
if total == 0:
out("[FAIL] suite summary total is 0 (no assertion ran): '{}'".format(name))
continue
if passed != total:
out("[FAIL] suite summary inconsistent: {} -> {}/{} PASSED".format(
name, passed, total))
continue
out("[OK] suite summary ok: {} -> {}/{} PASSED".format(name, passed, total))
ok = True
if not ok:
bad_summary.append(name)
res["suites_bad"] = list(bad_summary)
# 自检记录联合判定(S7):/log/selftest.log 定长 64B 记录(格式见 App/log/sys_log.h),
# 仅故障写盘:文件缺失 / 0 字节 = 无故障 → PASS;存在任何一条记录 → FAIL
selftest_fail = False
if selftest_pass:
sp = Path(selftest_log) if selftest_log else None
if sp is None or not sp.is_file() or sp.stat().st_size == 0:
out("[OK] selftest: no fault record (file missing or empty)")
res["selftest"] = {"path": str(sp) if sp else None, "faults": 0, "records": []}
else:
raw = sp.read_bytes()
n = len(raw) // 64
if len(raw) % 64:
out("[WARN] selftest log has {} trailing incomplete bytes (ignored)".format(len(raw) % 64))
recs = []
for i in range(n):
rec = raw[i * 64:(i + 1) * 64]
ts = rec[0:19].decode("ascii", errors="replace")
desc = rec[31:59].split(b"\0", 1)[0].decode("utf-8", errors="replace")
recs.append({"no": i, "ts": ts, "desc": desc})
res["selftest"] = {"path": str(sp), "faults": n, "records": recs}
if n > 0:
selftest_fail = True
out("[FAIL] selftest: {} fault record(s) in {}".format(n, selftest_log))
for r in recs:
out(" #{}: {} {}".format(r["no"], r["ts"], r["desc"]))
else:
out("[OK] selftest: no fault record (0 complete records)")
if show_tail > 0 and lines:
res["tail"] = lines[-show_tail:]
out("")
out("=== Last {} line(s) of log ===".format(show_tail))
for ln in lines[-show_tail:]:
out(ln)
out("")
if (missing or hits or bad_summary or selftest_fail or boot_issue
or order_issue or timing_issue):
out("VERDICT: FAIL")
return finish(1)
out("VERDICT: PASS")
return finish(0)
def main(argv=None):
# 控制台输出固定 UTF-8:Windows 代码页(GBK/UTF-8 终端)均不会让本脚本崩溃
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
ap = argparse.ArgumentParser(description="MCU debug log marker check (pure stdlib)")
ap.add_argument("--log-path", required=True, help="boot log file to check")
ap.add_argument("--required", default=REQUIRED_DEFAULT,
help="required markers, ';' separated (case-sensitive)")
ap.add_argument("--forbidden", default=FORBIDDEN_DEFAULT,
help="forbidden patterns, ';' separated (case-insensitive)")
ap.add_argument("--require-suite-summary", default="",
help="suite names that MUST appear as '=== <name>: N/M PASSED (failed=0) ===' "
"with N>0, ';' separated (T-2). Empty = off")
ap.add_argument("--show-tail-lines", type=int, default=40,
help="show last N log lines (0 = off)")
ap.add_argument("--require-selftest-pass", action="store_true",
help="judge /log/selftest.log local copy (S7): "
"missing/empty = PASS, any 64B record = FAIL")
ap.add_argument("--selftest-log", default="",
help="local copy path of /log/selftest.log (with --require-selftest-pass)")
ap.add_argument("--boot-marker", default=BOOT_MARKER_DEFAULT,
help="per-boot banner substring (default '{}')".format(BOOT_MARKER_DEFAULT))
ap.add_argument("--max-boot-count", type=int, default=0,
help="fail if the boot banner appears 0 times or more than N times "
"(0 = check off). run_boot_test.py passes 1 by default")
ap.add_argument("--expect-reset-cause", default="",
help="';'-separated substrings the FIRST reset cause must match "
"(case-insensitive). Empty = report only, no verdict")
ap.add_argument("--require-order", default="",
help="'>'-separated markers that must appear in that line order "
"(e.g. 'App init OK>Network Init>net init: OK'). Empty = off")
ap.add_argument("--done-marker", default="",
help="marker whose line marks boot completion, for --max-boot-ms")
ap.add_argument("--max-boot-ms", type=int, default=0,
help="fail if time from first line to --done-marker exceeds N ms "
"(needs '[+Nms]' line prefix; 0 = off)")
ap.add_argument("--json", action="store_true",
help="emit one JSON object instead of the text verdict (exit code unchanged)")
args = ap.parse_args(argv)
return verify(args.log_path, args.required, args.forbidden,
args.show_tail_lines, suite_summary=args.require_suite_summary,
selftest_pass=args.require_selftest_pass,
selftest_log=args.selftest_log,
boot_marker=args.boot_marker,
max_boot_count=args.max_boot_count,
expect_reset_cause=args.expect_reset_cause,
require_order=args.require_order,
done_marker=args.done_marker,
max_boot_ms=args.max_boot_ms,
as_json=args.json)
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -0,0 +1 @@
__pycache__/
+58
View File
@@ -0,0 +1,58 @@
---
name: stlink-flash
description: ST-LINK 命令行烧录/复位/回读校验(自带独立脚本 stlink_flash.py,纯标准库,拷到任何 ST-Link+STM32 项目即用):-P 烧录+复位运行 / -CmpFile 全片回读比对 / 只复位。封装"烧后必须 -Rst -Run + 回读校验"的铁律与 -CmpFile 复位目标等实测陷阱。
whenToUse: 需要向 STM32 目标烧录 .hex/.bin、只复位重启目标、校验芯片内容与刚构建镜像是否一致、或排查"反复重下载芯片仍是旧镜像"类烧录假成功时。
---
# stlink-flash — ST-LINK 烧录/复位/回读(独立可运行 skill)
**执行体 = 本 skill 自带脚本** `.dsh/skills/stlink-flash/scripts/stlink_flash.py`
(纯标准库;拷整个 skill 目录到别的 ST-Link 项目即可用,不依赖仓库脚本)。
```powershell
# cwd = 项目根目录
& $PY ".dsh\skills\stlink-flash\scripts\stlink_flash.py" list
& $PY ".dsh\skills\stlink-flash\scripts\stlink_flash.py" flash [hex] # 烧录 + -Rst -Run
& $PY ".dsh\skills\stlink-flash\scripts\stlink_flash.py" cmp [hex] # 回读比对(会复位目标)
& $PY ".dsh\skills\stlink-flash\scripts\stlink_flash.py" reset # 只复位运行
& $PY ".dsh\skills\stlink-flash\scripts\stlink_flash.py" hard # nRST 100ms 脉冲(CLI 拒 RM= 时自动降级为复位)
```
镜像缺省取项目档案 `hex`;ST-LINK_CLI 解析:`--cli` > 档案 `stlink_cli` >
`$DSH_STLINK_CLI` > `$DTU_STLINK_CLI` > PATH(**显式设了但无效 = 立即报错,不回退**)。
退出码:`0`=OK / `1`=探针或目标错误 / `2`=CLI 未找到 / `3`=用法。
成功判据内置:flash = rc0 + `Programming Complete`;cmp = 行首 `No difference found`
(输出原样透传,serial-boot-test 的 `readback_ok` 默认值直接可用)。
## 本仓库
**`@download` = 本 skill**(2026-10-10 起为仓库文档口径)。原 `MDK-ARM\reset.bat`
(`flash`/`cmp`/`halt`/`run`/`list` 子命令)已并入本 skill 的
`flash/cmp/reset/hard/halt/run/list` 并于 **2026-10-10 删除**;`@build`/`@download`
两条旧批处理入口至此全部收敛到 skill(一个能力一个实现)。
环境变量 `DTU_STLINK_CLI`(未设回退 PATH;设了但无效 = 报错 exit 2)。
## 铁律与陷阱(实测教训)
- **烧录必须带 `-Rst -Run`,不可省略**:裸 `-P` 会报 `Programming Complete`,但目标核
处于死循环/异常状态时写入并不落地(芯片内容不变)——"反复重下载、芯片一直是旧镜像、
二分法失效"的根因。上层若做自动闭环,固件里加编译期身份戳(`fw build: __DATE__
__TIME__`)才能把这种假成功揪出来(serial-boot-test 的 `fw_marker` 机制)。
- **烧录后必须回读校验**:`-CmpFile` 全片逐字节比对,`No difference found` 才算真写入;
不一致 = 本次运行作废。
- **`-CmpFile` 连接时会复位目标**:板子当场重打一遍开机日志——在自动闭环里必须排在
**串口日志采集之后**,否则开机日志被截断、启动锚点计数误判成"中途复位"。
- **只重启不烧录**用 `reset` 子命令(`-c SWD -Rst -Run`);nRST 未接线的台架(3 线 SWD)
这是唯一复位手段——`hard`(`RM=Hrst -HardRst PULSE=100`)在部分 CLI 版本(v3.5.0 实测)
被拒,本 skill 会打印 `RESET_NOTE` 并**自动降级**为这条系统复位(仍按降级结果给 rc)。
- 探针在位先 `list`;`Programming Complete` 但行为像旧固件 → 先怀疑缺 `-Rst -Run`,
再怀疑 hex 没重编(对比 hex 时间戳)。
- 完整复位语义/故障排查(本仓库):`docs/测试说明/ST-LINK_CLI_复位使用说明.md`。
## 与其它 skill 的衔接
- 编译 = `keil-build` skill(先 0 错 0 警再烧)。
- `serial-boot-test` 档案里 `flash_cmd` = 本 skill 入口(如
`".venv\\Scripts\\python.exe .dsh\\skills\\stlink-flash\\scripts\\stlink_flash.py flash"`)、
`readback_cmd` = `"... stlink_flash.py cmp {hex}"`、
`readback_ok` = `no difference found`——闭环脚本已保证"回读排在采集之后"。
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""stlink_flash.py - ST-LINK 命令行烧录/复位/回读校验(纯标准库,可移植 skill 版)。
用法(**项目根目录**下运行,任意 python3):
python <skill>/scripts/stlink_flash.py list
python <skill>/scripts/stlink_flash.py flash [镜像.hex|.bin] # 烧录 + -Rst -Run
python <skill>/scripts/stlink_flash.py cmp [镜像.hex|.bin] [--base 0x08000000]
python <skill>/scripts/stlink_flash.py reset # 只复位运行
python <skill>/scripts/stlink_flash.py hard # nRST 100ms 脉冲(CLI 拒 RM= 时自动降级为系统复位)
python <skill>/scripts/stlink_flash.py halt | run
镜像缺省取项目档案 "hex"(解析顺序同下)。
ST-LINK_CLI 解析(命令行 > 档案 > 环境 > PATH):
--cli > 档案 "stlink_cli" > $DSH_STLINK_CLI > $DTU_STLINK_CLI > PATH
(环境变量**显式设了但路径无效 = 立即报错,不静默回退**)
铁律(实测教训,脚本已内置):
- 烧录必带 -Rst -Run:裸 -P 在目标核死循环时报 Complete 但内容不落地
- flash 成功判据 = rc==0 且输出含 "Programming Complete"
- cmp 成功判据 = 某行以 "No difference found" 开头(原样透传 CLI 输出,
上层 run_boot_test 的 readback_ok 默认值直接可用)
- cmp 连接时会**复位目标**(板子重打开机日志)→ 自动闭环里必须排在日志采集之后
- hard 复位 = `RM=Hrst -HardRst PULSE=100`;v3.5.0 CLI 拒 `RM=` → **自动降级为系统复位**
(打印 RESET_NOTE,仍按降级结果给 rc);本机 nRST 未接线时这条即唯一可用路径
退出码: 0=OK 1=探针/目标错误 2=CLI 未找到/无效 3=用法错误
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
def find_config_file():
env = os.environ.get("DSH_SERIAL_CONFIG")
if env:
return Path(env)
cwd = Path.cwd()
candidates = [cwd / ".serialtest" / "config.json",
cwd / ".opencode" / "serial_config.json"]
for c in candidates:
if c.is_file():
return c
return candidates[0]
def load_config():
f = find_config_file()
if not f.is_file():
return {}, ""
try:
raw = json.loads(f.read_text(encoding="utf-8-sig"))
except (OSError, ValueError) as e:
return {}, "cannot parse {}: {}".format(f, e)
if not isinstance(raw, dict):
return {}, "config root is not an object: {}".format(f)
return raw, ""
def find_cli(cli_arg, cfg):
for label, val in (("--cli", cli_arg),
("config 'stlink_cli'", cfg.get("stlink_cli")),
("$DSH_STLINK_CLI", os.environ.get("DSH_STLINK_CLI")),
("$DTU_STLINK_CLI", os.environ.get("DTU_STLINK_CLI"))):
if val:
if Path(val).is_file():
return Path(val), label
raise SystemExit("ENV_ERROR: {} is set but not a file: {} "
"(fix the value; no silent fallback)".format(label, val))
w = shutil.which("ST-LINK_CLI.exe") or shutil.which("ST-LINK_CLI")
if w:
return Path(w), "PATH"
raise SystemExit("ENV_ERROR: ST-LINK_CLI.exe not found "
"(--cli / config 'stlink_cli' / $DSH_STLINK_CLI / $DTU_STLINK_CLI / PATH)")
def run_cli(cli, args, timeout):
t0 = time.monotonic()
try:
# stdin=DEVNULL 必须:ST-LINK_CLI 编程完成后会停在 "run application to exit"
# 等按键,若 stdin 是未关闭的管道/控制台会**永久挂起**(2026-10-10 台架实测:
# 编程已成功、板子已复位运行,CLI 却 180s 不退 → 闭环误判 flash rc=-1)。
proc = subprocess.run([str(cli)] + args, capture_output=True, timeout=timeout,
stdin=subprocess.DEVNULL)
rc = proc.returncode
raw = (proc.stdout or b"") + (proc.stderr or b"")
except subprocess.TimeoutExpired as e:
rc = -1
raw = (e.stdout or b"") + (e.stderr or b"")
text = raw.decode("utf-8", errors="replace")
for line in text.strip().splitlines():
print(line)
print("RC: {} elapsed_ms={}".format(rc, int((time.monotonic() - t0) * 1000)))
return rc, text
def main(argv=None):
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
ap = argparse.ArgumentParser(description="ST-LINK CLI flash/reset/readback (pure stdlib)")
ap.add_argument("command", choices=["list", "flash", "cmp", "reset", "hard", "halt", "run"])
ap.add_argument("image", nargs="?", default=None,
help="image file (.hex/.bin; default: config 'hex')")
ap.add_argument("--cli", default=None, help="ST-LINK_CLI.exe path")
ap.add_argument("--base", default="0x08000000", help="cmp base address")
ap.add_argument("--timeout", type=int, default=None,
help="subprocess timeout (default: flash 300 / cmp 180 / other 60)")
args = ap.parse_args(argv)
cfg, err = load_config()
if err:
print("ENV_ERROR: {}".format(err))
return 3
cli, src = find_cli(args.cli, cfg)
print("CLI: {} ({})".format(cli, src))
if args.command in ("list", "reset", "hard", "halt", "run"):
if args.image:
print("NOTE: image argument ignored for '{}'".format(args.command))
timeout = args.timeout or 60
if args.command == "list":
rc, _ = run_cli(cli, ["-List"], timeout)
print("STLINK_RC={}".format(rc))
return 0 if rc == 0 else 1
if args.command == "reset":
rc, _ = run_cli(cli, ["-c", "SWD", "-Rst", "-Run", "-Q"], timeout)
print("STLINK_RC={}".format(rc))
return 0 if rc == 0 else 1
if args.command == "hard":
print("RESET: nRST pulse 100ms (falls back to system reset if CLI lacks RM=)")
rc, _ = run_cli(cli, ["-c", "SWD", "RM=Hrst", "-HardRst", "PULSE=100", "-Q"],
timeout)
if rc != 0:
# v3.5.0 CLI 帮助与实现不符:RM= 被拒(Unknown debug protocol or option)。
# 回退 = 与 `reset` 同款的系统复位 + 运行;本机 nRST 未接线时这就是唯一可用路径。
print("RESET_NOTE: this ST-LINK_CLI rejects RM= (v3.5.0 known gap), "
"falling back to system reset")
rc, _ = run_cli(cli, ["-c", "SWD", "-Rst", "-Run", "-Q"], timeout)
print("STLINK_RC={}".format(rc))
return 0 if rc == 0 else 1
if args.command == "halt":
rc, _ = run_cli(cli, ["-c", "SWD", "-Rst", "-Halt"], timeout)
print("STLINK_RC={}".format(rc))
return 0 if rc == 0 else 1
rc, _ = run_cli(cli, ["-c", "SWD", "-Run"], timeout)
print("STLINK_RC={}".format(rc))
return 0 if rc == 0 else 1
# flash / cmp 需要镜像
img = args.image or cfg.get("hex") or ""
if not img:
print("STLINK_ERROR: no image given and no config 'hex'")
return 3
img = Path(img)
if not img.is_file():
print("STLINK_ERROR: image not found: {}".format(img))
return 3
if args.command == "flash":
print("FLASH: {} then reset and run".format(img))
rc, text = run_cli(cli, ["-c", "SWD", "-P", str(img), "-Rst", "-Run", "-Q"],
args.timeout or 300)
if rc == 0 and "programming complete" in text.lower():
print("FLASH: OK")
print("STLINK_RC=0")
return 0
if rc == 0:
print("STLINK_ERROR: rc=0 but no 'Programming Complete' in output "
"(treat as failure - possible forwarded/hung run)")
print("STLINK_RC=1")
return 1
print("STLINK_RC=1")
return 1
# cmp:原样透传输出("No difference found" 独占行首 = 成功判据)
print("CMP: {} vs device flash @ {} (NOTE: connecting resets the target)".format(
img, args.base))
rc, text = run_cli(cli, ["-c", "SWD", "-CmpFile", str(img), args.base],
args.timeout or 180)
ok = any(ln.strip().lower().startswith("no difference found")
for ln in text.splitlines())
if ok:
print("CMP: OK (No difference found)")
print("STLINK_RC=0")
return 0
print("STLINK_ERROR: readback mismatch or compare failed")
print("STLINK_RC=1")
return 1
if __name__ == "__main__":
sys.exit(main())
+46
View File
@@ -0,0 +1,46 @@
---
name: ui-verify
description: UI 改动后的回归验证:5 步门禁(模拟器构建、菜单像素回归、内容页黄金参考、Keil 构建、代码规范)。
whenToUse: Drivers/BSP/UI、字体 ui_font_cn_12.c / ui_cn_chars.txt、菜单/内容页被修改之后,或用户要求"验证 UI / 跑 UI 回归 / UI 改动检查"时。
---
# UI 回归验证
UI 相关改动完成后必须跑完 5 个门禁再提交。完整说明见 `docs/UI设计/UI_自动化测试流程.md`。
## 步骤(仓库根目录执行,python 一律用 .venv)
```powershell
cmake --build simulator/build --config Debug # 0) 构建模拟器(须 0 警告)
.venv\Scripts\python.exe simulator/tests/check_menu_rows.py # 1) 菜单 8 状态像素回归 + 池基线门禁
.venv\Scripts\python.exe simulator/tests/check_content_pages.py # 2) 内容页黄金参考回归 + 往返池门禁
& '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py # 3) Keil 构建(BUILD_OK,0 错 0 警)
.venv\Scripts\python.exe scripts/check_coding_standard.py # 4) 代码规范检查(PASS)
```
退出码:0=PASS / 1=断言失败 / 2=环境错误(exe 缺失等,先按 AGENTS.md 重建对应环境再重跑)。
## 失败处置决策
| 现象 | 判定 | 处置 |
|---|---|---|
| 内容页 FAIL,且**有意**改了该页视觉 | 预期变化 | 先肉眼确认新效果正确(窗口模式或 `i1_to_png.py` 出图对照 mockup),再 `.venv\Scripts\python.exe simulator/tests/check_content_pages.py --update` 刷参考,`git diff` 检查 ref/ 变化后随代码提交 |
| 内容页 FAIL,但**未**有意改视觉 | bug(缺字/几何漂移/样式回归) | 修代码;**禁止**刷参考掩盖 |
| 池水位门禁失败但像素一致 | 结构/样式变更(常量样式转换、增删框体等) | 确认新值合理(残留分解见 `菜单页面设计.md` §9.5)→ 更新脚本常量 + 在流程文档变更记录登记 |
| 菜单回归失败 | 结构断言(白带占比/边框/往返一致) | 按脚本打印的未过断言清单逐项修代码,无参考文件可刷 |
## 池水位门禁常量(在脚本头部)
| 常量 | 脚本 | 当前值 | 含义 |
|---|---|---|---|
| `MENU_POOL_BASELINE` | check_menu_rows.py | 4784 B | 菜单根框建完的 LVGL 池 used(x64 模拟器) |
| `POOL_AFTER_ROUNDTRIP` | check_content_pages.py | 4864 B | 开页-关页往返后 = 基线 + 80 B 一次性残留 |
**禁止为过门禁盲目改常量**——无法解释的漂移 = 内存回归 bug。
## 关联注意
- `.venv` 缺失/换机:`& <本机任一 python ≥3.10> -m venv .venv; & '.venv\Scripts\python.exe' -m pip install -r requirements.txt`(`requirements.txt` 已含 `pre-commit`,重建后 L0 门禁直接可跑)
- 新增 UI `.c` 文件先走 `new-source-file` skill 双端注册,否则一端静默缺文件
- 字体重生成后全页大面积像素差异属预期:确认字形正确后 `--update` 刷参考
- 涉及新界面/大改时本 skill 是 P3 门禁,完整管线(P0~P5)见 `docs/UI设计/UI_界面制作流程.md`
+27
View File
@@ -1 +1,28 @@
# ===== 行尾策略(2026-10-09 定,勿删本段)=====
# 仓库 blob 与检出工作区统一 LF。原因:CubeMX 重生成写的是 **LF**,而此前工作区的行尾
# 由每台机器各自的 `core.autocrlf` 决定(本机 = true → 检出成 CRLF),于是每次重生成后
# 40+ 个第三方文件在 `git status` 里显示"已修改"(内容归一化后其实完全一致)。
# 写死属性后,行为不再依赖各机的 `core.autocrlf`,换机器 / 重新 clone 也不会复发。
* text=auto eol=lf
# 批处理脚本必须 CRLF:cmd.exe 对 LF-only 文件里的 goto/标签处理不可靠
*.bat text eol=crlf
*.cmd text eol=crlf
# 二进制显式标注(`text=auto` 靠内容嗅探,PNG 未走 LFS,尤其要写死)
*.png binary
*.gif binary
*.ttf binary
*.otf binary
*.ico binary
*.dll binary
*.lib binary
*.a binary
*.o binary
*.exe binary
*.bin binary
*.zip binary
*.pdf binary
# LFS 图片:必须排在 `*` 之后才能覆盖它(-text 使其不参与行尾转换)
*.jpg filter=lfs diff=lfs merge=lfs -text
+457
View File
@@ -0,0 +1,457 @@
# Gitea Actions CI —— STM32F4-Base 持续集成
#
# 覆盖范围(**只做不依赖硬件的层**,见 docs/测试说明/测试开发标准流程.md §1/§9):
# L0 静态门禁 = pre-commit 全部钩子(等价本地 `pre-commit run --all-files`)
# L0.5 主机侧纯逻辑单测 = test/host/**(MSVC 离线编译,零硬件)
#
# ⛔ L1 板端套件(test/board/,需真机 + ST-LINK)/ L2 @test 闭环 / L3 人工目检
# **不上 CI** —— 需要开发板与烧录器,只能在本地跑。
#
# 明确不做的两件事(都是刻意为之):
# 1. `@build`(Keil UV4)—— 需本机 Keil 授权,runner 上不具备;且 L0 的 cppcheck +
# 代码规范已覆盖"编译前的静态问题",真编译仍是开发者的本地红线(AGENTS.md)。
# 2. `mem-numbers` 钩子 —— 它的事实源是 `MDK-ARM/**/*.map`(Keil 构建产物、已 gitignore),
# 无 `@build` 即无 `.map`。故本 workflow 在**检测不到 .map 时自动跳过该钩子**
# (见下方 "决定跳过哪些钩子" 步骤),有 .map 的 runner(如自建带 Keil 的机器)则照常跑。
#
# 🔴 门禁盲区声明(S14,2026-10-02):跳过 `mem-numbers` 意味着 **CI 不守 Flash/RAM 体积门禁**。
# 体积变更(新增任务/缓冲/代码)不会在 CI 被拦下;必须靠**本地 `@build` 后**
# `python scripts/check_mem_numbers.py --scan` 兜底。故该跳过以 `::warning::` 明示
# (不是弱化的 `::notice::`),并写入 job step-summary 的醒目行。
#
# 产物存档(S3,2026-10-02):两个 job 把关键输出 `Tee-Object` 到 `ci-artifacts/*.log`,
# 并写 $GITHUB_STEP_SUMMARY(Gitea UI 直接可见)+ `::notice::` 汇总行。
# **刻意不用 `actions/upload-artifact`**——内网可能拉不到外层 action(见 CI配置指南 §4.3);
# 零依赖方案下日志留在 runner 工作区,UI 通过 step-summary 即可排障。
# ⚠️ `ci-artifacts/` 已 gitignore(运行时产物)。
#
# 触发:push(除纯文档路径外)与 pull_request。
#
# 🔴 shell 用 `powershell` 而非 `pwsh`(2026-10-02 实测修正):
# `pwsh` = PowerShell 7,**不是 Windows 自带**;本机 runner 只有
# Windows PowerShell 5.1(`C:\Windows\System32\WindowsPowerShell\v1.0\`),
# 用 `shell: pwsh` 会直接报 `Cannot find: pwsh in PATH` → job 失败(跑不到任何业务逻辑)。
# `shell: powershell` 在任意 Windows 上都存在。
#
# 🔴🔴 `run:` 脚本内容必须【纯 ASCII】(2026-10-02 第二次实测修正):
# runner 把每个 `run:` 块写入临时 `.ps1`(`...\act\workflow\N.ps1`)再交给
# Windows PowerShell 5.1 执行,而该临时文件**无 BOM** → PS 5.1 按 **GBK/CP936** 解码。
# 脚本里的 UTF-8 中文字节会与相邻引号错位 → 报
# `TerminatorExpectedAtEndOfString` / `字符串缺少终止符: "` / `缺少右"}"`
# (报错行号正是含中文的那一行,极易误判为逻辑错误)。
# **故本文件所有 `run:` 块内零非 ASCII**(中文仅出现在 YAML 的 name:/注释里——
# 那些由 Gitea 按 UTF-8 读,不喂给 PowerShell,安全)。
# ⚠️ 与 AGENTS.md「字符串字面量必须纯 ASCII」同源;改脚本时务必守住。
#
# ⚠️ 前置(部署到 Gitea 时需确认):
# - 该 Gitea 实例已启用 Actions:`[actions] ENABLED = true`(site admin 配置)
# - 已注册一个 **Windows** runner(labels 含 `windows`),且装有:
# Python 3.x、cppcheck、pre-commit、MSVC(VS2019/2022 的 C++ 工具链 + Windows SDK)
# - 缺 MSVC 时 L0.5 步骤会**降级为提示并跳过**(不硬失败),但会打印醒目 WARNING
#
# 🔧 runner 机器级环境变量(可选项覆盖;runner 以 SYSTEM 服务运行,用户级变量不可见):
# DTU_PRECOMMIT_PYTHON / DTU_CPPCHECK / DTU_MSVC_CL —— 未设置时按 PATH / 标准 VS 布局自动发现。
# 变量显式设置但值无效时 job 直接报变量名并失败(不静默回退)。配置方法见
# docs/环境与部署/本地工具路径环境变量方案.md §5.3。
#
# 本地等价命令(与 CI 同一套判定,见 scripts/ci_local.py):
# & '.venv\Scripts\python.exe' scripts/ci_local.py
name: CI
on:
push:
branches: ['**']
paths-ignore:
- 'docs/**'
- '**/*.md'
- '.workbuddy-ai/**'
pull_request:
paths-ignore:
- 'docs/**'
- '**/*.md'
- '.workbuddy-ai/**'
workflow_dispatch:
# 同一分支的旧运行自动取消,省 runner
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
# ------------------------------------------------------------------
# L0:静态门禁(pre-commit 钩子全集)
# ------------------------------------------------------------------
l0-gates:
name: L0 静态门禁 (pre-commit)
runs-on: windows
steps:
- uses: actions/checkout@v4
with:
# 钩子要对比基线与历史(doc-links 等读工作区即可,但 gitlint/cubemx 需完整历史)
fetch-depth: 0
- name: 环境自检(Python / cppcheck / pre-commit)
shell: powershell
run: |
# NOTE: keep every run: block pure ASCII. The runner writes this block
# to a BOM-less .ps1 executed by Windows PowerShell 5.1, which decodes
# it as GBK -- non-ASCII bytes there break string quoting.
$repoRoot = (Get-Location).Path
# --- pick a Python that actually has pre-commit -------------------
# The runner runs as SYSTEM, whose PATH is NOT the dev user's PATH.
# Probe order per the DTU_* env var plan (kept in sync with
# scripts/ci_local.py):
# DTU_PRECOMMIT_PYTHON (explicit; invalid value = hard error)
# -> PATH python -> repo .venv
#
# CAUTION 1: a failing native command writes to stderr; with
# $ErrorActionPreference='Stop' that would abort the whole script
# (NativeCommandError) before the next candidate is tried. So relax
# the preference around each probe, and restore it afterwards.
#
# CAUTION 2: pre-commit may be installed in the USER site of some
# account (e.g. %APPDATA%\Roaming\Python\...), which SYSTEM cannot
# see at all -- no python.exe path can help then. Probe with `-s`
# (no user-site) so we only accept an interpreter that works for
# SYSTEM, and say so clearly when none is found.
$py = $null
$saved = $ErrorActionPreference
$envPy = $env:DTU_PRECOMMIT_PYTHON
if ($envPy) {
if (-not (Test-Path $envPy)) {
$ErrorActionPreference = 'Stop'
Write-Host "::error::DTU_PRECOMMIT_PYTHON is set but does not exist: $envPy"
Write-Host "::error::fix the variable (full path to a python.exe with pre-commit), or unset it to use auto-discovery"
exit 1
}
$ErrorActionPreference = 'Continue'
& $envPy -s -m pre_commit --version 2>&1 | Out-Null
$rc = $LASTEXITCODE
$ErrorActionPreference = $saved
if ($rc -ne 0) {
$ErrorActionPreference = 'Stop'
Write-Host "::error::DTU_PRECOMMIT_PYTHON does not have a SYSTEM-visible pre-commit: $envPy"
exit 1
}
$py = $envPy
} else {
$candidates = @(
(Get-Command python -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source),
(Join-Path $repoRoot '.venv\Scripts\python.exe')
)
foreach ($c in $candidates) {
if (-not $c) { continue }
if (-not (Test-Path $c)) { continue }
$ErrorActionPreference = 'Continue'
& $c -s -m pre_commit --version 2>&1 | Out-Null
$rc = $LASTEXITCODE
$ErrorActionPreference = $saved
if ($rc -eq 0) { $py = $c; break }
}
}
$ErrorActionPreference = 'Stop'
if (-not $py) {
Write-Host "::error::no Python with a SYSTEM-visible pre-commit (DTU_PRECOMMIT_PYTHON not set; tried PATH python and repo .venv)"
Write-Host "::error::pre-commit is probably installed only in a USER site (invisible to SYSTEM)."
Write-Host "::error::fix: set a MACHINE-level DTU_PRECOMMIT_PYTHON for the runner service and restart it, or install pre-commit globally for the SYSTEM account (see the Gitea runner deployment guide in docs/)"
exit 1
}
Write-Host "using python: $py"
& $py --version
& $py -s -m pre_commit --version
"PYTHON=$py" | Out-File -FilePath $env:GITHUB_ENV -Append
# --- cppcheck -----------------------------------------------------
# Order per the DTU_* plan: DTU_CPPCHECK (explicit; invalid = hard
# error, no fallback) -> PATH (Get-Command cppcheck). No fixed
# install-dir guess: a USER-scope PATH entry is invisible to SYSTEM,
# so on the runner set a MACHINE-level DTU_CPPCHECK if cppcheck is
# not on SYSTEM's PATH.
$envCpp = $env:DTU_CPPCHECK
if ($envCpp) {
if (-not (Test-Path $envCpp)) {
Write-Host "::error::DTU_CPPCHECK is set but does not exist: $envCpp"
exit 1
}
$dir = Split-Path -Parent $envCpp
$env:Path = "$env:Path;$dir"
$dir | Out-File -FilePath $env:GITHUB_PATH -Append
Write-Host "using cppcheck from DTU_CPPCHECK: $envCpp"
& $envCpp --version
} else {
$cpp = Get-Command cppcheck -ErrorAction SilentlyContinue
if (-not $cpp) {
Write-Host "::error::runner missing cppcheck (not on SYSTEM PATH) -- set a machine-level DTU_CPPCHECK, or add its directory to the machine PATH, then re-run"
exit 1
}
Write-Host "found cppcheck: $($cpp.Source)"
cppcheck --version
}
Write-Host "runner environment check passed"
- name: 安装 pre-commit 钩子到本仓库
shell: powershell
run: "& $env:PYTHON -s -m pre_commit install --install-hooks"
- name: 决定跳过哪些钩子(Keil 依赖)
id: skip
shell: powershell
run: |
# mem-numbers fact source is the Keil build artifact .map (gitignored).
# No .map => this hook cannot run => skip (not "pass", but "not applicable").
#
# S14 (2026-10-02): make this GAP loud. mem-numbers is the ONLY gate that
# checks docs/memory numbers against the real .map (Flash/RAM size). When
# it is skipped, CI effectively does NOT guard the size budget -- a silent
# ::notice:: was too easy to miss. So:
# - emit ::warning:: (not ::notice::) so it shows in the run summary
# - write a machine-readable marker file for the summary step
# - append a bold line to the job step-summary
$map = Get-ChildItem -Path 'MDK-ARM' -Recurse -Filter '*.map' -ErrorAction SilentlyContinue
if ($map) {
Write-Host "found .map: $($map[0].FullName) -- mem-numbers runs normally"
"value=" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"sizegate=on" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"> Volume gate (mem-numbers): RAN (.map present)" |
Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
} else {
Write-Host "::warning::no .map found (no Keil build) -- SKIPPING mem-numbers, so the Flash/RAM size gate does NOT run in CI. Size changes are NOT caught here; verify locally with 'python scripts/check_mem_numbers.py --scan' after a Keil build."
"value=mem-numbers" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"sizegate=off" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"> :warning: Volume gate (mem-numbers): **NOT RUN** -- no `.map` (no Keil build)." |
Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
}
- name: 运行 L0 门禁(--all-files)
shell: powershell
env:
SKIP: ${{ steps.skip.outputs.value }}
run: |
# Pure ASCII only (see file header). Capture output to an artifact file
# AND to the console (Tee-Object), so a failed run is debuggable from the
# Gitea UI without inspecting runner disk.
#
# CAUTION: the runner injects $ErrorActionPreference='Stop'. With `2>&1 |`,
# a native command's stderr becomes an ErrorRecord -> under 'Stop' that
# would ABORT the pipeline. Relax the preference around the pipeline and
# restore it afterwards (same pattern as the env-check step above).
New-Item -ItemType Directory -Force -Path 'ci-artifacts' | Out-Null
$log = 'ci-artifacts\l0-pre-commit.log'
if ($env:SKIP) { Write-Host "SKIP=$env:SKIP" }
$saved = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
& $env:PYTHON -s -m pre_commit run --all-files --show-diff-on-failure 2>&1 |
Tee-Object -FilePath $log
$rc = $LASTEXITCODE
$ErrorActionPreference = $saved
# Summarize hook results: count Passed / Failed / Skipped lines.
$lines = Get-Content $log -ErrorAction SilentlyContinue
$passed = @($lines | Select-String -Pattern 'Passed$').Count
$failed = @($lines | Select-String -Pattern 'Failed$').Count
$skipd = @($lines | Select-String -Pattern 'Skipped$').Count
$summary = "L0: pre-commit hooks -- passed=$passed failed=$failed skipped=$skipd (log: $log)"
Write-Host "::notice::$summary"
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### L0 static gates" -Encoding utf8
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary -Encoding utf8
if ($rc -ne 0) {
Write-Host "::error::L0 static gates failed (see output above and $log)"
exit $rc
}
- name: 归档 L0 日志(供下载/排障)
if: always()
shell: powershell
run: |
# S3: zero-dependency artifact archival. We do NOT use
# actions/upload-artifact (inner networks may not fetch outer actions);
# instead we keep the log on the runner AND surface its path + tail in
# the step summary, so the UI is self-sufficient for triage.
if (Test-Path 'ci-artifacts\l0-pre-commit.log') {
$size = (Get-Item 'ci-artifacts\l0-pre-commit.log').Length
Write-Host "archived: ci-artifacts/l0-pre-commit.log ($size bytes)"
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "" -Encoding utf8
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "Archived log: ``ci-artifacts/l0-pre-commit.log`` ($size bytes on runner)" -Encoding utf8
}
Write-Host "::notice::L0 artifacts kept on runner at: ci-artifacts\l0-pre-commit.log"
# ------------------------------------------------------------------
# L0.5:主机侧纯逻辑单测(零硬件;缺 MSVC 则跳过)
# ------------------------------------------------------------------
l05-host-unit-tests:
name: L0.5 主机侧单测 (MSVC)
runs-on: windows
steps:
- uses: actions/checkout@v4
- name: 探测 MSVC(VS BuildTools / Community 等)
id: msvc
shell: powershell
run: |
# MSVC discovery per the DTU_* plan, kept in sync with
# test/host/common/unit_test_harness.py:
# DTU_MSVC_CL (explicit x64 cl.exe; invalid = hard error)
# -> auto-probe standard VS roots derived from %ProgramFiles%
# / %ProgramFiles(x86)% (2019 / 2022 / 2026, all editions)
$found = $false
$envCl = $env:DTU_MSVC_CL
if ($envCl) {
if (-not (Test-Path $envCl)) {
Write-Host "::error::DTU_MSVC_CL is set but does not exist: $envCl"
exit 1
}
if ($envCl -notlike '*bin\Hostx64\x64\cl.exe') {
Write-Host "::error::DTU_MSVC_CL must be the x64 Host compiler (path ending bin\Hostx64\x64\cl.exe): $envCl"
exit 1
}
$found = $true
Write-Host "using cl.exe from DTU_MSVC_CL: $envCl"
} else {
$pf = ${env:ProgramFiles}
$pf86 = ${env:ProgramFiles(x86)}
if (-not $pf) { $pf = 'C:\Program Files' }
if (-not $pf86) { $pf86 = 'C:\Program Files (x86)' }
$roots = @(
(Join-Path $pf86 'Microsoft Visual Studio\2019\BuildTools'),
(Join-Path $pf86 'Microsoft Visual Studio\2019\Community'),
(Join-Path $pf86 'Microsoft Visual Studio\2019\Professional'),
(Join-Path $pf 'Microsoft Visual Studio\2022\BuildTools'),
(Join-Path $pf 'Microsoft Visual Studio\2022\Community'),
(Join-Path $pf 'Microsoft Visual Studio\2022\Professional'),
(Join-Path $pf 'Microsoft Visual Studio\2022\Enterprise'),
(Join-Path $pf 'Microsoft Visual Studio\2026\BuildTools'),
(Join-Path $pf 'Microsoft Visual Studio\2026\Community'),
(Join-Path $pf 'Microsoft Visual Studio\18\BuildTools'),
(Join-Path $pf 'Microsoft Visual Studio\18\Community'),
(Join-Path $pf 'Microsoft Visual Studio\18\Professional'),
(Join-Path $pf 'Microsoft Visual Studio\18\Enterprise')
)
foreach ($r in $roots) {
if (Test-Path $r) {
$cl = Get-ChildItem -Path $r -Recurse -Filter 'cl.exe' -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -like '*Hostx64\x64*' } | Select-Object -First 1
if ($cl) { $found = $true; Write-Host "found cl.exe: $($cl.FullName)"; break }
}
}
}
if ($found) {
"value=yes" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
} else {
Write-Host "::warning::runner has no MSVC (VS C++ toolchain) -- skipping L0.5. Install VS BuildTools and re-run."
"value=no" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
}
- name: 运行主机侧单测
id: l05
if: steps.msvc.outputs.value == 'yes'
shell: powershell
run: |
# Resolve a working Python first (SYSTEM's PATH python may be unusable).
# Order per the DTU_* plan: DTU_PRECOMMIT_PYTHON (explicit; invalid =
# hard error) -> PATH python -> repo .venv.
$repoRoot = (Get-Location).Path
$py = $null
$envPy = $env:DTU_PRECOMMIT_PYTHON
if ($envPy) {
if (-not (Test-Path $envPy)) {
Write-Host "::error::DTU_PRECOMMIT_PYTHON is set but does not exist: $envPy"
exit 1
}
$py = $envPy
}
if (-not $py) {
foreach ($c in @(
(Get-Command python -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source),
(Join-Path $repoRoot '.venv\Scripts\python.exe')
)) {
if ($c -and (Test-Path $c)) { $py = $c; break }
}
}
if (-not $py) { Write-Host "::error::no python found (set DTU_PRECOMMIT_PYTHON or fix the runner PATH)"; exit 1 }
Write-Host "using python: $py"
New-Item -ItemType Directory -Force -Path 'ci-artifacts' | Out-Null
# Iterate test/host/*/run_unit_test.py (each module ships its own runner via common harness)
$runners = Get-ChildItem -Path 'test/host' -Recurse -Filter 'run_unit_test.py' |
Sort-Object FullName
if (-not $runners) {
Write-Host "::warning::no test/host/**/run_unit_test.py found -- skipping"
exit 0
}
$fail = 0
$runnerCount = 0
$sumPass = 0
$sumTotal = 0
# Relax preference around each `2>&1 | Tee-Object` pipeline (native stderr
# becomes ErrorRecord -> 'Stop' would abort). See L0 step for details.
$savedPref = $ErrorActionPreference
foreach ($r in $runners) {
$runnerCount++
$name = $r.Directory.Name
$log = "ci-artifacts\l05-$name.log"
Write-Host "================================"
Write-Host "run: $($r.FullName)"
Write-Host "================================"
$ErrorActionPreference = 'Continue'
& $py $r.FullName 2>&1 | Tee-Object -FilePath $log
$rc = $LASTEXITCODE
$ErrorActionPreference = $savedPref
# Parse the module's own summary line, e.g.
# "=== System Clock Tests: 100/100 PASSED (failed=0) ==="
$m = Select-String -Path $log -Pattern ': (\d+)/(\d+) PASSED \(failed=(\d+)\)' -ErrorAction SilentlyContinue |
Select-Object -Last 1
if ($m) {
$sumPass += [int]$m.Matches[0].Groups[1].Value
$sumTotal += [int]$m.Matches[0].Groups[2].Value
Write-Host ("::notice::{0}: {1}" -f $name, $m.Matches[0].Value.Trim())
} else {
Write-Host "::warning::$name produced no '=== ... N/M PASSED ===' summary line"
}
if ($rc -ne 0) {
Write-Host "::error::unit test failed: $($r.FullName) (exit $rc)"
$fail = 1
}
}
$agg = "L0.5: $runnerCount runner(s), $sumPass/$sumTotal assertions passed, fail=$fail"
Write-Host "::notice::$agg"
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### L0.5 host-side unit tests" -Encoding utf8
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $agg -Encoding utf8
"count=$runnerCount" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"agg=$agg" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
exit $fail
- name: 归档 L0.5 日志(供下载/排障)
if: always()
shell: powershell
run: |
# S3: same zero-dependency archival as L0.
if (Test-Path 'ci-artifacts') {
$logs = Get-ChildItem -Path 'ci-artifacts' -Filter '*.log' -ErrorAction SilentlyContinue
foreach ($f in $logs) {
Write-Host "archived: $($f.FullName) ($($f.Length) bytes)"
}
$names = ($logs | ForEach-Object { '``' + $_.Name + '``' }) -join ', '
if ($names) {
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "" -Encoding utf8
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "Archived logs on runner: $names" -Encoding utf8
}
} else {
Write-Host "no ci-artifacts directory (L0.5 skipped or failed early)"
}
- name: L0.5 跳过说明
if: steps.msvc.outputs.value == 'no'
shell: powershell
run: |
Write-Host "L0.5 skipped: runner has no MSVC."
Write-Host "Local equivalent: & '.venv\Scripts\python.exe' scripts/ci_local.py"
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### L0.5 host-side unit tests" -Encoding utf8
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value ":warning: **NOT RUN** -- runner has no MSVC (VS C++ toolchain)." -Encoding utf8
exit 0
+47 -2
View File
@@ -7,14 +7,20 @@
*.map
*.lnp
*.dep
# Keil UV4 预处理列表文件("Generate preprocessed source file" 选项产生的构建中间物,勿入库)
*__i
*._ia
*.build_log.htm
*.htm
*.log
*.lst
*.dep
.workbuddy-ai/
# Keil MDK - Build/download logs (regenerated by build.bat / download.bat)
# Keil MDK - Build log (regenerated by keil_build.py)
MDK-ARM/build_log.txt
# legacy: download.bat removed 2026-10-10; entry kept so stale leftovers on
# other machines do not surface as untracked files
MDK-ARM/download_log.txt
MDK-ARM/err.txt
MDK-ARM/out.txt
@@ -33,6 +39,11 @@ MDK-ARM/RTE/
# STM32CubeMX (keep .ioc, it's the project source)
# *.ioc
# CubeMX 重生成时自动产生的旧文件备份(Src/Backup/、Inc/Backup/)——
# 纯本地临时物,内容 = 重生成前的快照;入库只会造成无意义的提交抖动
Src/Backup/
Inc/Backup/
# Visual Studio / IDE
.vs/
.vscode/
@@ -85,7 +96,19 @@ Lib/lvgl/sconscript
simulator/build/
sim_dbg.log
# LVGL PC Simulator - SDL2 SDK (downloaded, see docs/测试说明/LVGL模拟器部署与使用说明.md)
# 模拟器宿主侧产物(lvgl_sim.exe / 测试脚本在 CWD 生成,不入库):
# sim_config.ini = net_cfg_load 首启工厂默认落盘;sim_*.log = syslog 后端宿主文件
sim_config.ini
sim_run.log
sim_selftest.log
# LVGL PC Simulator - screenshot outputs (generated, see simulator/i1_to_png.py)
simulator/shot*.png
simulator/shot*.bin
simulator/*.ppm
simulator/__pycache__/
# LVGL PC Simulator - SDL2 SDK (downloaded, see docs/UI 设计/LVGL模拟器部署与使用说明.md §3.1 与 docs/项目部署环境要求.md 第 3 步)
simulator/SDL2/
# Python
@@ -96,4 +119,26 @@ tools/serial_tool/state/
# Binary / large files
*.bin
# 主机侧单元测试产物(MSVC 中间物;test/host/**/run_unit_test.py 正常写 %TEMP%,
# 但手工直接调 cl.exe 调试时可能落在 CWD。统一兜底忽略)
*.obj
*.exe
*.pdb
*.ilk
# 内容页黄金参考(ref/*.bin)与目检 PNG 均为本地生成物,不入库:
# 首次/换机后先跑 check_content_pages.py --update 生成 ref/*.bin,再跑 regen_ref_png.py 出 PNG
simulator/tests/ref/
# 架构图 PNG 预览(源为 docs/images/*.svg,PNG 本地渲染生成,不入库)
docs/images/*.png
*.pyc
# Python venv (unified env, see AGENTS.md "Python 环境"; rebuild via requirements.txt)
.venv/
# Serial assistant runtime state (daemon log/meta, local runtime artifacts)
.dsh/skills/serial-assistant/scripts/state/
# CI 运行时产物(.gitea/workflows/ci.yml 的 S3 步骤在 runner 工作区落盘的日志;
# 零依赖存档方案——落在工作区目录而非上传 artifact,故必须忽略以免误提交)
ci-artifacts/
+5 -4
View File
File diff suppressed because one or more lines are too long
-19
View File
@@ -1,19 +0,0 @@
---
description: Build the STM32F4-Base Keil MDK project. Use ONLY when the user asks to build, compile, or verify the project.
agent: build
---
Run the Keil MDK-ARM build by executing the build script with the bash tool. The repo root is the working directory; use the repo-relative path (the old absolute path `D:\Code\DTU 程序\...` is stale):
```powershell
& "MDK-ARM\build.bat"
```
Then read the tail of `MDK-ARM/build_log.txt` and report the result.
Exit code interpretation (per AGENTS.md):
- 0 = success (0 errors, 0 warnings) — PASS
- 1 = warnings only — FAIL, must fix
- 2+ = compile errors — FAIL, must fix
If the build fails, read `MDK-ARM/build_log.txt`, fix the reported errors/warnings, and rerun `@build` until it returns 0.
-20
View File
@@ -1,20 +0,0 @@
---
description: Download (flash) the program to the STM32 via Keil MDK. Use ONLY when the user asks to flash, download, program, or burn the firmware to the MCU.
agent: build
---
Run the Keil MDK flash download by executing the download script with the bash tool. The repo root is the working directory; use the repo-relative path (the old absolute path `D:\Code\DTU 程序\...` is stale):
```powershell
& "MDK-ARM\download.bat"
```
Then read the tail of `MDK-ARM/download_log.txt` and report the result.
This compiles the project then flashes the target via the configured debugger (ST-Link).
Exit code interpretation:
- 0 = download successful — PASS
- nonzero = download/build failed — see `MDK-ARM/download_log.txt` for details
Note: Since flashing overwrites the running firmware, confirm the debug probe is connected and the user is ready to flash before running this. If the user only wants to verify compilation (not flash), use `@build` instead.
-74
View File
@@ -1,74 +0,0 @@
---
description: Build → flash (download) → capture USART1 debug log → verify boot/test markers → iterate. The full automated test loop for the STM32F4-Base target.
agent: build
---
Run the full automated test loop: **build → download → capture serial debug → verify markers → report/iterate**.
Prerequisites (checked in step 0):
- `serial-mcp-server` CLI installed at `%LOCALAPPDATA%\serial-mcp-server\serial-mcp-server.exe` (Adancurusul/serial-mcp-server, v0.3.0).
- ST-Link probe connected; target powered. The USB-serial (CH340) may stay idle while flashing — ST-Link download does not conflict with the serial port.
## Procedure
### Step 0 — Pre-flight: serial-mcp-server binary + port
Verify the capture binary and target port exist:
```powershell
Test-Path "$env:LOCALAPPDATA\serial-mcp-server\serial-mcp-server.exe"
& "$env:LOCALAPPDATA\serial-mcp-server\serial-mcp-server.exe" list-ports --json
```
Read `.opencode/serial_config.json` for the authoritative `portName` (COM6) and match it in the list. If the binary is missing, build it from source (see `docs/测试说明/serial-mcp-server_串口调试.md`).
### Step 1 — Build verify
Run the build script (repo-relative; the repo root is the working directory):
```powershell
& "MDK-ARM\build.bat"
```
Interpretation (per AGENTS.md): exit code 0 = PASS (0 errors, 0 warnings). 1/2+ = FAIL. If it fails, read `MDK-ARM/build_log.txt`, fix, and rerun `@build` until exit code 0 **before** touching the hardware.
### Step 2 — Flash + capture boot log (one orchestrated call)
Use `test/run_boot_test.ps1` which starts the capture (waits for first byte),
then flashes, then collects and verifies — all in one call so the capture never
times out before flashing:
```powershell
powershell -ExecutionPolicy Bypass -File "test\run_boot_test.ps1" `
-Port COM6 -BaudRate 115200 `
-DurationMs 30000 -InitialTimeoutMs 90000 -IdleTimeoutMs 8000 -MaxBytes 65536 `
-FlashCmd "MDK-ARM\download.bat" `
-Required "App init OK;Network Init;net init: OK" `
-Forbidden "FAIL;ERROR;HardFault" `
-OutDir "C:\Users\16241\AppData\Local\Temp\opencode"
```
Exit codes: **0 = PASS**, 1 = FAIL (marker miss / forbidden hit), 2 = capture/flash/parse error.
### Step 3 — Iterate
- **PASS** → report the verdict and which markers matched; done.
- **FAIL** → the `[MISS]`/`[HIT]` lines + the log tail show what went wrong. Fix the code, then loop again from **Step 1**. Do not skip the build step.
## Alternative: manual MCP capture (interactive debug)
For interactive command/response (not boot-log capture):
1. `serial_list_ports` → match `portName` from `.opencode/serial_config.json` (COM6).
2. `serial_open` (port=COM6, baud_rate=115200) → note the returned `connection_id`.
3. `serial_write` / `serial_read` (connection_id, timeout_ms) for command-response.
4. `serial_close` to release the port.
## Notes
- **串口端口**:以 `.opencode/serial_config.json` 为准(本机为 COM6)。每次连接前先读它。
- **⚠️ 无历史缓冲**:serial-mcp-server 的 `read` 只能读调用后的字节。抓开机日志必须走 `run_boot_test.ps1`(先采集等 first-byte → 再烧录触发)。
- **⚠️ EOF 陷阱**:COM 刚释放立即重开会报 `UnexpectedEof`(tokio-serial 读 0 字节)。`serial_capture.ps1` 内置自动重试;别裸跑 CLI capture。
- **参数**:`IdleTimeoutMs` 需 ≥ boot 自然静默间隙(本机 ~3-4s,用 8000);`InitialTimeoutMs` 需覆盖编译+烧录耗时(用 90000)。
- UI (LVGL) task `ui_task` 已有 `UI port init: OK` / `UI menu init: OK` 打印,可用 `-Required "UI port init: OK;UI menu init: OK"` 自动回归 UI 初始化。
- Full capture/analysis details: `docs/测试说明/serial-mcp-server_串口调试.md` and `docs/测试说明/自动化测试流程.md`.
-6
View File
@@ -1,6 +0,0 @@
{
"portName": "COM6",
"portHint": "USB 串行设备",
"baudRate": 115200,
"note": "MCU 调试串口(USART1 → CH340 → 本机枚举为 COM6)。执行 @test 前先读取本文件,再用 serial_list_ports 匹配 portName 选择端口;如本机端口变化,请人工修改此文件。"
}
+155 -6
View File
@@ -16,7 +16,26 @@ repos:
rev: v5.0.0
hooks:
- id: trailing-whitespace
# 豁免 **CubeMX / Keil 生成的文件**(锚名 cubemx-generated,end-of-file-fixer 复用):
# 这类文件的内容事实源是 `.ioc` 与工具链,每次重生成都按工具自己的风格写回
# (行尾空白、文件末尾不带换行)。2026-10-09 实测:每次重生成后 `Src/`/`Inc/`
# 下 10+ 个文件各被补 1 行末尾空行,纯属提交抖动。
# ⚠ 只豁免**改写型**钩子(trailing-whitespace / end-of-file-fixer);
# cppcheck / 乱码 / 存活审计 / 任务表 / 内存数字等**只读检查**仍覆盖
# `Src/`、`Inc/`——`cubemx-survival` 必须能看到这些文件。
exclude: &cubemx-generated |
(?x)^(
Src/.*\.(c|h)$|
Inc/.*\.(c|h)$|
Drivers/CMSIS/|
Drivers/STM32F4xx_HAL_Driver/|
Middlewares/|
MDK-ARM/.*\.(uvprojx|uvoptx|sct|map)$|
\.mxproject$|
.*\.ioc$
)
- id: end-of-file-fixer
exclude: *cubemx-generated
- id: check-added-large-files
- id: check-merge-conflict
- id: check-yaml
@@ -51,24 +70,54 @@ repos:
'--suppress=noExplicitConstructor', '--suppress=useStlAlgorithm',
'--suppress=constParameterPointer', '--suppress=constParameterCallback',
'--suppress=redundantAssignment',
'--suppress=normalCheckLevelMaxBranches',
'-I', 'Inc', '-I', 'App', '-I', 'Drivers/BSP',
'-I', 'Drivers/STM32F4xx_HAL_Driver/Inc', '-I', 'Drivers/CMSIS/Include',
'-I', 'Middlewares/Third_Party/FreeRTOS/Source/include']
language: system
types: [c]
# 仅扫描项目自有代码;排除第三方库与 LVGL 依赖文件
# (UI 生成文件 ui_font_cn_12/ui_menu_tab 由生成器按工程约定产出,纳入受检)
exclude: |
(?x)^(
Drivers/CMSIS/|
Drivers/STM32F4xx_HAL_Driver/|
Drivers/BSP/UI/|
Drivers/BSP/NET/lftpd/|
Drivers/BSP/dhara/|
Drivers/BSP/NET/lftpd/|
Drivers/BSP/dhara/|
Lib/|
Middlewares/|
MDK-ARM/|
test/
)
# 4b) test/ 代码宽松静态检查(S5,2026-10-03 加)
# 背景:`test/` 此前完全豁免 pre-commit(靠"自律"),而 `test/board/` 下
# 6300+ 行 C 代码**且编入固件**(曾出现 `s_rx_buf` 使用先于声明)。
# 本钩子**只开 warning**(不引入存量 style 噪音),排除模拟器桩
# (`test/host/*/stub/`,第三方/桩头形态不受控)。
# 基线:2026-10-03 全量实测 **0 条 warning**(存量可控,故直接挂钩子)。
# 依据:docs/审查记录/测试与架构改进建议.md §3.4;实机测试实施指南.md §8.1
- id: test-cppcheck
name: cppcheck (test/, warning only)
entry: cppcheck
args: ['--enable=warning', '--error-exitcode=1',
'--inline-suppr', '--std=c99', '--quiet',
'--suppress=missingIncludeSystem', '--suppress=missingInclude',
'--suppress=invalidPrintfArgType_sint', '--suppress=invalidPrintfArgType_uint',
'--suppress=unusedFunction', '--suppress=unusedStructMember',
'--suppress=constParameterPointer', '--suppress=constParameterCallback',
'--suppress=redundantAssignment',
'--suppress=normalCheckLevelMaxBranches',
'-i', 'test/host/watchdog/stub',
'-i', 'test/host/cfg_file/stub',
'-i', 'test/host/sys_clock/stub',
'-I', 'Inc', '-I', 'App', '-I', 'Drivers/BSP',
'-I', 'Drivers/STM32F4xx_HAL_Driver/Inc', '-I', 'Drivers/CMSIS/Include',
'-I', 'Middlewares/Third_Party/FreeRTOS/Source/include',
'test/']
language: system
# 触发条件:test/ 下 C/头文件变动;扫描范围恒为整个 test/(pass_filenames=false)
files: ^test/.*\.(c|h)$
pass_filenames: false
- id: no-mojibake
name: Forbid U+FFFD mojibake
entry: python scripts/check_mojibake.py
@@ -85,9 +134,8 @@ repos:
(?x)^(
Drivers/CMSIS/|
Drivers/STM32F4xx_HAL_Driver/|
Drivers/BSP/UI/|
Drivers/BSP/NET/lftpd/|
Drivers/BSP/GD5F2GQ5UE/nand_ftl\.|
Drivers/BSP/NET/lftpd/|
Drivers/BSP/GD5F2GQ5UE/nand_ftl\.|
Drivers/BSP/dhara/|
Lib/|
Middlewares/|
@@ -96,3 +144,104 @@ repos:
Src/|
Inc/
)
# 5) 文档一致性门禁(D-7)
# 存在性:md 互链 / 反引号路径是否指向真实文件(防迁移/删除后残留死链)
- id: doc-links
name: 文档链接存在性 (D-3/D-7)
entry: python scripts/check_doc_links.py
language: system
files: \.md$
# 外部资料(芯片手册转录)链接形态不受控;第三方目录整体排除
exclude: |
(?x)^(
\.opencode/node_modules/|
Lib/lvgl/|
simulator/|
MDK-ARM/RTE/|
docs/芯片手册/
)
# 数值:README/AGENTS 任务表须与 Src/freertos.c 一致(D-2/D-7)
- id: task-table
name: FreeRTOS 任务表一致性 (D-2/D-7)
entry: python scripts/gen_task_table.py --check
language: system
files: ^(README\.md|AGENTS\.md|Src/freertos\.c)$
pass_filenames: false
# 体积:内存/Flash 数字须与最新 .map 一致(D-7 第三档)
# 只在内存文档或代码变动时触发;.map 未生成时脚本自身给出明确提示
- id: mem-numbers
name: 内存/Flash 数字一致性 (D-7 体积档)
entry: python scripts/check_mem_numbers.py --scan
language: system
files: ^(docs/内存管理/.*\.md|scripts/check_mem_numbers\.py|App/.*\.c|App/.*\.h|Src/.*\.c|Inc/.*\.h)$
pass_filenames: false
# 6) CubeMX 重生成存活审计(2026-09-29 加)
# 背景:.ioc 的 KeepUserCode 只保护 USER CODE 标记区;标记之外的手工改动
# 重生成即被覆盖,且多数不报编译错(崩溃采集被打回空 while(1)、加固宏被删)。
# 本钩子验证"重生成易失清单"各项是否仍在预期位置,并对关键区做哈希漂移检测。
# 范围:仅在这些易失文件变动时触发(改动 .ioc 或生成文件本身就值得复核)。
- id: cubemx-survival
name: CubeMX 重生成存活审计
entry: python scripts/check_cubemx_survival.py --check
language: system
files: ^(STM32F407-Demo\.ioc|Src/.*\.(c|h)|Inc/.*\.h|App/util/task_watchdog\.(c|h)|MDK-ARM/.*\.uvprojx|scripts/check_cubemx_survival\.py)$
pass_filenames: false
# 7) 看门狗判定状态机主机侧单元测试(2026-10-01 加)
# 背景:task_watchdog.c 的判定逻辑(位图取走清零 / 掩码 / miss_streak
# 累计 / 100ms 节流 / tick 回绕)是纯逻辑,板端套件无法穷举边界;本钩子
# 用 MSVC 离线编译真实源文件(#include 纳入)并用可观测桩驱动,秒级、零
# 硬件、不占 MCU RAM。仅在被测文件或其平台层 / 测试变动时触发。
# 详见 docs/测试说明/看门狗单元测试说明.md
- id: watchdog-unit-test
name: 看门狗判定状态机单测(主机侧)
entry: python test/host/watchdog/run_unit_test.py
language: system
files: ^(App/util/task_watchdog\.(c|h)|test/host/watchdog/.*|test/host/common/unit_test_harness\.py)$
pass_filenames: false
# 7b) 系统时钟时间换算主机侧单元测试(2026-10-02 加)
# 背景:sys_clock.c 的 Unix↔日历换算(闰年 / 月末 / 世纪 / 2038 上限)、
# 星期(Zeller)、"30 天惰性再基准化"(防 49.7 天 tick 回绕)、RTC 故障
# 降级路径均为纯逻辑;本钩子用 MSVC 离线编译真实源文件(#include 纳入)
# 并以可注入桩驱动(可控时钟 + 阴影 RTC)。首次落地即抓出 Zeller 公式
# 缺陷(1970~2099 全部日期星期皆错,详见测试说明文档)。
# files 含 sys_clock.c 依赖的 sd2506.h(时间结构/返回值定义)。
- id: sys-clock-unit-test
name: 系统时钟换算单测(主机侧)
entry: python test/host/sys_clock/run_unit_test.py
language: system
files: ^(App/util/sys_clock\.(c|h)|Drivers/BSP/SD2506/sd2506\.h|test/host/sys_clock/.*|test/host/common/unit_test_harness\.py)$
pass_filenames: false
# 7c) 配置引擎主机侧单元测试(2026-10-02 加,S1-b)
# 背景:cfg_file.c 的核心是"文本 <-> 结构"纯变换(INI 段/键解析、四种值
# 类型编解码、外科手术式保存、超长守卫、load/save/poll 契约);板端套件只
# 保存到缺失/新文件(走 s_gen_document 路径),**从不触发对已存在文件的外科
# 手术改写**,故掩盖了该路径的缺陷。本钩子用 MSVC 离线编译真实源文件
# (#include 纳入)+ 内存虚拟文件系统后端驱动,首次落地即抓出 P0:
# `s_encode(...) == 0` 恒假(其成功返回文本长度而非 0)使**外科手术保存的
# 受管值改写路径沦为死码**——对已存在 /config.ini 保存时受管键从不改写(静默
# 不落盘,且 net_cfg_save 仍报 OK)。详见测试说明文档。
- id: cfg-file-unit-test
name: 配置引擎单测(主机侧)
entry: python test/host/cfg_file/run_unit_test.py
language: system
files: ^(App/cfg/cfg_file\.(c|h)|App/cfg/cfg_file_store\.h|App/log/sys_log\.h|test/host/cfg_file/.*|test/host/common/unit_test_harness\.py)$
pass_filenames: false
# 8) 测试套件开关门禁(2026-10-01 加)
# 背景:test_config.h 的 TEST_SUITE_* 决定固件里编入哪个测试套件,
# 全部注释 = 产品构建。但此前**无任何门禁检查**——实测已提交的 HEAD 里
# TEST_SUITE_CH395F 就是开启状态(.map 含 34 处 test 代码),即仓库里
# 躺着的是测试固件。本钩子抓这个静默漏网:默认要求产品构建,跑套件回归
# 确需提交测试构建时用 `--allow-test-build` / ALLOW_TEST_BUILD=1 显式放行。
# 详见 docs/待处理内容.md T-1
- id: test-suite-switch
name: 测试套件开关=产品构建
entry: python scripts/check_test_suite.py
language: system
files: ^test/board/test_config\.h$
pass_filenames: false
+19
View File
@@ -0,0 +1,19 @@
{
"portName": "COM6",
"baudRate": 115200,
"flash_cmd": ".venv\\Scripts\\python.exe .dsh\\skills\\stlink-flash\\scripts\\stlink_flash.py flash",
"hex": "MDK-ARM\\STM32F407-Demo\\STM32F407-Demo.hex",
"fw_marker": "fw build: ",
"readback_cmd": ".venv\\Scripts\\python.exe .dsh\\skills\\stlink-flash\\scripts\\stlink_flash.py cmp {hex}",
"required": "App init OK;Network Init;net init: OK",
"forbidden": "FAIL;ERROR;HardFault;[ERR]",
"boot_marker": "reset cause:",
"max_boot_count": 1,
"done_marker": "net init: OK",
"settle_ms": 8000,
"max_wait_ms": 30000,
"first_wait_ms": 12000
}
+119 -139
View File
@@ -1,158 +1,138 @@
# STM32F4-Base
> **编译验证:所有代码修改后必须执行 `@build` 验证编译通过(0 错误 0 警告),否则不要提交。**
STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v5 (ARMCC, C99) 工具链**;FreeRTOS V10.3.1 / CMSIS-RTOS2;192KB SRAM / 1MB Flash。
## 构建
**任何代码修改后必须先 `@build` 验证(0 错误 0 警告)再提交;硬件闭环用 `@test`。**
- **仅支持 Keil MDK-ARM v5 (ARMCC)**,无其他工具链
- 命令行编译:`@build`(执行 `MDK-ARM/build.bat`,从仓库根目录用**相对路径**调用)
- `build.bat` 硬编码 Keil 路径 `C:\Keil_v5\UV4\UV4.exe`(本机已验证存在;换机器需改)
- 退出码:0 = 0 错误 0 警告 | 1 = 有警告或错误(查看 `MDK-ARM/build_log.txt`)
- ⚠️ **旧绝对路径陷阱**:仓库曾位于 `D:\Code\DTU 程序\STM32F4-Base`,`.opencode/command/*.md` 与 `test/run_boot_test.ps1` 中若出现该路径一律视为失效,改用仓库相对路径(如 `& "MDK-ARM\build.bat"`)
## 铁律:源码编辑与编码
## 硬件
- 改代码**只用 Edit 工具**(精确子串替换);批量替换用 Edit 的 replaceAll。**绝不用 `Get-Content`/`Set-Content`/`cmd` 重写含中文的文件**——整文件重写会按系统默认编码重解码 → `U+FFFD` 乱码且不可逆
- **字符串字面量必须纯 ASCII**(2026-09-15 实测):本机 ARMCC 对**无 BOM** 源文件按 CP936 解读,字符串内含**奇数个**汉字时末尾高字节吞掉结束引号 → 编译错(偶数个汉字侥幸通过);中文只写注释(注释不参与解析)。带 BOM 的文件(`Drivers/BSP/UI/*`、`App/task/ui_task.c`、`App/cfg/cfg_file.c`、`Lib/lv_conf.h`)经 BOM 按 UTF-8 解码,不受此条限制
- 确需脚本级重写:工程源码为 UTF-8(多数文件无 BOM,UI 系带 BOM),按原样同编码回写、保持 BOM 状态;拿不准就别碰
- 发现乱码:先统计 `EF BF BD` 字节数确认真损坏(勿信终端/Read 渲染);**不要急著 `git checkout`**(会丢弃全部未提交工作)——先用 Edit 只修坏行
- **Edit 工具会剥掉文件原有的 UTF-8 BOM**(2026-09-18 实测):带 BOM 文件(如 `App/cfg/cfg_file.c`)被 Edit 后 BOM 静默消失 → ARMCC 改按 CP936 解码 → 中文字符串字面量报 `#870-D: invalid multibyte character sequence`(**只有重编译该文件才暴露,历史"0 警告"不代表安全**)。改完带中文字面量的文件须核对 BOM 并按原样补回(只在文件头前缀 3 字节 `EF BB BF`,勿整文件重写)
- **行尾策略已写死进 `.gitattributes`(2026-10-09)**:`* text=auto eol=lf`(仓库 blob 与工作区统一 LF)+ `*.bat`/`*.cmd` 强制 CRLF(cmd.exe 对 LF-only 的 goto/标签不可靠)+ PNG/TTF/DLL/LIB 等显式 `binary` + `*.jpg` 走 LFS 且 `-text`(该条必须排在 `*` 之后才能覆盖)。**不要再依赖 `core.autocrlf`,也不要把工作区文件"改回 CRLF"**——CubeMX 重生成写的就是 LF;此前每次重生成后 40+ 个第三方文件在 `git status` 里显示"已修改"(内容归一化后一致),根因就是行尾由每台机器各自的 autocrlf 决定。第三方/生成文件的"文件末尾空行"已一次性对齐工具输出(`53bd9af`),**勿再用改写型钩子去修剪**(已在 `trailing-whitespace`/`end-of-file-fixer` 豁免)
## 构建与验证
- `@build` = **`keil-build` skill**:`& '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py`(仓库根目录执行;纯标准库,任意 python3 亦可)。退出码 0 = 0 错误 0 警告(PASS)、1 = 构建失败(打印报错行)、2 = UV4 未找到/无效、3 = 用法错误。UV4 解析链 `--uv4` > `.serialtest/config.json` 的 `uv4` > `$DSH_KEIL_UV4` > `$DTU_KEIL_UV4` > PATH(**显式设了但无效 = 立即报错、不静默回退**);日志 `MDK-ARM/build_log.txt`。⚠ **原 `MDK-ARM/build.bat` 已于 2026-10-10 删除**(判定口径同款,但无超时保护、UV4 只认 `DTU_KEIL_UV4`、失败时 dump 整份日志);`test/run_all_suites.py` 已改调本 skill。换机必设 `DTU_KEIL_UV4`,见 `docs/环境与部署/本地工具路径环境变量方案.md`
- `@test` / `.dsh/skills/serial-boot-test/scripts/run_boot_test.py` 一键闭环(2026-09-20 起为纯 Python 标准库实现,替代 PowerShell 版;`& '.venv\Scripts\python.exe' .dsh/skills/serial-boot-test/scripts/run_boot_test.py`):开串口+清日志 → 烧录(**`stlink-flash` skill**:`stlink_flash.py flash` = `-P <hex> -Rst -Run`;`flash_cmd` 默认取 `.serialtest/config.json`,本仓库已指向本 skill)→ **固件身份戳**(从刚烧进去的 hex 里抠 `fw build: <DATE> <TIME>` 追加成 Required 标记)→ wait-idle(等 RX 静默 8s,超时 30s,首字节 12s 未到判 `no_data`)→ 提取日志 → **烧录回读校验**(`stlink_flash.py cmp` = `-CmpFile`;不一致 = 本次运行作废 exit 2)→ `.dsh/skills/serial-boot-test/scripts/verify_log.py` 标记校验(含**启动锚点计数**:`reset cause:` 出现 0 次或 >1 次 = FAIL,`--max-boot-count` 调;Forbidden 命中**逐行打印行号+内容**;boot_log 每行带 `[+Nms]` daemon 毫秒时基 → 可查标记顺序 `--require-order` 与开机耗时 `--done-marker`/`--max-boot-ms`,实测 `net init: OK` = **+537ms**);退出码 0=PASS / 1=FAIL / 2=采集或烧录错误(含回读不一致)/ 3=用法/预检错误
- 默认校验标记:Required `App init OK` / `Network Init` / `net init: OK` + **自动追加的固件身份戳 `fw build: <__DATE__> <__TIME__>`**(`App/app/app_main.c` 开机打印,`run_boot_test.py` 从待烧 hex 里抠出该串当 Required——此前所有标记都是固定串,**旧镜像/错镜像照样全中**,"反复重下载、芯片一直是旧镜像"在自动闭环里不可见,2026-10-10 补;`--no-fw-check` 关闭);Forbidden `FAIL` / `ERROR` / `HardFault` / `[ERR]`(**四个 Fault 的崩溃日志都以 `[ERR]` 开头 → 一律判 FAIL**,这是刻意的:出现 Fault 即本次运行不通过);按模块定制标记(网络/存储/RTC/ADC/RS485/UI)见 `docs/测试说明/自动化测试流程.md`
- ⚠️ 旧绝对路径 `D:\Code\DTU 程序\...` 出现即失效(仓库已搬迁),一律改用仓库相对路径
- **下载/烧录铁律(2026-09-20 实测)**:走 **`stlink-flash` skill** 的 `& '.venv\Scripts\python.exe' .dsh\skills\stlink-flash\scripts\stlink_flash.py flash [文件]`(= `ST-LINK_CLI -c SWD -P <文件> -Rst -Run`),烧后 `-Rst -Run` **不可省略**——裸 `-P` 会报 `Programming Complete`,但目标核处于死循环/异常状态时写入并不落地(芯片内容不变;这就是 09/19~09/20 "反复重下载、芯片一直是旧镜像、二分法失效" 的根因);`.hex`/`.bin` 均可直烧(1MB ≈ 6.5s,快于 UV4 路径);只重启不烧录用 `stlink_flash.py reset`(= `-c SWD -Rst -Run`,本机 3 线接线唯一复位手段);`stlink_flash.py hard`(`RM=Hrst -HardRst PULSE=100`)在 v3.5.0 CLI 上被拒 → **自动降级**为系统复位并打印 `RESET_NOTE`。⚠ **原 `MDK-ARM/reset.bat` 已于 2026-10-10 删除**(子命令已全并入本 skill)
- **烧录后必须回读校验**:`& '.venv\Scripts\python.exe' .dsh\skills\stlink-flash\scripts\stlink_flash.py cmp [文件]`(= `ST-LINK_CLI -c SWD -CmpFile <文件> 0x08000000`,全片逐字节比对,`No difference found` 才算真正写入);`run_boot_test.py` 已默认自动跑(`--no-verify-flash` 关闭,不一致 → exit 2 本次运行作废)。⚠ **`-CmpFile` 连接时会复位目标**(2026-10-10 实测:比对瞬间板子重打一遍开机日志)→ 在自动闭环里必须排在**日志采集之后**,否则开机日志被截断且启动锚点计数会误判成"中途复位";CLI 经 `DTU_STLINK_CLI` 环境变量解析(未设时回退 PATH;本机已入 PATH,未设也能工作)(v3.5.0.0,探针 SN `0B250D12A218303030303032`)→ 完整用法/复位语义/故障排查 `docs/测试说明/ST-LINK_CLI_复位使用说明.md`
## Python 环境
- 统一入口 `& '.venv\Scripts\python.exe'`(3.12,fontTools/Pillow 已装);PATH 默认 `python` 是 msys64 的,**不要用它**跑依赖第三方库的脚本;纯标准库脚本任意 python3 均可
- 重建/换机:`& <本机任一 python ≥3.10> -m venv .venv` + `pip install -r requirements.txt`(含 `pre-commit`,重建后 L0 门禁直接可跑;`DTU_PRECOMMIT_PYTHON` 可指向装了 pre-commit 的其他解释器)(`.venv/` 已 gitignore)
- `requirements.txt` 必须**纯 ASCII**(Windows 下 pip 按 GBK 代码页解码,中文注释会 UnicodeDecodeError)
## 提交门禁(pre-commit)
- 任一失败即拦截,共 **18 个钩子**(`.pre-commit-config.yaml`,2026-10-10 清点):通用 5 个(`trailing-whitespace` / `end-of-file-fixer` / `check-added-large-files` / `check-merge-conflict` / `check-yaml`)+ `gitlint`(提交信息)+ `cppcheck`(warning/performance/portability/style)+ `test-cppcheck`(`test/` 只开 warning)+ `no-mojibake`(U+FFFD)+ `coding-standard`(规范 V1.0)+ 文档一致性三件套 `doc-links` / `task-table` / `mem-numbers` + `cubemx-survival`(重生成存活审计)+ 主机侧单测 `watchdog-unit-test` / `sys-clock-unit-test` / `cfg-file-unit-test` + `test-suite-switch`(测试开关=产品构建)。范围仅项目代码(第三方 `Lib/`/`Middlewares/`/CMSIS/HAL/`lftpd`/`dhara`/`MDK-ARM` 排除;`cppcheck`/`coding-standard` 另排除 `test/`,规范检查再排除 CubeMX 生成的 `Src/`/`Inc/`)
- **改写型钩子(`trailing-whitespace` + `end-of-file-fixer`)豁免 CubeMX/Keil 生成文件**(2026-10-09 定;配置里 YAML 锚点 `&cubemx-generated` 两处共用):`Src/*.{c,h}`、`Inc/*.{c,h}`、`Drivers/CMSIS/`、`Drivers/STM32F4xx_HAL_Driver/`、`Middlewares/`、`MDK-ARM/*.{uvprojx,uvoptx,sct,map}`、`.mxproject`、`*.ioc`——这类文件每次重生成按工具自己的风格写回(实测每次 10+ 个文件各被补 1 行末尾空行),改写只会制造提交抖动。⚠ **只读检查不受影响**:`cppcheck` / 乱码 / `cubemx-survival` / `task-table` / `mem-numbers` 仍覆盖 `Src/`、`Inc/`;`App/`、`Drivers/BSP/` 自研代码仍受改写型钩子检查
- 提交信息:**单行中文标题 ≤100 字符**(gitlint `title-max-length=100`)+ **正文每行 ≤80 字符**(gitlint 默认 `body-max-line-length`,本项目未关)→ 长说明一律用**短标题 + `git commit -F -` 多行正文**(中文长标题极易超限);UTF-8 无 BOM + `git commit -F <文件>`(防终端转码破坏)
- 代码规范 V1.0:小写下划线命名(`g_`/`s_`/`p_`/`a_` 前缀)、头文件保护宏 `XXX_H`、常量在右、文件头模块注释块 → `docs/代码规范/`
- 全库扫描 `pre-commit run --all-files`;详见 `docs/代码规范/pre-commit使用说明.md`
- **CI(Gitea Actions,2026-10-01 T-6)**:`.gitea/workflows/ci.yml` 在 push/PR 跑 **L0 静态门禁 + L0.5 主机侧单测**(Windows runner);**不含 L1/L2/L3**(需真机+ST-LINK)、**刻意不跑 `@build`**(需 Keil 授权)、无 `.map` 时自适应跳过 `mem-numbers`。本地等价自查:`& '.venv\Scripts\python.exe' scripts/ci_local.py`(**runner 未就绪时这是 L0 的唯一保障**,不能因"已配 CI"放宽本地门禁)。详见 `docs/测试说明/测试开发标准流程.md` §9
## 平台约束
- `Inc/`/`Src/` 中 CubeMX 生成文件带 `USER CODE BEGIN/END` 标记:自定义代码**只写标记区之间**;`Drivers/BSP/` 为纯手工代码
- **CubeMX 重生成会覆盖标记区之外的一切**(`.ioc` 的 `KeepUserCode=true` **只**保护 `USER CODE` 区;2026-09-29 两次重生成各覆盖 4~5 处定制,其中 P1 两处**不报编译错**、只在真出故障时暴露)。防止定制丢失按三层优先级:**L1 写进 `.ioc`**(凡 CubeMX 界面能表达的配置,如任务栈/优先级/队列/事件组/堆大小/IWDG 参数——权限最高,重生成只会照写)→ **L2 搬进 `USER CODE` 区**(**只信 CubeMX 当前模板真实生成的区名**,优先文件头 `USER CODE BEGIN 0` 这类从未变过的标准位)→ **L3 审计兜底**。**每次重生成后立刻跑 `python scripts/check_cubemx_survival.py`**(已挂 pre-commit `cubemx-survival` 钩子;改动 `.ioc`/`Src/`/`Inc/`/`task_watchdog`/`.uvprojx` 触发)——清单 21 项 + 哈希基线漂移检测,直接列出"被覆盖了哪几处"。**2026-10-09 第三次重生成(启用 ADC1)补充**:`Inc/FreeRTOSConfig.h` 的自造 `FREE_RTOS_CONFIG_HARDENING` 区**第二次**被抹 → 已改走 **L1**:`configCHECK_FOR_STACK_OVERFLOW=2` / `configUSE_MALLOC_FAILED_HOOK=1` 在 GUI 的 **FreeRTOS → Config parameters → Hook function related definitions** 里设(写进 `.ioc` 由模板生成,永不再丢;教训:**别凭印象断言"界面里没有该项"**,先翻标签页或查 `db/mcu/IP/*_Modes.xml`)。⚠️ 启用后 CubeMX 会在 `Src/freertos.c` 生成 `USER CODE BEGIN 4 / 5` 区(默认内容是**空桩**)——**钩子实现必须整体写进这两个区**(原先在 `USER CODE Application` 区的实现要搬走,否则同名函数两处定义 = **编译错**;配套 helper `hook_ptr_ok()` 须在钩子之前 → 放 `USER CODE BEGIN PD` 区)。审计项 `stack-overflow-hook-impl` / `malloc-failed-hook-impl` / `stack-overflow-hook-helper` 分别校验 4 / 5 / PD 三个区。⚠️ **不要依赖"自造标记区"**:2026-09-29 实测 CubeMX **换了一代中断函数模板**(旧 `HardFault_Handler`/`HardFault_Report` 自造区 → 新函数体内 `HardFault_IRQn 0`/`W1_HardFault_IRQn 0`),旧区**连同内容一起消失**(看起来仍像"遵守了规则")。**生成区内无标记位可用的改动**分两种处理(2026-10-09 更新):① 要**去掉/替换生成区里的一句调用** → 用宏改名升级为 **L2**:例 `Src/iwdg.c` 的 `USER CODE BEGIN 0` 里放空实现 `iwdg_deferred_start()` + `#define HAL_IWDG_Init(a_h) iwdg_deferred_start(a_h)`,生成那句 `HAL_IWDG_Init(&hiwdg)` 自动变空操作(`.map` 证实 `Removing ... HAL_IWDG_Init, 94 bytes`),生成区保持原样、**重生成零 diff**,不必再手工删。⚠ **IWDG 启动后无法停止**(唯一手段是复位),所以不能"反初始化",只能在启动前拦截。② 要**替换生成实体的定义**(4 个 Fault Handler 本体——本代模板整函数重写)→ 同一招升级为 **L2**(2026-10-09 已做):我们的 `__asm void X_Handler` 定义放 `USER CODE BEGIN 0`,**必须写在 `#define X X_Gen` 之前**(写在之后会被自己的宏改名),生成区恢复 CubeMX 原样(`HardFault_IRQn 0` / `W1_HardFault_IRQn 0` 标记区照留),不再用锚点注释。`.map` 复核口径:`startup_*.o(RESET) refers to stm32f4xx_it.o(.emb_text) for HardFault_Handler` + 4 个 `*_Gen` 各 `Removing ... (2 bytes)`。**L3 清单已清空:重生成应当零 diff**;若仍有 diff,跑审计脚本定位(`protected` 项的断言只能用代码符号,注释会被剥掉)。方法论详见 `docs/子系统设计/看门狗设计说明.md` §10.1/§10.2
- **4 个 Cortex-M4 Fault 全部采集现场**(2026-09-29):`HardFault` / `MemManage` / `BusFault` / `UsageFault` 均为手工 `__asm` Handler(取 MSP/PSP 帧指针)→ `B` 到 per-fault C 入口 `fault_entry_{hard,mem,bus,usage}`(各自带故障名)→ `hard_fault_report(tag, frame)` 打 `[ERR] <TAG> PC/LR/xPSR/R0-R3/CFSR/HFSR[/BFAR][/MMFAR]` 后 `NVIC_SystemReset()`。**后三者默认禁用**(复位后 `SHCSR` 只开 HardFault),不使能就会被**升级成 HardFault**、日志看不出根因 → `fault_handler_enable()`(`Src/stm32f4xx_it.c` 的 `USER CODE BEGIN 1` 区)在 `app_main_init()` **第一行**调用。**刻意不启用** `CCR.UNALIGN_TRP` / `CCR.DIV_0_TRP`(会把普通 bug 升级成死机)。⚠️ **ARMCC `__asm` 取不到 C 的数据符号**(`ADR r1, xxx_tag` 报 `A1516E: Bad symbol`,去 static 也无效)且无法传字符串参数 → 故障名必须由 C 侧 per-fault 入口带上,不要在 `__asm` 里取 C 变量
- **`SHCSR` 的 Fault 使能位无法在 CubeMX 配置**(2026-10-01 查模板源码证实):CubeMX 对 System Exception **只生成优先级(且仅当优先级 ≠ 0),从不生成使能代码**——`tpl_main_c.ftl` 的 `MX_NVIC_Init()` 用 `[#if vector.systemHandler=="false"]` 挡住 `HAL_NVIC_EnableIRQ`,而 `NVIC-STM32F417_Modes.xml` 里 `MemoryManagement/BusFault/UsageFault_IRQn` 都标 `Y`(=systemHandler);**全部模板中 `SHCSR` 只出现 1 次**且是 MPU 清理的 `&= ~MEMFAULTENA`(禁用),无任何 `|=` 置位;MPU 路径也堵死(`.ioc` 无 MPU,且 `HAL_MPU_Enable` 只置 `MEMFAULTENA` 一位)。⚠️ **`.ioc` 的 `NVIC.MemoryManagement_IRQn=true` 不代表已使能**(第 1 段只是"向量在 NVIC 表里可见")——别误以为勾了 Enabled 就等于开了故障。结论:只能手工写(现放 `USER CODE BEGIN 1` 区,属 L2,重生成不丢)+ 审计项 `fault-handler-enable` 兜底
- **`MDK-ARM/*.uvprojx` 每次 `@build` 后被 Keil UV4 自动改写,改动一律不入库**(2026-09-29 实测):UV4 构建时会把工程 XML 归一化成它本机风格的旧式写法(`<Tag />` → `<Tag></Tag>`、XML 声明加 `standalone="no"`),并把 `PackID` 降到**本机装的 DFP 版本**(本机实测 `3.1.1` → `3.0.0`,属**降级**)。这些差异是环境相关的,提交后会把别人的 Pack 版本也一起改掉。**判断方法**:`git diff MDK-ARM/STM32F407-Demo.uvprojx` 里若只见 XML 格式/`PackID` 差异而**无源文件挂载增删** → `git checkout -- MDK-ARM/STM32F407-Demo.uvprojx` 丢弃。只有真的新增/删除源文件(`grep 文件名` 计数变化)才提交
- **ARMCC 内联汇编语法(2026-09-29 踩坑,共 3 坑)**:① `__asm volatile ("MRS %0, lr" : "=r" (x))` 是 **GCC 语法**,ARMCC 报 `#18: expected a ")"`;② `__asm { MRS x, lr }` 块内联会把寄存器名当 C 标识符报 `#20: identifier "lr" is undefined`;③ `__asm` 块里**取不到 C 的数据符号**,`ADR r1, xxx_tag` 报 `A1516E: Bad symbol 'xxx_tag'`(去掉 `static` 也无效——汇编器只认自己作用域内的符号)。**结论**:整个函数用 `__asm void f(void) { ... }`;要传"字符串/表格"这类 C 侧数据,就让 `__asm` 只取寄存器参数然后 `B` 到一个普通 C 函数,由 C 侧补上。若要覆盖 CubeMX 生成的同名函数,用 `#define f f_Gen`(在生成实体之前)把生成实体改名——链接器会把未被引用的 `f_Gen` 自动移除(`.map` 可见 `Removing ... (N bytes)`),**零代码开销且不删生成区任何一行**
- FreeRTOS V10.3.1(CMSIS-RTOS V2);HAL 时基 = TIM7(非 SysTick),tick = 1ms;NVIC 4 位优先级分组,外设中断优先级 ≥ 5
- **内核启动(`osKernelStart`)前禁止任何 FreeRTOS/CMSIS-RTOS API 调用**(2026-09-20 启动失败实证根因,修复 `82411cf`):`osMessageQueueNew`/`osMessageQueuePut`/`osMutexNew`/`osSemaphoreNew` 等在内核启动前调用会杀死启动(09/19~09/20 "每次必死" 的代码层根因 = `syslog_mcu_init` 在内核启动前调用 `osMessageQueueNew`)。内核启动前代码的标准模式:纯寄存器/HAL 轮询操作 + **无锁静态缓冲**(如 `s_preboot[8]` 暂存内核前日志)+ **内核启动后惰性创建真队列**(任务上下文首次使用时,见 `syslog_drain`);凡在内核启动前执行的初始化函数里不得创建任何 CMSIS 对象
- CH395F SPI:每次事务须 `ch395f_spi_begin()/end()` 包裹;分频系数**必须为 8**(PCB 约束,勿"优化")
- **就绪屏障位必须 `osFlagsNoClear`**(2026-09-21 实证):`osEventFlagsWait` **默认消费**所等位,而 `SYS_EVT_NET_READY`/`SYS_EVT_STORAGE_READY` 是**状态位**——被 netTask/uiTask/timeSync 先消费掉后,`ftpTask` 的 `WaitAll(两位)` 永不成立 → FTP 21 端口静默不监听(无日志、无报错,纯靠可观测性才揪出来)。就绪位一律 `osFlagsWaitAny|osFlagsNoClear` / `osFlagsWaitAll|osFlagsNoClear`;命令位(`SYS_EVT_CFG_SAVE`/`SYS_EVT_NET_RECONFIG`)保持消费型等待
- **内存预算紧张**:新增任何任务/缓冲/栈前必须先核算 RAM 余量(`docs/内存管理/`、`待处理内容.md`);**栈数据来源**:`& '.venv\Scripts\python.exe' scripts/stack_report.py -s .dsh\skills\serial-assistant\scripts\state\serial.log`(Keil 静态调用图 → 每任务最坏链,+ 串口 `[STACK]` 运行时水位)与 `scripts/ram_report.py`(.map 静态占用);改栈或加大局部变量前后各跑一次
- **自研代码禁用 C 库堆分配**(2026-09-18,P0-1):禁止 `malloc/calloc/realloc/strdup/free`——本机工具链 MicroLIB 的 C 堆由 `startup_stm32f407xx.s` 的 `Heap_Size EQU 0x200` 限定为 **512 B**,KB 级分配必然返回 NULL 且**模拟器(宿主 CRT 堆)完全正常、照不出来**。需要动态内存只用 `pvPortMalloc/vPortFree`(FreeRTOS 堆 `configTOTAL_HEAP_SIZE`),或像配置引擎那样把分配收进平台后端(`cfg_store_alloc/free/heap_free`:MCU=FreeRTOS 堆、模拟器=宿主堆,共享核心零分配符号);分配失败必须有 `FAIL` 日志 + 自检上报,暂态峰值要写编译期预算
- **FatFs 打开模式陷阱(2026-09-20 台架实测,`Lib/FatFs` 移植版)**:`FA_OPEN_APPEND`(0x30) = `FA_OPEN_ALWAYS|FA_SEEKEND`,**不含 `FA_WRITE`(0x02)**——不带写位打开的文件 `f_write` 恒返回 `FR_DENIED`(7)(/log 写失败真根因);追加写必须用 `FA_WRITE|FA_OPEN_APPEND`。该移植的 `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 不同,勿照抄上游代码示例
- **FatFs 文件名约束(2026-09-20 台架实证,`.fschk` 无限重格式化事故根因)**:本移植 `Lib/FatFs/ffconf.h` 为 `FF_USE_LFN=0`,**只支持严格 8.3 短名**——主名≤8 字符、扩展≤3 字符、**不得以点开头**(`.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` 等)归入卷损坏触发集(整卷重格式化不可逆)
- **ADC 取数只用 `tpafe5160_snapshot()`(原子快照)**(2026-10-10 修 E-44/E-45/E-58):旧的 `get_buf()` / `data_ready()` / `clear_ready()` 已从驱动**删除**——`get_buf` 只给缓冲区指针、任务再逐通道拷,两步之间被 BUSY EXTI 抢占并跨过一帧周期就拷回**两帧混合**数据(8 通道失去同一时刻性,且无错误指示);`s_ready` 电平标志有三个写者,`start_conv_irq` 触发前清 0 = **重触发本身就会抹掉一帧未消费数据**。现口径:帧序号只增不清(`tpafe5160_frame_seq()`)、取数=临界区内拷贝(`tpafe5160_snapshot(dst, count)` 返回 1/0/-1,**仅任务上下文**)、FRSTDATA 全程无跳变(芯片不驱动并行总线)由 `tpafe5160_frstdata_err()` 计数且 `tpafe5160_init()` 首帧自检直接返回 `-3` + `[ERR]`。**消费者侧(L1 采集层)**:`App/algo/adc_input.c` 提供 `adc_input_poll()`(= TPAFE 快照 + STM32 ADC1 9 通道顺序扫描)与 `adc_input_raw()`(**按语义** `adc_semantic_t` 取数,不写死下标);映射表自检在 `adc_input_init()`(查每个 STM32 语义通道确实在扫描 rank 里,防"映射到从不扫描的通道 → 恒读陈旧值")
- **接 ADC / RS-485 消费者前的强制门禁(E-59,2026-10-10 固化)**:`adc_task` / `rs485_task` 从空桩转为真实消费者**之前**必须确认——① 驱动契约已修:TPAFE **E-44 / E-45 / E-58 已修 ✅**、RS485 **E-36 / E-37 已修 ✅** 但 **E-50(单缓冲无同步 → 包撕裂)未修 ❌**、**E-49(TX 超时拉 DE)未修 ❌**;② 栈预算:建任务前后各跑一次 `scripts/stack_report.py`。**`adc_task` 已过闸(B1,2026-10-10)**:消费者本体 + `adcTask`(512 words / Normal,`USER CODE` 区手工创建,同 timeSyncTask)+ 栈预算(静态 **560 B** / 余 **72%**,2026-10-10 实测)+ IWDG 心跳(`TASK_WD_ADC`)均已落地;**`rs485_task` 仍被挡(E-50 / E-49 未修)**。另按 N-18:V0.1 上两片转换器之间**禁止**做相位/功率/功率因数类运算
- **`DBG_*` 每条日志约 90 B Flash + 216 B 栈**(2026-10-02 S10 实测;2026-10-10 修正栈量):`dbg_log.h` 的 `DBG_INFO/ERROR/DEBUG` 在**每个调用点内联**行缓冲 `char __con_line[DBG_LINE_BUF_MAX]`(默认 = `console_log.h` 的 `DBG_CON_LINE_MAX` **192 B**)+ 时间戳缓冲 **24 B**(已并入同一数组,原为独立 32 B)+ 完整 `snprintf` 调用序列 → 单条约 **90 B Flash**(字符串常量另计)。给新模块接入日志时按"条数 × 90 B"估算 Flash 代价(实测 `sd2506_init` 加 6 条 = +576 B;`app_main` 加 3 条 = +180 B);**深调用链模块另需核算栈**——每条日志在栈上多占一份行缓冲、会进 `stack_report.py` 的最坏链:实测 defaultTask 那条链上 9 个帧各含一份 → **约 1.5 KB 纯粹是日志行缓冲**(2026-10-10 把该链 5312 → 2776 B 的主要杠杆之一)。故**日志不是免费的**——已覆盖的步骤不要再加"进入前/完成后"重复锚点,只在真正黑洞(原先静默返回)处补
- **深调用链模块收紧日志行缓冲:`#define DBG_LINE_BUF_MAX 128U`,写在 `#include "dbg_log.h"` 之前**(2026-10-10 加)。已收紧:`nand_ftl.c`/`gd5f2gq5ue.c`/`cfg_file.c`/`net_cfg.c`/`net_socket.c`/`ch395f.c`/`time_sync.c`/`default_task.c`。⚠ **取值必须 ≥ 该模块最长日志行**(时间戳 20 + 标签 + 正文),否则该行被截断——各文件注释已写出实测行长。改前/改后各跑一次 `stack_report.py`
<!-- TASK_TABLE:BEGIN generated by scripts/gen_task_table.py -->
| Task | 栈 (words) | 优先级 |
|---|---|---|
| defaultTask | 1024 | Normal |
| netTask | 960 | Normal |
| ftpTask | 1024 | Normal |
| testTask | 2048 | BelowNormal |
| uiTask | 4096 | BelowNormal |
| timeSyncTask | 512 | Normal |
| adcTask | 512 | Normal |
<!-- TASK_TABLE:END -->
(实际值以 `Src/freertos.c` 为准;testTask 仅在启用测试套件时创建。改动栈/缓冲前先核对此表与实测。2026-09-21 上调:defaultTask 512→1024(扛 mount/mkfs/FAT探针/cfg/syslog 深层 FatFS I/O,旧值经 `configCHECK_FOR_STACK_OVERFLOW=2` 钩子实测溢出)、testTask 1024→2048(存储套件 deep call chain + 局部缓冲溢出)。FreeRTOS 堆已迁 CCM,任务栈合计已不占 SRAM;2026-09-21 可观测性落地后实测:ftpTask 峰值曾达 3480B/4096B(余量 15%,低于 1024B 底线)→ 已按 `docs/内存管理/RAM使用分析与优化方案.md` §1.4 把 lftpd 9 处大局部缓冲静态化(BSS +4.6KB),复测峰值 **1184B/4096B(余量 71%)**;2026-10-10 新增 adcTask 后 **7 任务全 PASS**(adcTask 静态 560 B / 余 72%))
## 架构要点
`Src/main.c`(CubeMX 初始化)→ `app_main_init` → `osKernelStart` → 7 任务(`osKernelStart` 之前全是内核启动前代码:禁调任何 FreeRTOS/CMSIS-RTOS API,见「平台约束」)
- `app_main_init`(时间子系统优先,使后续日志带时间戳):`sd2506_init` → `sys_clock_init`(墙钟;RTC 故障时降级默认时间基准并返回 -1 触发"时钟芯片异常"自检;`sys_clock_get` 每 30 天惰性再基准化防 49.7 天 tick 回绕)→ `rs485_init` → `gd5f2gq5ue_init` → `tpafe5160_init` → **`adc_input_init`(L1 采集层)** → SD2506 诊断 → **启动 TIM6 1 kHz 采样节拍**(`HAL_TIM_Base_Start_IT(&htim6)`,2026-10-10;ISR 内触发 CONVST,见下)
- **就绪屏障**(`App/app/sys_events.h` Event Flags,取代魔法延时):`SYS_EVT_NET_READY`(netTask 置位)/ `STORAGE_READY`(defaultTask:卷挂载 + 网络配置加载完成)/ `FTP_READY` / `TIMESYNC_READY`;消费者用 `osEventFlagsWait` 阻塞等待
- netTask:`net_stack_bring_up`(配置已由 defaultTask 在屏障前加载,本任务只读 RAM 副本 → `net_init` + PHY 强制 100M 全双工 → 递增网络代次号)→ 置 NET_READY → 循环 `net_poll` + `net_process_messages`;消费 `SYS_EVT_NET_RECONFIG` → 立即重建网络(新 IP 免重启生效,各 TCP 服务据代次号重建监听)
- defaultTask:`f_mount` forced(无卷自动 `f_mkfs` FAT32;挂载后卷健康探针:小写两击确认,FAT 区陈旧/擦除时自动重格式化自愈,`/config.ini` 回落工厂默认——2026-09-20 /log 写失败修复)→ 网络配置加载(`net_cfg_load`;旧档迁移已于 2026-10-10 删除,见「模块约定」网口配置条)→ STORAGE_READY → LED 闪烁;同时是 syslog(`/log/run.log`·`selftest.log`,定长 64B 记录)的**唯一写者**与**配置引擎属主**(`cfg_file` 静态态单任务收敛于此:UI 保存请求执行 `net_cfg_save_run` + 2s `cfg_file_poll`,经 `SYS_EVT_CFG_SAVE` 事件唤醒;netTask/uiTask 不得直接触碰配置引擎,见 P1-3)
- adcTask(**B1,2026-10-10;L1 层 v2.3,2026-10-10**):**纯消费者**——每 1 ms 调 `adc_input_poll()`(`App/algo/adc_input.c`)= TPAFE `tpafe5160_snapshot()` 临界区原子快照(8 通道)+ **STM32 ADC1 9 通道软件触发顺序扫描**(L1-3)→ 帧统计 + 每 10 s 心跳日志;取数一律经 `adc_input_raw()` **按语义**(L1-4,不写死下标);只取数、不做保护运算(V0.1 仅 TPAFE 三路 `UA`/`UN`/`IA` 测量有效,见 N-18)。**采样节拍(1 kHz)由 TIM6 更新中断硬件触发**(`Src/main.c` 的 `HAL_TIM_PeriodElapsedCallback` 在 TIM6 分支内调 `tpafe5160_start_conv_irq()`,2026-10-10);本任务 1 ms 轮询仅作消费节奏。手写于 `Src/freertos.c` 的 `USER CODE` 区(同 timeSyncTask,512 words / Normal),纳入 IWDG `required_mask`(`TASK_WD_ADC`)。详见 `docs/子系统设计/ADC采样节拍设计说明.md`、`App/algo/README.md`
- **采集与算法层(L1/L2)落在 `App/algo/`**(2026-10-10 建):现有 `adc_input.c/h` = L1(语义通道映射 + STM32 ADC1 扫描);L2 相量层待建。新算法模块一律进这里,勿塞进 `App/task/`(任务只做消费/编排);见 `App/algo/README.md`
- 全景 `docs/架构设计/项目架构总览.md`(六层分层 / 启动时序 / 存储·网络·UI 栈 / 目录组织)
## 硬件引脚
| 外设 | 接口 | 引脚 |
|------|------|------|
| CH395F 以太网 | SPI2 | PB12 CS, PB13 SCK, PB14 SDO, PB15 SDI |
| GD5F2GQ5UE NAND | SPI1 | PE0 CS, PB3 SCK, PB4 MISO, PB5 MOSI, PB8 WP, PE1 HOLD |
| TPAFE5160 ADC | 并行16位 | PG0-PG15 数据, PD3 RD, PD4 CONVST, PD7 BUSY |
| USART1 | 串口 | PA9 TX, PA10 RX (115200bps) |
| RS-485 | UART5 | PC12 TX, PD2 RX, PD0 DE(半双工) |
| 6x LED | GPIO | PC4, PC5, PB1, PB2, PF11, PF12(低电平点亮) |
| TPAFE5160 ADC | 并行16位 | PG0-PG15 数据, PC8 RD, PC9 CONVST, PC7 BUSY(V0.1;FRSTDATA=PC6,OS0/OS1/OS2=PA12/PA11/PA8) |
| ADC 通道语义(V0.1,17 路 = 8×TPAFE + 9×STM32) | 见映射表 | **网名 `AINn` ≠ 板级 `ADCn` ≠ 转换器通道号**(13/17 路置换);语义 UA/UB/UC/UN=`AIN1~4`、IA/IB/IC/IN=`AIN8~11`、V_ZL2=温度、其余 8 路备用——取数**必须查** `docs/硬件平台/ADC通道映射说明.md`(TPAFE `buf[2]=IA`/`buf[5]=UN`/`buf[6]=UA`;网名 ADC17=PC4 用 `ADC_CHANNEL_14`,**不是** `ADC_CHANNEL_12`) |
| ADC 通道归属(V0.1 **硬件缺陷**) | 8 路电气量被分片 | **UA/UN/IA 走 TPAFE5160、UB/UC/IB/IC/IN 走 STM32 ADC 是缺陷,不是有意分配**(2026-10-10 更正;跨转换器无共同采样触发 → 无同一时刻性/相位不确定,16 位 ±5 V 双极性 vs 12 位 0~3.3 V 单极性)→ V0.1 上主板侧**禁止**跨转换器做相位/功率/功率因数/矢量运算;**下一版硬件(V0.2)须把 8 路电气量全部接 TPAFE**(登记簿 N-18,理由与改版方案见 `docs/硬件平台/ADC通道映射说明.md` §1) |
| USART1 调试串口 | UART | PA9 TX, PA10 RX (115200) |
| RS-485 | UART4 | PC10 TX, PC11 RX, PA15 DE(半双工) |
| 6x LED | GPIO | PC5, PB0, PB1, PB2, PF11, PF12(低电平点亮) |
MCU: STM32F407ZGTx @ 168MHz (HSE 8MHz → PLL M=4/N=168/P=2,见 `*.ioc`/`stm32f4xx_hal_conf.h`)
## 模块约定
## 架构
- **CH395F 单连接 socket**:监听与数据通道复用同一 Socket(CONNECT 后转数据通道,DISCONNECT/TIMEOUT 后重开 → LISTEN);所有 CH395F 中断在 `net_poll()` 内经 `GET_GLOB_INT_STATUS` 集中读取并清除,**不得**在 ISR 或其他地方单独处理
- 所有 `net_*` socket 操作经 `netMsgQueue` 消息队列在 netTask 串行执行(线程安全);非 netTask 任务直接调 `net_*` API,勿直接碰 CH395F 驱动
- **`net_*` 公开 API 的错误码经"任务通知值"回传**(2026-09-21 实证):`net_msg_t` 是**按值拷入队列**的,netTask 改的是自己那份副本,调用方永远看不到 → 错误码搭 `xTaskNotify()` 通知值回来(失败=负的具体错误码如 `NET_ERR_WOULDBLOCK`、成功=字节数/描述符),公开 API 恢复 errno 后对外统一返回 -1。**勿给 `net_msg_t` 加"回传用"字段**(调用方只会读到栈垃圾;曾据此"修"错误码,导致 `ui_net_sink` 每次空轮询都误判为真错误 → 连上即 `net_close`)
- **单连接 socket 的断开语义**:监听 Socket 在 CONNECT 后自身即数据通道(`g_client == g_listen`),EOF 时 net 层已 `auto_relisten`;应用层**不得**在 EOF 时 `net_close` 该 fd(会连监听一起毁掉 → 端口"连接被拒"),仅当数据 fd ≠ 监听 fd(模拟器多连接后端)时才关
- 网口配置:`/config.ini` [network] 段(工厂默认 192.168.1.100/24、gw .1;MAC 全零 = 芯片内置;UI 保存立即生效;**旧档 `/cfg/netcfg.bin` 迁移已于 2026-10-10 删除**——`net_cfg_legacy.c/h` 整体移除,旧档若存在则被忽略(不读/不删/不迁移)、配置回落工厂默认;删除理由 = 该一次性路径把整条配置写链挂进启动期调用图,defaultTask 静态最坏链 +600 B,且本仓库无携带旧档的设备)→ `docs/子系统设计/网络配置说明.md`
- **新 .c 文件必须手动注册到两处**才能双端编译:`MDK-ARM/STM32F407-Demo.uvprojx` + `simulator/CMakeLists.txt`(用 `new-source-file` skill);⚠ **若新文件落在工程里还没有的新目录,还要把该目录加进 `uvprojx` 的 `<IncludePath>`**(2026-10-10 实测:只挂 `<File>` 时该 `.c` 自己能编过——它与自己的头同目录,但**其他 TU `#include` 它的头会报 `cannot open source input file`**,报错落在消费者文件上、极易误判)
- **`MDK-ARM/*.uvprojx` 每次 `@build` 后被 Keil UV4 自动改写,改动一律不入库**(2026-09-29 实测):UV4 构建时会把工程 XML 归一化成它本机风格的旧式写法(`<Tag />` → `<Tag></Tag>`、XML 声明加 `standalone="no"`),并把 `PackID` 降到**本机装的 DFP 版本**(本机实测 `3.1.1` → `3.0.0`,属**降级**)。这些差异是环境相关的,提交后会把别人的 Pack 版本也一起改掉。**判断方法**:`git diff MDK-ARM/STM32F407-Demo.uvprojx` 里若只见 XML 格式/`PackID` 差异而**无源文件挂载增删** → `git checkout -- MDK-ARM/STM32F407-Demo.uvprojx` 丢弃。只有真的新增/删除源文件(`grep 文件名` 计数变化)才提交
- 测试构建:`test/board/test_config.h` 的 `TEST_SUITE_CH395F/STORAGE/GD5F/RTC` **互斥**(一次只能启用一个,`#error`);全部注释 = 产品构建;**该开关受 pre-commit 钩子 `test-suite-switch` 门禁**(默认要求产品构建,跑完套件回归须改回;确需提交测试构建用 `ALLOW_TEST_BUILD=1` 显式放行——2026-10-01 实测已提交 HEAD 里 `TEST_SUITE_CH395F` 长期开启,纯靠人记得改,故补此门禁);**破坏性用例默认关闭**:同文件 `ENABLE_DESTRUCTIVE_TESTS`(默认注释)控制写/擦 NAND、格式化、坏块注入与 FatFS 写类用例——关闭时套件为**只读模式**(开机横幅 `mode: READ-ONLY`,可随时在在用设备上跑、不动卷内容与容量),只在**产品首次上电/产线首启**或明确需要时打开(`mode: DESTRUCTIVE`);2026-09-21 实测一轮破坏性 GD5F 运行即让 dhara 多记一个坏块(容量 95248 → 95200;该计数持久化在 dhara journal checkpoint 头里,普通恢复模式清不掉,需全片擦除——见 `docs/测试说明/存储维护与容量恢复.md`);CH395F 套件 `test/board/ch395f_test.h`:硬件层 Phase 1~5 与 NET 层 Phase 6~11 互斥;CH395F 硬件层模式下 netTask(基础线程:无条件创建、全构建唯一实例)入口自挂起(不运行),testTask 独占 CH395F
- **改驱动前先读对应 `docs/问题记录/*_Trap_Records.md`**(CH395F / GD5F2GQ5UE / SD2506);各模块测试指南见 `docs/测试说明/`;**存储维护/容量恢复**(全片擦除恢复 dhara 坏块记账)见 `docs/测试说明/存储维护与容量恢复.md`
- **LVGL I1 绘制缓冲含 palette 头(2026-09-21 实证)**:索引色格式 draw buffer 布局 = `[palette 8B][pixel data stride×h]`(`lv_draw_buf_create` 加 8B、`lv_draw_buf_goto_xy`/`lv_refr get_max_row` 都按此跳/扣),但 `lv_display_set_buffers`/`lv_draw_buf_init` 的容量校验**只看 `stride×h`**——只给 3200B 会被静默接受:末 8 个像素字节写到缓冲外(曾打脏相邻 `s_frame_box[0]` 指针 → HardFault),且 flush 收到的首地址含 palette(整帧错位 8 字节)。`ui_port.c` 按 `stride×h + palette` 供给并在 flush 时 `+palette` 取像素;详见 `docs/UI设计/UI_设计架构.md` §1.4 第 8 条
- **MCU 端必须显式应用 dark mono 主题(2026-09-21 实证,远端"纯白屏"根因)**:`lv_display_create()` 会按 `lv_theme_mono_init(disp, false)`(白底黑字)初始化**单例**主题,且 `lv_display_set_theme()` 只把主题重应用到 `screens[0]`(底层)→ **活动屏幕仍是 LVGL 类默认浅灰 `F5F5F5`**;而 UI 内容恒用 `lv_color_white()` 绘制 → 白底白字不可见、远端整帧 99% 为亮(客户端显示纯白)。修法(`ui_port.c`):`lv_theme_mono_init(disp, true, ...)` + `lv_display_set_theme()` + 对活动屏幕再 `lv_theme_apply()`(实测 `F5F5F5` → `000000`);同时显式写 I1 调色板头 8B(`[0]=黑 [1]=白`,LVGL 不替调用方初始化);详见 `docs/UI设计/UI_设计架构.md` §1.4 第 9 条
```
Src/main.c → CubeMX 外设初始化 → app_main_init → osKernelStart
→ [FreeRTOS tasks: defaultTask, netTask, ftpTask, testTask, uiTask, timeSyncTask]
```
## 串口助手 serial_tool
- **app_main_init 顺序**(时间子系统优先,使后续日志带时间戳):`sd2506_init` → `sys_clock_init`(墙钟基准)→ `rs485_init` → `gd5f2gq5ue_init` → `tpafe5160_init` → SD2506 诊断 → `tpafe5160_start_conv_irq`
- **就绪屏障**(`App/sys_events.h` Event Flags,取代魔法延时/全局轮询):`SYS_EVT_NET_READY`(bit0, netTask 置位) / `SYS_EVT_STORAGE_READY`(bit1, defaultTask 置位) / `SYS_EVT_FTP_READY`(bit2) / `SYS_EVT_TIMESYNC_READY`(bit3);消费者用 `osEventFlagsWait` 阻塞等待
- **defaultTask**:FatFS 挂载(`f_mount` forced,无卷自动 `f_mkfs` FAT32)→ 置 `SYS_EVT_STORAGE_READY` → LED 闪烁循环
- **netTask**:延时 2s → `net_stack_bring_up()`(net_init + MAC + PHY 强制 100M 全双工 + 等链路 ≤3s)→ 置 `SYS_EVT_NET_READY` → 循环 `net_poll()` + `net_process_messages()` + `osDelay(1)`(tick=1ms)
- **ftpTask**:`osEventFlagsWait(NET_READY | STORAGE_READY, WaitAll)` 后 `lftpd_start("/", 21, &ftp)`(已无 7s 魔法延时)
- **testTask**:测试套件入口(`test/test_config.h` 选择;产品构建下空转)
- **uiTask**:等 `SYS_EVT_NET_READY` 后 `ui_port_init` → `ui_menu_init` → 启动 UI net sink
- **timeSyncTask**:TCP 墙钟时间同步(PC 推送)
- 功能编译开关:`App/app_cfg.h`(`APP_NET_ENABLE` / `APP_ADC_ENABLE` / `APP_RS485_ENABLE`)
### 任务栈
| Task | 栈大小(words) | 优先级 |
|---|---|---|
| defaultTask | 2048 | Normal |
| netTask | 4096 | Normal |
| ftpTask | 4096 | Normal |
| testTask | 4096 | BelowNormal |
| uiTask | 16384 | BelowNormal |
| timeSyncTask | 2048 | Normal |
## CH395F 设计原则
- **单连接 socket**:Socket 0 复用监听和数据通道,收到 CONNECT 后切换为数据通道,DISCONNECT/TIMEOUT 后重新 OPEN → LISTEN
- **中断统一处理**:所有 CH395F 中断在 `net_poll()` 中通过 `GET_GLOB_INT_STATUS` 集中读取并清除,不得在 ISR 或其他地方单独处理
## NET 网络层
- `net_socket.h/c` — BSD Socket API;**单连接模式**(`ch395f_set_fun_para(0x09)`:多 Socket + TCP Server 单连接 + 手动关闭),连接停留在 Socket 0
- `net_select.h/c` — select/poll I/O 多路复用
- 线程安全通过消息队列(`netMsgQueue`)实现,所有 CH395F 操作在 netTask 中串行处理
- `net_init` 顺序:check_exist → reset → `set_fun_para(0x09)` → MSS/重传/KeepAlive → IP → `ch395f_init()` → Socket 0~7 缓冲分配 → ping 使能
- 静态 IP 192.168.1.100/24(gw 192.168.1.1),PHY 强制 100M 全双工
- **新文件需手动添加到 Keil MDK 工程**才能编译
## 测试构建模式
- `test/test_config.h`:`TEST_SUITE_CH395F` / `TEST_SUITE_STORAGE` / `TEST_SUITE_GD5F` / `TEST_SUITE_RTC` **互斥**(一次构建只能启用一个,`#error`);全部注释 = 正常产品构建
- CH395F 套件沿用 `test/ch395f_test.h` 阶段宏:硬件层 Phase 1~5 与 NET 层 Phase 6~11 **互斥**(`#error`);硬件层模式下 netTask 不创建,testTask 独占 CH395F
- 各模块测试指南见 `docs/测试说明/`(CH395F / GD5F / SD2506 / FTP)
## 关键文件
| 路径 | 说明 |
|---|---|
| `Src/main.c` | 入口,CubeMX 生成 + USER CODE 区域 |
| `Src/freertos.c` | FreeRTOS task 创建,CubeMX 生成 + USER CODE 区域 |
| `App/app_main.c` | 应用初始化流程编排 |
| `App/sys_events.h` | 系统就绪事件(Event Flags 就绪屏障) |
| `App/app_cfg.h` | 功能模块编译开关 |
| `App/task/net_task.c` | netTask 主循环(网络初始化 + 轮询) |
| `test/test_config.h` | 测试套件选择(互斥开关) |
| `Drivers/BSP/NET/` | BSD Socket API 网络抽象层 + lftpd |
| `Drivers/BSP/CH395F/` | CH395F SPI 驱动 |
| `Drivers/BSP/GD5F2GQ5UE/` | NAND Flash 驱动 + dhara FTL + FatFS diskio |
| `Drivers/BSP/TPAFE5160/` | ADC 并行接口驱动 |
| `Drivers/BSP/SD2506/` | RTC 驱动 |
| `Drivers/BSP/RS485/` | RS-485 通信(UART5 + PD0 DE) |
| `Drivers/BSP/UI/` | LVGL UI 组件(与 PC 模拟器共用源码) |
## 驱动关键点
详见 `docs/` 目录:
- `docs/问题记录/CH395F_Trap_Records.md` — CH395F 已知陷阱
- `docs/问题记录/GD5F2GQ5UE_Trap_Records.md` — GD5F2GQ5UE 已知陷阱
- `docs/问题记录/SD2506_Trap_Records.md` — SD2506 RTC 已知陷阱
- `docs/测试说明/CH395F_Test_Guide.md` — 网络测试说明(含构建模式与执行顺序)
## 串口调试(serial-mcp-server MCP/CLI)
通过 **Adancurusul/serial-mcp-server** 自主读取/控制 **USART1 串口**(PA9 TX / PA10 RX,115200bps)的打印流。
- **串口端口单一配置源:`.opencode/serial_config.json`**(当前本机为 `COM6`)。端口变化只改这个文件。
- 用法、采集窗口模式、已知陷阱等详见 **`docs/测试说明/serial-mcp-server_串口调试.md`**
## 自动化测试(AI 迭代闭环)
改完代码后运行 **`@test`** 可自动执行完整闭环:**构建验证 → 串口捕获武装(serial-mcp-server 采集窗口)→ 烧录(`MDK-ARM/download.bat`,下载后 Reset & Run)→ 解析捕获 JSON → `test/verify_log.ps1` 标记校验 → PASS/FAIL 判定并迭代**。
- **推荐一键命令**:`test/run_boot_test.ps1` = 采集(含 EOF 重试)→ 烧录 → 提取日志 → verify 全流程,退出码 0=PASS / 1=FAIL / 2=采集或烧录错误 / 3=日志文件错误
- 判定标准:`@test` 内部先 `@build`(0 错误 0 警告),再 `@download`(退出码 0),日志用 `verify_log.ps1` 校验(默认 Required:`App init OK`/`Network Init`/`net init: OK`;Forbidden:`FAIL`/`ERROR`/`HardFault`)
- **采集必须在烧录前武装**(采集进程先打开 COM 等 first-byte,烧录触发复位后才抓得到开机日志);`run_boot_test.ps1` 的 `-FlashCmd` 分支已保证顺序
- 每次按模块定制标记(网络/存储/RTC/ADC/RS485)见 `docs/测试说明/自动化测试流程.md`
- UI 已有 DBG 打印(`UI port init: OK` / `UI menu init: OK` / `UI net sink: started`),可用 `-Required "UI port init: OK;UI menu init: OK"` 自动回归 UI 初始化;屏幕内容仍需人工目检
## 代码规范与 pre-commit
- **提交被 pre-commit 钩子门禁**,任一失败即拦截:cppcheck 静态分析(warning/performance/portability/style)+ 代码规范机械检查 + U+FFFD 乱码检查 + gitlint commit message
- 代码规范 V1.0(`docs/代码规范/嵌入式C语言代码规范(V1.0).md`):命名小写下划线(`g_`/`s_`/`p_`/`a_` 前缀)、头文件保护宏 `XXX_H`、常量在右、文件头模块注释块等;可机械校验项由 `scripts/check_coding_standard.py` 强制
- 检查范围仅项目自有代码;第三方(`Lib/`、`Middlewares/`、CMSIS/HAL、`lftpd`、`Drivers/BSP/UI`、`test/`)被排除
- 新机器启用:`pre-commit install`;全库扫描:`pre-commit run --all-files`
- 详见 `docs/代码规范/pre-commit使用说明.md`、`docs/代码规范/cppcheck使用说明.md`
- 入口:`& '.venv\Scripts\python.exe' .dsh/skills/serial-assistant/scripts/serial_tool.py <命令>`(daemon 自动拉起;全部命令 5s 超时、JSON 输出、exit 0/1/2/3)
- 全量历史 = 日志文件 `.dsh/skills/serial-assistant/scripts/state/serial.log`(RX/TX/SYS 时间戳行);agent 直接 Read/Grep 文件,**无采集窗口仪式**;20MB 上限自动截头保尾 10MB
- 常用:`open`(幂等)/ `send <文本>`(或 `--hex 0D0A`)/ `log --tail N --grep P [-i] [--regex]`(**先过滤后截尾**)/ `clear`(代际+1)/ `wait-idle --settle-ms 8000 --max-ms 30000 [--first-timeout-ms 12000]` / `status` / `close`
- 串口端口单一配置源:`.serialtest/config.json`(当前 COM6,2026-09-21 台架重插后由 COM7 变更;2026-10-10 由 `.opencode/serial_config.json` 迁入;串口采集/测试实现已收敛到 `.dsh/skills/serial-assistant` + `serial-boot-test`,仓库内不再有 `tools/serial_tool`/`test/host/run_boot_test` 副本);端口变化**只改这个文件**
- 命令/工作流/故障排查 `docs/测试说明/串口助手使用指南.md`;契约/daemon 行为/陷阱 `docs/测试说明/串口助手设计说明.md`
- **抓开机日志铁律(2026-09-20 实测;②③ 已于 2026-10-10 修复)**:① 顺序 `open` → `clear` → 烧录/复位 → `wait-idle` → 读日志校验标记(**采集必须先于烧录**,否则错过开机头几行);② `wait-idle` 的 CLI 超时现按 `--max-ms` **自动推导**,不再需要手工带 `--timeout`(旧版固定 5s 会在 daemon 还在等时掐断连接、daemon.log 刷 traceback);③ `log --grep` 已改为**先过滤后截尾**——旧实现先 `tail`(默认 50)后 grep,开机 marker 必然查不到,这才是当年"`log --grep` 实测不可信"的真根因,现可直接作判定依据(Read/Grep 文件仍等效)
- **`wait-idle` 判据(2026-10-10 修)**:`no_data` = **本次等待期间零字节**(旧实现比的是 daemon 累计 `rx_bytes` 是否为 0,常驻 daemon 下永不触发 → 板子全程静默也误报 `idle:true`);**首字节到达前不启动静默计时**(`--first-timeout-ms` 兜底,`run_boot_test.py --first-wait-ms` 默认 12000);返回的 `rx_bytes` 是本次增量而非累计
- **采集卫生与查询面(2026-10-10 第三批)**:无换行残行 **64KB 封顶**(板子跑飞/烧录期的二进制垃圾旧实现无限攒内存,还会把后续每条正常行拼成一条读不出的长行);`open`/`close`/`clear` = **会话边界**,残行一并丢弃(旧实现里上一段被复位截断的半行会粘进新会话第一行,开机日志首行因此不可读);`cap_truncate` **按行边界**截头(旧实现按字节切 → 截出来的首行是半行,破坏"只含完整行"不变量);`open` 不带 `--dtr/--rts` = **保持当前线路状态**(旧实现把"没给"当 False,重开即静默拉低),显式给则现场应用并在 `note` 回显;`ports` 回 `configured_present` + `hint`(系统里只有一个候选才给,多候选不猜),`open` 的 `port not found` 也带 `[hint: ...]`;`status` 回 `rx_age_ms`
- ⚠️ **看到两个 `serial_tool.py --serve` 进程是正常的**(2026-10-10 实测):`.venv\Scripts\python.exe` 是 venv stub,会派生基解释器(本机 = `C:\ProgramData\anaconda3\python.exe`)子进程跑真 daemon,`status.daemon.pid` 报的是子进程;**别把这对父子当成"多 daemon 并存"去 taskkill**(HTTP 口独占绑定 `SO_EXCLUSIVEADDRUSE` 已生效,`status.daemon.exclusive_bind: true` 可验)。真要清的是 `status`/`meta.json` 里那个 pid 之外的**独立**残留实例
- **`open` 报"拒绝访问/端口被占用"时**:先查**残留旧 daemon**(上一会话遗留的 `serial_tool.py --serve` python 进程——口句柄已死但进程仍占着串口与 HTTP 口 18765)→ `taskkill /F /PID <旧PID>` → 重新 `open`(自动拉起新 daemon,系统自动释放口);改 `.serialtest/config.json` 后同样必须先杀旧 daemon 再 `open`(daemon 启动时缓存配置)
## LVGL PC 模拟器
`simulator/`(CMake + SDL2 2.32.10 预编译库)可在 PC 上开发调试 UI,**与 MCU 端共用 UI 源码**(`Drivers/BSP/UI/ui_menu.c`、`ui_font_cn_12.c`),无需烧录:
- `simulator/`(CMake + SDL2 预编译库)与 MCU 端共用 UI 源码(`ui_menu.c`、`ui_font_cn_12.c`),免烧录开发 UI:`& "simulator\build_sim.bat"`(按本机 VS 自动选生成器:16=2019 / 17=2022 / 18=2026,**勿把生成器版本写死**;`build/` 缓存生成器与本机 VS 不符时自动删除重建)→ 运行 `simulator\build\Debug\lvgl_sim.exe`
- 自检:`--cfgtest`(配置闭环)/ `--logtest`(日志引擎)/ `--standby shot.bin`(待机屏截图)/ `--idle <秒>`
- UI 改动一律走 **`ui-verify` skill 五步门禁**;含新汉字的 UI 文案提交前先跑 **`font-regen` skill** 重生成中文字体
```powershell
cd simulator
cmake -B build -G "Visual Studio 16 2019" -A x64
cmake --build build --config Debug
# 运行 simulator\build\Debug\lvgl_sim.exe
```
## 文档与图形
- 详见 `docs/UI 设计/LVGL模拟器部署与使用说明.md`
- 中文字体:`ui_cn_chars.txt`(字符集)+ `tools/font_gen/` 生成 `Drivers/BSP/UI/ui_font_cn_12.c`,见 `docs/UI 设计/UI_中文字体生成与配置.md`
## 注意
- `Inc/` 和 `Src/` 中 CubeMX 生成的文件带有 `USER CODE BEGIN`/`END` 标记,自定义代码写在这些区域之间
- `Drivers/BSP/` 下的驱动文件为纯手工代码,不受 CubeMX 保护
- CH395F 每次 SPI 事务需用 `ch395f_spi_begin()` / `ch395f_spi_end()` 包裹
- CH395F SPI PCB 设计问题,分频系数必须为 8,速度相对较低
- FreeRTOS V10.3.1 via CMSIS-RTOS V2,HAL 时基用 TIM7(非 SysTick),tick = 1ms
- NVIC 优先级分组 4 位,外设中断优先级 ≥ 5(`configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY`)
- **内存预算紧张**:任务栈目前普遍偏大(见 `待处理内容.md`),新增任务/缓冲/栈前必须先核算 RAM 余量
## 源码编辑与编码安全(铁律)
本工程源码含大量中文注释,曾因错误的文本重写方式导致整文件乱码(`U+FFFD` 替换符,不可逆)。以下规则必须遵守:
- **绝不用 `Get-Content` / `Set-Content` / `cmd` 文本重写含中文的源码文件**。这类"整文件重写"会按系统默认编码重新解码再写回,多字节中文必被 `U+FFFD` 替换且不可逆。
- **改代码只用 Edit 工具**(精确子串替换,不重新编解码整个文件,安全);批量替换用 Edit 的 `replaceAll`。
- 必须脚本级替换时,使用显式且一致的编码(**本工程源码为 UTF-8 无 BOM**,如 Python `open(p, encoding="utf-8")` 同编码回写)——但**最稳的是别碰**,只用 Edit / git。
- **发现乱码先别急着 `git checkout`**:`git checkout` 会丢弃所有未提交工作,使损失扩大。先评估能否用 Edit 仅改坏掉的那几行。
- 验证是否真损坏用字节级检查(统计 `EF BF BD` 出现次数),不要依赖终端/Read 渲染判断。
- 配置文件 `docs/子系统设计/配置文件设计说明.md`;墙钟 `docs/子系统设计/系统墙钟时间维护说明.md`;控制台单写者日志 `docs/子系统设计/控制台单写者日志设计说明.md`;代码规范 `docs/代码规范/`;测试说明 `docs/测试说明/`;审计发现与待办 `docs/待处理内容.md`(统一登记簿:P-x 审计缺陷 / N·S·T-x 观察 / S1x·A-x 测试架构专项 / 阶段排序 / 诊断基线);引用关系图 `docs/架构设计/引用关系图说明.md`
- **登记簿类文档「完成即销项」**(2026-09-26 定规):`docs/待处理内容.md` 这类跨文档待办登记簿,事项修复/关闭后**直接从表中删除该行**(不保留"已消化"行、不建归档区)——结论与证据应已沉淀进对应模块文档/问题记录,登记簿只留**未办项**。销项时必须:① 先核对目标文档确已含完整记录(删了不丢信息)② 同步清理其他文档对该编号的引用(改为指向目标文档,或注明"已销项")③ 改完跑 `check_doc_links.py --all` + pre-commit
- 图形走 **`draw-diagram` skill**:精确布局图(协议帧/分层/屏障)用 `tools/arch_diagram/gen_*.py` 生成器(产物放 `docs/images/`,脚本与 .svg 一并提交);拓扑图(流程/时序/状态机)用 Mermaid;**勿手绘 ASCII 框图**
- **文件引用关系图**走 `tools/refgraph`:`& 'tools\refgraph\gen_refgraph.bat'` 生成文件级 + 模块级引用图(产物 `docs/images/引用关系图.*`,脚本与 .dot/.svg/.json 一并提交,PNG 本地预览不提交);项目文件变化后重跑更新;渲染依赖 Graphviz(winget 可装)
+37
View File
@@ -0,0 +1,37 @@
# App/ — 应用层
> **自研代码**(非 CubeMX 生成、非第三方库)。本目录是项目的**应用层**:任务编排、业务逻辑、
> 日志与配置引擎、工具函数。所有文件遵循 `docs/代码规范/嵌入式C语言代码规范_V1.0.md`。
## 职责
`Src/main.c`(CubeMX 初始化)→ `app_main_init`(本目录 `app/app_main.c`)→ `osKernelStart` →
6 个 FreeRTOS 任务(见 `task/`)。**`osKernelStart` 之前全是内核启动前代码,禁止调用任何
FreeRTOS/CMSIS-RTOS API**(见 `AGENTS.md`「平台约束」)。
## 目录结构(按职责分层)
| 目录 | 内容 | README |
|---|---|---|
| `app/` | **应用入口与全局**:`app_main.c/h`(初始化入口)、`app_cfg.h`(编译开关)、`sys_events.h`(就绪事件) | [`app/README.md`](app/README.md) |
| `cfg/` | **配置引擎**:`/config.ini` 解析 + 原子持久化(旧二进制档已废弃,2026-10-10) | [`cfg/README.md`](cfg/README.md) |
| `sync/` | **时间同步任务**:`time_sync.c/h` | [`sync/README.md`](sync/README.md) |
| `algo/` | **采集与算法层(L1/L2)**:`adc_input.c/h`(语义通道映射 + STM32 ADC1 扫描) | [`algo/README.md`](algo/README.md) |
| `log/` | **日志子系统**:syslog(NAND)+ 控制台单写者日志 | [`log/README.md`](log/README.md) |
| `task/` | **FreeRTOS 任务实现**(6 个任务) | [`task/README.md`](task/README.md) |
| `util/` | **通用工具**:CRC / 环形缓冲 / 墙钟时间 | [`util/README.md`](util/README.md) |
| `protocol/` | 通信协议(预留,当前为空) | [`protocol/README.md`](protocol/README.md) |
## 关键约束
- **配置引擎单任务收敛**:`cfg/` 为静态态、属主是 defaultTask;UI 保存请求经
`SYS_EVT_CFG_SAVE` 事件唤醒,netTask/uiTask **不得直接触碰**配置引擎
- **syslog 唯一写者**:`/log/*` 只由 defaultTask 写入
- 新增任务/缓冲/栈前**必须先核算 RAM 余量**(`docs/内存管理/`)
## 相关文档
- 架构全景:`docs/架构设计/项目架构总览.md`
- 启动顺序:`docs/架构设计/项目启动顺序管理.md`
- 配置引擎:`docs/子系统设计/配置文件设计说明.md`
- 日志:`docs/子系统设计/控制台单写者日志设计说明.md`、`docs/子系统设计/运行与自检记录设计说明.md`
+27
View File
@@ -0,0 +1,27 @@
# App/algo/ — 采集与算法层(L1 / L2)
> **自研代码**,属 `docs/算法/继电保护功能复现规划.md` §2.1 分层中的 **L1(采集与预处理)**
> 与 **L2(相量计算,拟)**。原则:**算法与 I/O 分离**——本层不碰寄存器、不碰任务调度,
> 只做"数"(码值 → 工程量)与"相量"(工程量 → 复相量),从而可成为主机侧单元测试
> (L0.5)的天然适格对象(落实登记簿 **A-4**)。
## 现有模块
| 文件 | 层 | 职责 |
|---|---|---|
| `adc_input.c/h` | L1 | **ADC 输入层**:① **语义通道映射**(L1-4)——`UA/UB/UC/UN/IA/IB/IC/IN/温度` → 物理通道,消费侧**按语义取数、不写死下标**;② **STM32 ADC1 9 通道软件触发顺序扫描**(L1-3);③ TPAFE5160 原子快照消费 |
## 关键约定
- **语义映射的唯一权威口径**是 `docs/硬件平台/ADC通道映射说明.md`(V0.1 有 13/17 路被置换,
网名编号 ≠ 通道号)。V0.1 上 8 路电气量分属两片转换器(硬件缺陷 **N-18**):`UA/UN/IA`
在 TPAFE5160、`UB/UC/IB/IC/IN` 在 STM32 ADC1。**V0.2 改版只改映射表内容**,本层 API
与上层语义不动。
- **取数一律经 `adc_input_raw()`**(按语义),禁止在消费侧直接索引驱动缓冲或写死下标。
- 本层**不做**跨转换器的相位 / 功率 / 功率因数 / 矢量类运算(V0.1 硬件缺陷 N-18 限制)。
## 相关文档
- 通道映射权威口径:`docs/硬件平台/ADC通道映射说明.md`
- 分层与路线图:`docs/算法/继电保护功能复现规划.md`(§2.1 分层、§3.1 L1 清单)
- 采样节拍:`docs/子系统设计/ADC采样节拍设计说明.md`
+237
View File
@@ -0,0 +1,237 @@
/*
* 模块名称:ADC 输入层(L1 采集与预处理)
* 模块功能:语义通道映射(L1-4)+ STM32 ADC1 顺序扫描(L1-3)+ TPAFE 快照消费。
* 消费侧经 adc_input_raw() 按语义取数,不写死下标。
* 适用平台:STM32F407ZGTx + TPAFE5160
* 作者:王建锋
* 创建日期:2026-10-10
* 修改记录:
* v1.0 2026-10-10 王建锋 创建:L1-3(STM32 ADC1 扫描)+ L1-4(语义映射)
*/
#include "adc_input.h"
#include <stddef.h>
#include "adc.h" /* hadc1:MX_ADC1_Init() 已配 9 通道 / 144 cycles / 软件触发 */
#include "tpafe5160.h" /* tpafe5160_snapshot():临界区原子快照(AGENTS 铁律) */
/* STM32 ADC1 单次转换轮询超时(ms)。单通道 ≈ 7.4 µs(144+12 cycles @ 21 MHz),
* 2 ms 余量充裕;超时即判本次扫描失败,绝不长时间阻塞消费节拍 */
#define ADC_INPUT_POLL_TIMEOUT_MS 2U
/* STM32 ADC1 通道号上限(F4 上 ADC_CHANNEL_0..15 的宏值即通道号 0..15) */
#define ADC_INPUT_STM32_CH_MAX 16U
/* ===== 语义映射表(L1-4)=====
* 权威口径:docs/硬件平台/ADC通道映射说明.md(2026-10-09 硬件确认的语义分配)。
* index 含义:TPAFE 源 = 驱动 buf 下标 0..7;STM32 源 = ADC1 通道号 0..15。
* ⚠ 消费侧一律经本表按语义取数,**不得写死下标**(V0.1 有 13/17 路被置换,
* 网名编号与通道号不等价);V0.2 改版(N-18)只改本表内容。 */
static const adc_channel_desc_t k_adc_map[ADC_SEM_COUNT] = {
[ADC_SEM_UA] = { ADC_SRC_TPAFE, 6U }, /* TPAFE buf[6](ch7) */
[ADC_SEM_UB] = { ADC_SRC_STM32, (uint8_t)ADC_CHANNEL_14 }, /* PC4 */
[ADC_SEM_UC] = { ADC_SRC_STM32, (uint8_t)ADC_CHANNEL_3 }, /* PA3 */
[ADC_SEM_UN] = { ADC_SRC_TPAFE, 5U }, /* TPAFE buf[5](ch6) */
[ADC_SEM_IA] = { ADC_SRC_TPAFE, 2U }, /* TPAFE buf[2](ch3) */
[ADC_SEM_IB] = { ADC_SRC_STM32, (uint8_t)ADC_CHANNEL_6 }, /* PA6 */
[ADC_SEM_IC] = { ADC_SRC_STM32, (uint8_t)ADC_CHANNEL_1 }, /* PA1 */
[ADC_SEM_IN] = { ADC_SRC_STM32, (uint8_t)ADC_CHANNEL_2 }, /* PA2 */
[ADC_SEM_TEMP] = { ADC_SRC_STM32, (uint8_t)ADC_CHANNEL_5 }, /* PA5(V_ZL2) */
};
/* STM32 ADC1 顺序扫描顺序(Rank 1..9),须与 MX_ADC1_Init() 的 Rank 配置严格一致:
* Rank1..8 = CH0..CH7(PA0..PA7),Rank9 = CH14(PC4)。 */
static const uint8_t k_stm32_rank_ch[ADC_INPUT_STM32_CH_NUM] = {
(uint8_t)ADC_CHANNEL_0, (uint8_t)ADC_CHANNEL_1, (uint8_t)ADC_CHANNEL_2,
(uint8_t)ADC_CHANNEL_3, (uint8_t)ADC_CHANNEL_4, (uint8_t)ADC_CHANNEL_5,
(uint8_t)ADC_CHANNEL_6, (uint8_t)ADC_CHANNEL_7, (uint8_t)ADC_CHANNEL_14,
};
/* STM32 ADC1 最新一帧原始码值,按 ADC1 通道号索引(0..15) */
static uint16_t s_stm32_raw[ADC_INPUT_STM32_CH_MAX];
/* TPAFE5160 最新一帧原始码值(16 位二进制补码),按驱动 buf 下标索引 */
static int16_t s_tpafe_raw[ADC_INPUT_TPAFE_CH_NUM];
static uint8_t s_tpafe_valid; /* TPAFE 侧是否已有有效帧 */
static uint8_t s_stm32_valid; /* STM32 侧是否已有有效扫描 */
static int s_stm32_rc; /* STM32 最近一次扫描状态码 */
static uint32_t s_stm32_errs; /* STM32 扫描累计失败次数 */
/*
* 函数功能:STM32 ADC1 顺序扫描一次(L1-3)
* 入口参数:无
* 返 回 值:0 - 成功;-1 - 启动失败;-2 - 转换轮询超时
* 限定条件:仅任务上下文调用;MX_ADC1_Init() 已完成
* 函数说明:软件触发一次 9 通道扫描(ScanConvMode=ENABLE、ContinuousConvMode=
* DISABLE),按 Rank 顺序逐通道轮询 EOC 并读 DR,结果按 ADC1 通道号
* 存入 s_stm32_raw[]。EOCSelection=SINGLE_CONV 且未用 DMA → 每个通道
* 产生一次 EOC,可逐通道轮询(见 HAL_ADC_PollForConversion 说明)。
*/
static int stm32_scan(void) {
uint8_t i;
if (HAL_ADC_Start(&hadc1) != HAL_OK) {
s_stm32_rc = ADC_INPUT_RC_START;
s_stm32_errs++;
return ADC_INPUT_RC_START;
}
for (i = 0u; i < ADC_INPUT_STM32_CH_NUM; i++) {
if (HAL_ADC_PollForConversion(&hadc1, ADC_INPUT_POLL_TIMEOUT_MS) != HAL_OK) {
(void)HAL_ADC_Stop(&hadc1);
s_stm32_rc = ADC_INPUT_RC_TIMEOUT;
s_stm32_errs++;
return ADC_INPUT_RC_TIMEOUT;
}
s_stm32_raw[k_stm32_rank_ch[i]] = (uint16_t)HAL_ADC_GetValue(&hadc1);
}
(void)HAL_ADC_Stop(&hadc1);
s_stm32_valid = 1u;
s_stm32_rc = ADC_INPUT_RC_OK;
return ADC_INPUT_RC_OK;
}
/*
* 函数功能:语义映射表自洽性自检
* 入口参数:无
* 返 回 值:0 - 自洽;-1 - 存在不自洽条目
* 限定条件:无
* 函数说明:逐条校验映射表与扫描配置是否一致——① TPAFE 源下标须落在
* buf[0..7];② STM32 源的通道号必须出现在 k_stm32_rank_ch[] 中,
* 否则该通道永远不会被扫描(读到的恒为陈旧值)。这是"通道宏写错 →
* 读到另一只脚/读不到"这一 §5 警告的代码级兜底(无信号源时无法做
* 引脚级注入验证,见 docs/硬件平台/ADC通道映射说明.md §8)。
*/
static int map_check(void) {
uint8_t s;
int rc = 0;
for (s = 0u; s < (uint8_t)ADC_SEM_COUNT; s++) {
if (k_adc_map[s].source == ADC_SRC_TPAFE) {
if (k_adc_map[s].index >= ADC_INPUT_TPAFE_CH_NUM) {
rc = -1;
}
} else {
uint8_t i;
uint8_t found = 0u;
for (i = 0u; i < ADC_INPUT_STM32_CH_NUM; i++) {
if (k_stm32_rank_ch[i] == k_adc_map[s].index) {
found = 1u;
}
}
if (found == 0u) {
rc = -1;
}
}
}
return rc;
}
/*
* 函数功能:ADC 输入层初始化
* 入口参数:无
* 返 回 值:0 - 成功;-1 - 语义映射表自检失败
* 限定条件:内核启动前调用(无 RTOS 依赖);STM32 ADC1 外设已由 MX_ADC1_Init 配好
* 函数说明:清空原始码值缓冲与统计,并做一次映射表自洽性自检;本层不重复配置
* 外设,也不启动任何转换(STM32 侧为软件触发,按拍扫描)。
*/
int adc_input_init(void) {
uint8_t i;
for (i = 0u; i < ADC_INPUT_STM32_CH_MAX; i++) {
s_stm32_raw[i] = 0u;
}
for (i = 0u; i < ADC_INPUT_TPAFE_CH_NUM; i++) {
s_tpafe_raw[i] = 0;
}
s_tpafe_valid = 0u;
s_stm32_valid = 0u;
s_stm32_rc = ADC_INPUT_RC_NOT_RUN;
s_stm32_errs = 0u;
return map_check();
}
/*
* 函数功能:采集一拍(TPAFE 原子快照 + STM32 ADC1 顺序扫描)
* 入口参数:无
* 返 回 值:TPAFE 快照结果(1=有新帧 0=无新帧 -1=参数错)
* 限定条件:仅**任务上下文**调用(TPAFE 快照走临界区)
* 函数说明:先取 TPAFE 一帧,再做 STM32 ADC1 扫描;两者相互独立,STM32 扫描
* 失败不影响 TPAFE 快照与返回值语义(STM32 侧状态另由
* adc_input_stm32_rc() 暴露)。
*/
int adc_input_poll(void) {
int r = tpafe5160_snapshot(s_tpafe_raw, (uint8_t)ADC_INPUT_TPAFE_CH_NUM);
if (r == 1) {
s_tpafe_valid = 1u;
}
(void)stm32_scan();
return r;
}
/*
* 函数功能:取语义通道的物理描述
* 入口参数:ch - 语义通道
* 返 回 值:指向静态描述表的指针;ch 越界返回 NULL
* 限定条件:无
* 函数说明:返回的是静态存储地址,调用方不得修改。
*/
const adc_channel_desc_t *adc_input_desc(adc_semantic_t ch) {
if ((unsigned int)ch >= (unsigned int)ADC_SEM_COUNT) {
return NULL;
}
return &k_adc_map[ch];
}
/*
* 函数功能:读取语义通道的原始码值(未标定)
* 入口参数:ch - 语义通道
* out - 输出原始码值(TPAFE 侧为 16 位补码;STM32 侧为 0..4095)
* 返 回 值:0 - 成功;-1 - 参数错;-2 - 该来源当前无有效数据
* 限定条件:无
* 函数说明:消费侧取数的唯一入口(按语义,不写死下标)。
*/
int adc_input_raw(adc_semantic_t ch, int32_t *out) {
if ((out == NULL) || ((unsigned int)ch >= (unsigned int)ADC_SEM_COUNT)) {
return -1;
}
if (k_adc_map[ch].source == ADC_SRC_TPAFE) {
if (s_tpafe_valid == 0u) {
return -2;
}
*out = (int32_t)s_tpafe_raw[k_adc_map[ch].index];
} else {
if (s_stm32_valid == 0u) {
return -2;
}
*out = (int32_t)s_stm32_raw[k_adc_map[ch].index];
}
return 0;
}
/*
* 函数功能:读取 STM32 ADC1 最近一次扫描状态码
* 入口参数:无
* 返 回 值:ADC_INPUT_RC_xxx
* 限定条件:无
* 函数说明:供心跳日志 / 自检观测 STM32 侧健康度。
*/
int adc_input_stm32_rc(void) {
return s_stm32_rc;
}
/*
* 函数功能:读取 STM32 ADC1 扫描累计失败次数
* 入口参数:无
* 返 回 值:累计失败次数(启动失败或转换超时均计入)
* 限定条件:无
* 函数说明:单调递增,供心跳日志观测。
*/
uint32_t adc_input_stm32_errs(void) {
return s_stm32_errs;
}
+127
View File
@@ -0,0 +1,127 @@
/*
* 模块名称:ADC 输入层(L1 采集与预处理)
* 模块功能:把两片转换器的原始码值按**语义**(UA/UB/UC/UN/IA/IB/IC/IN/温度)统一
* 暴露,使上层(UI / 自检 / 保护算法)**按语义取数,不写死下标**:
* ① 语义通道映射表(L1-4)——权威口径 docs/硬件平台/ADC通道映射说明.md;
* ② STM32 ADC1 9 通道软件触发顺序扫描(L1-3)。
* V0.1 上 8 路电气量分属两片转换器(硬件缺陷 N-18):UA/UN/IA 走
* TPAFE5160(16 位 / ±5 V 双极性),UB/UC/IB/IC/IN 走 STM32 ADC1
* (12 位 / 0~3.3 V 单极性)。V0.2 改版(8 路电气量全部接 TPAFE)
* 只改映射表内容,本层 API 与上层语义不动。
* 适用平台:STM32F407ZGTx + TPAFE5160
* 作者:王建锋
* 创建日期:2026-10-10
* 修改记录:
* v1.0 2026-10-10 王建锋 创建:L1-3(STM32 ADC1 扫描)+ L1-4(语义映射)
*/
#ifndef ADC_INPUT_H
#define ADC_INPUT_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* 语义通道(V0.1:8 路电气量 + 温度)。枚举值即语义编号,顺序不得随意变更;
* 备用通道(RESERVED)不接入语义,故不在此枚举内。 */
typedef enum {
ADC_SEM_UA = 0, /* A 相电压(TPAFE) */
ADC_SEM_UB, /* B 相电压(STM32) */
ADC_SEM_UC, /* C 相电压(STM32) */
ADC_SEM_UN, /* 零线电压(TPAFE) */
ADC_SEM_IA, /* A 相电流(TPAFE) */
ADC_SEM_IB, /* B 相电流(STM32) */
ADC_SEM_IC, /* C 相电流(STM32) */
ADC_SEM_IN, /* 零线电流(STM32) */
ADC_SEM_TEMP, /* 温度(STM32,V_ZL2) */
ADC_SEM_COUNT /* 语义通道数(哨兵) */
} adc_semantic_t;
/* 数据来源:V0.1 的 8 路电气量分属两片转换器(N-18) */
typedef enum {
ADC_SRC_TPAFE = 0, /* TPAFE5160:16 位、±5 V 双极性、8 通道同步 */
ADC_SRC_STM32 /* STM32 ADC1:12 位、0~3.3 V 单极性、9 通道顺序扫描 */
} adc_source_t;
/* 语义通道 → 物理通道描述 */
typedef struct {
adc_source_t source; /* 数据来源 */
uint8_t index; /* TPAFE 源:驱动 buf 下标 0..7;STM32 源:ADC1 通道号 0..15 */
} adc_channel_desc_t;
/* TPAFE5160 一帧通道数(= 驱动 buf 长度) */
#define ADC_INPUT_TPAFE_CH_NUM 8U
/* STM32 ADC1 顺序扫描通道数(Rank 1..9) */
#define ADC_INPUT_STM32_CH_NUM 9U
/* STM32 ADC1 最近一次扫描状态码 */
#define ADC_INPUT_RC_OK (0) /* 扫描成功 */
#define ADC_INPUT_RC_START (-1) /* HAL_ADC_Start 失败 */
#define ADC_INPUT_RC_TIMEOUT (-2) /* 转换轮询超时 */
#define ADC_INPUT_RC_NOT_RUN (-3) /* 尚未扫描过 */
/*
* 函数功能:ADC 输入层初始化
* 入口参数:无
* 返 回 值:0 - 成功;-1 - 语义映射表自检失败(映射指向了不会被扫描的通道)
* 限定条件:内核启动前调用(无 RTOS 依赖);STM32 ADC1 外设已由 MX_ADC1_Init 配好
* 函数说明:清空原始码值缓冲与统计,并做一次映射表自洽性自检;本层不重复配置外设。
*/
int adc_input_init(void);
/*
* 函数功能:采集一拍(TPAFE 原子快照 + STM32 ADC1 顺序扫描)
* 入口参数:无
* 返 回 值:TPAFE 快照结果(1=有新帧 0=无新帧 -1=参数错);STM32 侧状态另见
* adc_input_stm32_rc()
* 限定条件:仅**任务上下文**调用(TPAFE 快照走临界区)
* 函数说明:本函数只搬运原始码值,不做标定/运算。STM32 侧扫描失败不阻断 TPAFE
* 快照,也不影响返回值语义。
*/
int adc_input_poll(void);
/*
* 函数功能:取语义通道的物理描述
* 入口参数:ch - 语义通道
* 返 回 值:指向静态描述表的指针;ch 越界返回 NULL
* 限定条件:无
* 函数说明:供自检注入 / UI 通道序等需要"语义 → 物理通道"映射的场景使用。
*/
const adc_channel_desc_t *adc_input_desc(adc_semantic_t ch);
/*
* 函数功能:读取语义通道的原始码值(未标定)
* 入口参数:ch - 语义通道
* out - 输出原始码值(TPAFE 侧为 16 位二进制补码;STM32 侧为 0..4095)
* 返 回 值:0 - 成功;-1 - 参数错;-2 - 该来源当前无有效数据
* 限定条件:无
* 函数说明:消费侧取数的唯一入口(按语义,不写死下标)。取数前须先成功
* adc_input_poll() 一次。
*/
int adc_input_raw(adc_semantic_t ch, int32_t *out);
/*
* 函数功能:读取 STM32 ADC1 最近一次扫描状态码
* 入口参数:无
* 返 回 值:ADC_INPUT_RC_xxx
* 限定条件:无
* 函数说明:供心跳日志 / 自检观测 STM32 侧健康度。
*/
int adc_input_stm32_rc(void);
/*
* 函数功能:读取 STM32 ADC1 扫描累计失败次数
* 入口参数:无
* 返 回 值:累计失败次数(启动失败或转换超时均计入)
* 限定条件:无
* 函数说明:单调递增,供心跳日志观测。
*/
uint32_t adc_input_stm32_errs(void);
#ifdef __cplusplus
}
#endif
#endif /* ADC_INPUT_H */
+32
View File
@@ -0,0 +1,32 @@
# App/app/ — 应用入口与全局定义
> 应用层的**入口**与**全局定义**:初始化编排、功能编译开关、跨模块的就绪事件定义。
## 文件一览
| 文件 | 作用 |
|---|---|
| `app_main.c/h` | **应用初始化入口 `app_main_init`**:时间子系统优先 → RS485 → NAND → ADC → 诊断(在 `osKernelStart` 之前执行) |
| `app_cfg.h` | **功能模块编译开关**(裁剪测试套件/模块用) |
| `sys_events.h` | **系统就绪事件定义**(CMSIS-RTOS v2 Event Flags:`SYS_EVT_NET_READY` / `SYS_EVT_STORAGE_READY` / `SYS_EVT_FTP_READY` / `SYS_EVT_TIMESYNC_READY` 及命令位) |
## 初始化顺序(`app_main_init`)
```
sd2506_init // 时间子系统优先,使后续日志带时间戳
sys_clock_init // 墙钟(RTC 故障降级并返回 -1 → 触发自检)
rs485_init
gd5f2gq5ue_init
tpafe5160_init
SD2506 诊断 // ID / 时间 / 温度 / 电池
tpafe5160_start_conv_irq // 首次 ADC 转换
```
> ⚠️ 以上全部在内核启动前执行 → **禁止任何 FreeRTOS/CMSIS-RTOS API 调用**
> (`osMessageQueueNew` 等会杀死启动)。内核前日志走无锁静态缓冲(见 `../log/`)。
## 相关文档
- 架构全景:`docs/架构设计/项目架构总览.md`
- 启动顺序与就绪屏障:`docs/架构设计/项目启动顺序管理.md`
- 墙钟:`docs/子系统设计/系统墙钟时间维护说明.md`
+47
View File
@@ -0,0 +1,47 @@
/*
* 模块名称:Application Configuration
* 模块功能:应用层全局配置宏(**预留占位,当前均未接线**)
* 适用平台:STM32F407ZGTx
* 作者:王建锋
* 创建日期:2026-07-19
* 修改记录:
* v1.0 2026-07-19 王建锋 创建初始版本
* v1.1 2026-10-04 王建锋 P2-12:下列宏此前被表述为"功能模块使能开关",但全库
* 零代码引用(grep 仅本文件有定义)——"开关名存实亡"会
* 误导评审者以为模块可裁剪。现如实标注为**预留占位**:
* 当前**不门控任何代码**,各模块一律编译进产品。是否/
* 如何接线取决于产品定义(待处理内容.md §2 阶段 B0),
* 确认后再在真实初始化点(如 app_main.c 的 rs485_init /
* tpafe5160_init)加门控;在此之前**不要**把它们当开关用。
*/
#ifndef APP_CFG_H
#define APP_CFG_H
#ifdef __cplusplus
extern "C" {
#endif
/*
* 系统时钟源选择(**预留占位,未接线**)
* 0 - DWT CYCCNT(当前实现:console_log.c 直接使用 DWT,未读本宏)
* 1 - TIM2(未实现)
* 说明:本宏当前无任何消费点;实际的"与时间源无关硬上界"由 console_log.c 内联
* 使能 DWT 实现,不经过本宏。
*/
#define APP_SYS_TIME_SRC 0
/*
* 功能模块"预留开关"(**当前均未接线,不门控任何代码**)
* 三个宏一律保持定义(= 模块编译进产品);它们**不产生任何编译期效果**,
* 仅作为产品定义(B0)落定后接入真实门控的占位锚点。
*/
#define APP_NET_ENABLE
#define APP_ADC_ENABLE
#define APP_RS485_ENABLE
#ifdef __cplusplus
}
#endif
#endif /* APP_CFG_H */
+246
View File
@@ -0,0 +1,246 @@
/*
* 模块名称:Application Main Entry
* 模块功能:应用初始化流程编排、各模块启动、主循环
* 适用平台:STM32F407ZGTx
* 作者:王建锋
* 创建日期:2026-07-19
* 修改记录:
* v1.0 2026-07-19 王建锋 创建初始版本
* v1.1 2026-08-29 王建锋 将原 main.c 中各外设初始化统一收归 app_main_init,
* 并按"时间子系统优先"的顺序编排启动流程
* v1.2 2026-09-14 王建锋 新增自检埋点(RTC/NAND/ADC 失败 → sys_log 自检记录)+ syslog_mcu_init()
*/
#include "app_main.h"
#include "app_cfg.h"
#include "main.h" /* ST_DIR4_GPIO_Port / ST_DIR4_Pin 等 CubeMX 宏 */
#include "stm32f4xx_it.h" /* fault_handler_enable() */
#include "usart.h" /* huart5 */
#include "tim.h" /* htim6:1 kHz 采样节拍(TIM6 更新中断触发 CONVST) */
#include "rs485.h"
#include "sd2506.h"
#include "sys_clock.h"
#include "gd5f2gq5ue.h"
#include "tpafe5160.h"
#include "adc_input.h" /* L1 采集层:adc_input_init()(语义映射 + STM32 ADC1 扫描) */
#include "sys_log.h"
#include "task_watchdog.h" /* task_wd_seg_report():round 8 复位不丢"进段打点"上报 */
#define DBG_TAG "[APP]"
#include "dbg_log.h"
/* g_rs485 由 rs485.c 定义,经 rs485.h 的 extern 引用;接收缓冲内聚于句柄 rx_buf */
/* 复位原因锁存值(Src/main.c 在 HAL_Init() 后立即写入并清除 RCC_CSR)。
* 放这里而非 main.c,是因为需要与其它应用级全局量同层、且日志侧更好引用。 */
volatile uint32_t g_rcc_csr_latched;
/*
* 函数功能:把 RCC_CSR 锁存值翻译为可读的复位原因串(见手册 RM0090 §6.3.22)
* 入口参数:csr - 锁存的 RCC_CSR 值
* buf - 输出缓冲
* len - 缓冲容量
* 返回值:无
* 函数说明:按"最可信归因"的优先级组织——IWDG/WWDG 优先于软件复位
* (看门狗复位是我们真正关心的自动恢复事件;软件复位多为崩溃钩子
* 兜底,两者同时置位时报告看门狗更有价值)。纯 ASCII,防编码坑。
*/
static void reset_cause_str(uint32_t csr, char *buf, unsigned int len) {
const char *cause;
if ((csr & RCC_CSR_IWDGRSTF) != 0u) {
cause = "IWDG (independent watchdog)";
} else if ((csr & RCC_CSR_WWDGRSTF) != 0u) {
cause = "WWDG (window watchdog)";
} else if ((csr & RCC_CSR_SFTRSTF) != 0u) {
cause = "software (NVIC_SystemReset)";
} else if ((csr & RCC_CSR_PORRSTF) != 0u) {
cause = "power-on (POR)";
} else if ((csr & RCC_CSR_PINRSTF) != 0u) {
cause = "external pin (NRST)";
} else if ((csr & RCC_CSR_BORRSTF) != 0u) {
cause = "brown-out (BOR)";
} else {
cause = "unknown";
}
/* 简单有界拼接(不引 snprintf,避免依赖与栈开销) */
if (buf == NULL || len == 0u) {
return;
}
{
unsigned int i = 0u;
while ((i < (len - 1u)) && (cause[i] != '\0')) {
buf[i] = cause[i];
i++;
}
buf[i] = '\0';
}
}
/*
* 函数功能:应用层初始化
* 入口参数:无
* 返 回 值:0 - 成功,-1 - 失败
* 限定条件:CubeMX 外设初始化已完成
* 函数说明:在 CubeMX 外设初始化之后、FreeRTOS 调度器启动之前调用。
* 启动顺序以"时间子系统优先"为原则,使后续所有日志均带时间戳:
* 1) 时间子系统(SD2506 RTC → 墙钟基准)
* 2) RS-485
* 3) GD5F NAND
* 4) TPAFE5160 ADC → ADC 输入层(L1,语义映射 + STM32 ADC1)
* 5) SD2506 诊断信息
* 6) 启动 TIM6 1 kHz 采样节拍(ISR 内触发 CONVST)
*/
int app_main_init(void) {
/* 0) 使能 MemManage / BusFault / UsageFault(复位后默认只有 HardFault 使能)。
* 必须尽早——放在所有业务初始化**之前**,这样从第一行业务代码起,
* 任何内存/总线/非法指令类故障都会被独立捕获并打出故障名,
* 而不是被升级成 HardFault 后只看到笼统的 "HARDFAULT"。
* 纯寄存器写(SCB->SHCSR),不依赖日志/时钟,故放在最前无副作用。 */
fault_handler_enable();
/* 日志引擎初始化(创建待发队列;须先于任何 syslog_post) */
(void)syslog_mcu_init();
/* 启动锚点(2026-10-02 S10):本函数在内核启动前执行、不创建任何 RTOS 对象,
* 恒成功且无副作用;本行是"启动进度轨迹"的起点——之后的每一步都有结果日志,
* 故某次启动卡死时,最后一行日志即可指向卡住的步骤。 */
DBG_INFO("boot: syslog init OK");
/* 固件身份戳(2026-10-10):__DATE__/__TIME__ 由编译器展开,与前面的字面量做
* C 字符串拼接 → .rodata 里是一段连续 ASCII,可直接在 .hex 里原样搜到。
* 用途:@test 把"芯片里正在跑的镜像"和"刚编出来的镜像"对上号。此前开机标记
* 全是固定串(App init OK / Network Init / ...),旧镜像也照样全中,于是
* "反复重下载、芯片一直是旧镜像"这类事故在自动闭环里完全不可见
* (AGENTS.md 烧录铁律记的正是它)。代价:1 条 DBG ≈ 90 B Flash + 30 B 常量。 */
DBG_INFO("fw build: " __DATE__ " " __TIME__);
/* 0) 复位原因上报(锁存值由 Src/main.c 在 HAL_Init() 后立即采得)。
* 放在日志引擎之后、业务初始化之前:任何一次非上电复位都值得在
* 开机第一时间看到——尤其 IWDG,它意味着上一轮运行有任务卡死。
* 注意:RCC_CSR 已被 main.c 清零,本处只读锁存副本。 */
{
char cause[40];
uint32_t csr = g_rcc_csr_latched;
reset_cause_str(csr, cause, sizeof(cause));
DBG_INFO("reset cause: %s (CSR=0x%08lX)", cause, (unsigned long)csr);
/* 看门狗复位记入自检记录(只记载故障,符合既有约定) */
if ((csr & (RCC_CSR_IWDGRSTF | RCC_CSR_WWDGRSTF)) != 0u) {
syslog_post(SYSLOG_SELFTEST, "watchdog reset");
}
}
/* 0b) round 8 取证:上报上一轮 defaultTask 主循环的**最后进段**。
* 轨迹槽在 .noinit(scatter UNINIT_CCM),IWDG/引脚复位不清 SRAM →
* 误复位后开机第一时间可见 `PREVRUN seg=N`,直接指向主循环阻塞点
* (round 7 观察"判定分支未执行"经 round 8b 更正:真因是 IWDG 超时被静默
* 配成复位默认值,见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录)。
* 裸 UART 直发,且一次复位只报一次(内部清 magic)。 */
task_wd_seg_report();
/*
* 1) 时间子系统初始化(尽早完成,后续所有日志带时间戳)
* sd2506_init() 从 RTC 读取初始时间
* sys_clock_init() 将 RTC 时间转为 Unix epoch,建立墙钟基准
* S10(2026-10-02):sd2506_init 原先所有失败路径静默返回,是启动段
* 唯一无日志的初始化;现内部已逐路径打 DBG_ERROR,此处再补一行
* 带错误码的结果回执,使 I2C 不通时能看到"在哪一步、什么错"。
*/
{
int rtc_ret = sd2506_init();
DBG_INFO("SD2506 init: %s (ret=%d)",
(rtc_ret == SD2506_OK) ? "OK" : "FAILED", rtc_ret);
}
if (sys_clock_init() != 0) {
syslog_post(SYSLOG_SELFTEST, "clock chip fault");
}
/* 2) RS-485 初始化(UART4 + PD0 方向控制),初始化即自动开启接收 */
rs485_init(&g_rs485, &huart4, ST_DIR4_GPIO_Port, ST_DIR4_Pin);
DBG_INFO("RS485 init: OK (UART4, DE=PA15)");
/* 3) GD5F 初始化测试(BBT 全片扫描,启动段最耗时的一步) */
int ret = gd5f2gq5ue_init();
DBG_INFO("NAND init: %s",
(ret == GD5F_OK) ? "OK" :
(ret == GD5F_ERROR) ? "ERROR" :
(ret == GD5F_BUSY_TIMEOUT) ? "BUSY TIMEOUT" :
(ret == GD5F_ECC_ERROR) ? "ECC ERROR" :
(ret == GD5F_PROGRAM_FAIL) ? "PROGRAM FAIL" :
(ret == GD5F_ERASE_FAIL) ? "ERASE FAIL" :
(ret == GD5F_ID_MISMATCH) ? "ID MISMATCH" : "UNKNOWN");
if (ret != GD5F_OK) {
syslog_post(SYSLOG_SELFTEST, "NAND fault");
}
/* 4) TPAFE5160 ADC 测试 */
int tp_ret = tpafe5160_init();
DBG_INFO("ADC init: %s",
(tp_ret == TPAFE5160_OK) ? "OK" : "TIMEOUT");
if (tp_ret != TPAFE5160_OK) {
syslog_post(SYSLOG_SELFTEST, "ADC fault");
}
/* 4b) ADC 输入层(L1)初始化:清原始码值缓冲与统计 + **语义映射表自洽性自检**
* (映射指向了不会被扫描的通道 → 返回 -1,避免"读到恒为陈旧值"的静默错)。
* STM32 ADC1 外设已由 MX_ADC1_Init()(main.c)配好 9 通道 / 144 cycles /
* 软件触发,本层不重复配置、也不启动转换(软件触发按拍扫描)。
* 须在启动采样节拍(步骤 6)之前。 */
if (adc_input_init() != 0) {
DBG_ERROR("ADC input layer: map self-check FAILED");
syslog_post(SYSLOG_SELFTEST, "ADC map fault");
} else {
DBG_INFO("ADC input layer init: OK");
}
/* 5) SD2506 RTC 诊断信息 */
{
/* 读取 ID */
uint8_t sd_id[8];
sd2506_get_id(sd_id);
DBG_INFO("SD2506 ID: %02X%02X%02X%02X%02X%02X%02X%02X",
sd_id[0], sd_id[1], sd_id[2], sd_id[3],
sd_id[4], sd_id[5], sd_id[6], sd_id[7]);
/* 读取当前时间 */
sd2506_time_t sd_time;
sd2506_get_time(&sd_time);
DBG_INFO("SD2506 time: %04d-%02d-%02d %02d:%02d:%02d (week %d)",
sd_time.year, sd_time.month, sd_time.day,
sd_time.hour, sd_time.minute, sd_time.second,
sd_time.week);
/* 读取温度 */
int8_t sd_temp;
sd2506_get_temperature(&sd_temp);
DBG_INFO("SD2506 temp: %d C", sd_temp);
/* 读取电池电压 */
uint16_t sd_vbat;
sd2506_get_battery_voltage(&sd_vbat);
DBG_INFO("SD2506 battery: %d.%02d V",
sd_vbat / 1000, (sd_vbat % 1000) / 10);
}
/* 6) 启动 1 kHz 采样节拍:TIM6 更新中断 → ISR 内触发 CONVST(Src/main.c)。
* ⚠ 必须在 tpafe5160_init() 成功之后启动,否则 ISR 会在驱动首帧自检
* (E-44 有效性探针)期间乱发 CONVST。消费端 adcTask 退化为纯消费者。 */
(void)HAL_TIM_Base_Start_IT(&htim6);
DBG_INFO("App init OK");
return 0;
}
/*
* 函数功能:应用主循环
* 入口参数:无
* 返 回 值:无
* 限定条件:FreeRTOS 调度器启动后不再返回
* 函数说明:空闲任务可调用的应用层处理入口
*/
void app_main_run(void) {
for (;;) {
/* 应用层空闲处理 */
}
}
@@ -27,6 +27,10 @@ extern osEventFlagsId_t sys_evtHandle;
#define SYS_EVT_STORAGE_READY (1u << 1) /* defaultTask:FTL/FatFS 挂载完成 */
#define SYS_EVT_FTP_READY (1u << 2) /* ftpTask:lftpd_start 完成 */
#define SYS_EVT_TIMESYNC_READY (1u << 3) /* timeSyncTask:首次时间同步完成 */
#define SYS_EVT_NET_RECONFIG (1u << 4) /* UI:请求立即应用新网口配置(netTask 消费后重建网络) */
#define SYS_EVT_CFG_SAVE (1u << 5) /* UI:网口配置保存请求(defaultTask = 配置引擎属主,消费并写盘) */
#define SYS_EVT_CONSOLE_TX (1u << 6) /* 控制台单写者:生产者入环形缓冲后置位,defaultTask 消费并发送
* (消费型命令位,非就绪位——就绪位必须 osFlagsNoClear) */
#ifdef __cplusplus
}
-36
View File
@@ -1,36 +0,0 @@
/*
* 模块名称:Application Configuration
* 模块功能:应用层全局配置宏,控制功能模块的编译开关
* 适用平台:STM32F407ZGTx
* 作者:王建锋
* 创建日期:2026-07-19
* 修改记录:
* v1.0 2026-07-19 王建锋 创建初始版本
*/
#ifndef APP_CFG_H
#define APP_CFG_H
#ifdef __cplusplus
extern "C" {
#endif
/*
* 系统时钟源选择
* 0 - DWT CYCCNT(默认,0 外设开销)
* 1 - TIM2(32-bit 定时器,独立于 DWT)
*/
#define APP_SYS_TIME_SRC 0
/*
* 功能模块使能开关
*/
#define APP_NET_ENABLE
#define APP_ADC_ENABLE
#define APP_RS485_ENABLE
#ifdef __cplusplus
}
#endif
#endif /* APP_CFG_H */
-119
View File
@@ -1,119 +0,0 @@
/*
* 模块名称:Application Main Entry
* 模块功能:应用初始化流程编排、各模块启动、主循环
* 适用平台:STM32F407ZGTx
* 作者:王建锋
* 创建日期:2026-07-19
* 修改记录:
* v1.0 2026-07-19 王建锋 创建初始版本
* v1.1 2026-08-29 王建锋 将原 main.c 中各外设初始化统一收归 app_main_init,
* 并按"时间子系统优先"的顺序编排启动流程
*/
#include "app_main.h"
#include "app_cfg.h"
#include "main.h" /* ST_DIR4_GPIO_Port / ST_DIR4_Pin 等 CubeMX 宏 */
#include "usart.h" /* huart5 */
#include "rs485.h"
#include "sd2506.h"
#include "sys_clock.h"
#include "gd5f2gq5ue.h"
#include "tpafe5160.h"
#define DBG_TAG "[APP]"
#include "dbg_log.h"
/* g_rs485 由 rs485.c 定义,经 rs485.h 的 extern 引用;接收缓冲内聚于句柄 rx_buf */
/*
* 函数功能:应用层初始化
* 入口参数:无
* 返 回 值:0 - 成功,-1 - 失败
* 限定条件:CubeMX 外设初始化已完成
* 函数说明:在 CubeMX 外设初始化之后、FreeRTOS 调度器启动之前调用。
* 启动顺序以"时间子系统优先"为原则,使后续所有日志均带时间戳:
* 1) 时间子系统(SD2506 RTC → 墙钟基准)
* 2) RS-485
* 3) GD5F NAND
* 4) TPAFE5160 ADC
* 5) SD2506 诊断信息
* 6) 触发首次 ADC 转换
*/
int app_main_init(void) {
/*
* 1) 时间子系统初始化(尽早完成,后续所有日志带时间戳)
* sd2506_init() 从 RTC 读取初始时间
* sys_clock_init() 将 RTC 时间转为 Unix epoch,建立墙钟基准
*/
sd2506_init();
sys_clock_init();
/* 2) RS-485 初始化(UART5 + PD0 方向控制),初始化即自动开启接收 */
rs485_init(&g_rs485, &huart5, ST_DIR4_GPIO_Port, ST_DIR4_Pin);
DBG_INFO("RS485 init: OK (UART5, DE=PD0)");
/* 3) GD5F 初始化测试 */
int ret = gd5f2gq5ue_init();
DBG_INFO("NAND init: %s",
(ret == GD5F_OK) ? "OK" :
(ret == GD5F_ERROR) ? "ERROR" :
(ret == GD5F_BUSY_TIMEOUT) ? "BUSY TIMEOUT" :
(ret == GD5F_ECC_ERROR) ? "ECC ERROR" :
(ret == GD5F_PROGRAM_FAIL) ? "PROGRAM FAIL" :
(ret == GD5F_ERASE_FAIL) ? "ERASE FAIL" :
(ret == GD5F_ID_MISMATCH) ? "ID MISMATCH" : "UNKNOWN");
/* 4) TPAFE5160 ADC 测试 */
int tp_ret = tpafe5160_init();
DBG_INFO("ADC init: %s",
(tp_ret == TPAFE5160_OK) ? "OK" : "TIMEOUT");
/* 5) SD2506 RTC 诊断信息 */
{
/* 读取 ID */
uint8_t sd_id[8];
sd2506_get_id(sd_id);
DBG_INFO("SD2506 ID: %02X%02X%02X%02X%02X%02X%02X%02X",
sd_id[0], sd_id[1], sd_id[2], sd_id[3],
sd_id[4], sd_id[5], sd_id[6], sd_id[7]);
/* 读取当前时间 */
sd2506_time_t sd_time;
sd2506_get_time(&sd_time);
DBG_INFO("SD2506 time: %04d-%02d-%02d %02d:%02d:%02d (week %d)",
sd_time.year, sd_time.month, sd_time.day,
sd_time.hour, sd_time.minute, sd_time.second,
sd_time.week);
/* 读取温度 */
int8_t sd_temp;
sd2506_get_temperature(&sd_temp);
DBG_INFO("SD2506 temp: %d C", sd_temp);
/* 读取电池电压 */
uint16_t sd_vbat;
sd2506_get_battery_voltage(&sd_vbat);
DBG_INFO("SD2506 battery: %d.%02d V",
sd_vbat / 1000, (sd_vbat % 1000) / 10);
}
/* 6) 启动第一次转换(CubeMX 已配置 BUSY EXTI 中断) */
tpafe5160_start_conv_irq();
DBG_INFO("App init OK");
return 0;
}
/*
* 函数功能:应用主循环
* 入口参数:无
* 返 回 值:无
* 限定条件:FreeRTOS 调度器启动后不再返回
* 函数说明:空闲任务可调用的应用层处理入口
*/
void app_main_run(void) {
for (;;) {
/* 应用层空闲处理 */
}
}
+28
View File
@@ -0,0 +1,28 @@
# App/cfg/ — 配置引擎
> **`/config.ini` 配置引擎**:分层解析、持久化(原子写)。
> 属主 = **defaultTask**(单任务收敛,静态态);其它任务不得直接触碰。
## 文件一览
| 文件 | 作用 |
|---|---|
| `cfg_file.c/h` | **配置引擎核心**:解析 / 查询 / 保存 / 2s 周期 `cfg_file_poll` |
| `cfg_file_store.h` | **存储后端抽象接口**(MCU=FreeRTOS 堆 / 模拟器=宿主堆,共享核心零分配符号) |
| `cfg_file_store_mcu.c` | **MCU 端后端实现**:`/config.ini`(临时名由目标路径同目录推导 + `f_rename` 原子写) |
## 设计要点
- **单任务收敛**:`cfg_file` 为静态态,只有 defaultTask 操作;UI 保存请求经
`SYS_EVT_CFG_SAVE` 事件唤醒 defaultTask 执行(netTask/uiTask 不得直接调用)
- **零分配共享核心**:核心逻辑不含任何分配符号,动态内存收进平台后端
(`cfg_store_alloc/free`:MCU→`pvPortMalloc`,模拟器→宿主堆)
- **原子写**:写 `/config.TMP` 后 `f_rename` 替换,避免掉电损坏
- **旧档迁移已删除(2026-10-10)**:旧格式 `/cfg/netcfg.bin` 不再读取——存在则被忽略(不读、
不删、不迁移),配置回落工厂默认;`net_cfg_legacy.c/h` 与 `cfg_store_legacy_path()` 已移除
- **自研代码禁用 C 库堆分配**:MicroLIB 堆仅 512B(`malloc` 必返回 NULL)
## 相关文档
- `docs/子系统设计/配置文件设计说明.md` — 完整设计(分层/解析/持久化)
- `docs/子系统设计/网络配置说明.md` — 网络段
+1343
View File
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
/*
* 模块名称:cfg_file
* 模块功能:通用配置文件引擎(双端共享核心)公共接口——INI 段/键表解析校验、
* 外科手术式保存(保留注释/未知内容,缺失段/键写回)、出厂模板生成、
* 沉降去抖轮询与分段变更回调;平台文件操作经 cfg_file_store 后端
* 适用平台:STM32F407ZGTx / PC 模拟器(两端共用)
* 作者:王建锋
* 创建日期:2026-09-14
* 修改记录:
* v1.0 2026-09-14 王建锋 创建初始版本(P3:段键表引擎 + net_cfg 集成)
* v1.1 2026-09-15 王建锋 属主任务由 netTask 改为 defaultTask(配置引擎单一属主,P1-3)
* v1.2 2026-09-21 王建锋 E-01(错误码传播审计 P0):超长守卫契约同步——poll 沉降
* 后先按真实 st.size 判超长(旧实现查截断后读长恒假)、
* save 输出上限收紧至 CFG_FILE_MAX_LEN、写盘唯一闸口拒写
* 超长;超长处置补自检留痕
*/
#ifndef CFG_FILE_H
#define CFG_FILE_H
#ifdef __cplusplus
extern "C" {
#endif
#include <stdint.h>
#include <stddef.h>
/* 配置文件长度上限(字节);超过 = 文件损坏,按出厂模板重建
* (load 与 poll 同判,E-01:poll 在读取前基于真实 st.size 判定) */
#define CFG_FILE_MAX_LEN 4096u
/* 引擎可注册段数上限 */
#define CFG_FILE_MAX_SECTIONS 8u
/* 单段键数上限 */
#define CFG_FILE_MAX_KEYS 16u
/* 沉降去抖:变更基线连续 N 次轮询不变才重载(轮询周期 2s → 4s 沉降) */
#define CFG_FILE_SETTLE_CNT 2u
/*
* 键值类型
* UINT:无符号十进制;len = 目标字段字节数(1/2/4,小端)
* IP4 :点分 IPv4 "a.b.c.d";字段 uint8_t[4]
* MAC :十六进制 MAC "xx:xx:xx:xx:xx:xx"(分隔符兼容 - 与 .);字段 uint8_t[6]
* STR :ASCII 字符串;字段 char[len](len = 缓冲长度,自动补 NUL)
*/
typedef enum {
CFG_VAL_UINT = 0,
CFG_VAL_IP4,
CFG_VAL_MAC,
CFG_VAL_STR
} cfg_val_type_t;
/* 键定义(offset/len 均相对段 RAM 副本起始) */
typedef struct {
const char *name; /* 键名(小写) */
cfg_val_type_t type; /* 值类型 */
size_t offset; /* 字段偏移 */
size_t len; /* 字段长度 */
} cfg_key_def_t;
/* 段注册描述符 */
typedef struct {
const char *name; /* 段名(≤ 15 字符) */
void *p_data; /* 段 RAM 副本(模块自有静态区,引擎原地更新) */
const void *p_defaults; /* 工厂默认值(与 p_data 同布局;load 先复位再解析) */
size_t data_size; /* RAM 副本字节数 */
const cfg_key_def_t *p_keys; /* 键表 */
uint8_t key_count; /* 键数(1 ~ CFG_FILE_MAX_KEYS) */
void (*p_cb)(void); /* 变更回调(可空;poll 重载后该段有差异时调用;
注册段 data_size 须 ≤ 64 字节方可挂回调) */
} cfg_section_reg_t;
/*
* 函数功能:注册配置段(引擎建立"段 → RAM 副本 + 键表"映射)
* 入口参数:p_reg - 段注册描述符(不可为 NULL) const cfg_section_reg_t *
* 返回值:0 - 成功;-1 - 参数非法/段名重复/段数超限 int
* 限定条件:启动早期调用一次(重复注册同段名拒绝);
* 注册后 p_data/p_defaults/p_keys 生命周期须覆盖引擎使用期
*/
int cfg_file_register(const cfg_section_reg_t *p_reg);
/*
* 函数功能:从配置文件加载全部段(文件 = 配置唯一来源)
* 入口参数:无
* 返回值:0 - 正常(含回退工厂默认);-1 - 严重错误(RAM 已置工厂默认,仍安全) int
* 限定条件:至少注册一个段
* 函数说明: 1. 各段先复位工厂默认(文件缺失/损坏的键 = 工厂默认)
* 2. 文件缺失 → 按当前 RAM 副本生成出厂模板写盘(首启建文件)
* 3. 文件存在 → 解析:有效键写入 RAM 副本;无效键留工厂默认并留痕
* 4. 文件超长(> CFG_FILE_MAX_LEN)= 损坏 → 工厂默认 + 重建模板
* + 自检留痕(poll 检出运行期超长文件执行同一契约,E-01)
*/
int cfg_file_load(void);
/*
* 函数功能:保存全部段到配置文件(外科手术式)
* 入口参数:无
* 返回值:0 - 成功;-1 - 写失败(原文件保持不动) int
* 限定条件:至少注册一个段
* 函数说明:文件存在 → 逐行保留注释/未知段/未知键,受管段/键按 RAM 副本
* 值原位改写,缺失段/键补写;文件缺失/损坏 → 按 RAM 副本重建
* 完整文档(含文件头注释);写盘 = 临时文件 + 原子改名;输出超
* CFG_FILE_MAX_LEN → 拒绝保存返回 -1(原文件不动 + 自检上报,
* 引擎写盘闸口同拒,E-01)
*/
int cfg_file_save(void);
/*
* 函数功能:配置文件变更轮询(沉降去抖 + 重载 + 分段变更回调)
* 入口参数:无
* 返回值:无
* 限定条件:由配置引擎属主任务周期调用(建议 2s;MCU = defaultTask,
* 模拟器 = 主循环);引擎静态状态无跨任务保护,属主唯一即无竞态
* 函数说明:(size, mtime 变更令牌) 相对基线变化且连续 CFG_FILE_SETTLE_CNT
* 次轮询稳定 → 先查 st.size 超 CFG_FILE_MAX_LEN = 损坏 → 工厂默认
* + 重建模板 + 自检留痕(不触发段回调:保留在线修复通道,重启
* 收敛,E-01);否则重载全部段(各段复位工厂默认后解析);挂回调
* 段有差异 → 调用其回调(如触发网络重建);重载后刷新基线
* (回调触发的写盘不引发二次重载)
*/
void cfg_file_poll(void);
/*
* 函数功能:取段 RAM 副本指针
* 入口参数:p_name - 段名 const char *
* 返回值:段 RAM 副本指针;未注册返回 NULL void *
* 限定条件:无
*/
void *cfg_file_section_data(const char *p_name);
#ifdef __cplusplus
}
#endif
#endif /* CFG_FILE_H */
+113
View File
@@ -0,0 +1,113 @@
/*
* 模块名称:cfg_file_store
* 模块功能:cfg_file 平台存储后端接口——带路径的文件操作抽象;
* MCU 实现 = NAND 上 FatFS(cfg_file_store_mcu.c),
* 模拟器实现 = 宿主文件系统(cfg_file_store_sim.c)
* 适用平台:双端接口(实现分居两端)
* 作者:王建锋
* 创建日期:2026-09-14
* 修改记录:
* v1.0 2026-09-14 王建锋 创建初始版本
*/
#ifndef CFG_FILE_STORE_H
#define CFG_FILE_STORE_H
#ifdef __cplusplus
extern "C" {
#endif
#include <stdint.h>
#include <stddef.h>
/*
* 文件变更基线:size + mtime 变更令牌
* mtime 仅作等值比较(不等 = 可能变更):
* MCU 端 = FAT 打包时间(fdate<<16 | fmtime,分钟级粒度)
* 模拟器 = 宿主文件 mtime(秒级)
*/
typedef struct {
uint32_t size;
uint32_t mtime;
} cfg_file_stat_t;
/*
* 函数功能:分配共享核心临时缓冲(由平台后端提供:MCU = FreeRTOS 堆 pvPortMalloc,
* 模拟器 = 宿主 CRT malloc)
* 入口参数:size - 字节数 size_t
* 返回值:缓冲指针(内容未清零);失败返回 NULL void *
* 限定条件:必须与 cfg_store_free 配对;共享核心(cfg_file.c / net_cfg.c)禁止直接
* 调用 C 库 malloc——MCU 端 MicroLIB C 堆仅 512 B(startup Heap_Size=0x200),
* KB 级分配必然失败,一律经本接口取 FreeRTOS 堆
*/
void *cfg_store_alloc(size_t size);
/*
* 函数功能:释放 cfg_store_alloc 分配的临时缓冲
* 入口参数:p_buf - 缓冲指针(NULL 安全) void *
* 返回值:无
* 限定条件:p_buf 必须来自 cfg_store_alloc(不得跨分配器释放、不得释放栈地址)
*/
void cfg_store_free(void *p_buf);
/*
* 函数功能:当前空闲堆字节数(分配失败诊断用,非精确预算)
* 入口参数:无
* 返回值:空闲字节数;模拟器不跟踪宿主堆,恒返回 0 uint32_t
* 限定条件:无
*/
uint32_t cfg_store_heap_free(void);
/*
* 函数功能:读文件(最多 cap 字节)
* 入口参数:p_path - 文件路径 const char *
* p_buf - 接收缓冲(不可为 NULL) uint8_t *
* cap - 缓冲容量 size_t
* p_nread- 实际读字节数输出(不可为 NULL) size_t *
* 返回值:0 - 成功;-1 - 文件缺失/读失败 int
* 限定条件:调用方保证 p_buf 容量 ≥ 所需长度(引擎读上限 CFG_FILE_MAX_LEN)
*/
int cfg_store_read(const char *p_path, uint8_t *p_buf, size_t cap, size_t *p_nread);
/*
* 函数功能:原子写文件(先写临时文件,成功后改名覆盖目标)
* 入口参数:p_path - 目标文件路径 const char *
* p_buf - 待写数据(不可为 NULL) const uint8_t *
* len - 数据长度 size_t
* 返回值:0 - 成功;-1 - 写/改名失败(目标文件保持不动,临时文件清理) int
* 限定条件:中途失败不得损坏目标文件(掉电安全)
*/
int cfg_store_write_atomic(const char *p_path, const uint8_t *p_buf, size_t len);
/*
* 函数功能:文件状态(size + mtime 变更令牌)
* 入口参数:p_path - 文件路径 const char *
* p_st - 输出(不可为 NULL) cfg_file_stat_t *
* 返回值:0 - 成功;-1 - 文件缺失/读失败 int
* 限定条件:无
*/
int cfg_store_stat(const char *p_path, cfg_file_stat_t *p_st);
/*
* 函数功能:删除文件
* 入口参数:p_path - 文件路径 const char *
* 返回值:0 - 成功;-1 - 文件缺失/删除失败 int
* 限定条件:无
*/
int cfg_store_remove(const char *p_path);
/* 函数功能:取配置文件路径(如 MCU "/config.ini"、模拟器 "sim_config.ini") */
const char *cfg_store_config_path(void);
/* 函数功能:取配置文件临时文件路径(write_atomic 内部用) */
const char *cfg_store_tmp_path(void);
/* 注:旧版遗留档 getter `cfg_store_legacy_path()` 已于 2026-10-10 随旧档迁移
* net_cfg_legacy 一并删除(旧格式 /cfg/netcfg.bin 不再兼容、不再读取) */
#ifdef __cplusplus
}
#endif
#endif /* CFG_FILE_STORE_H */
+279
View File
@@ -0,0 +1,279 @@
/*
* 模块名称:cfg_file_store (MCU 后端)
* 模块功能:cfg_file 存储后端——NAND 上 FatFS 文件 /config.ini(卷根,8.3 合规);
* 原子写 = 临时文件 config.TMP(与目标同目录)+ f_rename;mtime 变更令牌 =
* (fdate << 16) | fmtime
* 适用平台:STM32F407ZGTx
* 作者:王建锋
* 创建日期:2026-09-14
* 修改记录:
* v1.0 2026-09-14 王建锋 创建初始版本
* v1.1 2026-09-18 王建锋 增 cfg_store_alloc / cfg_store_free / cfg_store_heap_free:
* 共享核心临时缓冲改走 FreeRTOS 堆(P0-1,MicroLIB C 堆仅 512B)
* v1.2 2026-09-22 王建锋 原子写修复:rename 前先 unlink 已存在目标——本 FatFs
* 移植 f_rename 不覆盖既有目标(FR_EXIST),旧实现一切
* "目标存在的覆盖写"必然失败(E-01 台架验证暴露)
* v1.3 2026-10-02 王建锋 四个后端函数纳入卷锁 try 语义(vol_lock_wait(0)):
* 与 ftpTask 的 lftpd 文件操作互斥(并发 FTL 写 →
* dhara journal 竞争实证);busy 返回 -1 由调用方
* 按既有失败语义处理(poll 保留 RAM/保存待重试),
* 本任务 = 看门狗喂狗点,绝不阻塞等锁
* v1.4 2026-10-05 王建锋 原子写临时文件由 p_path 推导(P3-2):与目标同目录的
* 固定 8.3 名 config.TMP,不再硬编码卷根;目标恒为
* "/config.ini" 时推导结果与旧值逐字节一致
*/
/* 头文件包含区 */
#include <string.h>
#include "FreeRTOS.h"
#include "cfg_file_store.h"
#include "ff.h"
#include "vol_lock.h"
/* 静态变量定义区 */
static const char s_path[] = "/config.ini";
static const char s_path_tmp[] = "/config.TMP";
/* 函数定义区 */
/*
* 函数功能:读文件(最多 cap 字节)
* 入口参数:p_path - 文件路径 const char *
* p_buf - 接收缓冲(不可为 NULL) uint8_t *
* cap - 缓冲容量 size_t
* p_nread - 实际读字节数输出(不可为 NULL) size_t *
* 返回值:0 - 成功;-1 - 文件缺失/读失败 int
* 限定条件:卷已挂载(未挂载时 f_open 返回 FR_NO_SYSTEM,转 -1)
* 函数说明:FIL 为**函数级 static**而非局部——本 FatFs 移植 FF_FS_TINY=0,
* `FIL` 内含 `buf[FF_MAX_SS]`=512 B(Lib/FatFs/ff.h),一个局部对象就吃掉
* 528 B 栈,是配置/syslog 调用链栈深的主要来源(2026-10-10 栈预算整改)。
* 安全性:整个 FIL 生命周期都在 `vol_lock_wait()/release()` 区间内,
* 跨任务访问被卷锁串行化,且本函数不递归、不嵌套自身
*/
int cfg_store_read(const char *p_path, uint8_t *p_buf, size_t cap, size_t *p_nread) {
static FIL f; /* static:省 528 B 栈;卷锁内串行使用 */
FRESULT fr;
UINT br = 0u;
if (p_buf == NULL || p_nread == NULL) {
return -1;
}
if (vol_lock_wait(0u) == 0u) {
return -1; /* 卷被占用:本轮跳过(调用方按读失败语义处理) */
}
fr = f_open(&f, p_path, FA_READ);
if (fr != FR_OK) {
vol_lock_release();
return -1;
}
fr = f_read(&f, p_buf, (UINT)cap, &br);
(void)f_close(&f);
if (fr != FR_OK) {
vol_lock_release();
return -1;
}
*p_nread = (size_t)br;
vol_lock_release();
return 0;
}
/* 临时文件名(8.3 合规)与路径缓冲上限 */
#define CFG_STORE_TMP_NAME "config.TMP"
#define CFG_STORE_TMP_PATH_MAX 24u
/*
* 函数功能:由目标路径推导同目录的临时文件路径(P3-2)
* 入口参数:p_path - 目标文件路径 const char *
* p_out - 输出缓冲 char *
* cap - 缓冲容量 size_t
* 返回值:0 - 成功;-1 - 参数非法/缓冲不足 int
* 函数说明:取 p_path 的目录前缀(含末尾 '/';无 '/' 则用当前目录),拼固定 8.3 名
* config.TMP。目标 "/config.ini" → "/config.TMP"(与旧硬编码值一致)
*/
static int s_build_tmp_path(const char *p_path, char *p_out, size_t cap) {
const char *p_slash;
size_t dirlen;
if (p_path == NULL || p_out == NULL) {
return -1;
}
p_slash = strrchr(p_path, '/');
dirlen = (p_slash != NULL) ? ((size_t)(p_slash - p_path) + 1u) : 0u;
if (dirlen + sizeof(CFG_STORE_TMP_NAME) > cap) {
return -1;
}
if (dirlen != 0u) {
(void)memcpy(p_out, p_path, dirlen);
}
(void)memcpy(p_out + dirlen, CFG_STORE_TMP_NAME, sizeof(CFG_STORE_TMP_NAME));
return 0;
}
/*
* 函数功能:原子写文件(先写与目标同目录的 config.TMP,成功后 unlink 既有目标 + f_rename 替换)
* 入口参数:p_path - 目标文件路径 const char *
* p_buf - 待写数据(不可为 NULL) const uint8_t *
* len - 数据长度 size_t
* 返回值:0 - 成功;-1 - 写/改名失败(目标文件保持不动,临时文件清理) int
* 限定条件:卷已挂载;中途失败(含掉电)不损坏目标文件
* 函数说明:`FIL` 用函数级 static 而非局部(FF_FS_TINY=0 → 单个 FIL = 528 B 栈);
* 与 cfg_store_read 各持自己的 static 对象,互不共享,且全程在卷锁区间内
*/
int cfg_store_write_atomic(const char *p_path, const uint8_t *p_buf, size_t len) {
static FIL f; /* static:省 528 B 栈;卷锁内串行使用 */
FRESULT fr;
UINT bw = 0u;
char tmp_path[CFG_STORE_TMP_PATH_MAX];
if (p_buf == NULL) {
return -1;
}
/* P3-2:临时文件由 p_path 推导(同目录 + 固定 8.3 名),不再硬编码卷根 */
if (s_build_tmp_path(p_path, tmp_path, sizeof(tmp_path)) != 0) {
return -1;
}
if (vol_lock_wait(0u) == 0u) {
return -1; /* 卷被占用:本轮跳过(保存请求由 2s 轮询重试) */
}
fr = f_open(&f, tmp_path, FA_WRITE | FA_CREATE_ALWAYS);
if (fr != FR_OK) {
vol_lock_release();
return -1;
}
fr = f_write(&f, (const void *)p_buf, (UINT)len, &bw);
(void)f_close(&f);
if (fr != FR_OK || bw != (UINT)len) {
(void)f_unlink(tmp_path);
vol_lock_release();
return -1;
}
/* 本 FatFs 移植的 f_rename 对已存在目标返回 FR_EXIST、不覆盖(ff.c
* "new name already in use" 检查,上游同语义)——"目标存在的覆盖写"必须先
* unlink 目标再 rename。2026-09-22 台架实证(E-01 验证暴露):缺本步时
* 一切非首启写(UI 保存/模板重建)静默失败,模拟器后端(POSIX rename
* 覆盖语义)照不出。unlink→rename 非原子窗口掉电 = 配置文件暂失,
* 开机 load 按"首启"重建工厂模板——与函数失败语义一致,丢失边界 =
* 本次进行中的保存内容(目标原值本就是待覆盖的旧代数据) */
(void)f_unlink(p_path); /* 目标不存在返回 FR_NO_FILE,无害 */
fr = f_rename(tmp_path, p_path);
if (fr != FR_OK) {
(void)f_unlink(tmp_path);
vol_lock_release();
return -1;
}
vol_lock_release();
return 0;
}
/*
* 函数功能:文件状态(size + mtime 变更令牌)
* 入口参数:p_path - 文件路径 const char *
* p_st - 输出(不可为 NULL) cfg_file_stat_t *
* 返回值:0 - 成功;-1 - 文件缺失/读失败 int
* 限定条件:无
* 函数说明:FAT 时间分钟级粒度——同分钟内同长度重写不可检出(可接受,
* 见《配置文件设计说明》§6.2 mtime 令牌说明)
*/
int cfg_store_stat(const char *p_path, cfg_file_stat_t *p_st) {
FILINFO fno;
FRESULT fr;
if (p_st == NULL) {
return -1;
}
if (vol_lock_wait(0u) == 0u) {
return -1; /* 卷被占用:调用方按"文件缺失/读失败"语义处理
(poll = 保留 RAM 配置,安全) */
}
fr = f_stat(p_path, &fno);
if (fr != FR_OK) {
vol_lock_release();
return -1;
}
p_st->size = (uint32_t)fno.fsize;
p_st->mtime = (uint32_t)(((uint32_t)fno.fdate << 16) | (uint32_t)fno.ftime);
vol_lock_release();
return 0;
}
/*
* 函数功能:删除文件
* 入口参数:p_path - 文件路径 const char *
* 返回值:0 - 成功;-1 - 文件缺失/删除失败 int
* 限定条件:无
*/
int cfg_store_remove(const char *p_path) {
if (vol_lock_wait(0u) == 0u) {
return -1;
}
if (f_unlink(p_path) != FR_OK) {
vol_lock_release();
return -1;
}
vol_lock_release();
return 0;
}
/*
* 函数功能:取配置文件路径
* 入口参数:无
* 返回值:"/config.ini"(卷根,8.3 合规:CONFIG + INI) const char *
* 限定条件:无
*/
const char *cfg_store_config_path(void) {
return s_path;
}
/*
* 函数功能:取配置文件临时文件路径
* 入口参数:无
* 返回值:"/config.TMP"(卷根,8.3 合规:CONFIG + TMP) const char *
* 限定条件:无
*/
const char *cfg_store_tmp_path(void) {
return s_path_tmp;
}
/*
* 旧版遗留文件 /cfg/netcfg.bin 的路径 getter(cfg_store_legacy_path)已于
* 2026-10-10 随旧档迁移 net_cfg_legacy 一并删除:无调用者即无必要保留
* (留着"看起来还支持旧档"的入口会误导后续维护)
*/
/*
* 函数功能:分配共享核心临时缓冲(FreeRTOS 堆)
* 入口参数:size - 字节数 size_t
* 返回值:缓冲指针(内容未清零);失败返回 NULL void *
* 限定条件:任务上下文调用(heap_4 内部临界区自护);释放必须用 cfg_store_free
* 函数说明:MCU 端 MicroLIB C 堆仅 512B(startup Heap_Size=0x200),共享核心的 KB 级
* 缓冲若走 C 库 malloc 必然返回 NULL,故统一由本接口取 FreeRTOS 堆(P0-1)
*/
void *cfg_store_alloc(size_t size) {
return pvPortMalloc(size);
}
/*
* 函数功能:释放 cfg_store_alloc 分配的临时缓冲
* 入口参数:p_buf - 缓冲指针(NULL 安全) void *
* 返回值:无
* 限定条件:p_buf 必须来自 cfg_store_alloc(禁止释放栈地址或 C 库指针)
*/
void cfg_store_free(void *p_buf) {
if (p_buf != NULL) {
vPortFree(p_buf);
}
}
/*
* 函数功能:FreeRTOS 堆当前空闲字节数(分配失败诊断)
* 入口参数:无
* 返回值:空闲字节数(未计入碎片影响) uint32_t
* 限定条件:无
*/
uint32_t cfg_store_heap_free(void) {
return (uint32_t)xPortGetFreeHeapSize();
}
+62
View File
@@ -0,0 +1,62 @@
/*
* 模块名称:Volume Lock(卷锁)— FreeRTOS 实现
* 模块功能:osMutex 串行化 NAND FatFs 卷访问(设计/使用约定见 vol_lock.h)
* 适用平台:STM32F407ZGTx + FreeRTOS
* 作者:王建锋
* 创建日期:2026-10-02
* 修改记录:
* v1.0 2026-10-02 王建锋 创建
*/
/* 头文件包含区 */
#include "vol_lock.h"
#include "cmsis_os.h"
/* 静态变量定义区 */
static osMutexId_t s_lock; /* 卷串行化互斥锁(vol_lock_init 创建) */
/* 函数定义区 */
/*
* 函数功能:创建卷串行化互斥锁(幂等,重复调用安全)
* 入口参数:无 void
* 返回值:0 - 成功(已创建或已存在);-1 - osMutexNew 失败 int
* 限定条件:可在内核启动后任意时刻调用;本实现不检查是否已启动调度器
*/
int vol_lock_init(void) {
if (s_lock != NULL) {
return 0; /* 幂等 */
}
s_lock = osMutexNew(NULL);
return (s_lock != NULL) ? 0 : -1;
}
/*
* 函数功能:获取卷锁(首次调用自动 vol_lock_init)
* 入口参数:timeout_ms - 等待上限(毫秒);0 = try 语义(不阻塞) uint32_t
* 返回值:1 - 已持锁;0 - 超时/未取得,或锁创建失败(fail-open) int
* 限定条件:返回 0 时调用方**不得** vol_lock_release;fail-open 是为保证
* 看门狗喂狗点(defaultTask)绝不能因等锁而阻塞
*/
int vol_lock_wait(uint32_t timeout_ms) {
if (s_lock == NULL) {
(void)vol_lock_init();
if (s_lock == NULL) {
return 1; /* 锁创建失败:fail-open(不阻塞,保持启动段可用) */
}
}
return (osMutexWait(s_lock, timeout_ms) == osOK) ? 1 : 0;
}
/*
* 函数功能:释放卷锁
* 入口参数:无 void
* 返回值:无 void
* 限定条件:仅在 vol_lock_wait() 返回 1 后调用;锁未创建时静默返回
*/
void vol_lock_release(void) {
if (s_lock != NULL) {
(void)osMutexRelease(s_lock);
}
}
+65
View File
@@ -0,0 +1,65 @@
/*
* 模块名称:Volume Lock(卷锁)
* 模块功能:NAND FatFs 卷的跨任务串行化锁——FatFs 卷状态(FS 窗口缓冲/FAT
* 缓存)与 FTL 核心状态(单页 RAM 缓存 + dhara journal)均非线程安全;
* defaultTask(syslog 落盘/配置引擎/存储 bring-up)与 ftpTask(lftpd
* 文件操作)对卷的访问必须在本锁下串行执行
* 适用平台:STM32F407ZGTx + FreeRTOS(CMSIS-RTOS V2)
* 作者:王建锋
* 创建日期:2026-10-02
* 修改记录:
* v1.0 2026-10-02 王建锋 创建(2026-10-02 台架实证:defaultTask 与
* ftpTask 并发 FTL 写 → dhara journal 竞争 →
* 长耗时恢复旋转饥饿 defaultTask 主循环(喂狗点)
* → IWDG 误复位;单写者卷属主假设在多任务 FatFs
* 调用下不成立,须显式串行化)
*
* 使用约定(铁律):
* - 锁跨越"一次完整文件操作序列"(open...close / opendir...closedir /
* 单发 stat/unlink/原子写),不得只锁单个 f_read/f_write;
* - defaultTask 调用方一律用非阻塞 try(vol_lock_wait(0)):defaultTask 是
* 看门狗喂狗点(task_wd_poll),阻塞等锁 = 主循环停摆 = 停喂 → IWDG 误复位;
* try 失败 = 本轮跳过(syslog 条目滞留队列/配置保存待下轮重试);
* - ftpTask 调用方用 100ms 分片阻塞等待 + 每片心跳(lftpd_task_wd_beat),
* 防长等待导致 FTP 心跳位缺席误复位;
* - 锁在内核启动后由 defaultTask 存储 bring-up 创建(vol_lock_init 幂等)。
*/
#ifndef VOL_LOCK_H
#define VOL_LOCK_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* 函数功能:创建卷锁(幂等)
* 入口参数:无
* 返回值:0 - 成功/已创建;-1 - 创建失败 int
* 限定条件:内核启动后调用(defaultTask 存储 bring-up 首行)
*/
int vol_lock_init(void);
/*
* 函数功能:获取卷锁
* 入口参数:timeout_ms - 等待超时(0 = 非阻塞 try) uint32_t
* 返回值:1 - 已获取;0 - 超时未获取(卷被占用) int
* 限定条件:见文件头使用约定(defaultTask 必须 timeout_ms=0)
*/
int vol_lock_wait(uint32_t timeout_ms);
/*
* 函数功能:释放卷锁(获取方任务释放)
* 入口参数:无
* 返回值:无
* 限定条件:必须与 vol_lock_wait 成对;未获取时调用无害(内部判空)
*/
void vol_lock_release(void);
#ifdef __cplusplus
}
#endif
#endif /* VOL_LOCK_H */
+34
View File
@@ -0,0 +1,34 @@
# App/log/ — 日志子系统
> 两类日志:**持久化运行日志**(`sys_log`,落 NAND)与**控制台日志**(`console_log`,走串口)。
> 两者都遵循**单写者**模型,避免多任务交错输出。
## 文件一览
| 文件 | 作用 |
|---|---|
| `sys_log.h` | **日志接口(平台无关)**:定长 64B 记录写入 `/log/run.log`·`selftest.log` |
| `sys_log_mcu.c` | `sys_log` 的 MCU 端后端实现(NAND + FatFs) |
| `console_log.c/h` | **控制台单写者日志**:多任务日志经队列汇入唯一写者,避免串口输出交错 |
## 关键机制
### syslog(运行/自检记录)
- **唯一写者 = defaultTask**;其它任务只投递,不直接写盘
- **单记录定长 64 B**,循环写;待机页可显示记录
- **内核启动前的日志**走**无锁静态缓冲** `s_preboot[8]` 暂存(因为内核启动前不能建队列),
内核启动后由 `syslog_drain` 惰性创建真队列并排空
- ⚠️ 写盘必须校验 `f_write` 返回的**字节数**(该 FatFs 移植在簇分配失败时返回 `FR_OK` + 短写,
不报错误码);追加写必须 `FA_WRITE|FA_OPEN_APPEND`(`FA_OPEN_APPEND` 单独用**不含写位**)
### console_log(控制台单写者)
- 多任务的生产者 → **单写者消费者**,消除任务间日志行交错
- 设计详见 `docs/子系统设计/控制台单写者日志设计说明.md`
## 相关文档
- `docs/子系统设计/控制台单写者日志设计说明.md`
- `docs/子系统设计/运行与自检记录设计说明.md`
- FatFs 陷阱:`AGENTS.md`「平台约束」
+498
View File
@@ -0,0 +1,498 @@
/*
* 模块名称:Console Single-Writer Logging — 控制台单写者日志(USART1)实现
* 模块功能:控制台单写者日志的环形缓冲 + 唯一写者发送实现(API 见 console_log.h);
* 兼载 MicroLIB printf 底层 fputc 重定向
* 适用平台:STM32F407ZGTx(仅 MCU 端)
* 作者:王建锋
* 创建日期:2026-09-24
* 修改记录:
* 2026-09-24 王建锋 创建(自 Src/usart.c USER CODE 区迁出,实现逻辑不变,
* 仅变更文件位置:HAL 库生成文件不得包含应用代码)
* 2026-10-02 王建锋 僵尸通道喂狗节奏保护(台架 gen-288 实证):round-3 的
* "单次 drain 预算 250ms" 只限单次调用——ORE 锁存在外设软复位后
* 毫秒级重锁(PA10 悬空 + RX 使能),背靠背 drain 每轮再耗 100ms
* 块超时 → 主循环周期拉到 450~500ms → 100ms 节流的喂狗抽样间隙
* 超过 IWDG 最坏容限 1.7s → 停喂 → 误复位(且心跳位全员健康、
* 无 stall 日志,纯节奏性饥饿)。修复:发送故障后立即结束本轮
* drain 并进入 300ms 发送冷却(冷却期内 drain 直接让位主循环),
* 保证喂狗节奏 ≤ ~300ms。根治(USART1 TX-only 灭 ORE 机制)
* 另行推进(.ioc 结构性修改 + CubeMX 重生成,见待处理内容①)。
* 2026-10-02 王建锋 round 8b 加固:发送超时判据改用 DWT CYCCNT(时间源无关)。
* 台架 round 8a 取证(见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录)定位到:defaultTask
* 冻结在 console 突发行内、无 STALL、复位为 IWDGRSTF。唯一自洽机理 =
* HAL_UART_Transmit 的超时判据 `HAL_GetTick() - Tickstart` 依赖
* TIM7 的 uwTick —— TIM7 一旦停摆,超时永不成立 → 写者死等 TXE →
* 唯一喂狗者停摆 → IWDG 复位。本改动给"裸发送"与"缓冲满等待"两处
* 都换成 DWT 周期计数硬上界(DWT 使能失败时退回旧 HAL 行为)。
* 另加 USART1 未初始化(huart1.Instance == NULL)短路:防止在
* HAL_Init 早期/断言-故障上报路径上解引用 NULL 读写地址 0。
* 2026-10-10 王建锋 新增 dbg_console_writer_ready() 查询:写者未注册时
* con_append 走无锁直发(前提是"此窗口只有 defaultTask 记日志"),
* adcTask(B1)等周期任务此时落日志会与 defaultTask 的存储初始化
* 日志字节级交错(台架实测 [ADC_TASK] 首帧行与 [NAND_FTL] 行交错)。
* 提供就绪查询供周期任务门控自身日志,不改直发路径本身。
*/
#include "console_log.h"
#include <stdio.h>
#include "usart.h"
#include "FreeRTOS.h"
#include "task.h"
#include "cmsis_os.h"
#include "sys_events.h"
/* 刻意不 include "sys_log.h":日志子系统不得回调本模块(调用图成环 →
* armlink 静态栈深在每个任务上虚高,见 dbg_console_drain 故障路径注释)。
* TX 故障留痕改由写者任务在 drain 之外补记 */
/* ===== 控制台单写者日志(2026-09-24 加固,HardFault 定位结论驱动)=====
* 背景:旧路径 DBG_* → printf → fputc **每字符一次** HAL_UART_Transmit。HAL 的锁只保证
* "单字节"原子,任务切换发生在字符之间 → 两行日志逐字符交错(2026-09-24 崩溃前
* 实测:"[2026-[2026-0909-24 15:2-24 15:27:37:39]…");更严重的是字符串指针一旦
* 被破坏,脏指针会直接进入 UART 缓冲遍历(同日 HardFault:BFAR=0x10010000 恰为
* CCM 末尾+1,PC 落在 HAL_UART_Transmit 的缓冲读取处)。
* 架构:**defaultTask 是唯一串口发送者**;其余任务/上下文只把文本写入环形缓冲。
* ① 行级原子:整行在一次临界区内拷入环形缓冲,写者按块一次 HAL_UART_Transmit
* 发出 → 行与行不可能再交错(UART 只有这一个调用点);
* ② 指针可控:进 UART 的缓冲恒为本文件的环形缓冲,调用方脏指针再也到不了 UART;
* ③ 写者未注册(内核启动早期/裸机)或中断上下文:回退直接阻塞发送(无并发或必须裸发)。
* 约束:不新增任务、不动态分配;环形缓冲为 BSS(SRAM1,非 CCM),无 DMA 可达性冲突。
*/
#define DBG_CON_RING_SIZE 4096U /* 环形缓冲字节数(必须为 2 的幂) */
#define DBG_CON_CHUNK_MAX 256U /* 写者单次 HAL_UART_Transmit 字节上限 */
#define DBG_CON_WAIT_MS 200U /* 缓冲满时生产者最长等待(ms),仍满则丢弃并计数 */
#define DBG_CON_TX_TIMEOUT_MS 100U /* 单次发送超时:256B 块 @115200 标称 22ms;僵尸通道限时 + 自愈 */
#define DBG_CON_DRAIN_BUDGET_MS 250U /* 单次 drain 连续发送上限:让位喂狗轮询(缺失容忍 800ms) */
#define DBG_CON_FAULT_COOL_MS 300U /* 故障后发送冷却:ORE 锁存毫秒级重锁,背靠连发无益
且拖长主循环周期 → 喂狗节奏饥饿(gen-288 实证);
冷却期内 drain 只让位、不发送,下轮重试 */
/* ==== round 8b:与时间源无关的硬上界(DWT CYCCNT)====
* HAL_UART_Transmit 的超时判据是 `HAL_GetTick() - Tickstart > Timeout`,而 HAL 时基
* = TIM7 的 uwTick。TIM7 停摆 → 超时永不成立 → 写者死等 TXE → 唯一喂狗者停摆 →
* IWDG 误复位(round 8a 台架现场)。改用 DWT CYCCNT(内核硬件周期计数,不依赖任何
* 中断/uwTick)做判据;DWT 使能失败时退回 HAL_UART_Transmit(旧行为,功能不退化)。 */
#define CON_TX_TIMEOUT_CYC (SystemCoreClock / 10U) /* 发送硬上界 = 100ms 对应周期数 */
/*
* DWT 不可用时的**指令计数**上界(替代 HAL_UART_Transmit 回退,2026-10-10)。
* 为什么不再回退 HAL:① HAL_UART_Transmit 的超时判据是 HAL_GetTick——断言/停摆
* 现场 tick 未必在走,等待无上界;② 它的调用链 `HAL_UART_Transmit →
* UART_WaitOnFlagUntilTimeout` 会被 armlink 挂进**每个**碰日志的任务的最坏链
* (configASSERT 在 FreeRTOS 内部可达 → 三个内核钩子都在链尾),实测每条链多背
* 64 B 栈。计数上界同样有界、零外部依赖。
* 取值:115200 bps 下一字节 87 µs;轮询循环 ~5 周期 → 每字节约 3000 次迭代,
* 取 20000 留 6 倍余量(单字节卡死最多数毫秒级即放弃,不无限自旋)
*/
#define CON_TX_GUARD_MAX 20000u
#define CON_APPEND_WAIT_CYC (SystemCoreClock / 5U) /* 缓冲满等待硬上界 = 200ms 对应周期数 */
static uint8_t s_con_ring[DBG_CON_RING_SIZE];
static volatile uint16_t s_con_head; /* 生产者写入位置 */
static volatile uint16_t s_con_tail; /* 写者读取位置 */
static volatile uint32_t s_con_drop; /* 因缓冲满丢弃的行数 */
static volatile uint32_t s_con_tx_fault; /* TX 失败(僵尸通道)次数,均经外设软复位自愈 */
static volatile uint8_t s_con_tx_fault_log; /* TX 故障留痕待上报(由写者任务在 drain 之外补记 run.log) */
static volatile uint32_t s_con_cool_until; /* 故障冷却截止 tick(0 = 无冷却) */
static volatile uint8_t s_con_draining; /* 1 = 处于 drain 中(重入保护) */
static volatile uint8_t s_con_writer_up; /* 1 = 写者已注册(此后走环形缓冲) */
static volatile uint8_t s_con_dwt_up; /* 1 = DWT CYCCNT 已使能(硬上界基准可用) */
static TaskHandle_t s_con_writer_task; /* 写者任务句柄(自等保护用) */
/*
* 函数功能:计算环形缓冲已用字节数(head-tail 差值回绕取模,无锁)
* 入口参数:无
* 返回值:已用字节数 uint16_t
*/
static uint16_t con_used(void) {
return (uint16_t)((uint16_t)(s_con_head - s_con_tail) & (DBG_CON_RING_SIZE - 1U));
}
/*
* 函数功能:惰性使能 DWT 周期计数器(时间源无关的硬上界基准)
* 入口参数:无
* 返回值:无
* 限定条件:任意上下文可调(幂等;纯寄存器写,不创建任何内核对象)
* 函数说明:CoreDebug->DEMCR.TRCENA=1 → DWT->CYCCNT=0 → DWT->CTRL.CYCCNTENA=1;
* 使能成功置 s_con_dwt_up=1(供超时判据选择),失败保持 0(退回 HAL 超时)。
* 在 Cortex-M4 上 DWT 恒存在;失败仅可能出现在调试单元被硬件关闭时。
*/
static void con_dwt_ensure(void) {
if (s_con_dwt_up != 0U) {
return;
}
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
DWT->CYCCNT = 0U;
DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
if ((DWT->CTRL & DWT_CTRL_CYCCNTENA_Msk) != 0U) {
s_con_dwt_up = 1U;
}
}
/*
* 函数功能:DWT 不可用时的计数上界发送(每字节/TC 各数 CON_TX_GUARD_MAX 次)
* 入口参数:u - USART 寄存器基址(调用方已判非 NULL) USART_TypeDef *
* p_buf - 待发数据;len - 字节数
* 返回值:HAL_OK - 已发出;HAL_TIMEOUT - 计数上界内未完成(本块放弃)
* 限定条件:USART1 已初始化
* 函数说明:刻意不用 HAL_UART_Transmit——理由见 CON_TX_GUARD_MAX 处注释
* (tick 依赖不可信 + 其调用链污染所有任务的最坏栈链)
*/
static HAL_StatusTypeDef con_tx_counted(USART_TypeDef *u, const uint8_t *p_buf, uint16_t len) {
uint32_t guard;
uint16_t i;
for (i = 0U; i < len; i++) {
guard = 0U;
while ((u->SR & USART_SR_TXE) == 0U) {
if (++guard >= CON_TX_GUARD_MAX) {
return HAL_TIMEOUT;
}
}
u->DR = (uint16_t)p_buf[i];
}
guard = 0U;
while ((u->SR & USART_SR_TC) == 0U) {
if (++guard >= CON_TX_GUARD_MAX) {
return HAL_TIMEOUT;
}
}
return HAL_OK;
}
/*
* 函数功能:有界裸发送(不依赖 uwTick / HAL 超时)
* 入口参数:p_buf - 待发数据;len - 字节数(调用方已限 ≤ DBG_CON_CHUNK_MAX)
* 返回值:HAL_OK - 已全部写入发送寄存器;HAL_TIMEOUT - 有界上界内未完成
* 限定条件:USART1 已初始化
* 函数说明:寄存器级轮询 TXE/TC,超时判据用 DWT CYCCNT(CON_TX_TIMEOUT_CYC);
* DWT 未使能时走 con_tx_counted(指令计数上界,2026-10-10 取代
* HAL_UART_Transmit 回退)。本函数只做 TX,
* 不触碰 RX/错误标志——僵尸通道自愈由调用方按返回值处理。
* ⚠ USART1 未初始化(huart1.Instance == NULL,如 HAL_Init 早期或
* 断言/故障上报路径)时立即返回 HAL_ERROR:否则会解引用 NULL 去读写
* 地址 0(flash 别名区)→ 在故障处理里再触发故障(2026-10-02 台架实测)。
*/
static HAL_StatusTypeDef con_tx_bounded(const uint8_t *p_buf, uint16_t len) {
USART_TypeDef *u;
uint32_t t0;
uint16_t i;
con_dwt_ensure();
if (huart1.Instance == NULL) {
return HAL_ERROR; /* USART1 未初始化:无可用通道,安全让位 */
}
if (s_con_dwt_up == 0U) {
return con_tx_counted(huart1.Instance, p_buf, len);
}
u = huart1.Instance;
t0 = DWT->CYCCNT;
for (i = 0U; i < len; i++) {
while ((u->SR & USART_SR_TXE) == 0U) {
if ((uint32_t)(DWT->CYCCNT - t0) >= CON_TX_TIMEOUT_CYC) {
return HAL_TIMEOUT; /* 有界:本块放弃(调用方自愈) */
}
}
u->DR = (uint16_t)p_buf[i];
}
while ((u->SR & USART_SR_TC) == 0U) {
if ((uint32_t)(DWT->CYCCNT - t0) >= CON_TX_TIMEOUT_CYC) {
return HAL_TIMEOUT;
}
}
return HAL_OK;
}
/*
* 函数功能:控制台裸发送(异常/早期路径专用:有界、单次、不加锁)
* 入口参数:buf - 文本缓冲;len - 字节数(超上限截断)
* 限定条件:串口已初始化;仅用于写者未注册、中断/异常上下文、崩溃上报
* 函数说明:走 con_tx_bounded(DWT 硬上界),tick 停摆时仍能在有界时间内返回。
*/
void dbg_console_write_raw(const char *buf, unsigned len) {
if ((buf == NULL) || (len == 0U)) {
return;
}
if (len > DBG_CON_CHUNK_MAX) {
len = DBG_CON_CHUNK_MAX;
}
(void)con_tx_bounded((const uint8_t *)buf, (uint16_t)len);
}
/*
* 函数功能:注册控制台写者(defaultTask 在进入主循环前调用一次)
* 函数说明:注册后生产者改走环形缓冲;未注册时直接阻塞发送(内核启动早期)
*/
void dbg_console_writer_attach(void) {
s_con_writer_task = xTaskGetCurrentTaskHandle();
s_con_writer_up = 1U;
}
/*
* 函数功能:查询控制台写者是否已注册(1=已注册,此后日志走环形缓冲行级原子路径)
* 返回值:s_con_writer_up(volatile 读,任意任务上下文可调)
* 限定条件:无
* 函数说明:供**周期性任务**在写者就绪前抑制自身日志。写者未注册时 con_append 走
* dbg_console_write_raw 无锁直发(设计前提是"此窗口只有 defaultTask 记日志"),
* 其余任务此时落日志会与 defaultTask 的存储初始化日志逐字节交错
* (2026-10-10 台架:adcTask 首帧日志与 [NAND_FTL] 初始化行交错)。
*/
uint8_t dbg_console_writer_ready(void) {
return s_con_writer_up;
}
/*
* 函数功能:USART1 僵尸通道自愈(清错误锁存 + 整外设重初始化)
* 函数说明:RX 悬空噪声(PA10 未接线)锁存的 ORE/NE/FE/PE 使 HAL 轮询 TX 恒快速失败
* (控制台静默、系统不复位);发送器卡死状态则使 TXE 等待耗满超时(冻结唯一
* 写者 → 看门狗误复位,2026-10-02 台架实证)。外设软复位清锁存与卡死状态后
* 重新初始化,故障时间被单块超时限定
*/
static void con_uart_reinit(void) {
__NVIC_ClearPendingIRQ(USART1_IRQn);
__HAL_RCC_USART1_CLK_DISABLE();
__HAL_RCC_USART1_CLK_ENABLE();
huart1.gState = HAL_UART_STATE_RESET;
huart1.RxState = HAL_UART_STATE_RESET;
huart1.ErrorCode = HAL_UART_ERROR_NONE;
(void)MX_USART1_UART_Init();
}
/*
* 函数功能:排空环形缓冲(仅写者任务调用)
* 函数说明:按块一次 con_tx_bounded 发出(DWT 硬上界,不依赖 uwTick);每块完成后
* 再推进读指针(临界区保护);发送失败 → 外设软复位自愈(丢当前块);
* 单次调用最多连续发 DBG_CON_DRAIN_BUDGET_MS 后让位主循环,保证看门狗轮询
* 不被僵尸通道饿死
*/
void dbg_console_drain(void) {
uint16_t used;
uint32_t t0;
if (s_con_writer_up == 0U) {
return;
}
if (s_con_draining != 0U) {
return; /* 重入保护:写者自记录不触发嵌套 drain */
}
s_con_draining = 1U;
t0 = (uint32_t)HAL_GetTick();
/* 故障冷却:僵尸通道(ORE 重锁)秒内不自愈,冷却期内不发送——
* 本轮 drain 直接让位主循环,保住喂狗节奏(gen-288 停喂误复位的
* 直接诱因 = 背靠连发的块超时链)。无符号差值跨 49.7 天回绕安全 */
if (s_con_cool_until != 0U) {
if ((uint32_t)(t0 - s_con_cool_until) < DBG_CON_FAULT_COOL_MS) {
s_con_draining = 0U;
return;
}
s_con_cool_until = 0U;
}
while ((used = con_used()) != 0U) {
uint16_t tail;
uint16_t contig;
uint16_t n;
HAL_StatusTypeDef st;
tail = s_con_tail;
contig = (uint16_t)(DBG_CON_RING_SIZE - tail); /* 到环尾的连续段 */
n = (used < contig) ? used : contig;
if (n > DBG_CON_CHUNK_MAX) {
n = DBG_CON_CHUNK_MAX;
}
st = con_tx_bounded(&s_con_ring[tail], n);
taskENTER_CRITICAL();
s_con_tail = (uint16_t)((uint16_t)(s_con_tail + n) & (DBG_CON_RING_SIZE - 1U));
taskEXIT_CRITICAL();
if (st != HAL_OK) {
/* 僵尸通道:软复位自愈 + 进入发送冷却(ORE 毫秒级重锁,
* 继续背靠连发只会把主循环耗在块超时链上);本块丢弃
* (行已出环,不可重放) */
con_uart_reinit();
s_con_tx_fault++;
s_con_cool_until = (uint32_t)HAL_GetTick();
if (s_con_tx_fault == 1U) {
static const char note[] = "\r\n[WARN] CONSOLE tx fault: UART reinit (self-healed)\r\n";
/* 刻意**不在这里**调 syslog_post():syslog_post → dbg_console_write_line
* → con_append → dbg_console_drain 会与本函数构成**调用图成环**。运行时
* 有 s_con_draining 重入标志挡着,不会真递归;但 armlink 静态调用图会把
* 整条日志尾巴在每个任务的最坏链上多叠一层(实测 6 个任务的最坏链末尾
* 全是这个环)→ 栈深数字虚高、真实风险反而被掩盖。
* 改法:只置标志,由写者任务(defaultTask 主循环,本就是 syslog 唯一
* 写者)在 drain **之外**补记 run.log(见 dbg_console_tx_fault_log_take) */
s_con_tx_fault_log = 1U;
(void)dbg_console_write_raw(note, (unsigned)sizeof(note) - 1U);
}
break; /* 故障即止:本轮 drain 到此为止,让位主循环 */
}
if ((uint32_t)(HAL_GetTick() - t0) >= DBG_CON_DRAIN_BUDGET_MS) {
break; /* 预算用尽:让位主循环(看门狗轮询),下轮续排 */
}
}
s_con_draining = 0U;
/* 丢弃可观测性:缓冲满丢行必须显式可见(否则会掩盖开机自检/测试标记的丢失) */
if (s_con_drop != 0U) {
char note[64];
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);
}
}
}
/*
* 函数功能:把一段文本放入控制台(写者已注册)/直接发出(未注册或中断上下文)
* 返回值:0 - 已入缓冲或已直发;-1 - 缓冲满丢弃(计入 dbg_console_dropped)
* 函数说明:生产者不持锁等待过久:最多 DBG_CON_WAIT_MS,仍满则丢弃该行;
* 写者自身记录日志时先排空缓冲,避免自等。
* 等待上界优先用 DWT 真实时间(CON_APPEND_WAIT_CYC,时间源无关)——
* tick 停摆时 vTaskDelay 本身也会停,故不能用它作唯一判据(round 8b)。
*/
static int con_append(const char *buf, uint16_t len) {
uint32_t waited = 0U;
uint32_t d0 = 0U;
uint8_t have_dwt = 0U;
if ((buf == NULL) || (len == 0U)) {
return -1;
}
if ((s_con_writer_up == 0U) || (__get_IPSR() != 0U)) {
dbg_console_write_raw(buf, len);
return 0;
}
if (len > (DBG_CON_RING_SIZE / 2U)) {
len = (uint16_t)(DBG_CON_RING_SIZE / 2U); /* 超长保护(正常路径不会触发) */
}
if ((xTaskGetCurrentTaskHandle() == s_con_writer_task) && (s_con_draining == 0U)) {
dbg_console_drain(); /* 写者自己记录:先腾空,避免自等(drain 进行中则跳过,防嵌套) */
}
con_dwt_ensure();
if (s_con_dwt_up != 0U) {
d0 = DWT->CYCCNT;
have_dwt = 1U;
}
for (;;) {
uint16_t used = con_used();
uint16_t free_space = (uint16_t)((DBG_CON_RING_SIZE - 1U) - used);
if (free_space >= len) {
break;
}
if (have_dwt != 0U) {
if ((uint32_t)(DWT->CYCCNT - d0) >= CON_APPEND_WAIT_CYC) {
s_con_drop++;
return -1;
}
} else if (waited >= DBG_CON_WAIT_MS) {
s_con_drop++;
return -1;
}
vTaskDelay(1);
waited++;
}
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); /* 唤醒写者 */
}
return 0;
}
/*
* 函数功能:把一行已格式化的文本交给单写者控制台(生产者入口,**非可变参数**)
* 入口参数:line - 整行文本(含结尾换行);len - 字节数
* 返回值:写入字节数;-1 - 参数非法或被丢弃
* 函数说明:整行一次入环形缓冲 → 行级原子(不会与他行交错);调用方缓冲随即可复用。
* 刻意不用自研可变参数函数(规范 S12 7.6);格式化由 DBG_PRINT 宏内
* 库 snprintf 在调用点完成。
*/
int dbg_console_write_line(const char *line, unsigned len) {
if ((line == NULL) || (len == 0U)) {
return -1;
}
if (len > DBG_CON_LINE_MAX) {
len = DBG_CON_LINE_MAX; /* 截断(丢尾,保开头的标记信息) */
}
if (con_append(line, (uint16_t)len) != 0) {
return -1;
}
return (int)len;
}
/*
* 函数功能:已丢弃的日志行数(诊断用)
*/
uint32_t dbg_console_dropped(void) {
return s_con_drop;
}
/*
* 函数功能:已发生的 TX 故障(僵尸通道自愈)次数(诊断用)
*/
uint32_t dbg_console_tx_fault(void) {
return s_con_tx_fault;
}
/*
* 函数功能:取走"控制台 TX 故障待上报"标志(读后清)
* 返回值:1 - 需要补记一条 run.log 证据,0 - 无
* 限定条件:**写者任务**(defaultTask)在 dbg_console_drain() 之外调用
* 函数说明:断调用图成环用,见 dbg_console_drain 故障路径注释
*/
uint8_t dbg_console_tx_fault_log_take(void) {
uint8_t pending = s_con_tx_fault_log;
s_con_tx_fault_log = 0U;
return pending;
}
/*
* 函数功能:MicroLIB printf 底层重定向(库内部/遗留 printf 调用)
* 函数说明:同样只把字节交给单写者路径;未注册写者时直发(与旧行为一致)
*/
int fputc(int ch, FILE *f) {
uint8_t b = (uint8_t)ch;
(void)f;
(void)con_append((const char *)&b, 1U);
return ch;
}
+72
View File
@@ -0,0 +1,72 @@
/*
* 模块名称:Console Single-Writer Logging — 控制台单写者日志(USART1 调试串口)
* 模块功能:调试控制台单写者环形缓冲的公共 API:所有任务/上下文(含 printf)只把
* 已格式化整行文本入环形缓冲,写者任务(defaultTask)为唯一
* HAL_UART_Transmit 发送点(行级原子 + 指针可控)
* 使用方法:
* 1. 生产者:DBG_ERROR/DBG_INFO/DBG_DEBUG 宏(dbg_log.h,内部用库 snprintf
* 有界格式化整行后调 dbg_console_write_line);或直接调 dbg_console_write_line
* 2. 写者任务:进入主循环前调 dbg_console_writer_attach() 注册为唯一发送者,
* 循环中周期调 dbg_console_drain() 排空(defaultTask 已接线,见 Src/freertos.c)
* 适用平台:STM32F407ZGTx(仅 MCU 端;模拟器不编译本模块——模拟器 DBG_*
* 直接映射 printf,见 simulator/dbg_log.h)
* 作者:王建锋
* 创建日期:2026-09-24
* 修改记录:
* 2026-09-24 王建锋 创建(自 Src/usart.c USER CODE 区迁出:
* HAL 库生成文件不得包含应用代码)
*/
#ifndef CONSOLE_LOG_H
#define CONSOLE_LOG_H
#ifdef __cplusplus
extern "C" {
#endif
#include <stdio.h>
#include <stdint.h>
/*
* 单写者控制台 API(与 DBG_ENABLE 无关,任何构建都必须可见):
* dbg_console_write_line - 生产者入口:调用方已格式化好的整行文本一次入环形缓冲
* (刻意**不用**自研可变参数函数——规范 S12 7.6 禁止;格式化在
* DBG_PRINT 宏内用库 snprintf 完成)
* dbg_console_write_raw - 异常/早期路径:有界单次直发(不加锁)
* dbg_console_writer_attach- 写者任务(defaultTask)注册自己为唯一发送者
* dbg_console_writer_ready - 写者是否已注册(1=已注册)。供**周期性任务**在写者就绪前
* 抑制日志用:写者未注册时 con_append 走无锁直发路径
* (仅假设 defaultTask 在日志),其余任务此时落日志会与
* defaultTask 的存储初始化日志字节级交错(2026-10-10 台架实测)
* dbg_console_drain - 写者任务排空缓冲并发送(单次有界;TX 失败自动软复位自愈)
* dbg_console_dropped - 因缓冲满丢弃的行数(诊断)
* dbg_console_tx_fault - 因僵尸通道软复位自愈的次数(诊断)
* dbg_console_tx_fault_log_take
* - 取走"TX 故障待上报"标志(读后清)。**必须**由写者任务在
* dbg_console_drain() 之外调用后自行 syslog_post——本模块
* 刻意不调日志子系统,否则形成 write_line→con_append→drain
* →syslog_post→write_line 调用图成环,静态栈深全线虚高
*/
#define DBG_CON_LINE_MAX 192U /* 单行文本上限(含结尾 '\0',超出截断) */
int dbg_console_write_line(const char *line, unsigned len);
void dbg_console_write_raw(const char *buf, unsigned len);
void dbg_console_writer_attach(void);
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);
uint8_t dbg_console_tx_fault_log_take(void);
/*
* 函数功能:MicroLIB printf 底层重定向(C 库 retarget 钩子,须为全局符号)
* 入口参数:ch - 字符;f - 文件流(忽略)
* 返回值:原样返回 ch
* 限定条件:printf 调用点执行(任意任务上下文);字节交单写者路径
*/
int fputc(int ch, FILE *f);
#ifdef __cplusplus
}
#endif
#endif /* CONSOLE_LOG_H */
+141
View File
@@ -0,0 +1,141 @@
/*
* 模块名称:System Log — 运行/自检记录引擎(双端共享核心)
* 模块功能:定长 64B 文本日志记录的公共 API、格式常量与行拆分;
* MCU 后端 = FatFS /log 目录日志文件(sys_log_mcu.c),模拟器后端 = 宿主文件(sim_syslog.c)
* 适用平台:STM32F407ZGTx + PC 模拟器(共享头,无 OS/LVGL 依赖)
* 作者:王建锋
* 创建日期:2026-09-14
* 修改记录:
* v1.0 2026-09-14 王建锋 创建初始版本(设计见 docs/子系统设计/运行与自检记录设计说明.md §4)
*/
#ifndef SYS_LOG_H
#define SYS_LOG_H
#ifdef __cplusplus
extern "C" {
#endif
#include <stdint.h>
#include <stddef.h>
/*
* 日志 ID:决定落盘文件与容量上限
*/
typedef enum {
SYSLOG_RUN = 0u, /* 运行记录 /log/run.log:上电/重启事件 */
SYSLOG_SELFTEST, /* 自检记录 /log/selftest.log:开机自检故障(仅故障才写) */
SYSLOG_COUNT /* 哨兵值,非有效 ID */
} syslog_id_t;
/*
* 记录格式(定长 64B,一行一条;设计文档 §4.2):
* [0..18] "YYYY-MM-DD HH:MM:SS"(ISO 8601 空格变体,19 字符;墙钟无效 = "1970-01-01 08:00:00")
* [19] ' '
* [20..29] epoch 秒 ASCII "%010u"(机器解析/上电重启分类用;墙钟无效 = "0000000000")
* [30] ' '
* [31..58] 描述 UTF-8(≤28B = 9 汉字),不足补 '\0'
* [59..63] '\0' 填充
*/
#define SYSLOG_REC_SIZE 64u /* 单条记录字节数(定长) */
#define SYSLOG_TS_LEN 19u /* 时间戳字段长度 "YYYY-MM-DD HH:MM:SS" */
#define SYSLOG_EPOCH_OFFS 20u /* epoch ASCII 字段偏移 */
#define SYSLOG_EPOCH_LEN 10u /* epoch ASCII 字段长度(2026 年 Unix 秒 = 10 位) */
#define SYSLOG_DESC_OFFS 31u /* 描述字段偏移 */
#define SYSLOG_DESC_MAX 28u /* 描述最大字节数(受框架行缓冲 CP_ROW_BUF_LEN=32 约束) */
/*
* 容量上限(条数;满时删除重建,设计文档 §4.3)
*/
#define SYSLOG_RUN_MAX 1024u /* /log/run.log:64KB */
#define SYSLOG_SELFTEST_MAX 512u /* /log/selftest.log:32KB */
/*
* 上电/重启分类窗口(秒):run.log 末条 epoch 距 now < 该值 → "reboot",否则 "power on"
*/
#define SYSLOG_REBOOT_WINDOW_S 600u
/*
* 函数功能:投递一条日志(任意上下文;非阻塞,队列满则丢弃)
* 入口参数:id - 日志 ID syslog_id_t
* desc - 描述串(UTF-8,≤SYSLOG_DESC_MAX 字节;超长截断) const char *
* 返回值:无
* 限定条件:MCU 端 FreeRTOS 启动前调用走静态待发数组(由 defaultTask 挂载后落盘)
*/
void syslog_post(syslog_id_t id, const char *desc);
/*
* 函数功能:获取记录条数(= filesize / SYSLOG_REC_SIZE,O(1))
* 入口参数:id - 日志 ID syslog_id_t
* 返回值:条数;文件缺失/未挂载返回 0 uint32_t
*/
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
*/
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);
/*
* 函数功能:读取末条记录的 epoch 秒(上电/重启分类用)
* 入口参数:id - 日志 ID syslog_id_t
* p_epoch- 输出缓冲(不可为 NULL;无记录时置 0) uint32_t *
* 返回值:0 - 成功读到末条;-1 - 文件缺失/空 int
*/
int syslog_last_epoch(syslog_id_t id, uint32_t *p_epoch);
/*
* MCU 后端专属 API(sys_log_mcu.c;模拟器端无对应实现,勿在共享代码调用)
*/
/*
* 函数功能:日志引擎初始化(创建 FreeRTOS 消息队列)
* 入口参数:无
* 返回值:0 - 成功;-1 - 队列创建失败 int
* 限定条件:app_main_init() 起始处调用,先于任何 syslog_post()
*/
int syslog_mcu_init(void);
/*
* 函数功能:卷挂载成功后的一次性处理(建 /log 目录 + 写"上电/重启"分类记录)
* 入口参数:无
* 返回值:无
* 限定条件:defaultTask f_mount/f_mkfs 成功且置位 SYS_EVT_STORAGE_READY 前调用一次
*/
void syslog_mcu_mounted(void);
/*
* 函数功能:排空待发日志并落盘(启动前数组 + 运行期队列,FIFO)
* 入口参数:无
* 返回值:无
* 限定条件:仅 defaultTask(唯一写者)调用;卷未就绪时直接返回(条目滞留待重试)
*/
void syslog_drain(void);
/*
* 函数功能:把一条取证行追加到 /log/wdev.log(掉电不丢;变长行,不走 64B 定长格式)
* 入口参数:p_desc - 取证描述行(调用方已格式化;不可为 NULL) const char *
* 返回值:0 - 成功;-1 - 卷占用或写失败(证据可能回滚,**不阻断判定**) int
* 限定条件:poller(defaultTask)上下文调用;卷锁一律 try(绝不阻塞,
* 本上下文 = 看门狗喂狗点)
* 函数说明:round 7 取证通道(设计见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录:
* 误复位后 RAM 态随复位消失、stall 行经僵尸串口丢失,故证据必须落盘)。
* 内部在释放卷锁后立刻追加一条 s_append(SYSLOG_RUN, ...) 写另一个
* 文件——FTL 单页缓存语义为"写到不同 LPN 才把前一脏页刷进 NAND",
* 没有该 flush 诱导,本行证据会在约 1.7s 后的 IWDG 复位中回滚丢失。
* 文件 > 8KB 时先 unlink 重建,防长跑无界增长。
*/
int sys_log_evidence(const char *p_desc);
#ifdef __cplusplus
}
#endif
#endif /* SYS_LOG_H */
+498
View File
@@ -0,0 +1,498 @@
/*
* 模块名称:System Log (MCU 后端) — 运行/自检记录落盘
* 模块功能:FatFS /log 目录日志文件(run.log/selftest.log)单写者落盘;启动前静态待发数组 + FreeRTOS 消息队列,
* defaultTask(唯一写者)排空落盘;容量满删除重建;上电/重启分类
* 适用平台:STM32F407ZGTx
* 作者:王建锋
* 创建日期:2026-09-14
* 修改记录:
* v1.0 2026-09-14 王建锋 创建初始版本(设计见 docs/子系统设计/运行与自检记录设计说明.md §4.4)
* v1.1 2026-09-21 王建锋 消息队列改为内核启动后由 syslog_drain 惰性创建
* (修复 osKernelStart 前创建 FreeRTOS 内核对象导致的启动失败)
* v1.2 2026-10-02 王建锋 s_append 落盘序列纳入卷锁 try 语义(vol_lock_wait(0)):
* 与 ftpTask 的 lftpd 文件操作互斥(并发 FTL 写 →
* dhara journal 竞争实证);busy 返回 -2 静默顺延
* (本任务 = 看门狗喂狗点,绝不阻塞等锁)
* v1.3 2026-10-02 王建锋 新增 sys_log_evidence():把看门狗停喂现场
* 证据落 /log/wdev.log(掉电不丢)+ flush 诱导
* (round 7 纯取证,见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录)
* v1.4 2026-10-04 王建锋 P2-9:syslog_mcu_mounted() 改为幂等 + 卷锁保护;
* defaultTask 每 2s 复查,建 /log 曾失败时自动重试
* (原"仅调用一次"→ 失败后 drain 永久 no-op)。
* P2-10:s_read_raw() 纳入卷锁 try 语义,与写者
* s_append 的"容量满 unlink 重建"互斥,消除
* "UI 读记录(FA_READ) 时文件被 unlink"竞态窗口。
*/
/* 头文件包含区 */
#include <stdio.h>
#include <string.h>
#define DBG_TAG "[SYSLOG]"
#include "dbg_log.h"
#include "sys_log.h"
#include "ff.h"
#include "cmsis_os.h"
#include "sys_clock.h"
#include "vol_lock.h"
/* 静态变量定义区 */
static const char *const s_paths[SYSLOG_COUNT] = { "/log/run.log", "/log/selftest.log" };
static const uint32_t s_caps[SYSLOG_COUNT] = { SYSLOG_RUN_MAX, SYSLOG_SELFTEST_MAX };
#define SYSLOG_QUEUE_MAX 8u
/* 待发条目:desc 为调用方静态串(Flash 字面量),只存指针不拷贝 */
typedef struct {
uint8_t id;
const char *desc;
} syslog_entry_t;
static syslog_entry_t s_preboot[SYSLOG_QUEUE_MAX]; /* 启动前待发数组(RTOS 前单生产者/后单消费者,免锁) */
static volatile uint8_t s_preboot_cnt; /* 数组条目数 */
static osMessageQueueId_t s_queue; /* 运行期消息队列(syslog_drain 首次调用时惰性创建) */
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:建目录失败日志去重(成功复位) */
/* 函数定义区 */
/*
* 函数功能:追加一条记录(格式化 64B + FA_OPEN_APPEND;容量满先删除重建)
* 入口参数:id - 日志 ID syslog_id_t
* desc - 描述串(UTF-8,超长截断至 SYSLOG_DESC_MAX) const char *
* 返回值:0 - 成功;-1 - 写失败;-2 - 卷被占用(顺延,非故障) int
* 限定条件:仅 defaultTask(唯一写者)调用;卷已挂载
*/
static int s_append(syslog_id_t id, const char *desc) {
static FIL f; /* static:FF_FS_TINY=0 → 局部 FIL 吃 528 B 栈;
* 本函数全程在 vol_lock 区间内,跨任务被卷锁串行化 */
FRESULT fr;
UINT bw = 0u;
uint8_t rec[SYSLOG_REC_SIZE];
char ts[SYSLOG_TS_LEN + 1u];
uint32_t epoch;
size_t dl;
if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT || desc == NULL) {
return -1;
}
/* 卷锁 try:本任务 = 看门狗喂狗点,绝不允许阻塞等锁(阻塞 = 喂狗停摆 =
* 误复位);busy = FTP 文件操作持有卷,本轮顺延(条目滞留队列/待发
* 数组),静默跳过、不计故障 */
if (vol_lock_wait(0u) == 0u) {
return -2;
}
if (syslog_count(id) >= s_caps[id]) {
fr = f_unlink(s_paths[id]);
DBG_INFO("cap full, recreate %s", s_paths[id]);
if (fr != FR_OK && fr != FR_NO_FILE) {
vol_lock_release();
return -1;
}
}
memset(rec, 0u, sizeof(rec));
if (sys_clock_get_str(ts, sizeof(ts)) == NULL) {
memcpy(ts, "1970-01-01 08:00:00", SYSLOG_TS_LEN);
}
ts[SYSLOG_TS_LEN] = '\0';
epoch = sys_clock_get();
if (epoch == UINT32_MAX) {
epoch = 0u;
}
memcpy(rec, ts, SYSLOG_TS_LEN);
rec[SYSLOG_TS_LEN] = ' ';
(void)snprintf((char *)(rec + SYSLOG_EPOCH_OFFS), SYSLOG_EPOCH_LEN + 1u, "%010u", (unsigned int)epoch);
rec[SYSLOG_DESC_OFFS - 1u] = ' ';
dl = strlen(desc);
if (dl > SYSLOG_DESC_MAX) {
dl = SYSLOG_DESC_MAX;
}
memcpy(rec + SYSLOG_DESC_OFFS, desc, dl);
/* L1 文件级自愈:陈旧/损坏条目 → 丢弃重建、重试一次(与上方容量满重建
* 同一机制);L2 错误去重:持续故障只报第一次,恢复前静默(消除"每次
* 开机同一错误刷屏")。打开模式必须带 FA_WRITE 位:本 FatFs 移植的
* FA_OPEN_APPEND(0x30) 不含 FA_WRITE(0x02),不带写位打开的文件 f_write
* 恒返回 FR_DENIED(7)——2026-09-20 台架实测 /log 写失败 ×5/开机 真根因 */
static uint8_t s_fault_reported[SYSLOG_COUNT];
int rc = 0;
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)) {
(void)f_unlink(s_paths[id]);
fr = f_open(&f, s_paths[id], FA_WRITE | FA_OPEN_APPEND);
}
if (fr == FR_OK) {
fr = f_write(&f, rec, SYSLOG_REC_SIZE, &bw);
(void)f_close(&f);
if (fr != FR_OK || bw != SYSLOG_REC_SIZE) {
(void)f_unlink(s_paths[id]);
bw = 0u;
fr = f_open(&f, s_paths[id], FA_WRITE | FA_OPEN_APPEND);
if (fr == FR_OK) {
fr = f_write(&f, rec, SYSLOG_REC_SIZE, &bw);
(void)f_close(&f);
}
if (fr != FR_OK || bw != SYSLOG_REC_SIZE) {
rc = -1;
}
}
} else {
rc = -1;
}
if (rc == 0) {
s_fault_reported[id] = 0u;
} else if (s_fault_reported[id] == 0u) {
s_fault_reported[id] = 1u;
DBG_ERROR("write %s fail (%d)", s_paths[id], (int)fr);
}
vol_lock_release();
return rc;
}
/*
* 函数功能:读取第 idx 条原始记录(64B)
* 入口参数:id - 日志 ID syslog_id_t
* idx - 记录序号(0 起) uint32_t
* p_rec- 输出缓冲(SYSLOG_REC_SIZE 字节,不可为 NULL) uint8_t *
* 返回值:0 - 成功;-1 - 越界/读失败/卷忙 int
* 函数说明:P2-10(2026-10-04):读者纳入卷锁(try),与写者 s_append 的
* "容量满 unlink 重建"互斥——原实现读时无锁,写者 unlink 与读者
* open/read 可交错,读到的可能是半删除态文件。try 语义(timeout=0):
* 本路径可由 uiTask 调用,uiTask 在 required_mask 内、须每 100ms 喂
* 心跳,绝不能阻塞等锁;busy = 本次读失败(UI 下轮刷新重读,非故障)。
*/
static int s_read_raw(syslog_id_t id, uint32_t idx, uint8_t *p_rec) {
static FIL f; /* static:省 528 B 栈;open~close 全在卷锁区间内
* (uiTask 与写者任务由 vol_lock 串行化,见上方 P2-10 说明) */
FRESULT fr;
UINT br = 0u;
int rc;
if (p_rec == NULL || idx >= syslog_count(id)) {
return -1;
}
if (vol_lock_wait(0u) == 0u) {
return -1; /* 卷被占用:本次读失败(非故障,下轮重读) */
}
fr = f_open(&f, s_paths[id], FA_READ);
if (fr != FR_OK) {
vol_lock_release();
return -1;
}
fr = f_lseek(&f, (FSIZE_t)idx * SYSLOG_REC_SIZE);
if (fr == FR_OK) {
fr = f_read(&f, p_rec, SYSLOG_REC_SIZE, &br);
}
(void)f_close(&f);
rc = (fr == FR_OK && br == SYSLOG_REC_SIZE) ? 0 : -1;
vol_lock_release();
return rc;
}
/*
* 函数功能:日志引擎初始化(内核启动前安全空操作)
* 入口参数:无
* 返回值:0 - 成功 int
* 限定条件:app_main_init() 起始处调用(osKernelStart 之前);
* 不得调用任何 FreeRTOS API(内核对象须在内核启动后才可创建)
* 函数说明:启动前投递走 s_preboot 静态数组(syslog_post 按内核状态自动分流);
* FreeRTOS 消息队列由 syslog_drain 在运行期(defaultTask 上下文)惰性创建。
* 历史缺陷:此处曾 osMessageQueueNew 创建队列,早于 osKernelInitialize
* 提前拉起内核,其后任务创建在未完成初始化的内核状态上执行,导致启动失败
*/
int syslog_mcu_init(void) {
return 0;
}
/*
* 函数功能:卷挂载成功后的一次性处理(建 /log 目录 + 写"上电/重启"分类记录)
* 入口参数:无
* 返回值:无
* 限定条件:defaultTask f_mount/f_mkfs 成功后调用;**可重复调用(幂等)**——
* 产品运行期 defaultTask 每 2s 复查一次(见下 P2-9 说明)
* 函数说明:P2-9(2026-10-04):原实现"仅调用一次",若此处建 /log 失败则
* s_fs_ready 恒 0 → syslog_drain 永久 no-op(运行期日志与自检全部
* 滞留/丢弃),且无任何重试。现改为幂等:s_fs_ready 已置位则立即
* 返回;否则重试建目录。defaultTask 在 STORAGE_READY 置位后每 2s
* 调用本函数——卷已就绪但日志后端未就绪时自动补建目录。
* FS 操作纳入卷锁 try:运行期重试时 ftpTask 可能正操作卷,try 失败
* = 本轮顺延(下轮再试),绝不阻塞(本任务 = 看门狗喂狗点)。
*/
void syslog_mcu_mounted(void) {
FILINFO finfo;
uint32_t last = 0u;
const char *desc = "power on";
if (s_fs_ready != 0u) {
return; /* 幂等:已就绪 */
}
/* 卷锁 try:运行期重试时与 ftpTask 卷访问串行;busy = 本轮顺延 */
if (vol_lock_wait(0u) == 0u) {
return;
}
if (f_stat("/log", &finfo) != FR_OK) {
FRESULT fr = f_mkdir("/log");
if (fr != FR_OK && fr != FR_EXIST) {
/* 失败日志去重:持续失败只报第一次(防 2s 周期刷屏),成功后复位 */
if (s_mount_fail_reported == 0u) {
s_mount_fail_reported = 1u;
DBG_ERROR("mkdir /log fail (%d), will retry", (int)fr);
}
vol_lock_release();
return;
}
}
vol_lock_release();
s_mount_fail_reported = 0u;
s_fs_ready = 1u;
/* 上电/重启分类:末条 epoch 距 now < SYSLOG_REBOOT_WINDOW_S → 重启,否则上电 */
if (syslog_last_epoch(SYSLOG_RUN, &last) == 0 && sys_clock_get() != UINT32_MAX) {
if ((uint32_t)(sys_clock_get() - last) < SYSLOG_REBOOT_WINDOW_S) {
desc = "reboot";
}
}
(void)s_append(SYSLOG_RUN, desc);
DBG_INFO("runlog boot entry: %s", desc);
}
/*
* 函数功能:排空待发日志并落盘(启动前数组 + 运行期队列,FIFO);首次调用时惰性创建运行期队列
* 入口参数:无
* 返回值:无
* 限定条件:仅 defaultTask(唯一写者)调用;卷未就绪时直接返回(条目滞留待重试)
*/
void syslog_drain(void) {
syslog_entry_t e;
/* 运行期惰性创建消息队列:本函数仅由 defaultTask(调度器运行中)调用,
保证内核对象创建发生在 osKernelStart 之后 */
if (s_queue == NULL) {
s_queue = osMessageQueueNew(SYSLOG_QUEUE_MAX, sizeof(syslog_entry_t), &s_queue_attr);
if (s_queue == NULL) {
DBG_ERROR("queue create fail");
}
}
if (!s_fs_ready || s_queue == NULL) {
return;
}
while (s_preboot_cnt > 0u) {
e = s_preboot[0];
if (s_append((syslog_id_t)e.id, e.desc) != 0) {
break; /* 落盘失败:保留剩余条目,下轮重试 */
}
memmove(&s_preboot[0], &s_preboot[1], sizeof(syslog_entry_t) * (size_t)(s_preboot_cnt - 1u));
s_preboot_cnt--;
}
for (;;) {
if (osMessageQueueGet(s_queue, &e, 0u, 0u) != osOK) {
break; /* 队列空 */
}
{
int arc = s_append((syslog_id_t)e.id, e.desc);
if (arc == -2) {
break; /* 卷被占用(FTP 文件操作进行中):本轮顺延,静默
(条目已出队;run.log 尽力而为语义,不计故障) */
}
if (arc != 0) {
DBG_ERROR("drop entry id=%d", (int)e.id);
break; /* 落盘失败:停止排空,避免连续丢弃 */
}
}
}
}
/*
* 函数功能:投递一条日志(任意上下文;非阻塞,队列满则丢弃)
* 入口参数:id - 日志 ID syslog_id_t
* desc - 描述串(UTF-8,静态存储期,超长截断) const char *
* 返回值:无
* 限定条件:FreeRTOS 启动前走静态待发数组;desc 必须为静态/常量串
*/
void syslog_post(syslog_id_t id, const char *desc) {
syslog_entry_t e;
if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT || desc == NULL) {
return;
}
e.id = (uint8_t)id;
e.desc = desc;
if (s_queue != NULL && osKernelGetState() == osKernelRunning) {
if (osMessageQueuePut(s_queue, &e, 0u, 0u) != osOK) {
DBG_ERROR("queue full, drop: %s", desc);
}
} else {
uint8_t n = s_preboot_cnt;
if (n < SYSLOG_QUEUE_MAX) {
s_preboot[n] = e;
s_preboot_cnt = n + 1u;
} else {
DBG_ERROR("preboot full, drop: %s", desc);
}
}
}
/*
* 函数功能:获取记录条数(= filesize / SYSLOG_REC_SIZE,O(1))
* 入口参数:id - 日志 ID syslog_id_t
* 返回值:条数;文件缺失/未挂载返回 0 uint32_t
*/
uint32_t syslog_count(syslog_id_t id) {
FILINFO finfo;
if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT) {
return 0u;
}
if (f_stat(s_paths[id], &finfo) != FR_OK) {
return 0u;
}
return (uint32_t)finfo.fsize / SYSLOG_REC_SIZE;
}
/*
* 函数功能:读取第 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
*/
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];
size_t dl = 0u;
if (p_ts == NULL || p_desc == NULL || ts_len <= SYSLOG_TS_LEN || desc_len <= SYSLOG_DESC_MAX) {
return -1;
}
if (s_read_raw(id, idx, rec) != 0) {
return -1;
}
memcpy(p_ts, rec, SYSLOG_TS_LEN);
p_ts[SYSLOG_TS_LEN] = '\0';
while (dl < SYSLOG_DESC_MAX && rec[SYSLOG_DESC_OFFS + dl] != '\0') {
dl++;
}
if (dl >= desc_len) {
dl = desc_len - 1u;
}
memcpy(p_desc, rec + SYSLOG_DESC_OFFS, dl);
p_desc[dl] = '\0';
return 0;
}
/*
* 函数功能:读取末条记录的 epoch 秒(上电/重启分类用)
* 入口参数:id - 日志 ID syslog_id_t
* p_epoch- 输出缓冲(不可为 NULL;无记录时置 0) uint32_t *
* 返回值:0 - 成功读到末条;-1 - 文件缺失/空 int
*/
int syslog_last_epoch(syslog_id_t id, uint32_t *p_epoch) {
uint8_t rec[SYSLOG_REC_SIZE];
uint32_t v = 0u;
uint32_t i;
if (syslog_count(id) == 0u) {
if (p_epoch != NULL) {
*p_epoch = 0u;
}
return -1;
}
if (s_read_raw(id, syslog_count(id) - 1u, rec) != 0) {
if (p_epoch != NULL) {
*p_epoch = 0u;
}
return -1;
}
for (i = 0u; i < SYSLOG_EPOCH_LEN; i++) {
char c = (char)rec[SYSLOG_EPOCH_OFFS + i];
if (c >= '0' && c <= '9') {
v = v * 10u + (uint32_t)(c - '0');
}
}
if (p_epoch != NULL) {
*p_epoch = v;
}
return 0;
}
/*
* 函数功能:把一条取证行追加到 /log/wdev.log(掉电不丢;round 7 纯取证)
* 入口参数:p_desc - 取证描述行(调用方已格式化;不可为 NULL) const char *
* 返回值:0 - 成功;-1 - 卷占用或写失败(证据可能回滚,不阻断判定) int
* 限定条件:poller(defaultTask)上下文调用;卷锁一律 try(绝不阻塞)
* 函数说明:
* 为什么必须落盘:误复位后 RAM 态随复位消失、`[WD] task stall` 行经僵尸串口
* 可能丢失(2026-10-02 台架 round 3~6 绕圈的判据缺口),故证据必须落到
* **掉电不丢**的盘上,复位后经 FTP RETR 回读。
* flush 诱导(关键):写完 wdev.log 后立刻 s_append(SYSLOG_RUN, ...) 写另一个
* 文件——FTL 单页缓存语义为"写到**不同 LPN** 才把前一脏页刷进 NAND";没有
* 这一步,本行证据会在约 1.7s 后的 IWDG 复位中回滚丢失(409 条里被回滚的
* 07:56 记录即同类教训)。s_append 自行取卷锁(非递归锁),故必须在
* vol_lock_release() **之后**调用,绝不可在持锁状态下调用。
* 容量护栏:> 8KB 先 unlink 重建,防长跑无界增长(8.3 合法名 wdev.log)。
*/
int sys_log_evidence(const char *p_desc) {
static FIL f; /* static:省 528 B 栈(FF_FS_TINY=0 → FIL=528 B) */
static char line[192]; /* static:省 192 B 栈;本函数只有 poller(defaultTask)
* 一个调用者(task_wd_poll 内),且全程在卷锁区间内 */
FRESULT fr;
UINT bw = 0u;
UINT line_len;
char ts[SYSLOG_TS_LEN + 1u];
int n;
int rc = 0;
if (p_desc == NULL) {
return -1;
}
/* 卷锁 try:本上下文 = 看门狗喂狗点,绝不阻塞等锁 */
if (vol_lock_wait(0u) == 0u) {
return -1;
}
/* 容量护栏:超过 8KB 先删除重建(防长跑无界增长) */
{
FILINFO finfo;
if (f_stat("/log/wdev.log", &finfo) == FR_OK && finfo.fsize > 8192u) {
(void)f_unlink("/log/wdev.log");
}
}
if (sys_clock_get_str(ts, sizeof(ts)) == NULL) {
memcpy(ts, "1970-01-01 08:00:00", SYSLOG_TS_LEN);
}
ts[SYSLOG_TS_LEN] = '\0';
n = snprintf(line, sizeof(line), "%s %s\r\n", ts, p_desc);
if (n <= 0) {
vol_lock_release();
return -1;
}
line_len = (UINT)strlen(line);
fr = f_open(&f, "/log/wdev.log", FA_WRITE | FA_OPEN_APPEND);
if (fr == FR_OK) {
fr = f_write(&f, line, line_len, &bw);
(void)f_close(&f);
if (fr != FR_OK || bw != line_len) {
rc = -1;
}
} else {
rc = -1;
}
vol_lock_release();
/* flush 诱导:写另一个文件(不同 LPN)把 wdev.log 的脏页刷进 NAND。
* 必须在释放卷锁之后调用(s_append 自行取锁,非递归锁不可重入)。 */
(void)s_append(SYSLOG_RUN, "wd stall declared");
return rc;
}
+12
View File
@@ -0,0 +1,12 @@
# App/protocol/ — 通信协议(预留)
> **占位目录**:规划用于存放自定义通信协议实现(解析 / 组帧 / 状态机)。
> 当前为空(仅 `.gitkeep`),协议相关实现暂分散在:
> - RS-485 侧:`App/task/rs485_task.c` + `App/util/crc.c` + `App/util/ringbuf.c`
> - 网络侧:`Drivers/BSP/NET/`
> - 远端显示:`docs/UI设计/RemoDispBus协议与接收设计说明.md`
## 何时使用本目录
当出现**跨接口复用的协议逻辑**(如统一的帧编解码器、协议状态机)时,落到此处,
避免散落到各 task 里。新增后请在本 README 补一行说明。
+29
View File
@@ -0,0 +1,29 @@
# App/sync/ — 时间同步
> 墙钟时间与外部时间源的同步(timeSyncTask)。墙钟本身由 `../util/sys_clock.c` 维护,
> 本目录负责**从时间源拉取/接收时间并校准**。
## 文件一览
| 文件 | 作用 |
|---|---|
| `time_sync.c/h` | **时间同步任务**:等待网络就绪 → 与时间源交互 → 校准墙钟 |
## 工作流
```
timeSyncTask 启动
↓ osEventFlagsWait(SYS_EVT_NET_READY, ...|osFlagsNoClear) // 状态位不可消费!
time_sync 主循环 → 与时间源通信 → sys_clock 校准
↓
置 SYS_EVT_TIMESYNC_READY
```
> ⚠️ 等待**就绪位**(`SYS_EVT_NET_READY`)必须 `osFlagsNoClear`——它是状态位,
> 用默认消费型等待会把它清掉,导致后续消费者(如 ftpTask)永远等不到。
## 相关文档
- `docs/子系统设计/系统墙钟时间维护说明.md` — 墙钟基准 / tick 回绕防护
- `test/host/time_sync_server.py` — PC 端对时推送服务(测试用)
- `docs/架构设计/项目启动顺序管理.md` — 就绪屏障
+254
View File
@@ -0,0 +1,254 @@
/*
* 模块名称:Time Sync — PC 本地时间推送接收(TCP)
* 模块功能:在独立任务 timeSyncTask 中以 TCP Server 方式监听端口,接收 PC 推送的本地时间包,
* 调用 sys_clock_set_unix() 更新系统墙钟并写回 SD2506 RTC
* 适用平台:STM32F407ZGTx + CH395F
* 作者:王建锋
* 创建日期:2026-08-27
* 修改记录:
* v1.0 2026-08-27 王建锋 创建初始版本(UDP 推送,netTask 内轮询)
* v1.1 2026-08-28 王建锋 改为 TCP Server,迁入独立任务 timeSyncTask,
* 所有 socket 操作经 net_socket 线程安全 API 走消息队列,
* netTask 仅负责 net_poll/net_process_messages,多任务并发安全
* v1.2 2026-10-02 王建锋 round 8b 消抖:等待片长 200ms→50ms,并把各处长
* 等待(500/2000/200ms)统一走 time_sync_delay_fed()
* 按 ≤50ms 分片、每片喂心跳——消除"心跳滑出两个连续
* 100ms 抽样窗"的节拍抖动(round 7 台架 snap=0x17)
*/
#include <string.h>
#include "time_sync.h"
#include "net_socket.h"
#include "sys_clock.h"
#include "sys_events.h"
#include "task_watchdog.h"
/* 深调用链模块(timeSyncTask 栈仅 2048 B,本任务链 = 建监听 → net_bind → CH395F):
* 日志行缓冲收到 128 B(默认 192),本模块最长行渲染后 < 128 → 不截断 */
#define DBG_LINE_BUF_MAX 128U
#define DBG_TAG "[TIME_SYNC]"
#include "dbg_log.h"
/* 监听端口:PC 推送端(TCP 客户端)需连到本端口 */
#define TIME_SYNC_PORT 8888
/*
* E-02 头阻塞修复 + L3 pending 化(timeSync 侧;lftpd 侧见 lftpd_inet.c):
* 旧实现用单次 30s 阻塞 net_recv(默认 NET_RECV_TIMEOUT_MS)等时间包——等待期间
* netTask(全部 socket 操作的唯一执行者)被独占,其余任务的网络操作(远端显示 /
* FTP)整体停摆(docs/问题记录/错误码传播审计.md E-02)。
* 2026-09-22 先落地分片有界读(5ms 片),同日 L3 pending 化后改为"挂起请求":
* 每次等待 = 一次挂起登记(netTask 零占用,完成按主循环节拍检查),调用方
* 单次超时 = 剩余总预算(总等待 ≤ TIME_SYNC_TOTAL_BUDGET_MS,语义与旧 30s
* 一致);等待期间调用方在自己上下文睡任务通知,旧分片协议(片长/片间让出)退役。
*/
#define TIME_SYNC_TOTAL_BUDGET_MS 30000U
/* 看门狗分片等待:单次 net_recv_to 的等待片长上限。须显著小于看门狗抽样窗
* (task_watchdog.c WD_POLL_PERIOD_MS=100ms)——片长 ≤50ms 时每片前喂的心跳
* 必然落在同一个 100ms 抽样窗内,消除"心跳恰好滑过两个连续窗口"的边界抖动
* (round 7 台架稳态 snap=0x17 即抽样窗 vs 心跳节拍的良性错位,round 8b 消抖)。
* 语义上仅把一次长等待拆成多次短等待,总预算不变。 */
#define TIME_SYNC_WAIT_SLICE_MS 50U
/*
* 函数功能:可喂心跳的延时(把长等待切成 ≤50ms 短片,每片喂一次心跳)
* 入口参数:ms - 总延时(ms) uint32_t
* 返回值:无
* 限定条件:任务上下文调用
* 函数说明:看门狗抽样窗为 100ms(task_watchdog.c WD_POLL_PERIOD_MS)。把任意长
* 等待切成 ≤TIME_SYNC_WAIT_SLICE_MS 的短片、每片前喂心跳,使心跳必然
* 落在单个抽样窗内——语义等价于一次长延时,仅把"心跳节奏"钉在窗内
* (round 8b 消抖;不改变总延时预算)。
*/
static void time_sync_delay_fed(uint32_t ms) {
while (ms > 0U) {
uint32_t slice = (ms > TIME_SYNC_WAIT_SLICE_MS) ? TIME_SYNC_WAIT_SLICE_MS : ms;
task_wd_beat(TASK_WD_TIMESYNC);
vTaskDelay(pdMS_TO_TICKS(slice));
ms -= slice;
}
}
/*
* 函数功能:创建并监听 TCP 时间同步服务端口
* 入口参数:无
* 返回值:监听 Socket 描述符(>=0),失败返回 -1
* 限定条件:net_init() 已成功;可在非 netTask 上下文调用
* 函数说明:net_socket/net_bind/net_listen 均为线程安全 API,内部经消息队列由 netTask 串行处理。
* 单连接模式下监听 Socket 在收到连接后会转为数据通道,连接关闭后即被释放,
* 因此每次调用都会新建监听 Socket(与 lftpd 同模式),失败自动重试。
*/
static int time_sync_open_listener(void) {
int retry;
for (retry = 0; retry < 5; retry++) {
task_wd_beat(TASK_WD_TIMESYNC); /* 每次重试前喂心跳(见下方 500ms/2000ms 延时) */
int s = net_socket(NET_AF_INET, NET_SOCK_STREAM);
if (s < 0) {
DBG_ERROR("time_sync: net_socket FAIL");
time_sync_delay_fed(500U);
continue;
}
struct net_sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = NET_AF_INET;
addr.sin_port = net_htons(TIME_SYNC_PORT);
addr.sin_addr.s_addr = NET_INADDR_ANY;
if (net_bind(s, (struct net_sockaddr *)&addr, sizeof(addr)) != 0) {
DBG_ERROR("time_sync: bind port %d FAIL", TIME_SYNC_PORT);
net_close(s);
time_sync_delay_fed(2000U);
continue;
}
if (net_listen(s) != 0) {
DBG_ERROR("time_sync: listen FAIL");
net_close(s);
time_sync_delay_fed(2000U);
continue;
}
DBG_INFO("time_sync: TCP listen on :%d (sock=%d)", TIME_SYNC_PORT, s);
return s;
}
return -1;
}
/*
* 函数功能:处理一个已建立的时间同步连接
* 入口参数:conn_sock - 已建立的连接 Socket 描述符(单连接模式下即监听 Socket 自身)
* 返回值:无
* 函数说明:pending 读取 PC 发来的时间包("TIME"+uint32 大端 本地时间),解析后
* 写回 RTC,然后关闭连接。每次等待 = 一次挂起请求(netTask 零占用,
* L3 pending 化),单次超时 = 剩余总预算(≤30s,语义与旧 30s 阻塞一致);
* 跨调用累积,容忍时间包被 TCP 分段送达
*/
static void time_sync_handle_conn(int conn_sock) {
uint8_t buf[16];
int got = 0;
TickType_t t0 = xTaskGetTickCount();
while (got < (int)sizeof(buf)) {
/* 单次预算 = 剩余总预算(L3 pending:等待期 netTask 零占用,调用方
* 在自己上下文睡任务通知,旧片间 vTaskDelay(1) 让出退役)
* ⚠ 看门狗约束:本任务在 required_mask 内,而单次 net_recv_to 可睡满
* 30s 预算 —— 等待期必须周期性喂心跳,否则"对端连上但不发时间包"这种
* 完全正常的场景会被判为任务卡死(见设计说明 §4.4 长耗时白名单)。
* 做法:把长等待切成 WD 窗口内的短片(片长 ≤200ms),每片先喂心跳。 */
TickType_t elapsed = xTaskGetTickCount() - t0;
uint32_t rem = (elapsed >= (TickType_t)pdMS_TO_TICKS(TIME_SYNC_TOTAL_BUDGET_MS)) ? 0U
: (uint32_t)((TickType_t)TIME_SYNC_TOTAL_BUDGET_MS - elapsed);
if (rem == 0U) {
DBG_INFO("time_sync: no TIME packet in 30s, drop conn");
break; /* 总预算耗尽(语义同旧 30s 超时) */
}
task_wd_beat(TASK_WD_TIMESYNC);
if (rem > TIME_SYNC_WAIT_SLICE_MS) {
rem = TIME_SYNC_WAIT_SLICE_MS; /* 分片:等待期保持心跳节奏 */
}
int n = net_recv_to(conn_sock, buf + got, (int)(sizeof(buf) - got), rem);
if (n > 0) {
got += n;
if (got >= 8) {
break; /* 时间包收齐,解析 */
}
continue; /* 包未收齐(分段送达),继续等余下字节 */
}
if (n == 0) {
break; /* 对端关闭(EOF) */
}
if (n == -2) {
continue; /* 本片预算等满无数据 → 下一片重算剩余预算 */
}
break; /* 真错误 */
}
if (got >= 8) {
if (buf[0] == 'T' && buf[1] == 'I' && buf[2] == 'M' && buf[3] == 'E') {
uint32_t epoch = ((uint32_t)buf[4] << 24) |
((uint32_t)buf[5] << 16) |
((uint32_t)buf[6] << 8) |
((uint32_t)buf[7]);
sys_clock_set_unix(epoch);
DBG_INFO("time_sync: wall clock updated (epoch=%lu)", (unsigned long)epoch);
}
}
net_close(conn_sock);
}
/*
* 函数功能:时间同步任务主体(独立 FreeRTOS 任务)
* 入口参数:argument - 未使用
* 返回值:无
* 限定条件:net_init() 已由 netTask 完成;netTask 正在处理消息队列
* 函数说明:等待网络栈就绪后,循环:建监听 -> 等连接 -> 处理 -> 关闭 -> 重建监听。
* 全部 socket 操作经消息队列由 netTask 串行执行,与本任务/其它网络任务并发安全。
*/
void time_sync_task(void *argument) {
(void)argument;
task_wd_bind(TASK_WD_TIMESYNC); /* FTL 心跳路由:登记本任务句柄(入口一次) */
/* 等网络栈真正就绪(取代拍脑袋的 8s 魔法延时),确保 netTask 已开始处理消息;
* osFlagsNoClear:就绪位是状态,不能被本任务消费掉(否则其它等待者永不唤醒) */
if (sys_evtHandle != NULL) {
/* osFlagsNoClear:就绪位是状态,不能被本任务消费掉(否则其它等待者永不唤醒)。
* 2026-10-02 台架修复:等待改 100ms 分片 + 每片喂心跳——IWDG 必需掩码含
* TS 位,原 osWaitForever 无心跳 → 启动初期(存储已就绪、网络未就绪)
* 掩码 800ms 不可达 → 停喂 → 复位循环(2026-10-02 台架根因之一)。
* 100ms 分片 = 抽样窗,纯任务睡眠零队列开销。 */
for (;;) {
uint32_t r = osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY,
osFlagsWaitAny | osFlagsNoClear, 100u);
if ((r & osFlagsError) == 0u) {
break; /* NET_READY 已置位(状态位,未被消费) */
}
task_wd_beat(TASK_WD_TIMESYNC); /* 看门狗:分片喂心跳(屏障等待态) */
}
}
for (;;) {
/* 心跳:本任务在 required_mask 内。以下每一处可能长于 100ms 抽样窗的
* 阻塞点(监听重试 2s、accept 轮询 200ms/次、收尾 50ms)都就近补心跳。 */
task_wd_beat(TASK_WD_TIMESYNC);
/* 记录本次监听对应的网络代次;代次变化(网络重建)则放弃旧监听重建 */
uint32_t gen = net_get_generation();
int ls = time_sync_open_listener();
if (ls < 0) {
DBG_ERROR("time_sync: open listener FAIL, retry later");
time_sync_delay_fed(2000U);
continue;
}
/* 等待 PC 连接:net_accept 在无连接时立即返回 -1,循环重试同一监听 Socket */
int conn = -1;
do {
conn = net_accept(ls, NULL, NULL);
if (conn < 0) {
if (net_get_generation() != gen) {
net_close(ls); /* 网络重建:旧监听失效,回外层重建 */
break;
}
time_sync_delay_fed(200U);
}
} while (conn < 0);
if (conn < 0) {
DBG_INFO("time_sync: network re-init, relisten");
continue;
}
time_sync_handle_conn(conn);
/* conn(单连接模式下即 ls)已被 net_close 释放,下次循环重建监听 */
time_sync_delay_fed(50U);
}
}
+39
View File
@@ -0,0 +1,39 @@
# App/task/ — FreeRTOS 任务实现
> 6 个任务的入口函数与任务体。任务创建在 `Src/freertos.c`(`MX_FREERTOS_Init`),
> **栈大小/优先级的唯一事实源也是 `Src/freertos.c`**;本目录只放任务**实现**。
## 任务清单
| 文件 | 任务 | 优先级 | 职责 |
|---|---|---|---|
| `default_task.c/h` | defaultTask | Normal | FTL + FatFS 挂载、配置引擎属主、syslog 唯一写者、LED 指示 |
| `net_task.c/h` | netTask | Normal | `net_stack_bring_up` → 置 NET_READY → 循环 `net_poll` + `net_process_messages` |
| `ui_task.c/h` | uiTask | BelowNormal | LVGL 界面(菜单 / 内容页 / 待机)+ 推流 |
| `adc_task.c/h` | (ADC 任务) | — | ADC 采集消费:TPAFE5160 快照 + STM32 ADC1 顺序扫描,经 [`../algo/adc_input.c`](../algo/README.md) **按语义**取数(采样节拍由 TIM6 硬件触发) |
| `rs485_task.c/h` | (RS-485 任务) | — | 半双工 RS-485 协议通信 |
| `../sync/time_sync.c` | timeSyncTask | Normal | 墙钟时间同步(实现不在本目录,见 [`../sync/README.md`](../sync/README.md)) |
> 上表栈大小/优先级以 `Src/freertos.c` 为准(`README.md`/`AGENTS.md` 的任务表由
> `scripts/gen_task_table.py` 生成,勿手抄)。
## 就绪屏障(重要)
任务间**不用魔法延时**,改用 `App/app/sys_events.h` 的 Event Flags:
- `SYS_EVT_NET_READY` / `SYS_EVT_STORAGE_READY` 等是**状态位**——等待时必须
`osFlagsWaitAny|osFlagsNoClear`,**不能用默认消费型等待**(否则先到的消费者会清掉位,
导致 `ftpTask` 的 `WaitAll(两位)` 永不成立 → FTP 静默不监听)
- `SYS_EVT_CFG_SAVE` / `SYS_EVT_NET_RECONFIG` 等**命令位**保持消费型等待
## 平台约束(改本目录前必读)
- **内核启动前禁止任何 FreeRTOS/CMSIS-RTOS API 调用**(`osMessageQueueNew` 等会杀死启动)
- 就绪屏障位一律用 `osFlagsNoClear`
- 内存预算紧张,改栈/缓冲前先核算(`docs/内存管理/`)
## 相关文档
- 启动顺序与屏障:`docs/架构设计/项目启动顺序管理.md`
- 内存与定栈规则:`docs/内存管理/RAM使用分析与优化方案.md`
- 栈水位实测:`scripts/stack_report.py`
+122 -7
View File
@@ -1,28 +1,72 @@
/*
* 模块名称:ADC Sampling Task
* 模块功能:TPAFE5160 8 通道同步采样,数据缓存与通知
* 模块功能:ADC 采集消费——TPAFE5160 快照 + STM32 ADC1 顺序扫描(经 L1 层
* App/algo/adc_input.c 按语义取数)+ 帧统计(节拍由 TIM6 硬件定时器提供)
* 状态说明:**B1 已落地(2026-10-10)**——本任务从空桩转为真实消费者:
* 用 tpafe5160_snapshot() 原子取数(AGENTS 铁律),统计帧数/无新帧次数,
* 并纳入看门狗 required_mask(TASK_WD_ADC)。
* **采样节拍(v2.2,2026-10-10)**:由 **TIM6 更新中断(1 kHz)** 硬件触发——
* ISR 内调 tpafe5160_start_conv_irq()(Src/main.c 的
* HAL_TIM_PeriodElapsedCallback);本任务**不再自行触发**,退化为**纯消费者**,
* 1ms 轮询(osDelayUntil)仅作消费节奏。详见
* docs/子系统设计/ADC采样节拍设计说明.md。
* **L1 采集层(v2.3,2026-10-10)**:取数改走 App/algo/adc_input.c——
* 本任务每拍调 adc_input_poll()(TPAFE 快照 + STM32 ADC1 9 通道扫描),
* 经 adc_input_raw() **按语义**读通道(L1-3/L1-4),不再直接触碰驱动缓冲。
* V0.1 上只有 TPAFE 三路(UA/UN/IA)测量有效;本任务只取数,不做保护运算。
* 接入前必读:取数只用 adc_input_raw() / tpafe5160_snapshot()(临界区原子快照,
* 仅任务上下文;旧的 get_buf/data_ready/clear_ready 已删除);接消费者前的
* 强制门禁见根 AGENTS.md「接 ADC / RS-485 消费者前的强制门禁」+ 建任务前后
* 各跑 scripts/stack_report.py 做栈预算。
* 适用平台:STM32F407ZGTx + TPAFE5160
* 作者:王建锋
* 创建日期:2026-07-19
* 修改记录:
* v1.0 2026-07-19 王建锋 创建初始版本
* v1.1 2026-10-05 王建锋 P3-11:注明"预留桩(未启用)",避免误认为已实现
* v2.0 2026-10-10 王建锋 B1:实现真实消费者(1ms 节拍 + 原子快照 + 帧统计 +
* 看门狗心跳),由 Src/freertos.c 创建为 adcTask
* v2.1 2026-10-10 王建锋 日志门控:首帧/周期统计仅在 dbg_console_writer_ready()
* 为真后落日志。写者未注册时控制台走无锁直发,与
* defaultTask 的存储初始化日志字节级交错(台架实测);
* 就绪前挂起上报,就绪后补报最近一帧。
* v2.2 2026-10-10 王建锋 采样节拍改由 TIM6 更新中断(1 kHz)硬件触发;本任务
* 删除自行触发,退化为纯消费者(见文件头"采样节拍")。
* v2.3 2026-10-10 王建锋 L1-3/L1-4:取数改走 App/algo/adc_input.c(语义映射 +
* STM32 ADC1 9 通道扫描);首帧日志补全 8 路电气量 + 温度,
* 心跳日志增 adc1_rc / adc1_err 观测 STM32 侧健康度。
*/
#include "adc_task.h"
#include "cmsis_os.h"
#include "adc_input.h" /* L1 采集层:adc_input_poll()/adc_input_raw()(按语义取数) */
#include "tpafe5160.h"
#include "task_watchdog.h"
#include "console_log.h"
#define DBG_TAG "[ADC_TASK]"
#include "dbg_log.h"
/* 消费轮询周期(ms)。采样节拍由 TIM6(1 kHz)提供,本周期仅作消费节奏 */
#define ADC_TASK_BEAT_MS 1U
/* 心跳日志周期(ms):周期性上报帧统计,作为采集链路存活的观测面 */
#define ADC_TASK_REPORT_MS 10000U
/* 统计:已消费帧数 / 无新帧次数(首拍必为无新帧,之后应稳定为 0 增量)。
* 原始码值缓冲由 L1 层 App/algo/adc_input.c 持有(消费侧按语义取数,不写死下标) */
static uint32_t s_frames_consumed;
static uint32_t s_frames_idle;
/*
* 函数功能:ADC 任务初始化
* 入口参数:无
* 返 回 值:0 - 成功,-1 - 失败
* 限定条件:TPAFE5160 硬件已上电
* 函数说明:配置采样参数,准备数据缓冲区
* 限定条件:tpafe5160_init() 已在 app_main_init()(内核启动前)完成
* 函数说明:驱动初始化不在本任务(见限定条件);此处仅清帧统计。
*/
int adc_task_init(void) {
s_frames_consumed = 0u;
s_frames_idle = 0u;
DBG_INFO("adc_task_init");
return 0;
}
@@ -31,13 +75,84 @@ int adc_task_init(void) {
* 函数功能:ADC 任务主函数
* 入口参数:arg - FreeRTOS 传入参数(未使用)
* 返 回 值:无
* 限定条件:adc_task_init() 已成功调用
* 函数说明:定时触发 TPAFE5160 同步采样并处理数据
* 限定条件:adc_task_init() 已调用;内核已启动
* 函数说明:每 1ms 采集一拍(adc_input_poll():TPAFE 原子快照 + STM32 ADC1 9 通道
* 顺序扫描)并统计。采样节拍由 TIM6 硬件定时器提供(ISR 内触发 CONVST),
* 本任务不再触发。稳态下 consumed 每拍 +1、idle 不再增长;idle 持续增长 =
* EXTI 未在读帧或 TIM6 未启动(异常),由周期心跳日志暴露。
*/
void adc_task_func(void const *arg) {
void adc_task_func(void *arg) {
uint32_t next = osKernelGetTickCount();
uint32_t report_at = next + ADC_TASK_REPORT_MS;
uint8_t first_logged = 0u;
(void)arg;
for (;;) {
osDelay(1000);
int r;
/* 1) 采集一拍:L1 层 adc_input_poll() = TPAFE 原子快照 + STM32 ADC1 顺序扫描
* (仅任务上下文;TPAFE 侧 1=有新帧 0=无新帧 -1=参数错)。
* 采样节拍由 TIM6 更新中断提供(ISR 内触发 CONVST),本任务不触发。 */
r = adc_input_poll();
if (r == 1) {
s_frames_consumed++;
} else if (r == 0) {
s_frames_idle++;
} else {
/* r == -1:参数错。调用点 count 为常量,不可达;不计入 idle 以区分异常 */
}
/* 2) 首帧码值上报:**写者就绪后**才落日志。写者未注册时 con_append 走
* 无锁直发路径,与 defaultTask 的存储初始化日志会字节级交错
* (2026-10-10 台架实测);此时挂起上报,就绪后补报最近一帧。
* 8 路电气量 + 温度全部经 adc_input_raw() **按语义**取数(L1-4),
* 用于验证"语义 → 物理通道"映射与两片转换器取数契约 */
if ((first_logged == 0u) && (s_frames_consumed != 0u) &&
(dbg_console_writer_ready() != 0u)) {
int32_t ua = 0, ub = 0, uc = 0, un = 0;
int32_t ia = 0, ib = 0, ic = 0, in = 0, temp = 0;
(void)adc_input_raw(ADC_SEM_UA, &ua);
(void)adc_input_raw(ADC_SEM_UB, &ub);
(void)adc_input_raw(ADC_SEM_UC, &uc);
(void)adc_input_raw(ADC_SEM_UN, &un);
(void)adc_input_raw(ADC_SEM_IA, &ia);
(void)adc_input_raw(ADC_SEM_IB, &ib);
(void)adc_input_raw(ADC_SEM_IC, &ic);
(void)adc_input_raw(ADC_SEM_IN, &in);
(void)adc_input_raw(ADC_SEM_TEMP, &temp);
first_logged = 1u;
DBG_INFO("first: UA=%ld UB=%ld UC=%ld UN=%ld IA=%ld IB=%ld IC=%ld IN=%ld T=%ld",
(long)ua, (long)ub, (long)uc, (long)un,
(long)ia, (long)ib, (long)ic, (long)in, (long)temp);
}
/* 3) 看门狗心跳(本任务已纳入 required_mask) */
task_wd_beat(TASK_WD_ADC);
/* 4) 周期心跳日志:帧统计 + 驱动侧 FRSTDATA 错误计数。
* 写者就绪前抑制(同 2) 的交错理由);到点无论是否落日志都推进下个上报点 */
{
uint32_t now = osKernelGetTickCount();
if ((int32_t)(now - report_at) >= 0) {
if (dbg_console_writer_ready() != 0u) {
DBG_INFO("frames=%lu idle=%lu seq=%lu frst_err=%u adc1_rc=%d adc1_err=%lu",
(unsigned long)s_frames_consumed,
(unsigned long)s_frames_idle,
(unsigned long)tpafe5160_frame_seq(),
(unsigned)tpafe5160_frstdata_err(),
adc_input_stm32_rc(),
(unsigned long)adc_input_stm32_errs());
}
report_at = now + ADC_TASK_REPORT_MS;
}
}
/* 5) 等下一个节拍(固定周期;落后则立即补齐,不累积漂移) */
next += ADC_TASK_BEAT_MS;
(void)osDelayUntil(next);
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ extern "C" {
#endif
int adc_task_init(void);
void adc_task_func(void const *arg);
void adc_task_func(void *arg);
#ifdef __cplusplus
}
+514
View File
@@ -0,0 +1,514 @@
/*
* 模块名称:Default Application Task
* 模块功能:主控任务(defaultTask)任务体实现:
* ① 启动段:存储 bring-up(挂载 + 空卷自动建卷 + 卷健康探针两击 +
* 自愈重格式化,E-31 失败后主循环 3×2s 有界重试)+ 网络配置加载
* + STORAGE_READY 屏障 + "保存网络配置"回调注册
* ② 控制台单写者:dbg_console_writer_attach 注册 + 主循环 dbg_console_drain
* 排空(串口唯一发送者,见 App/log/console_log.c 设计说明)
* ③ 主循环:配置保存执行 + 2s 配置轮询 + LED 心跳 + syslog 落盘
* + 栈水位观测([STACK] 报告)
* 适用平台:STM32F407ZGTx + FreeRTOS(CMSIS-RTOS V2)
* 作者:王建锋
* 创建日期:2026-09-25
* 修改记录:
* v1.0 2026-09-25 王建锋 自 Src/freertos.c 迁出 StartDefaultTask 任务体及
* stack_probe_poll / storage_bring_up / 保存通知回调
* (CubeMX 生成文件只留任务创建接线与崩溃钩子)
* v1.1 2026-10-02 王建锋 存储 bring-up 首行创建卷锁(vol_lock_init,幂等):
* FTP 文件操作与 syslog/配置的卷访问自此串行化
* (2026-10-02 台架:并发 FTL 写 → journal 竞争 →
* 本任务主循环喂狗点饥饿 → IWDG 误复位)
*/
#include "default_task.h"
#include "test_config.h"
#include "cmsis_os.h"
#include "FreeRTOS.h"
#include "task.h"
#include "main.h"
#include "ff.h"
#include "nand_ftl.h"
#include "net_cfg.h"
#include "cfg_file.h"
#include "sys_log.h"
#include "sys_events.h"
/* 深调用链模块(本任务 = 存储 bring-up + 配置引擎属主 + syslog 唯一写者,
* 全项目最深链在此):日志行缓冲收到 128 B(默认 192),本模块最长行渲染后
* < 128 → 不截断 */
#define DBG_LINE_BUF_MAX 128U
#include "dbg_log.h"
#include "task_watchdog.h"
#include "vol_lock.h"
/* 任务句柄与属性块(CubeMX 生成全局,定义于 Src/freertos.c):
* 栈水位观测需各任务句柄 + 属性块 stack_size(配置栈大小的唯一定义处) */
extern osThreadId_t defaultTaskHandle;
extern const osThreadAttr_t defaultTask_attributes;
extern osThreadId_t netTaskHandle;
extern const osThreadAttr_t netTask_attributes;
extern osThreadId_t ftpTaskHandle;
extern const osThreadAttr_t ftpTask_attributes;
extern osThreadId_t testTaskHandle;
extern const osThreadAttr_t testTask_attributes;
extern osThreadId_t uiTaskHandle;
extern const osThreadAttr_t uiTask_attributes;
extern osThreadId_t timeSyncTaskHandle;
extern const osThreadAttr_t timeSyncTask_attributes;
extern osThreadId_t adcTaskHandle;
extern const osThreadAttr_t adcTask_attributes;
/* Private defines -----------------------------------------------------------*/
/* 栈水位观测节奏:首次报告须晚于 bring-up 深链(mount/mkfs/探针/cfg/syslog),
* 之后每周期只判定"是否出现更深峰值",无新峰值不打印(保持串口静默窗口) */
#define STACK_PROBE_FIRST_MS 5000u
#define STACK_PROBE_PERIOD_MS 2000u
/* S11(2026-10-03):堆低水位告警阈值(B)。
* 依据:2026-10-03 上板两次开机的 min_ever 实测下限 3,712 B(configTOTAL_HEAP_SIZE
* = 48KB)——低于此值说明堆余量 <8%,新增任务/缓冲随时可能分配失败(隐性风险)。
* 阈值取 3,072 B 留出运行间 ~600B 波动余量,避免把正常波动误报为故障。 */
#define HEAP_LOW_WATER_B 3072u
/* E-31 自愈策略(2026-09-21 审计 P0 修复):启动期存储挂载失败后,
* 主循环按 2s 周期重跑完整 bring-up 的次数上限;耗尽则维持无存储降级
* (自检已上报、卷保留、不重格式化),等下次上电重新 resume */
#define STORAGE_BRINGUP_RETRIES 3u
/* Private typedef -------------------------------------------------------------*/
typedef struct {
const char *p_name;
const osThreadId_t *p_id;
const osThreadAttr_t *p_attr; /* 配置栈大小来源(stack_size) */
} stack_probe_task_t;
static const stack_probe_task_t s_stack_probe[] = {
{ "default", &defaultTaskHandle, &defaultTask_attributes },
{ "net", &netTaskHandle, &netTask_attributes },
{ "ftp", &ftpTaskHandle, &ftpTask_attributes },
{ "test", &testTaskHandle, &testTask_attributes },
{ "ui", &uiTaskHandle, &uiTask_attributes },
{ "timesync", &timeSyncTaskHandle, &timeSyncTask_attributes },
{ "adc", &adcTaskHandle, &adcTask_attributes },
};
#define STACK_PROBE_COUNT (sizeof(s_stack_probe) / sizeof(s_stack_probe[0]))
static void stack_probe_poll(void);
#if !defined(TEST_SUITE_STORAGE)
static void default_task_net_cfg_save_notify(void);
static FRESULT storage_bring_up(FATFS *fs);
#endif
/*
* 函数功能:defaultTask 任务体(2026-09-25 自 Src/freertos.c StartDefaultTask 迁出,
* 逻辑零改动)
* 入口参数:argument - 未用
* 返回值:无(任务不返回)
* 限定条件:仅由 defaultTask 调用(CubeMX 接线 StartDefaultTask 转调);本任务
* 只在内核运行期被调度,启动段允许使用 CMSIS 对象
* 函数说明:启动段 = 存储 bring-up + 保存回调注册 + 控制台写者注册;
* 主循环 = 事件等待(配置保存/控制台待发,50ms 兜底)→ 控制台排空
* → 配置保存执行 → 2s 配置轮询(含 E-31 有界重试)→ LED 心跳
* → syslog 落盘 → 栈水位观测
*/
void default_task_func(void *argument) {
(void)argument;
task_wd_bind(TASK_WD_DEFAULT); /* FTL 心跳路由:登记本任务句柄(入口一次) */
/* 系统启动期挂载 NAND 上的 FatFS 卷,供 FTP 及其它模块访问。
* f_mount(forced=1) 会触发 disk_initialize() → nand_ftl_init()
* (dhara_map_resume)。此前默认构建从未挂载,导致 FTP 的
* LIST/STOR/RETR 全部返回 550(f_opendir 失败)。
* 完整 bring-up 序列(挂载 + 空卷自动建卷 + 健康探针 + 自愈重格式化
* + 日志挂载 + 配置加载 + 就绪屏障)实现于 storage_bring_up()。
*
* E-31(2026-09-21 审计,P0 修复):dhara_map_resume 失败不再被当
* "新卷"静默重格式化(扫描中任何一次瞬态毛刺都会销毁全部用户数据)——
* nand_ftl 仅把 DHARA_E_TOO_BAD(真空白)判为新卷,其余失败使
* disk_initialize 失败 → f_mount 失败 → 下方自检上报并保留卷;
* 瞬态失败由主循环有界重试(STORAGE_BRINGUP_RETRIES 次 × 2s),
* 成功后补跑"存储就绪"序列(日志挂载/配置加载/屏障置位,均幂等)。
* 探针/重格式化自愈策略仍在 storage_bring_up 内(2026-09-20 修复,
* 小写两击确认、FR_INVALID_NAME 不入重格式化集、写保护类只上报,
* 详见该函数注释)。 */
#if !defined(TEST_SUITE_STORAGE)
/* E-31 自愈:存储 bring-up 剩余重试次数(0 = 已就绪或已耗尽);
* 须在启动块之前声明(启动块失败路径写入、主循环读取) */
uint32_t storage_retry = 0u;
#endif
#if !defined(TEST_SUITE_STORAGE)
/* STORAGE 套件构建下 defaultTask 完全跳过存储 bring-up(挂载/配置/日志盘写),
* 卷由 testTask 的 storage_test_run 独占——测试含 nand_ftl_deinit 掉电模拟,
* 与任何后台文件写入并发都会互相踩坏 FTL/FatFs 状态(2026-09-20 台架实证:
* 双写并发导致 mkfs 半途冻结 + BBT 被误写) */
static FATFS default_fatfs;
FRESULT fr = storage_bring_up(&default_fatfs);
if (fr != FR_OK) {
/* 存储不可写:自检故障仅能滞留待发队列(串口已告警) */
syslog_post(SYSLOG_SELFTEST, "filesystem fault");
/* E-31 自愈:启动主循环中的有界重试(每 2s 一次) */
storage_retry = STORAGE_BRINGUP_RETRIES;
}
/* 注册"保存网络配置"请求通知:UI(uiTask)投递 → 置事件 → 本任务立即执行写盘
* (配置引擎属主 = 本任务;UI 保存不再直接触碰配置文件引擎,P1-3) */
net_cfg_set_save_req_cb(default_task_net_cfg_save_notify);
#else
/* STORAGE 套件构建:挂载/配置引擎/日志盘写全部跳过,存储由 testTask 独占 */
DBG_INFO("[FS] mount/cfg skipped (STORAGE suite owns storage)");
#endif
/* 配置 poll 周期计数(每 40 次循环 = 2s 轮询一次,与引擎文档/模拟器一致。
* 循环基准 50ms:控制台"待发"事件即时唤醒,无事件时 50ms 兜底,日志时延 <=50ms) */
uint32_t cfg_poll_cnt = 0u;
uint32_t led_tick = HAL_GetTick();
/* 注册控制台写者:本任务此后是串口唯一发送者(其余任务只写环形缓冲)。
* 见 App/log/console_log.c "控制台单写者日志"——消除并发写造成的日志逐字符交错,
* 并让脏指针无法再进入 HAL_UART_Transmit 的缓冲遍历。 */
dbg_console_writer_attach();
/* 启动看门狗(延后启动):此处位于全部长耗时初始化之后——存储 bring-up 深链
* (mount/mkfs/FAT 探针/syslog 挂载/配置加载)已完成,监视一开始即处于稳态。
* 这正是"启动段不喂狗、主循环段才喂"的落点(见 docs/子系统设计/看门狗设计说明.md
* §4.1):MX_IWDG_Init() 只填参数不启动,启动延后到此时。
* 代价(显式声明):从复位到此处约数秒的窗口无看门狗保护,该段死机表现为
* "无任何输出 + LED 不闪",可人工辨识。 */
task_wd_start();
for (;;) {
/* round 8 取证:进段打点——每步前记录段号到 .noinit 槽(复位不丢)。
* 误复位后开机 `PREVRUN seg=N` 即"上一轮主循环死在哪个调用点"
* (round 7 初判"poller 自身停摆"经 round 8b 更正:真因是 IWDG 超时被静默
* 配成复位默认值,见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录)。 */
task_wd_seg(TASK_WD_SEG_POLL);
/* 心跳抽样与喂狗判定(内部节流至 100ms;全员到位才喂,否则累计停喂) */
task_wd_poll();
/* 本任务自己的心跳 */
task_wd_beat(TASK_WD_DEFAULT);
/* 等待"保存网络配置"或"控制台待发":任一即醒;无事件 50ms 后返回
* (日志时延 = 事件唤醒,实测无丢失;50ms 兜底仅防事件位异常) */
task_wd_seg(TASK_WD_SEG_EVTWAIT);
if (sys_evtHandle != NULL) {
(void)osEventFlagsWait(sys_evtHandle,
SYS_EVT_CFG_SAVE | SYS_EVT_CONSOLE_TX,
osFlagsWaitAny, 50u);
}
/* 控制台唯一发送点:把环形缓冲中的日志按块发出(无待发时立即返回) */
task_wd_seg(TASK_WD_SEG_DRAIN);
dbg_console_drain();
/* 控制台 TX 故障留痕:由本任务(syslog 唯一写者)在 drain **之外**补记 run.log。
* console_log 内部刻意不调 syslog_post —— 那会构成日志调用图成环,
* 让 armlink 静态栈深在每个任务上都多叠一条日志尾巴(见 console_log.c) */
if (dbg_console_tx_fault_log_take() != 0U) {
syslog_post(SYSLOG_RUN, "console tx fault, uart reinit");
}
/* 执行待处理保存(配置引擎属主:写盘 + 成功后立即应用);无请求 = 空操作 */
#if !defined(TEST_SUITE_STORAGE)
task_wd_seg(TASK_WD_SEG_CFGSAVE);
net_cfg_save_run();
#endif
/* 配置文件变更轮询(2s 周期;沉降去抖在引擎内) */
if ((cfg_poll_cnt++ % 40u) == 0u) {
#if !defined(TEST_SUITE_STORAGE)
task_wd_seg(TASK_WD_SEG_CFGPOLL);
/* E-31 自愈:启动期挂载失败时,每 2s 重跑完整 bring-up——
* 瞬态毛刺(如 dhara resume 扫描的瞬时 SPI 故障)通常第二次
* 即恢复;成功后由 storage_bring_up 补跑启动时跳过的
* "存储就绪"序列(日志挂载/配置加载/屏障置位,均幂等);
* 持续性故障耗尽重试则维持无存储降级(自检已在启动/耗尽时
* 上报,卷保留、不重格式化,等下次上电重新 resume) */
if (storage_retry > 0u) {
FRESULT rfr = storage_bring_up(&default_fatfs);
if (rfr == FR_OK) {
storage_retry = 0u;
DBG_INFO("[FS] storage bring-up OK on retry");
} else if (--storage_retry == 0u) {
DBG_ERROR("[FS] storage retries exhausted (fr=%d), "
"storage disabled", (int)rfr);
syslog_post(SYSLOG_SELFTEST, "filesystem fault");
}
}
/* P2-9:卷已就绪(STORAGE_READY)但日志后端未就绪(建 /log 曾失败,
* s_fs_ready 恒 0 → syslog_drain 永久 no-op)→ 2s 周期重试(幂等;
* 就绪后 syslog_mcu_mounted 立即返回,仅一次 volatile 读)。 */
if (sys_evtHandle != NULL
&& (osEventFlagsGet(sys_evtHandle) & SYS_EVT_STORAGE_READY) != 0u) {
syslog_mcu_mounted();
}
cfg_file_poll();
#endif
}
/* LED 心跳:500ms 翻转一次(与旧 500ms 循环节奏等效;循环基准改 50ms 后
* 不能再每轮翻转,否则变成 10Hz 闪烁) */
task_wd_seg(TASK_WD_SEG_LED);
if ((HAL_GetTick() - led_tick) >= 500u) {
led_tick = HAL_GetTick();
HAL_GPIO_TogglePin(LED1_GPIO_Port, LED1_Pin);
HAL_GPIO_TogglePin(LED2_GPIO_Port, LED2_Pin);
HAL_GPIO_TogglePin(LED3_GPIO_Port, LED3_Pin);
}
#if !defined(TEST_SUITE_STORAGE)
/* 排空运行期日志(上电/重启分类与自检故障落盘) */
task_wd_seg(TASK_WD_SEG_SYSLOG);
syslog_drain();
#endif
/* 栈水位观测:首次 5s 后报告,此后每 2s 采样、出现更深峰值才打印 */
task_wd_seg(TASK_WD_SEG_STACK);
stack_probe_poll();
}
}
/*
* 函数功能:栈水位周期观测(可观测性,2026-09-21 自 Src/freertos.c 迁出,
* 逻辑零改动)
* 入口参数:无
* 返回值:无
* 限定条件:仅 defaultTask 主循环调用
* 函数说明:
* 数据源:任务栈"历史最小剩余"——内核按特征值填充法统计(与
* configCHECK_FOR_STACK_OVERFLOW=2 同一机制),osThreadGetStackSpace() 取剩余
* 字节,配置字节取各任务 *_attributes.stack_size(唯一定义处,避免常量重复;
* 本版 CMSIS 包装只有 GetStackSpace 实现,无 osThreadGetStackSize)。
* 峰值占用 = 配置 - 剩余。
* 定栈规则(docs/内存管理/RAM使用分析与优化方案.md):新栈 = 峰值*1.5 + 1KB ISR
* 余量,并要求 free_min >= 新栈*30% 才算合格。
* 节奏:首次 STACK_PROBE_FIRST_MS 后报告(bring-up 深链已跑过),此后每
* STACK_PROBE_PERIOD_MS 采样一次,只在出现更深峰值时打印——既不改串口静默
* 窗口,也不漏掉之后才走到的更深分支(如卷修复/配置保存路径)。
* 输出(纯 ASCII,便于 @test 采集):
* [STACK] <task> peak_used=<B>/<cfg> free_min=<B> (<pct>%)
* [STACK] heap free=<B> min_ever=<B>
* 未覆盖 idle/timer 任务(内核静态小栈、纯循环,未取句柄)。
*/
static void stack_probe_poll(void) {
static uint32_t s_free_min[STACK_PROBE_COUNT]; /* 历史最小剩余(B),0=未采样 */
static uint32_t s_last_tick;
static uint8_t s_reported;
const TickType_t now = xTaskGetTickCount();
uint32_t i;
uint8_t deeper = 0u;
if (s_reported == 0u) {
if (now < pdMS_TO_TICKS(STACK_PROBE_FIRST_MS)) {
return;
}
} else if ((uint32_t)(now - s_last_tick) < (uint32_t)pdMS_TO_TICKS(STACK_PROBE_PERIOD_MS)) {
return;
}
s_last_tick = (uint32_t)now;
/* S11 堆低水位告警(2026-10-03):min_ever 跌破阈值 → 串口告警 + 自检留痕。
* 独立于下方"出现更深栈峰值才打印"的抑制:堆水位与栈峰值无关,须每 2s 采样。
* xPortGetMinimumEverFreeHeapSize() 单调不增 → 一旦跌破即持续成立,故只报一次
* (latch),既不刷屏,也不会漏(s_heap_alarm 置位后不再复报)。 */
{
static uint8_t s_heap_alarm;
const uint32_t min_ever = (uint32_t)xPortGetMinimumEverFreeHeapSize();
if ((s_heap_alarm == 0u) && (min_ever < HEAP_LOW_WATER_B)) {
s_heap_alarm = 1u;
DBG_ERROR("[HEAP] low water: %lu B (< %lu B)",
(unsigned long)min_ever, (unsigned long)HEAP_LOW_WATER_B);
(void)syslog_post(SYSLOG_SELFTEST, "heap low water");
}
}
for (i = 0u; i < (uint32_t)STACK_PROBE_COUNT; i++) {
const osThreadId_t th = *(s_stack_probe[i].p_id);
uint32_t free_now;
if (th == NULL) {
continue; /* 任务未创建 */
}
free_now = osThreadGetStackSpace(th);
if (free_now == 0u) {
continue; /* 句柄不可用:跳过 */
}
if ((s_free_min[i] == 0u) || (free_now < s_free_min[i])) {
s_free_min[i] = free_now; /* 刷新历史最小(= 最深峰值) */
deeper = 1u;
}
}
if ((s_reported != 0u) && (deeper == 0u)) {
return; /* 无新峰值:静默 */
}
s_reported = 1u;
for (i = 0u; i < (uint32_t)STACK_PROBE_COUNT; i++) {
const uint32_t free_min = s_free_min[i];
const uint32_t cfg = (uint32_t)s_stack_probe[i].p_attr->stack_size;
if (free_min == 0u) {
continue;
}
if (cfg == 0u) {
continue;
}
DBG_INFO("[STACK] %s peak_used=%lu/%lu free_min=%lu (%lu%%)",
s_stack_probe[i].p_name,
(unsigned long)(cfg - free_min), (unsigned long)cfg,
(unsigned long)free_min,
(unsigned long)((free_min * 100u) / cfg));
}
DBG_INFO("[STACK] heap free=%lu min_ever=%lu",
(unsigned long)xPortGetFreeHeapSize(),
(unsigned long)xPortGetMinimumEverFreeHeapSize());
}
/*
* 函数功能:存储 bring-up 完整序列(挂载 + 空卷自动建卷 + 卷健康探针两击 +
* 自愈重格式化 + 日志挂载 + 配置加载 + "存储就绪"屏障;
* 2026-09-25 自 Src/freertos.c 迁出,逻辑零改动)
* 入口参数:fs - 调用方持有的 FATFS 实例(defaultTask 局部 static)
* 返回值:FRESULT - FR_OK = 就绪(含屏障已置位);其它 = 失败(卷保留)
* 限定条件:仅 defaultTask 调用(配置引擎/日志盘写属主);STORAGE 套件构建
* 下整体不参与编译(卷由 testTask 独占)
* 函数说明:
* 由 defaultTask 调用:启动期一次;若失败,主循环每 2s 重试至多
* STORAGE_BRINGUP_RETRIES 次(E-31 自愈策略,2026-09-21):dhara_map_resume
* 失败分类修复后(nand_ftl.c 仅真空白判新卷,其余失败保留卷并返回
* disk_initialize 失败),重试让瞬态毛刺(SPI 瞬时故障/扫描窗口抖动)
* 在数秒内免重启恢复,成功时补跑启动期跳过的就绪序列(日志挂载/配置
* 加载/屏障置位,三者均幂等、可重跑);持续故障耗尽重试后进入无存储
* 降级态(自检已上报、卷保留、不重格式化,等下次上电重新 resume)。
*
* 探针/重格式化策略(2026-09-20 台架实证修复,原启动块注释迁入):
* 小写两击确认防 FTL 瞬态抖动误触发;仅结构性错误集合(已排除
* FR_INVALID_NAME)触发自愈重格式化;写保护/卷锁/参数类只上报不重格式化
* (防对写锁卷整卷擦除)。
*/
#if !defined(TEST_SUITE_STORAGE)
static FRESULT storage_bring_up(FATFS *fs) {
FRESULT fr;
/* 卷锁创建(幂等):必须早于本卷任何跨任务使用者——ftpTask 的 lftpd 文件
* 操作受 STORAGE_READY 屏障门控,屏障在本函数末尾才置位,时序天然安全 */
(void)vol_lock_init();
fr = f_mount(fs, "", 1);
if (fr == FR_NO_FILESYSTEM) {
/* 卷为空(如恢复/坏块重建后清空):自动格式化建卷
* (MKFS_PARM 与存储测试一致:FM_FAT32 / 无分区表) */
DBG_INFO("[FS] no FAT volume, formatting NAND...");
static uint8_t fs_work[FF_MAX_SS];
MKFS_PARM opts = { FM_FAT32, 0, 0, 0, 0 };
/* 格式化前清空 FTL map 树:新卷从空树开始写,journal
* 不与旧卷残留共存(结构卫生)。注:这不是"无限重格式化"的根因——
* 真根因是 FatFs 探针文件名非法(见下方探针注释)。 */
(void)nand_ftl_format();
fr = f_mkfs("", &opts, fs_work, sizeof(fs_work));
if (fr == FR_OK) {
fr = f_mount(fs, "", 1);
} else {
DBG_ERROR("[FS] f_mkfs failed (fr=%d)", (int)fr);
}
}
if (fr == FR_OK) {
/* 探针:/fschk.tmp 小写一次,验证 FAT 链真实可用。本移植
* f_write 簇分配失败返回 FR_OK+短写 → 必须校验写回字节数;短写按
* FR_DENIED 归一化(落入下方结构性集合触发重格式化)。
* ⚠ 事故教训(2026-09-20 台架实证,"无限重格式化"真根因):本移植
* FF_USE_LFN=0,ff.c create_short 把 ".fschk" 点后的 FSCHK 当扩展域
* 解析(5 字符 > 3 上限)→ f_open 恒返 FR_INVALID_NAME(6)(纯 RAM
* 判定、不碰盘)→ 曾误入下方结构性错误集合 → 每 boot 假失败 →
* 无限重格式化。探针名必须严格 8.3:名≤8、扩展≤3、不以点开头。 */
#define FS_VOL_PROBE(h) do { \
static FIL __chk; /* static:FF_FS_TINY=0 → 局部 FIL 吃 528 B 栈;探针只在 \
* defaultTask 启动/自愈路径跑,无并发 */ \
static const uint8_t __chkbuf[8] = { 0 }; \
UINT __bw = 0u; \
FRESULT __wr; \
(h) = f_open(&__chk, "/fschk.tmp", FA_WRITE | FA_OPEN_ALWAYS); \
if ((h) == FR_OK) { \
__wr = f_write(&__chk, __chkbuf, (UINT)sizeof(__chkbuf), &__bw); \
(void)f_close(&__chk); \
if (__wr == FR_OK && __bw == (UINT)sizeof(__chkbuf)) { \
(h) = FR_OK; \
(void)f_unlink("/fschk.tmp"); \
} else { \
(h) = (__wr != FR_OK) ? __wr : FR_DENIED; \
} \
} \
} while (0)
FRESULT hfr;
FS_VOL_PROBE(hfr);
if (hfr != FR_OK) {
FS_VOL_PROBE(hfr); /* 两击确认 */
}
if (hfr != FR_OK) {
/* ⚠ FR_INVALID_NAME 绝不可入本集合:它是路径名语法错误(纯 RAM 判定、
* 不碰盘),与卷健康无关(曾误入 → "无限重格式化"事故) */
if (hfr == FR_DENIED || hfr == FR_INVALID_OBJECT ||
hfr == FR_INT_ERR || hfr == FR_DISK_ERR ||
hfr == FR_NO_FILE || hfr == FR_NO_PATH ||
hfr == FR_NO_FILESYSTEM) {
DBG_INFO("[FS] volume FAT invalid (fr=%d), reformatting...",
(int)hfr);
f_mount(NULL, "", 0);
/* 格式化前清空 FTL map 树(同首启路径:新卷从空树写起) */
(void)nand_ftl_format();
{
static uint8_t fs_work2[FF_MAX_SS];
MKFS_PARM opts2 = { FM_FAT32, 0, 0, 0, 0 };
fr = f_mkfs("", &opts2, fs_work2, sizeof(fs_work2));
if (fr == FR_OK) {
fr = f_mount(fs, "", 1);
}
}
if (fr == FR_OK) {
DBG_INFO("[FS] volume reformatted OK");
} else {
DBG_ERROR("[FS] volume repair failed (fr=%d)", (int)fr);
syslog_post(SYSLOG_SELFTEST, "storage fault");
}
} else {
/* 其余错误(写保护/卷锁/参数类)不重格式化——只上报,
* 防对写锁卷整卷擦除(FTL 磨损最大操作) */
DBG_ERROR("[FS] volume probe failed (fr=%d)", (int)hfr);
syslog_post(SYSLOG_SELFTEST, "storage fault");
fr = FR_INT_ERR;
}
}
#undef FS_VOL_PROBE
if (fr == FR_OK) {
DBG_INFO("[FS] FATFS mounted");
/* 日志引擎挂载后处理:建 /log 目录 + 写"上电/重启"分类记录(唯一写者) */
syslog_mcu_mounted();
/* 加载网络配置进 RAM 副本(配置引擎属主 = 本任务;只读 INI,
* 文件缺失/损坏 = 工厂默认,不阻塞组网)。须在置位 STORAGE_READY 前,
* 使"存储就绪"屏障蕴含"配置已就绪",netTask 组网时配置恒有效 */
(void)net_cfg_load();
/* 广播"存储就绪"(= 卷挂载 + 配置加载完成),唤醒依赖文件系统的任务 */
if (sys_evtHandle != NULL) {
osEventFlagsSet(sys_evtHandle, SYS_EVT_STORAGE_READY);
}
}
} else {
DBG_ERROR("[FS] f_mount failed (fr=%d)", (int)fr);
}
return fr;
}
#endif /* !TEST_SUITE_STORAGE */
/*
* 函数功能:"保存网络配置"请求通知(注册给 net_cfg 的保存交接;
* 2026-09-25 自 Src/freertos.c 迁出,逻辑零改动)
* 入口参数:无
* 返回值:无
* 限定条件:仅由 uiTask 上下文经 net_cfg 回调调用(只置事件位,无内核对象依赖)
* 函数说明:uiTask 投递保存请求 → 置 SYS_EVT_CFG_SAVE → defaultTask 循环
* 立即唤醒执行写盘
*/
#if !defined(TEST_SUITE_STORAGE)
static void default_task_net_cfg_save_notify(void) {
if (sys_evtHandle != NULL) {
(void)osEventFlagsSet(sys_evtHandle, SYS_EVT_CFG_SAVE);
}
}
#endif
+27
View File
@@ -0,0 +1,27 @@
/*
* 模块名称:Default Application Task
* 模块功能:主控任务(defaultTask)任务体:存储 bring-up(挂载/建卷/探针/自愈/
* 有界重试)、网络配置引擎属主(保存执行 + 2s 轮询)、控制台单写者
* (注册 + 主循环排空)、syslog 落盘、LED 心跳、栈水位观测
* 适用平台:STM32F407ZGTx + FreeRTOS(CMSIS-RTOS V2)
* 作者:王建锋
* 创建日期:2026-09-25
* 修改记录:
* v1.0 2026-09-25 王建锋 自 Src/freertos.c StartDefaultTask 任务体迁出
* (CubeMX 生成文件只留任务创建接线与崩溃钩子)
*/
#ifndef DEFAULT_TASK_H
#define DEFAULT_TASK_H
#ifdef __cplusplus
extern "C" {
#endif
void default_task_func(void *argument);
#ifdef __cplusplus
}
#endif
#endif /* DEFAULT_TASK_H */
+157 -10
View File
@@ -10,6 +10,14 @@
* v1.1 2026-07-19 王建锋 migrate from freertos.c StartNetTask
* v1.2 2026-07-21 王建锋 网络初始化移入本任务(net_init + TCP 监听)
* v1.3 2026-07-22 王建锋 单连接模式重构,增加大文件/边界/KeepAlive 测试
* v1.4 2026-09-14 王建锋 新增自检埋点:PHY 链路 3s 未建立 → sys_log "comms fault"
* v1.5 2026-09-15 王建锋 配置加载移交 defaultTask(配置引擎属主,P1-3);
* 本任务只读 RAM 副本
* v1.6 2026-09-15 王建锋 P1-6:入口加 CH395F 硬件层测试模式自挂起守卫
* (基础线程无条件创建,该模式下不运行);
* freertos.c USER CODE 区重复创建已删除
* v1.7 2026-10-02 王建锋 P1-8:初始组网失败/重建失败 → 自检记录 + 30s 周期重试
* (原实现:初始失败 = netTask 无限驻留;重建失败 = 静默丢配置)
*/
#include <string.h>
@@ -17,24 +25,63 @@
#include "cmsis_os.h"
#include "net_socket.h"
#include "net_select.h"
#include "net_cfg.h"
#include "ch395f.h"
#include "sys_events.h"
#include "sys_log.h"
#include "test_config.h" /* 测试模式宏(ENABLE_HW_LAYER_TESTS);产品构建全未定义,无作用 */
#include "task_watchdog.h"
#define DBG_TAG "[NET_TASK]"
#include "dbg_log.h"
/*
* 函数功能:"立即应用网口配置"平台回调(注册给 net_cfg)
* 入口参数:无
* 返回值:无
* 限定条件:保存写盘成功后经 net_cfg_request_apply() 调用(MCU 上由
* defaultTask 上下文调用;任意任务上下文均安全)
* 函数说明:只置事件位;真正的重建在主循环内完成(CH395F SPI 归 netTask 独占,
* UI 不得直接操作)
*/
/* P1-8(2026-10-02):重建失败延迟重试的截止 tick;0 = 无待重试 */
static uint32_t s_reinit_retry_at;
static void net_reconfig_request_cb(void) {
if (sys_evtHandle != NULL) {
(void)osEventFlagsSet(sys_evtHandle, SYS_EVT_NET_RECONFIG);
}
}
/*
* 函数功能:CH395F 协议栈一次性初始化(两种构建模式共用)
* 入口参数:无
* 返 回 值:0 - 成功,-1 - 失败
* 函数说明:net_init → MAC 打印 → PHY 强制 100M 全双工 → 广播网络就绪事件。
* 函数说明:net_cfg 已由 defaultTask 在就绪屏障前加载进 RAM 副本(配置引擎
* 属主 = defaultTask,本任务不触碰配置文件引擎)→ 只读副本组网 →
* net_init → MAC 回写
* 配置模块 → MAC 打印 → PHY 强制 100M 全双工 → 广播网络就绪事件。
* NET 层模式由 netTask 调用;硬件层模式(阶段 1~5)netTask 不创建,
* 由 testTask 调用后独占 CH395F。
*/
int net_stack_bring_up(void) {
const net_cfg_t *cfg;
char ip[16];
char mask[16];
char gw[16];
DBG_INFO("=== Network Init ===");
if (net_init("192.168.1.100", "255.255.255.0", "192.168.1.1") != 0) {
/* 网络配置:defaultTask 已在 SYS_EVT_STORAGE_READY 屏障前置入 RAM 副本
* (首启/文件缺失/损坏 = 工厂默认,与既有硬编码组网值一致);
* 此处只读副本,不触碰配置文件引擎 */
cfg = net_cfg_get();
net_cfg_fmt_ipv4(cfg->ip, ip, sizeof(ip));
net_cfg_fmt_ipv4(cfg->mask, mask, sizeof(mask));
net_cfg_fmt_ipv4(cfg->gw, gw, sizeof(gw));
if (net_init(ip, mask, gw, cfg->mac) != 0) {
DBG_ERROR("net init: FAIL");
return -1;
}
@@ -45,6 +92,10 @@ int net_stack_bring_up(void) {
DBG_INFO("MAC: %02X:%02X:%02X:%02X:%02X:%02X",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
/* 回写芯片实际 MAC 到配置模块(UI "网口设置"页只读影子,
* 不直接触 CH395F SPI——SPI 归 netTask 集中处理) */
net_cfg_set_active_mac(mac);
uint8_t ipinf[20];
ch395f_get_ip_inf(ipinf);
DBG_INFO("IP_INF: IP=%d.%d.%d.%d GW=%d.%d.%d.%d MASK=%d.%d.%d.%d",
@@ -67,14 +118,27 @@ int net_stack_bring_up(void) {
if (phy != CH395F_PHY_DISCONN) {
break;
}
/* 心跳埋点:本循环单次最长约 1.1s(含 PHY 仍未就绪的 60 次迭代里
* 每次 50ms 延时叠加),远超看门狗 100ms 抽样窗——必须在延时段补心跳,
* 否则 PHY 未插网线时本任务会被误判为卡死(见设计说明 §4.4 长耗时白名单) */
task_wd_beat(TASK_WD_NET);
osDelay(50);
}
DBG_INFO("PHY: %s",
(phy == CH395F_PHY_100M_FULL) ? "100M FULL" :
(phy == CH395F_PHY_100M_HALF) ? "100M HALF" :
(phy == CH395F_PHY_10M_FULL) ? "10M FULL" :
(phy == CH395F_PHY_10M_HALF) ? "10M HALF" :
(phy == CH395F_PHY_DISCONN) ? "DISCONNECT" : "UNKNOWN");
(phy == CH395F_PHY_10M_HALF) ? "10M HALF" :
(phy == CH395F_PHY_DISCONN) ? "DISCONNECT" : "UNKNOWN");
/* 自检埋点:链路超时未建立 → 记入自检记录(仅故障才写) */
if (phy == CH395F_PHY_DISCONN) {
syslog_post(SYSLOG_SELFTEST, "comms fault");
}
/* 网络已重建完成、对外可用:递增代次号,通知各 TCP 服务(FTP/对时/远程显示)
* 旧 Socket 已随芯片复位失效、应重建监听 */
net_generation_inc();
return 0;
}
@@ -95,19 +159,67 @@ int net_task_init(void) {
* 函数功能:网络任务主函数
* 入口参数:arg - FreeRTOS 传入参数(未使用)
* 返 回 值:无
* 函数说明:启动后先调用 net_stack_bring_up() 初始化 CH395F 协议栈,
* 函数说明:等待存储就绪(就绪屏障 = 卷挂载 + 网络配置加载完成,10s 超时
* 回退工厂默认,存储故障不阻塞组网)→ net_stack_bring_up() 初始化
* CH395F 协议栈,
* 再进入轮询循环处理网络事件。
* 仅 NET 层测试模式(阶段 6~10)会创建本任务;
* 硬件层模式下 netTask 不创建,CH395F 由 testTask 独占。
* 本任务 = 基础线程:CubeMX 生成区无条件创建(全构建唯一实例);
* CH395F 硬件层测试模式(阶段 1~5)下入口自挂起(不 bring_up、
* 不轮询、不广播 NET_READY),由 testTask 独占 CH395F。
*/
void net_task_func(void const *arg) {
(void)arg;
osDelay(2000);
task_wd_bind(TASK_WD_NET); /* FTL 心跳路由:登记本任务句柄(入口一次) */
if (net_stack_bring_up() != 0) {
/* 硬件层测试模式:testTask 独占 CH395F,本任务自挂起(基础线程恒创建,
* 该模式下"不运行"):不 bring_up、不 net_poll、不广播 NET_READY;
* ftpTask/timeSyncTask 将永久阻塞于屏障 = 预期行为(见《项目启动顺序管理》) */
#if defined(ENABLE_HW_LAYER_TESTS)
DBG_INFO("netTask parked: HW-layer test mode, CH395F owned by testTask");
for (;;) {
/* 该模式下本任务已被 task_wd_required_mask() 剔除,喂心跳无副作用;
* 保留埋点以免掩码配置变更时意外误杀 */
task_wd_beat(TASK_WD_NET);
osDelay(1000);
}
#endif
/* 配置在 NAND 的 FatFS 卷上:等存储就绪再读(取代原 2s 魔法延时);
* 超时(存储异常)继续以工厂默认组网,不卡网络启动 */
if (sys_evtHandle != NULL) {
/* osFlagsNoClear:就绪位是状态,不能被本任务消费(ftpTask 等仍要 WaitAll 两位)。
* 2026-10-02 台架修复:10s 超时等待改 100ms 分片 + 每片喂心跳——原实现
* 在等待期无心跳:若存储 bring-up 失败,本等待跑满 10s 超时,而 IWDG
* 此时已启动(defaultTask 在 bring-up 后即启动),NET 位长期缺失 →
* 必需掩码不可达 → 复位循环,E-31 的 2s 自愈重试根本没有机会。
* 分片喂位保证"存储未就绪"降级态下 NET 位持续新鲜。 */
uint32_t rounds_left = 100u; /* 100 × 100ms = 原 10s 超时语义 */
for (;;) {
osDelay(1000);
uint32_t r = osEventFlagsWait(sys_evtHandle, SYS_EVT_STORAGE_READY,
osFlagsWaitAny | osFlagsNoClear, 100u);
if ((r & osFlagsError) == 0u) {
break; /* 存储就绪 */
}
task_wd_beat(TASK_WD_NET); /* 看门狗:分片喂心跳(屏障等待态) */
if ((--rounds_left) == 0u) {
break; /* 10s 超时:工厂默认组网,不阻塞网络启动 */
}
}
}
/* P1-8(2026-10-02):初始组网失败不自驻留——自检台账留痕 + 30s 周期重试
* (1s 分片、每片喂心跳):线缆/芯片恢复即可当场完成组网;原 for(;;)osDelay
* 会令看门狗 NET 位持续陈旧,并阻塞全部下游就绪屏障 */
for (;;) {
if (net_stack_bring_up() == 0) {
break;
}
DBG_ERROR("netTask: init FAIL, retry in 30s");
(void)syslog_post(SYSLOG_SELFTEST, "net init fail");
for (uint32_t i = 0U; i < 30U; i++) {
osDelay(1000U);
task_wd_beat(TASK_WD_NET);
}
}
@@ -116,10 +228,45 @@ void net_task_func(void const *arg) {
osEventFlagsSet(sys_evtHandle, SYS_EVT_NET_READY);
}
/* 注册"立即应用网口配置"回调:UI 保存成功后经 net_cfg_request_apply() 触发 */
net_cfg_set_apply_cb(net_reconfig_request_cb);
DBG_INFO("netTask: started (single-socket mode)");
/* ==================== 主轮询循环 ==================== */
for (;;) {
/* 心跳:本任务在监视掩码内(ENABLE_HW_LAYER_TESTS 下由 task_wd_required_mask
* 剔除,本埋点无副作用) */
task_wd_beat(TASK_WD_NET);
/* UI 保存网口配置后请求立即应用:在此重建网络(新 IP/掩码/网关/MAC)。
* 重建完成后代次号已递增,各 TCP 服务据此重建监听;期间连接会断开(预期)。
* P2-11(2026-10-04):用 osEventFlagsWait(timeout=0) 原子"取走并清除"RECONFIG
* 位,替换原 Get+Clear 两步;原写法在 Get 与 Clear 之间若有生产者在同一位上
* Set,该次请求会被 Clear 一并吞掉(丢事件窗口)。 */
if (sys_evtHandle != NULL) {
uint32_t evt = osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_RECONFIG,
osFlagsWaitAny, 0U);
if ((evt & osFlagsError) == 0u) {
DBG_INFO("netTask: apply new net config (re-init)");
if (net_stack_bring_up() == 0) {
(void)osEventFlagsSet(sys_evtHandle, SYS_EVT_NET_READY);
s_reinit_retry_at = 0U;
} else {
/* P1-8(2026-10-02):重建失败 → 自检留痕 + 30s 延迟重试(重事件位),
* 新配置不再静默丢失(原实现仅一条 DBG_ERROR 后丢弃) */
DBG_ERROR("netTask: re-init FAIL, retry in 30s");
(void)syslog_post(SYSLOG_SELFTEST, "net reinit fail");
s_reinit_retry_at = (uint32_t)HAL_GetTick() + 30000U;
}
} else if ((s_reinit_retry_at != 0U)
&& ((int32_t)((uint32_t)HAL_GetTick() - s_reinit_retry_at) >= 0)) {
/* P1-8:30s 倒计时到 → 重新触发重建(符号差处理 tick 回绕) */
s_reinit_retry_at = 0U;
(void)osEventFlagsSet(sys_evtHandle, SYS_EVT_NET_RECONFIG);
}
}
net_poll();
net_process_messages();
osDelay(1);
+5 -3
View File
@@ -7,6 +7,8 @@
* 修改记录:
* v1.0 2026-07-19 王建锋 创建初始版本
* v1.1 2026-08-24 王建锋 提取 net_stack_bring_up() 供硬件层/NET 层两种测试模式共用
* v1.2 2026-09-15 王建锋 硬件层模式描述修正:netTask 恒创建(基础线程),
* 该模式下自挂起不运行(P1-6 守卫)
*/
#ifndef NET_TASK_H
@@ -16,9 +18,9 @@
extern "C" {
#endif
/* CH395F 协议栈一次性初始化(net_init + MAC 打印 + PHY 配置 + 广播网络就绪事件)。
* 返回 0 成功 / -1 失败。NET 层模式由 netTask 调用;硬件层模式(阶段 1~5)
* netTask 不创建,由 testTask 调用后独占 CH395F。 */
/* CH395F 协议栈一次性初始化(net_init + MAC 打印 + PHY 配置 + 广播网络就绪事件)。
* 返回 0 成功 / -1 失败。产品 / NET 层模式由 netTask 调用;硬件层模式(阶段 1~5)
* netTask 自挂起(创建但不运行),由 testTask 调用后独占 CH395F。 */
int net_stack_bring_up(void);
int net_task_init(void);
+4
View File
@@ -1,11 +1,15 @@
/*
* 模块名称:RS485 Communication Task
* 模块功能:RS-485 轮询收发、协议帧解析与响应
* 状态说明:**预留桩(未启用)**——采集/协议链路尚未实现,产品定义未定
* (见 docs/待处理内容.md 阶段 B0/B2)。当前 init/func 为空实现,
* 不收发、不解析。
* 适用平台:STM32F407ZGTx
* 作者:王建锋
* 创建日期:2026-07-19
* 修改记录:
* v1.0 2026-07-19 王建锋 创建初始版本
* v1.1 2026-10-05 王建锋 P3-11:注明"预留桩(未启用)",避免误认为已实现
*/
#include "rs485_task.h"
+63 -3
View File
@@ -5,15 +5,35 @@
* 作者:王建锋
* 创建日期:2026-08-30
* 修改记录:
* v1.1 2026-09-14 王建锋 新增待机显示页初始化(ui_standby_init,空闲 60s 切入)
*/
#include "ui_task.h"
#include <string.h>
#include "cmsis_os.h"
#include "sys_events.h"
#include "ui_port.h"
#include "ui_menu.h"
#include "ui_net_sink.h"
#include "ui_standby.h"
#include "sys_clock.h"
#include "test_config.h" /* TEST_LATENCY_PROBE(链路时延探针编译开关) */
#include "net_latency_test.h" /* M3 探针:ui_loop(生产循环实测周期) */
#include "task_watchdog.h"
/*
* 函数功能:待机屏底部时钟提供回调(平台差异:MCU = sys_clock_get_str)
* 入口参数:buf - 时间串输出缓冲 char *
* len - buf 容量 size_t
* 返回值:无
*/
static void ui_task_time_cb(char *buf, size_t len) {
if (sys_clock_get_str(buf, len) == NULL && len > 0u) {
buf[0] = '\0'; /* 墙钟无效:空串(待机屏时钟行留白) */
}
}
#define DBG_TAG "[UI_TASK]"
#include "dbg_log.h"
@@ -27,8 +47,19 @@
*/
static void ui_task_wait_net_ready(void) {
if (sys_evtHandle != NULL) {
osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY,
osFlagsWaitAny, osWaitForever);
/* osFlagsNoClear:就绪位是状态,不能被本任务消费掉(否则其它等待者永不唤醒)。
* 2026-10-02 台架修复:等待改 100ms 分片 + 每片喂心跳(与 timeSync/netTask/
* ftpTask 入口同模式)——IWDG 必需掩码含 UI 位,原 osWaitForever 无心跳:
* IWDG 启动(存储就绪)时网络未必就绪 → 掩码 800ms 不可达 → 停喂 → 复位循环。
* 100ms 分片 = 抽样窗,纯任务睡眠零队列开销。 */
for (;;) {
uint32_t r = osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY,
osFlagsWaitAny | osFlagsNoClear, 100u);
if ((r & osFlagsError) == 0u) {
break; /* NET_READY 已置位(状态位,未被消费) */
}
task_wd_beat(TASK_WD_UI); /* 看门狗:分片喂心跳(屏障等待态) */
}
}
}
@@ -43,6 +74,11 @@ static void ui_task_wait_net_ready(void) {
void ui_task_func(void *argument) {
(void)argument;
task_wd_bind(TASK_WD_UI); /* FTL 心跳路由:登记本任务句柄(入口一次) */
/* 等待网络就绪期间不可能喂狗:本任务此时尚未进入主循环,而就绪位由 netTask 置位。
* 该段若长期不成立,看门狗会复位——这是期望行为(网络 bring-up 卡死须暴露)。
* 注意本任务在 required_mask 内,主循环跑起来后须保证每 100ms 窗口内至少喂一次。 */
DBG_INFO("UI task: started, wait net ready");
ui_task_wait_net_ready();
DBG_INFO("UI task: net ready, init port");
@@ -59,14 +95,38 @@ void ui_task_func(void *argument) {
for (;;) { osDelay(1000); }
}
DBG_INFO("UI menu init: OK");
if (ui_standby_init(60000u, ui_task_time_cb) != 0) {
DBG_ERROR("UI standby init: FAIL");
} else {
DBG_INFO("UI standby: ready (idle=60s)");
}
if (ui_net_sink_start(UI_NET_SINK_PORT) != 0) {
DBG_ERROR("UI net sink: start FAIL");
} else {
DBG_INFO("UI net sink: started (port %u)", (unsigned)UI_NET_SINK_PORT);
}
DBG_INFO("UI net sink: started (port %u)", (unsigned)UI_NET_SINK_PORT);
#if defined(TEST_LATENCY_PROBE)
/* M3 探针 ui_loop:主循环实测周期(= LVGL 渲染 + 推流轮询 + osDelay(10))。
* 每轮开头先结算"上一轮到此轮"的间隔,再重置标记 → 得到真实循环周期。
* 单写者(本任务)/单读者(testTask),样本经 lat_probe_add 落静态缓冲。 */
lat_tmark_t tm_loop;
uint8_t tm_loop_valid = 0U;
#endif
while (1) {
#if defined(TEST_LATENCY_PROBE)
if (tm_loop_valid != 0U) {
lat_probe_add_seg("ui_loop", lat_mark_us(&tm_loop));
}
lat_mark(&tm_loop);
tm_loop_valid = 1U;
#endif
ui_port_step(10);
ui_net_sink_poll();
/* 心跳:ui_port_step 内含 LVGL 渲染,单轮耗时可能逼近 10ms 量级;
* 100ms 抽样窗内可容纳多轮,此埋点足够 */
task_wd_beat(TASK_WD_UI);
osDelay(10);
}
}
-138
View File
@@ -1,138 +0,0 @@
/*
* 模块名称:Time Sync — PC 本地时间推送接收(TCP)
* 模块功能:在独立任务 timeSyncTask 中以 TCP Server 方式监听端口,接收 PC 推送的本地时间包,
* 调用 sys_clock_set_unix() 更新系统墙钟并写回 SD2506 RTC
* 适用平台:STM32F407ZGTx + CH395F
* 作者:王建锋
* 创建日期:2026-08-27
* 修改记录:
* v1.0 2026-08-27 王建锋 创建初始版本(UDP 推送,netTask 内轮询)
* v1.1 2026-08-28 王建锋 改为 TCP Server,迁入独立任务 timeSyncTask,
* 所有 socket 操作经 net_socket 线程安全 API 走消息队列,
* netTask 仅负责 net_poll/net_process_messages,多任务并发安全
*/
#include <string.h>
#include "time_sync.h"
#include "net_socket.h"
#include "sys_clock.h"
#include "sys_events.h"
#define DBG_TAG "[TIME_SYNC]"
#include "dbg_log.h"
/* 监听端口:PC 推送端(TCP 客户端)需连到本端口 */
#define TIME_SYNC_PORT 8888
/*
* 函数功能:创建并监听 TCP 时间同步服务端口
* 入口参数:无
* 返回值:监听 Socket 描述符(>=0),失败返回 -1
* 限定条件:net_init() 已成功;可在非 netTask 上下文调用
* 函数说明:net_socket/net_bind/net_listen 均为线程安全 API,内部经消息队列由 netTask 串行处理。
* 单连接模式下监听 Socket 在收到连接后会转为数据通道,连接关闭后即被释放,
* 因此每次调用都会新建监听 Socket(与 lftpd 同模式),失败自动重试。
*/
static int time_sync_open_listener(void) {
int retry;
for (retry = 0; retry < 5; retry++) {
int s = net_socket(NET_AF_INET, NET_SOCK_STREAM);
if (s < 0) {
DBG_ERROR("time_sync: net_socket FAIL");
vTaskDelay(pdMS_TO_TICKS(500));
continue;
}
struct net_sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = NET_AF_INET;
addr.sin_port = net_htons(TIME_SYNC_PORT);
addr.sin_addr.s_addr = NET_INADDR_ANY;
if (net_bind(s, (struct net_sockaddr *)&addr, sizeof(addr)) != 0) {
DBG_ERROR("time_sync: bind port %d FAIL", TIME_SYNC_PORT);
net_close(s);
vTaskDelay(pdMS_TO_TICKS(2000));
continue;
}
if (net_listen(s) != 0) {
DBG_ERROR("time_sync: listen FAIL");
net_close(s);
vTaskDelay(pdMS_TO_TICKS(2000));
continue;
}
DBG_INFO("time_sync: TCP listen on :%d (sock=%d)", TIME_SYNC_PORT, s);
return s;
}
return -1;
}
/*
* 函数功能:处理一个已建立的时间同步连接
* 入口参数:conn_sock - 已建立的连接 Socket 描述符(单连接模式下即监听 Socket 自身)
* 返回值:无
* 函数说明:阻塞读取 PC 发来的时间包("TIME"+uint32 大端 本地时间),解析后写回 RTC,然后关闭连接
*/
static void time_sync_handle_conn(int conn_sock) {
uint8_t buf[16];
int len;
len = net_recv(conn_sock, buf, sizeof(buf), 0);
if (len >= 8) {
if (buf[0] == 'T' && buf[1] == 'I' && buf[2] == 'M' && buf[3] == 'E') {
uint32_t epoch = ((uint32_t)buf[4] << 24) |
((uint32_t)buf[5] << 16) |
((uint32_t)buf[6] << 8) |
((uint32_t)buf[7]);
sys_clock_set_unix(epoch);
DBG_INFO("time_sync: wall clock updated (epoch=%lu)", (unsigned long)epoch);
}
}
net_close(conn_sock);
}
/*
* 函数功能:时间同步任务主体(独立 FreeRTOS 任务)
* 入口参数:argument - 未使用
* 返回值:无
* 限定条件:net_init() 已由 netTask 完成;netTask 正在处理消息队列
* 函数说明:等待网络栈就绪后,循环:建监听 -> 等连接 -> 处理 -> 关闭 -> 重建监听。
* 全部 socket 操作经消息队列由 netTask 串行执行,与本任务/其它网络任务并发安全。
*/
void time_sync_task(void *argument) {
(void)argument;
/* 等网络栈真正就绪(取代拍脑袋的 8s 魔法延时),确保 netTask 已开始处理消息 */
if (sys_evtHandle != NULL) {
(void)osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY,
osFlagsWaitAny, osWaitForever);
}
for (;;) {
int ls = time_sync_open_listener();
if (ls < 0) {
DBG_ERROR("time_sync: open listener FAIL, retry later");
vTaskDelay(pdMS_TO_TICKS(2000));
continue;
}
/* 等待 PC 连接:net_accept 在无连接时立即返回 -1,循环重试同一监听 Socket */
int conn = -1;
do {
conn = net_accept(ls, NULL, NULL);
if (conn < 0) {
vTaskDelay(pdMS_TO_TICKS(200));
}
} while (conn < 0);
time_sync_handle_conn(conn);
/* conn(单连接模式下即 ls)已被 net_close 释放,下次循环重建监听 */
vTaskDelay(pdMS_TO_TICKS(50));
}
}
+39
View File
@@ -0,0 +1,39 @@
# App/util/ — 通用工具函数
> 平台无关的纯工具模块(无 FreeRTOS 依赖、无硬件依赖),可在 MCU 与 PC 模拟器中共用。
## 文件一览
| 文件 | 作用 |
|---|---|
| `sys_clock.c/h` | **墙钟时间管理**:RTC 基准、tick 回绕防护、每 30 天惰性再基准化 |
| `task_watchdog.c/h` | **独立看门狗(IWDG)判定状态机**:任务心跳位图 + defaultTask 单一喂狗者 |
> **已删除(2026-10-02)**:`crc.c/h`(CRC-16 Modbus / CRC-8)与 `ringbuf.c/h`(SPSC 环形缓冲)
> 两模块**零调用者**且未编入任何构建——CRC 从未被链接使用:名义上的关联点 `net_cfg_legacy.c`
> (旧档 `/cfg/netcfg.bin` 迁移,**该文件亦已于 2026-10-10 删除**)自带静态 `s_crc16_modbus()`,
> 从不调用本模块;环形缓冲由 `App/log/console_log.c` 内联实现(`s_con_ring`)。
> 保留只会造成"有统一实现"的误导,故整体移除(详见 `docs/待处理内容.md` E-15 销项)。
## 设计要点
### sys_clock
- 以 SD2506 RTC 为基准,HAL tick 计数补齐亚秒精度
- **49.7 天 tick 回绕防护**:每 30 天惰性再基准化(`sys_clock_get` 内部触发)
- RTC 故障时降级到默认时间基准并返回 -1,触发"时钟芯片异常"自检上报
- 设计详见 `docs/子系统设计/系统墙钟时间维护说明.md`
### task_watchdog
- **任务心跳位图**:各任务只置位(`task_wd_beat`),校验者取走并清零(单字原子,无锁)
- **单一喂狗者**:`defaultTask` 每 100 ms 抽样判定,仅当位图等于必需掩码时才喂狗
- **硬件访问收敛**:`IWDG` 寄存器与 `HAL_GetTick()` 经 `wd_platform_*` 弱钩子访问
(声明在 `task_watchdog.h` 末段),MCU 端为 `__weak` 默认实现,主机侧单测以同名强符号覆盖 → 可在 PC 上跑纯逻辑回归
- 设计详见 `docs/子系统设计/看门狗设计说明.md`;单测见 `docs/测试说明/看门狗单元测试说明.md`
## 相关文档
- `docs/子系统设计/系统墙钟时间维护说明.md`
- `docs/子系统设计/看门狗设计说明.md`
- `docs/测试说明/看门狗单元测试说明.md`
-120
View File
@@ -1,120 +0,0 @@
/*
* 模块名称:CRC Utility
* 模块功能:CRC-16 Modbus (poly=0x8005, init=0xFFFF, refin=true, refout=true, xorout=0x0000)
* CRC-8 (poly=0x07, init=0x00, refin=false, refout=false, xorout=0x00)
* 适用平台:通用嵌入式平台
* 作者:王建锋
* 创建日期:2026-07-19
* 修改记录:
* v1.0 2026-07-19 王建锋 创建初始版本
*/
#include "crc.h"
static const uint16_t s_crc16_table[256] = {
0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440,
0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40,
0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841,
0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40,
0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41,
0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641,
0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040,
0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240,
0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441,
0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41,
0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840,
0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41,
0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40,
0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640,
0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041,
0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240,
0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441,
0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41,
0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840,
0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41,
0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40,
0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640,
0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041,
0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241,
0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440,
0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40,
0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841,
0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40,
0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41,
0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641,
0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040,
};
static const uint8_t s_crc8_table[256] = {
0x00, 0x07, 0x0E, 0x09, 0x1C, 0x1B, 0x12, 0x15,
0x38, 0x3F, 0x36, 0x31, 0x24, 0x23, 0x2A, 0x2D,
0x70, 0x77, 0x7E, 0x79, 0x6C, 0x6B, 0x62, 0x65,
0x48, 0x4F, 0x46, 0x41, 0x54, 0x53, 0x5A, 0x5D,
0xE0, 0xE7, 0xEE, 0xE9, 0xFC, 0xFB, 0xF2, 0xF5,
0xD8, 0xDF, 0xD6, 0xD1, 0xC4, 0xC3, 0xCA, 0xCD,
0x90, 0x97, 0x9E, 0x99, 0x8C, 0x8B, 0x82, 0x85,
0xA8, 0xAF, 0xA6, 0xA1, 0xB4, 0xB3, 0xBA, 0xBD,
0xC7, 0xC0, 0xC9, 0xCE, 0xDB, 0xDC, 0xD5, 0xD2,
0xFF, 0xF8, 0xF1, 0xF6, 0xE3, 0xE4, 0xED, 0xEA,
0xB7, 0xB0, 0xB9, 0xBE, 0xAB, 0xAC, 0xA5, 0xA2,
0x8F, 0x88, 0x81, 0x86, 0x93, 0x94, 0x9D, 0x9A,
0x27, 0x20, 0x29, 0x2E, 0x3B, 0x3C, 0x35, 0x32,
0x1F, 0x18, 0x11, 0x16, 0x03, 0x04, 0x0D, 0x0A,
0x57, 0x50, 0x59, 0x5E, 0x4B, 0x4C, 0x45, 0x42,
0x6F, 0x68, 0x61, 0x66, 0x73, 0x74, 0x7D, 0x7A,
0x89, 0x8E, 0x87, 0x80, 0x95, 0x92, 0x9B, 0x9C,
0xB1, 0xB6, 0xBF, 0xB8, 0xAD, 0xAA, 0xA3, 0xA4,
0xF9, 0xFE, 0xF7, 0xF0, 0xE5, 0xE2, 0xEB, 0xEC,
0xC1, 0xC6, 0xCF, 0xC8, 0xDD, 0xDA, 0xD3, 0xD4,
0x69, 0x6E, 0x67, 0x60, 0x75, 0x72, 0x7B, 0x7C,
0x51, 0x56, 0x5F, 0x58, 0x4D, 0x4A, 0x43, 0x44,
0x19, 0x1E, 0x17, 0x10, 0x05, 0x02, 0x0B, 0x0C,
0x21, 0x26, 0x2F, 0x28, 0x3D, 0x3A, 0x33, 0x34,
0x4E, 0x49, 0x40, 0x47, 0x52, 0x55, 0x5C, 0x5B,
0x76, 0x71, 0x78, 0x7F, 0x6A, 0x6D, 0x64, 0x63,
0x3E, 0x39, 0x30, 0x37, 0x22, 0x25, 0x2C, 0x2B,
0x06, 0x01, 0x08, 0x0F, 0x1A, 0x1D, 0x14, 0x13,
0xAE, 0xA9, 0xA0, 0xA7, 0xB2, 0xB5, 0xBC, 0xBB,
0x96, 0x91, 0x98, 0x9F, 0x8A, 0x8D, 0x84, 0x83,
0xDE, 0xD9, 0xD0, 0xD7, 0xC2, 0xC5, 0xCC, 0xCB,
0xE6, 0xE1, 0xE8, 0xEF, 0xFA, 0xFD, 0xF4, 0xF3,
};
/*
* 函数功能:计算 CRC-16 Modbus
* 入口参数:data - 数据指针
* len - 数据长度
* 返 回 值:16 位 CRC 值
* 限定条件:data 为非空指针
* 函数说明:多项式 0x8005,初始值 0xFFFF,结果异或 0x0000
*/
uint16_t crc16_modbus(const uint8_t *data, size_t len) {
uint16_t crc = 0xFFFF;
size_t i;
for (i = 0; i < len; i++) {
crc = (crc >> 8) ^ s_crc16_table[(crc ^ data[i]) & 0xFF];
}
return crc;
}
/*
* 函数功能:计算 CRC-8
* 入口参数:data - 数据指针
* len - 数据长度
* 返 回 值:8 位 CRC 值
* 限定条件:data 为非空指针
* 函数说明:多项式 0x07,初始值 0x00
*/
uint8_t crc8(const uint8_t *data, size_t len) {
uint8_t crc = 0;
size_t i;
for (i = 0; i < len; i++) {
crc = s_crc8_table[crc ^ data[i]];
}
return crc;
}
-28
View File
@@ -1,28 +0,0 @@
/*
* 模块名称:CRC Utility
* 模块功能:CRC-16 Modbus & CRC-8 查表计算
* 适用平台:通用嵌入式平台
* 作者:王建锋
* 创建日期:2026-07-19
* 修改记录:
* v1.0 2026-07-19 王建锋 创建初始版本
*/
#ifndef CRC_H
#define CRC_H
#ifdef __cplusplus
extern "C" {
#endif
#include <stdint.h>
#include <stddef.h>
uint16_t crc16_modbus(const uint8_t *data, size_t len);
uint8_t crc8(const uint8_t *data, size_t len);
#ifdef __cplusplus
}
#endif
#endif /* CRC_H */
-154
View File
@@ -1,154 +0,0 @@
/*
* 模块名称:Ring Buffer
* 模块功能:字节环形缓冲区实现,head == tail 为空,head + 1 == tail 为满
* 适用平台:通用嵌入式平台
* 作者:王建锋
* 创建日期:2026-07-19
* 修改记录:
* v1.0 2026-07-19 王建锋 创建初始版本
*/
#include "ringbuf.h"
/*
* 函数功能:初始化环形缓冲区
* 入口参数:rb - 缓冲区控制块指针
* pool - 存储池指针
* size - 存储池大小(字节)
* 返 回 值:无
* 限定条件:rb 和 pool 均为非空指针
* 函数说明:size 保留一个空位用于区分空/满状态
*/
void ringbuf_init(ringbuf_t *rb, uint8_t *pool, size_t size) {
rb->buf = pool;
rb->size = size;
rb->head = 0;
rb->tail = 0;
}
/*
* 函数功能:写入数据到环形缓冲区
* 入口参数:rb - 缓冲区控制块指针
* data - 源数据指针
* len - 待写入长度
* 返 回 值:实际写入字节数
* 限定条件:rb 和 data 均为非空指针
* 函数说明:空间不足时写入能容纳的最大字节数
*/
size_t ringbuf_put(ringbuf_t *rb, const uint8_t *data, size_t len) {
size_t cap = 0;
size_t n = 0;
size_t i = 0;
cap = rb->size - 1 - ringbuf_avail(rb);
n = (len < cap) ? len : cap;
for (i = 0; i < n; i++) {
rb->buf[rb->head] = data[i];
rb->head = (rb->head + 1) % rb->size;
}
return n;
}
/*
* 函数功能:从环形缓冲区读取数据
* 入口参数:rb - 缓冲区控制块指针
* data - 目标数据指针
* len - 期望读取长度
* 返 回 值:实际读取字节数
* 限定条件:rb 和 data 均为非空指针
* 函数说明:读取后数据从缓冲区移除
*/
size_t ringbuf_get(ringbuf_t *rb, uint8_t *data, size_t len) {
size_t n = 0;
size_t i = 0;
n = ringbuf_avail(rb);
n = (n < len) ? n : len;
for (i = 0; i < n; i++) {
data[i] = rb->buf[rb->tail];
rb->tail = (rb->tail + 1) % rb->size;
}
return n;
}
/*
* 函数功能:预读取数据(不移除)
* 入口参数:rb - 缓冲区控制块指针
* data - 目标数据指针
* len - 期望读取长度
* 返 回 值:实际可读取字节数
* 限定条件:rb 和 data 均为非空指针
* 函数说明:与 ringbuf_get 的区别是不移动 tail 指针
*/
size_t ringbuf_peek(const ringbuf_t *rb, uint8_t *data, size_t len) {
size_t n = 0;
size_t i = 0;
n = ringbuf_avail(rb);
n = (n < len) ? n : len;
for (i = 0; i < n; i++) {
size_t idx = (rb->tail + i) % rb->size;
data[i] = rb->buf[idx];
}
return n;
}
/*
* 函数功能:获取缓冲区中有效数据长度
* 入口参数:rb - 缓冲区控制块指针
* 返 回 值:有效数据字节数
* 限定条件:rb 为非空指针
*/
size_t ringbuf_avail(const ringbuf_t *rb) {
if (rb->head >= rb->tail) {
return rb->head - rb->tail;
}
return rb->size - rb->tail + rb->head;
}
/*
* 函数功能:获取缓冲区剩余空间
* 入口参数:rb - 缓冲区控制块指针
* 返 回 值:剩余空间字节数(保留一个空位)
* 限定条件:rb 为非空指针
*/
size_t ringbuf_space(const ringbuf_t *rb) {
return rb->size - 1 - ringbuf_avail(rb);
}
/*
* 函数功能:判断缓冲区是否为空
* 入口参数:rb - 缓冲区控制块指针
* 返 回 值:1 - 空,0 - 非空
* 限定条件:rb 为非空指针
*/
int ringbuf_is_empty(const ringbuf_t *rb) {
return (rb->head == rb->tail) ? 1 : 0;
}
/*
* 函数功能:判断缓冲区是否为满
* 入口参数:rb - 缓冲区控制块指针
* 返 回 值:1 - 满,0 - 非满
* 限定条件:rb 为非空指针
*/
int ringbuf_is_full(const ringbuf_t *rb) {
return (ringbuf_avail(rb) == rb->size - 1) ? 1 : 0;
}
/*
* 函数功能:重置缓冲区
* 入口参数:rb - 缓冲区控制块指针
* 返 回 值:无
* 限定条件:rb 为非空指针
*/
void ringbuf_reset(ringbuf_t *rb) {
rb->head = 0;
rb->tail = 0;
}
-42
View File
@@ -1,42 +0,0 @@
/*
* 模块名称:Ring Buffer
* 模块功能:线程安全的字节环形缓冲区(单生产者-单消费者模型)
* 适用平台:通用嵌入式平台
* 作者:王建锋
* 创建日期:2026-07-19
* 修改记录:
* v1.0 2026-07-19 王建锋 创建初始版本
*/
#ifndef RINGBUF_H
#define RINGBUF_H
#ifdef __cplusplus
extern "C" {
#endif
#include <stdint.h>
#include <stddef.h>
typedef struct {
uint8_t *buf;
size_t size;
size_t head;
size_t tail;
} ringbuf_t;
void ringbuf_init(ringbuf_t *rb, uint8_t *pool, size_t size);
size_t ringbuf_put(ringbuf_t *rb, const uint8_t *data, size_t len);
size_t ringbuf_get(ringbuf_t *rb, uint8_t *data, size_t len);
size_t ringbuf_peek(const ringbuf_t *rb, uint8_t *data, size_t len);
size_t ringbuf_avail(const ringbuf_t *rb);
size_t ringbuf_space(const ringbuf_t *rb);
int ringbuf_is_empty(const ringbuf_t *rb);
int ringbuf_is_full(const ringbuf_t *rb);
void ringbuf_reset(ringbuf_t *rb);
#ifdef __cplusplus
}
#endif
#endif /* RINGBUF_H */
+151 -33
View File
@@ -6,12 +6,20 @@
* 创建日期:2026-07-19
* 修改记录:
* v1.0 2026-07-19 王建锋 创建初始版本
* v1.1 2026-09-13 王建锋 新增 sys_clock_get_time()(日历时间获取器,供 FatFs 时间钩子等使用)
* v1.2 2026-09-13 王建锋 文件移入 App/util/(改归 ④中间件层:通用平台时间服务,多服务共用)
* v1.3 2026-09-15 王建锋 修复 49.7 天墙钟回绕:sys_clock_get 每 30 天惰性再基准
* (自我参照、无损、零 I2C);修正"未初始化"哨兵判断;
* sys_clock_init 检查 RTC 读返回值/写回复验,失败时降级为
* 编译期默认时间基准并返回 -1(激活 app_main 时钟芯片自检)
*/
#include "sys_clock.h"
#include "main.h"
#include <string.h>
#include <stdio.h>
#include "FreeRTOS.h" /* taskENTER_CRITICAL:惰性再基准化的并发保护 */
#include "task.h"
#define DBG_TAG "[CLK]"
#include "dbg_log.h"
@@ -40,6 +48,13 @@
static volatile uint32_t s_base_tick = 0; /* HAL_GetTick() 基准 */
static volatile uint32_t s_base_unix_secs = UINT32_MAX;
/*
* 再基准化周期(惰性再基准化,见 sys_clock_get 说明):
* 32 位 tick 差值窗口必须恒 < 一整圈(2^32 ms ≈ 49.7 天),否则差值混叠、
* 墙钟"倒退"一圈。30 天 = 2,592,000,000 ms < 2^32,为惰性触发延迟留 19.7 天余量。
*/
#define SYS_CLOCK_REBASE_MS (30u * 24u * 3600u * 1000u)
/* ============================================================
* 内部辅助函数:Unix time ↔ sd2506_time_t 转换
* ============================================================ */
@@ -47,24 +62,41 @@ static volatile uint32_t s_base_unix_secs = UINT32_MAX;
/*
* 函数功能:根据公历日期计算星期几
* 入口参数:y - 年份 uint16_t 1970~2099
* m - 月份 uint8_t 1~12
* m - 月份 uint8_t 1~12(**1-based**,与 sd2506_time_t.month 一致)
* d - 日 uint8_t 1~31
* 返回值:星期编号 uint8_t 0=Sunday, 1=Monday, ... 6=Saturday
* 限定条件:输入为公历合法日期
* 函数说明:1. 采用 Zeller 公式(变种),结果 0 表示周日
* 2. 与 unix_to_sd2506() 内星期算法保持一致
* 函数说明:1. 采用 Zeller 同余式(Gregorian):
* h = (d + ⌊13(m+1)/5⌋ + K + ⌊K/4⌋ + ⌊J/4⌋ + 5J) mod 7
* 其中 mm 为调整月(1/2 月按上年 13/14 月处理),
* K = wy % 100,J = wy / 100;h: 0=Saturday, 1=Sunday, ...
* 结果映射 (h+6) mod 7 → 0=Sunday(sd2506 约定)。
* 注:5J ≡ -2J (mod 7),取 5J 以保证各项非负(无符号安全)。
* 2. 与 unix_to_sd2506() 内星期算法保持一致(后者按 0-based 月
* 调用本函数前 +1,切勿再传 0-based 月)。
* 修改记录:
* 2026-10-02 修正:原式错用 (wy%100 + wy/100/4 - wy/100/15)(缺 K/4、
* 5J/世纪项,且把 1-based 月当 2-based 用),实测 1970~2099
* 全部日期星期皆错(主机侧单测 TC-CLK-014/019 发现)。
*/
static uint8_t rtc_calc_week(uint16_t y, uint8_t m, uint8_t d) {
uint32_t wy = y;
uint32_t wm = m;
uint32_t wm = m; /* wm: 1-based 月,1/2 月上调为 13/14 月 */
if (wm <= 2) {
if (wm <= 2u) {
wy--;
wm += 12u;
}
/* Zeller 公式:世纪项 + 月项 + 日项,模 7 得星期(0=周日) */
return (uint8_t)(((uint32_t)(wy % 100u + wy / 100u / 4u - wy / 100u / 15u) +
(uint32_t)((26u * (wm + 1u)) / 10u) + (uint32_t)(d % 100u)) % 7u);
{
uint32_t K = wy % 100u;
uint32_t J = wy / 100u;
uint32_t h;
/* 各项均非负:d + 月项 + K + K/4 + J/4 + 5J,模 7 得 0=Saturday */
h = (uint32_t)(d + (13u * (wm + 1u)) / 5u + K + K / 4u + J / 4u + 5u * J) % 7u;
/* h:0=Sat,1=Sun,... → 0=Sun 约定 */
return (uint8_t)((h + 6u) % 7u);
}
}
/*
@@ -121,17 +153,10 @@ static void unix_to_sd2506(uint32_t unix_secs, sd2506_time_t *p_sd_time) {
}
d = (uint32_t)(day_count + 1uL);
/* 星期(Zeller's congruence,0=Sunday) */
uint64_t wy = y;
uint64_t wm = m;
if (wm <= 2) {
wy--;
wm += 12uL;
}
/* Zeller 公式同 rtc_calc_week():世纪项 + 月项 + 日项,模 7 得星期(0=周日) */
uint8_t week = ((uint32_t)(wy % 100uLL + wy / 100uLL / 4uLL - wy / 100uLL / 15uLL) +
(uint32_t)(((uint64_t)26 * (wm + 1uL)) / 10uLL) +
(uint32_t)(d % 100uLL)) % 7uLL;
/* 星期:复用 rtc_calc_week()(1-based 月;此处 m 为 0-based → 传 m+1)
* 2026-10-02 修正:原内联变种公式有误(与 rtc_calc_week 同缺陷),
* 统一收敛到 rtc_calc_week,避免两处算法再次发散。 */
uint8_t week = rtc_calc_week((uint16_t)y, (uint8_t)(m + 1u), (uint8_t)d);
/* sd2506_time_t.year 采用完整年份(如 2026),与 sd2506_set_time
* 中 year-2000、sd2506_get_time 中 +2000 的约定保持一致;
@@ -153,12 +178,17 @@ static void unix_to_sd2506(uint32_t unix_secs, sd2506_time_t *p_sd_time) {
/*
* 函数功能:初始化系统墙钟时间基准
* 入口参数:无
* 返回值:0 - 成功(始终返回 0)
* 返回值:0 - 成功;-1 - RTC 不可用(启用编译期默认值时降级为编译期默认时间
* 基准:墙钟仍可用,返回值报告芯片故障;否则不建基准,墙钟保持"未初始化")
* 限定条件:CubeMX 外设初始化完成、SD2506 RTC 驱动已初始化
* 函数说明:1. 从 SD2506 读取初始时间,必要时按编译期默认宏校时(RTC_DEFAULT_INIT_*)
* 2. 将 RTC 时间换算为 Unix epoch 秒数
* 函数说明:1. 从 SD2506 读取初始时间(检查返回值:I2C 读失败 → 判 RTC 异常),
* 必要时按编译期默认宏校时(RTC_DEFAULT_INIT_*),写回后复验范围
* 2. 将 RTC 时间换算为 Unix epoch 秒数(仅合法值进入换算,防垃圾月份在
* i < month - 1 处下溢空转约 40 亿次)
* 3. 记录当前 HAL Tick 与 Unix 秒数为基准,供 sys_clock_get() 后续推算
* 4. 注意:sd2506_get_time() 返回的 year 已是完整年份(驱动内 +2000),
* 4. RTC 不可用时:启用编译期默认值则以默认时间建基准(仅 RAM、不依赖芯片)
* 并返回 -1;否则不建基准,返回 -1
* 5. 注意:sd2506_get_time() 返回的 year 已是完整年份(驱动内 +2000),
* 换算时不得再加 2000,否则年份虚增导致 Unix 秒数超 uint32 回绕
*/
int sys_clock_init(void) {
@@ -166,14 +196,17 @@ int sys_clock_init(void) {
uint32_t year, month, day, hour, minute, second;
int64_t unix_secs = 0;
int i;
int rtc_ok; /* RTC 读取可用(1)/不可用(0) */
int degraded = 0; /* 1 = 以编译期默认时间建基准(RTC 故障,返回值报 -1) */
/* ============================================================
* Step 1:从 SD2506 硬件 RTC 读取初始时间
* ============================================================
* 注意:sd2506_get_time() 返回的 year 已是完整年份(如 2026,
* 驱动内部已 +2000)。此处【禁止】再 +2000,否则年份虚增到 4026,
* 换算出的 Unix 秒数远超 uint32_t 上限被截断回绕,时间彻底错乱。 */
sd2506_get_time(&sd_time);
* 换算出的 Unix 秒数远超 uint32_t 上限被截断回绕,时间彻底错乱。
* 返回值必须检查:I2C 失败时字段为垃圾,不得用于建立基准。 */
rtc_ok = (sd2506_get_time(&sd_time) == SD2506_OK) ? 1 : 0;
/* ============================================================
* Step 1.1:开发期校时(可选,由 RTC_DEFAULT_INIT_* 宏控制)
@@ -183,7 +216,8 @@ int sys_clock_init(void) {
* 判定"时间无效":年份越界、月/日越界(仅粗判,不细校 2 月 29 日等)。 */
#if defined(RTC_DEFAULT_INIT_ENABLE)
{
int rtc_invalid = (sd_time.year < 2000 || sd_time.year > 2099 ||
int rtc_invalid = (rtc_ok == 0) ||
(sd_time.year < 2000 || sd_time.year > 2099 ||
sd_time.month < 1 || sd_time.month > 12 ||
sd_time.day < 1 || sd_time.day > 31);
#if (RTC_DEFAULT_INIT_FORCE == 1)
@@ -205,7 +239,7 @@ int sys_clock_init(void) {
DBG_INFO("sys_clock_init: RTC set %04d-%02d-%02d %02d:%02d:%02d",
init_t.year, init_t.month, init_t.day,
init_t.hour, init_t.minute, init_t.second);
sd2506_get_time(&sd_time); /* 重新读取校准后的值,确保后续换算用真实 RTC 数据 */
rtc_ok = (sd2506_get_time(&sd_time) == SD2506_OK) ? 1 : 0; /* 重新读取校准后的值(同样检查返回值) */
} else {
DBG_ERROR("sys_clock_init: RTC default write FAIL");
}
@@ -213,6 +247,39 @@ int sys_clock_init(void) {
}
#endif
/*
* 写回后复验范围:能写入但读回仍越界的芯片同样判故障;
* 同时保证进入 Step 2 换算循环的必为合法值(见函数说明第 2 条)。
*/
if (rtc_ok != 0 &&
(sd_time.year < 2000 || sd_time.year > 2099 ||
sd_time.month < 1 || sd_time.month > 12 ||
sd_time.day < 1 || sd_time.day > 31)) {
rtc_ok = 0;
}
if (rtc_ok == 0) {
#if (RTC_DEFAULT_INIT_ENABLE == 1)
/*
* 降级基准:以编译期默认时间建立基准(仅 RAM、不依赖芯片)。
* 墙钟保持可用(日志时间戳为默认时间);返回值报告芯片故障,
* 触发 app_main 的 "clock chip fault" 自检。
*/
DBG_ERROR("sys_clock_init: RTC abnormal, fallback to default time");
sd_time.year = RTC_DEFAULT_YEAR;
sd_time.month = RTC_DEFAULT_MONTH;
sd_time.day = RTC_DEFAULT_DAY;
sd_time.hour = RTC_DEFAULT_HOUR;
sd_time.minute = RTC_DEFAULT_MINUTE;
sd_time.second = RTC_DEFAULT_SECOND;
sd_time.week = rtc_calc_week(sd_time.year, sd_time.month, sd_time.day);
degraded = 1;
#else
DBG_ERROR("sys_clock_init: RTC read FAIL, wall clock uninitialized");
return -1;
#endif
}
/* 取出各时间字段(year 已是完整年份,直接用于下文 Unix 换算) */
year = (uint32_t)sd_time.year;
month = (uint32_t)sd_time.month;
@@ -273,24 +340,52 @@ int sys_clock_init(void) {
DBG_INFO("sys_clock_init: wall clock base = unix %lu, base_tick=%lu",
(unsigned long)s_base_unix_secs, (unsigned long)s_base_tick);
return 0;
return (degraded != 0) ? -1 : 0;
}
/*
* 函数功能:获取当前系统墙钟时间(Unix 时间戳)
* 入口参数:无
* 返回值:当前 Unix 秒数 uint32_t 未初始化时返回 UINT32_MAX
* 限定条件:sys_clock_init() 已成功调用
* 限定条件:sys_clock_init() 已调用
* 函数说明:1. 以基准 Tick 与基准 Unix 秒数为起点,按当前 HAL Tick 与 1000ms
* 的整除差推算流逝秒数(避免每毫秒累加,减少漂移与溢出风险)
* 2. 若推算结果为负(基准未建立),返回 UINT32_MAX 表示无效
* 的整除差推算流逝秒数(避免每毫秒累加,减少漂移与溢出风险);
* 32 位无符号模减对 Tick 回绕天然无缝
* 2. 差值窗口达到 SYS_CLOCK_REBASE_MS(30 天)时触发"惰性再基准化":
* 基准前移流逝秒数(墙钟值不变)、差值窗口清零,亚秒余数结转入
* 新窗(base_tick = now − 余数)故无损,保证差值永不因一整圈
* 2^32 ms(≈49.7 天)而混叠
* 3. 再基准化为自我参照:纯内存操作、零 I2C、非阻塞——不重读 RTC
* 重锚(RTC 精度远逊 CPU Tick,重锚反而吸收其漂移);临界区 +
* 区内复核防多任务并发重复累加
* 4. 若基准未建立(init 失败),返回 UINT32_MAX 表示无效
*/
uint32_t sys_clock_get(void) {
uint32_t now_tick = HAL_GetTick();
int64_t unix_secs = (int64_t)s_base_unix_secs + (int64_t)((uint32_t)(now_tick - s_base_tick) / 1000uL);
uint32_t diff = now_tick - s_base_tick; /* 32 位模减,跨 Tick 回绕无缝 */
int64_t unix_secs;
if (s_base_unix_secs != UINT32_MAX && diff > SYS_CLOCK_REBASE_MS) {
uint32_t secs = diff / 1000u;
uint32_t rem = diff - secs * 1000u; /* 亚秒余数,结转入新窗 → 无损 */
taskENTER_CRITICAL();
diff = now_tick - s_base_tick; /* 临界区内复核:另一任务可能已先再基准化 */
if (diff > SYS_CLOCK_REBASE_MS) {
s_base_unix_secs += secs; /* 基准前移:墙钟值本身不变 */
s_base_tick = now_tick - rem; /* 新窗自 (now − 余数) 起算,余数续计 */
diff = rem;
}
taskEXIT_CRITICAL();
}
if (s_base_unix_secs == UINT32_MAX) {
return UINT32_MAX; /* 未初始化(显式哨兵判断) */
}
unix_secs = (int64_t)s_base_unix_secs + (int64_t)(diff / 1000u);
if (unix_secs < 0) {
return UINT32_MAX; /* 未初始化 */
return UINT32_MAX;
}
return (uint32_t)unix_secs;
@@ -323,6 +418,29 @@ char *sys_clock_get_str(char *buf, size_t buf_size) {
return buf;
}
/*
* 函数功能:获取当前系统墙钟时间(日历时间)
* 入口参数:p_time - 输出时间结构指针(非 NULL)
* 出口参数:p_time - 填充年/月/日/时/分/秒/星期
* 返回值:0 - 成功, -1 - 失败(墙钟未初始化或参数无效)
* 限定条件:sys_clock_init() 已调用
* 函数说明:1. 取当前 Unix 秒数后经 unix_to_sd2506() 换算为日历时间
* 2. 与调试日志时间戳同一时间源(开机 RTC 基准 + tick 推进,运行期零 I2C)
*/
int sys_clock_get_time(sd2506_time_t *p_time) {
uint32_t unix_secs = sys_clock_get();
if (p_time == NULL) {
return -1;
}
if (unix_secs == UINT32_MAX) {
return -1;
}
unix_to_sd2506(unix_secs, p_time);
return 0;
}
/*
* 函数功能:设置(更新)系统墙钟时间基准,并可写回 SD2506 RTC
* 入口参数:unix_secs - 新的 Unix 时间戳 uint32_t 0~0x7FFFFFFF
+13
View File
@@ -7,6 +7,8 @@
* 创建日期:2026-07-19
* 修改记录:
* v1.0 2026-07-19 王建锋 创建初始版本
* v1.1 2026-09-13 王建锋 新增 sys_clock_get_time()(日历时间获取器,供 FatFs 时间钩子等使用)
* v1.2 2026-09-13 王建锋 文件移入 App/util/(改归 ④中间件层:通用平台时间服务,多服务共用)
*/
#ifndef SYS_CLOCK_H
@@ -48,6 +50,17 @@ uint32_t sys_clock_get(void);
*/
char *sys_clock_get_str(char *buf, size_t buf_size);
/*
* 函数功能:获取当前系统墙钟时间(日历时间)
* 入口参数:p_time - 输出时间结构指针(非 NULL)
* 出口参数:p_time - 填充年/月/日/时/分/秒/星期
* 返回值:0 - 成功, -1 - 失败(墙钟未初始化或参数无效)
* 限定条件:sys_clock_init() 已调用
* 函数说明:等价于 "sys_clock_get() + 内部日历换算",为需要日历字段的
* 使用方(如 FatFs 时间钩子)提供类型化接口
*/
int sys_clock_get_time(sd2506_time_t *p_time);
/*
* 函数功能:设置系统墙钟时间
* 入口参数:unix_secs - Unix epoch time (秒)
+721
View File
@@ -0,0 +1,721 @@
/*
* 模块名称:Task Watchdog(IWDG + 任务心跳位图)
* 模块功能:把独立看门狗(IWDG)与"全部任务存活"绑定,使单任务死锁/饿死/
* 断言挂死可自动复位恢复,而非人工断电。
* 核心语义:喂狗 = "所有被监视任务都在抽样窗口内动过"。
* 适用平台:STM32F407ZGTx + FreeRTOS(CMSIS-RTOS V2)
* 作者:王建锋
* 创建日期:2026-09-29
* 修改记录:
* v1.0 2026-09-29 王建锋 创建:心跳位图 + 抽样判定 + IWDG 延后启动
* v1.1 2026-10-02 王建锋 round 7 纯取证埋点:近 8 次抽样位图环 + 最大喂狗
* 间隔统计 + 停喂现场落 /log/wdev.log(掉电不丢);
* 全部在 TASK_WATCHDOG_UNIT_TEST 守卫内,判定状态机
* 与主机侧单测逐字不变(见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录)
* v1.2 2026-10-02 王建锋 round 8 取证:复位不丢的"进段打点"(.noinit 槽 +
* PREVRUN 开机上报 + STALL 行附 seg)——round 7 结论为
* "poller 自身停摆",需定位 defaultTask 主循环阻塞点
* v1.3 2026-10-02 王建锋 round 8b 取证:第二个 .noinit 槽"时间源存活性"
* (TIM7 ISR 每 1ms 写 uwTick/FreeRTOS tick/计数)+
* TICKRUN 开机上报(含相对进段时刻的增量 duw/dfr)——
* round 8a 定位到 console 通道后,需钉死是 TIM7(HAL tick)
* 还是 SysTick(FreeRTOS tick) 停摆(见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录)
*
* v1.4 2026-10-02 王建锋 **P0 修复**:wd_platform_start() 补写 IWDG 的
* PR/RLR(= WD_IWDG_PRESCALER / WD_IWDG_RELOAD,即设计值
* /64 + 1249 → 1701~2498ms)。此前为"延后启动"删掉
* HAL_IWDG_Init() 时,一并丢掉了**唯一**写这两只寄存器的
* 路径 → 实机停在复位默认值 PR=0/RLR=4095(≈349~512ms),
* 比设计短约 5 倍 → 800ms 容忍分支成死代码、任何 >400ms
* 心跳缺口直接 IWDG 误复位(round 3~8 全部"空 STOR 必
* 复位"的共同根因)。SWD 现场改回设计值后同一用例 100% 通过
*
* 设计依据:docs/子系统设计/看门狗设计说明.md
* - §3.1 为何不用"每任务各自喂狗"(那只检测全系统同时挂死)
* - §3.3 为何用"单字位图 + 取走清零"(免临界区,天然原子)
* - §4.3 超时与窗口的取值推导(含 LSI 偏快 47kHz 的最坏情况校验)
* - §4.5 为何由 defaultTask 在 bring-up 完成后启动
* - §5.3 为何"停喂"而不直接 NVIC_SystemReset(保留 IWDGRSTF 权威归因)
*
* ⚠ 本模块不创建任何 FreeRTOS/CMSIS-RTOS 对象,可安全用于内核启动前后的上下文。
*
* === 可测性设计(2026-10-01)===
* 硬件访问(IWDG 寄存器读写 / HAL_GetTick)下沉到 task_watchdog.h 末段声明的
* 三个 __weak 钩子(wd_platform_feed / _start / _tick_ms);MCU 端默认实现见
* 文件末尾,行为与重构前逐字一致。判定状态机因此成为纯逻辑,主机侧单测
* (test/host/watchdog/)以强符号覆盖这三个钩子后即可离线穷举验证。
* 详见 docs/测试说明/看门狗单元测试说明.md。
*/
#include "task_watchdog.h"
#include "main.h" /* HAL_GetTick()(HAL 时基 = TIM7,1ms) */
#include "iwdg.h" /* hiwdg 参数来源(CubeMX 生成,见 Src/iwdg.c) */
#ifndef TASK_WATCHDOG_UNIT_TEST
#include <string.h> /* strlen(取证行裸 UART 直发长度) */
#include "FreeRTOS.h" /* xTaskGetCurrentTaskHandle / TaskHandle_t(FTL 心跳路由) */
#include "task.h"
#include "lftpd.h" /* lftpd_task_wd_beat() 弱钩子声明(本文件提供强符号,见下方) */
#include "sys_log.h" /* SYSLOG_RUN(flush 诱导落盘)+ sys_log_evidence()(round 7 取证落盘) */
#include "console_log.h" /* dbg_console_tx_fault()(僵尸通道计数) */
#endif
#define DBG_TAG "[WD]"
#include "dbg_log.h"
/* Private defines -----------------------------------------------------------*/
/* 抽样周期:defaultTask 主循环为 50ms 基准,100ms 是其整数倍(见 §4.3) */
#define WD_POLL_PERIOD_MS 100u
/* 连续未达标容忍次数:8 × 100ms = 800ms。
* 需容纳 NAND 擦写(最坏数百 ms)等长耗时操作,同时远小于 IWDG 最坏超时
* 1701ms,保证"停喂后仍能复上电"(§4.3 的包含关系) */
#define WD_MISS_TOLERANCE 8u
/* IWDG 超时参数:PR=4 → 64 分频;RLR=1249
* 标称 64 × 1249 / 32000 ≈ 2498 ms
* 最坏 64 × 1249 / 47000 ≈ 1701 ms(LSI 偏快时超时变短,按最坏校核)
* ⚠ 这两只值**必须写进寄存器**才生效——由 wd_platform_start() 在启动前写入
* (PR/RLR 的写访问需先写 KR=0x5555 解锁)。2026-10-02 之前无人写它们,
* 寄存器一直停在**复位默认值**(PR=0 → /4、RLR=4095 ≈ 349~512ms),
* 详见 wd_platform_start() 的函数说明。 */
#define WD_IWDG_PRESCALER 4u
#define WD_IWDG_RELOAD 1249u
/* IWDG 寄存器键值(RM0090 §21.4.4;KR 为只写寄存器,读回恒为 0) */
#define WD_IWDG_KEY_UNLOCK 0x00005555u /* 解锁 PR/RLR 写访问 */
#define WD_IWDG_KEY_FEED 0x0000AAAAu /* 重装计数器(喂狗) */
#define WD_IWDG_KEY_START 0x0000CCCCu /* 启动(此后不可停,只能靠复位) */
/* PVU/RVU 同步等待上界:PR/RLR 写入后需 ≤5 个 LSI 周期(≈0.15ms)同步到
* LSI 域,其间对应 PVU/RVU 位置 1 且**后续写会被丢弃**。此上界(168MHz 下
* 约 1ms)只为避免 LSI 异常时死等,正常路径远用不到。 */
#define WD_IWDG_SYNC_SPIN 0x10000u
/* Private types -------------------------------------------------------------*/
/* Private variables ---------------------------------------------------------*/
/* 心跳位图:各任务只置位,校验者"取走并清零"。
* 单字读-改-写在 Cortex-M4 上天然原子,无需临界区(§3.3) */
static volatile uint32_t s_hb_bits;
static uint32_t s_miss_streak; /* 连续未达标轮数(达到容忍上限即停喂) */
static uint32_t s_miss_total; /* 累计未达标轮数(观测用) */
static uint32_t s_last_mask; /* 上次抽样到的位图(定位卡死任务用) */
static uint32_t s_started; /* 1 = IWDG 已启动 */
static uint32_t s_last_tick; /* 上次抽样时刻(100ms 节流基准) */
static uint32_t s_stall_evidenced; /* 1 = 本停摆 episode 已落盘证据(门闩;见 task_wd_poll) */
/* ==== round 7 纯取证状态(仅 MCU 构建;判定状态机逐字不变)====
* 设计背景见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录:误复位后 RAM 态随复位消失,
* 故把"停喂现场"快照落盘(/log/wdev.log)供复位后回读定性。 */
#ifndef TASK_WATCHDOG_UNIT_TEST
#define WD_SNAP_HIST_DEPTH 8u
static uint32_t s_snap_hist[WD_SNAP_HIST_DEPTH]; /* 近 8 次抽样位图(环形,定位缺席位) */
static uint32_t s_snap_hist_idx; /* 环形写索引(写入后自增) */
static uint32_t s_last_feed_tick; /* 上次喂狗时刻(ms) */
static uint32_t s_max_feed_gap; /* 观测到的最大喂狗间隔(ms) */
static uint32_t s_feed_seen; /* 1 = 已记录首个喂狗时刻 */
/* ==== round 8 取证:复位不丢的"进段打点"槽(.noinit → SRAM 保留)====
* IWDG/引脚复位不改变 SRAM 内容,故复位后仍能读出"上一轮 defaultTask 主循环
* 最后走到哪一段"。magic 用于区分"本模块写过"与上电时的随机值。
* 布局:[0]=magic [1]=段号 [2]=进段时刻(ms) [3]=循环序号
* ⚠ `zero_init` 必须与 `section` 同时给:只给 section 时 armcc 会把该段当
* "带初值 RW"(有 load 地址 → 启动 scatterload 会拷贝初值 = 抹掉保留内容);
* zero_init 使其成为 ZI,配合 scatter 的 UNINIT 执行区 → 启动**完全不碰**该段。 */
#define WD_SEG_MAGIC 0x57445347u /* 'WDSG' */
__attribute__((section(".noinit"), zero_init)) volatile uint32_t s_wd_seg_trail[4];
/* ==== round 8b 取证:复位不丢的"时间源存活性"槽(.noinit → SRAM 保留)====
* 两个槽(均在 UNINIT_CCM,复位不清):
* s_wd_tick_cur [0]=magic [1]=uwTick(最新) [2]=本轮 TIM7 中断计数 [3]=保留
* —— TIM7 ISR 每 1ms 更新("本轮活动值")
* s_wd_tick_prev[0]=magic [1]=uwTick(上一轮最后) [2]=上一轮 TIM7 中断计数
* —— 由 task_wd_tick_snapshot() 在 main() 开头(**HAL_Init 之前**)拷一次
* ⚠ 为何必须在 HAL_Init 之前快照:TIM7 在 HAL_Init 里就 `HAL_TIM_Base_Start_IT`
* 开始中断,1ms 后就会把 cur 覆盖成本轮的值;而开机上报 task_wd_seg_report()
* 要等到 app_main_init(≈117ms)才跑——不先搬走,上一轮的终值就丢了
* (2026-10-02 台架实测:报告里的 uw 恒为 ~117 = 本轮开机时刻,而非上一轮终值)。
* 判读(duw = prev[1] - t,t = seg 槽的进段时刻):
* duw ≈ 0 → TIM7 与 defaultTask 同刻停摆(HAL 时基停摆)
* duw ≈ 2000+ → TIM7 活到复位(冻结不是 HAL 时基停摆,另有阻塞点)
* prev[2] → 上一轮 TIM7 总共跳了多少次(≈ 该轮存活毫秒数,可交叉核对)
* ⚠ 严禁在本 ISR 里调用任何 FreeRTOS API(含 xTaskGetTickCountFromISR):
* TIM7 远早于 xPortStartScheduler(),此时 port.c 的 ulMaxPRIGROUPValue 仍为 0
* → vPortValidateInterruptPriority() 的 configASSERT 必失败 → 断言打印在
* "USART1 尚未初始化"(huart1.Instance == NULL)的上下文里 → 卡死在 TIM7 ISR 内、
* 开机零输出(2026-10-02 台架实测:IPSR=0x47=TIM7、uwTick 冻结于 1)。
* 本探针只写 SRAM + 读 uwTick,无任何内核对象/API 依赖。 */
#define WD_TICK_MAGIC 0x5744544Bu /* 'WDTK' */
__attribute__((section(".noinit"), zero_init)) volatile uint32_t s_wd_tick_cur[4];
__attribute__((section(".noinit"), zero_init)) volatile uint32_t s_wd_tick_prev[4];
#endif
/* Private function prototypes -----------------------------------------------*/
static uint32_t wd_required_mask(void);
#ifndef TASK_WATCHDOG_UNIT_TEST
static void wd_feed_track(void);
static void wd_stall_evidence(uint32_t snapshot);
#endif
/* Private functions ---------------------------------------------------------*/
/*
* 函数功能:必需心跳位掩码(编译期按构建配置生成)
* 入口参数:无
* 返回值:本次构建要求"必须动过"的任务位集合
* 限定条件:无
* 函数说明:按 §4.1/§4.2 的设计,监视范围随构建配置变化:
* - testTask 仅在启用测试套件时创建(其不置心跳位,任何构建均不
* 入掩码;注意抽样为精确匹配,"会置位者"的位集合须恰等于掩码)
* - ENABLE_HW_LAYER_TESTS 下 netTask 入口自挂起(CH395F 由 testTask
* 独占)→ NET_READY 位永不置位 → ftpTask / uiTask / timeSyncTask
* 永久阻塞于屏障等待,但自 2026-10-02 起等待按 100ms 分片喂心跳
* → 该构建"会置位者"= default + ftp + ui + timesync(netTask 的
* 1s 挂起心跳无法持续供位,仍剔除;抽样为精确匹配,掩码必须恰等于
* 会置位的任务集合)(2026-10-02 台架:旧版"仅剔 NET 位 + 等待者
* 不置位"两种旧状态都会导致测试构建 IWDG 复位循环)
* - ftpTask 统一纳入(其主循环为 lftpd 事件循环):驱动侧节拍点
* 调用弱钩子 lftpd_task_wd_beat()(见 lftpd.h),本文件提供强
* 符号将其路由至 TASK_WD_FTP(2026-10-02 落地,台架 IWDG 复位
* 循环的根因修复:此前 FTP 位无埋点,产品构建必需掩码永不满足)
*/
static uint32_t wd_required_mask(void) {
uint32_t m = (1u << (uint32_t)TASK_WD_DEFAULT)
| (1u << (uint32_t)TASK_WD_NET)
| (1u << (uint32_t)TASK_WD_FTP)
| (1u << (uint32_t)TASK_WD_UI)
| (1u << (uint32_t)TASK_WD_TIMESYNC)
| (1u << (uint32_t)TASK_WD_ADC); /* adcTask 不依赖任何屏障,任何构建都置位 */
#if defined(ENABLE_HW_LAYER_TESTS)
/* 见函数说明:CH395F 硬件层测试构建 = default + 屏障等待者(等待分片喂位)
* + adcTask(其采集不依赖网络/存储屏障,任何构建都在置位) */
m = (1u << (uint32_t)TASK_WD_DEFAULT)
| (1u << (uint32_t)TASK_WD_FTP)
| (1u << (uint32_t)TASK_WD_UI)
| (1u << (uint32_t)TASK_WD_TIMESYNC)
| (1u << (uint32_t)TASK_WD_ADC);
#endif
return m;
}
#ifndef TASK_WATCHDOG_UNIT_TEST
/*
* 函数功能:记录喂狗时刻并累计最大喂狗间隔(round 7 纯取证)
* 入口参数:无
* 返回值:无
* 限定条件:由 wd_platform_feed() 内调用(任意喂狗路径均经过)
* 函数说明:直接量化"停喂前喂狗节奏是否已变慢"——若 s_max_feed_gap 在停喂
* 前已 > 100ms,则属节奏性饥饿(喂狗周期被拉长)而非硬停摆。
* 只更新观测变量,不参与判定(判定状态机逐字不变)。
*/
static void wd_feed_track(void) {
uint32_t now = wd_platform_tick_ms();
if (s_feed_seen != 0u) {
uint32_t gap = now - s_last_feed_tick; /* 无符号差:回绕安全 */
if (gap > s_max_feed_gap) {
s_max_feed_gap = gap;
}
}
s_last_feed_tick = now;
s_feed_seen = 1u;
}
/*
* 函数功能:把停喂现场落盘到 /log/wdev.log(round 7 纯取证)
* 入口参数:snapshot - 本次抽样到的位图 uint32_t
* 返回值:无
* 限定条件:task_wd_poll 停喂分支(defaultTask 上下文)
* 函数说明:一行内含 snap/req(本次与必需掩码)、h(近 8 次抽样历史,定位
* 缺席位)、mg(最大喂狗间隔)、cf(console tx_fault 僵尸通道计数)。
* 经 sys_log_evidence 落盘(内部含 flush 诱导);只取证,不改判定。
* 串口 stall 行经僵尸通道可能丢失,故此处独立于串口留盘上证据。
*/
static void wd_stall_evidence(uint32_t snapshot) {
char hbuf[(WD_SNAP_HIST_DEPTH * 9u) + 1u];
char line[192];
unsigned i;
unsigned o = 0u;
int n;
/* 按时间顺序拼接历史(环形起点 = 最旧一条) */
for (i = 0u; i < WD_SNAP_HIST_DEPTH; i++) {
uint32_t v = s_snap_hist[(s_snap_hist_idx + i) % WD_SNAP_HIST_DEPTH];
o += (unsigned)snprintf(hbuf + o, sizeof(hbuf) - o,
(i == 0u) ? "%08X" : ",%08X", (unsigned)v);
}
n = snprintf(line, sizeof(line),
"STALL snap=0x%08X req=0x%08X h=%s mg=%lu cf=%lu seg=%lu",
(unsigned)snapshot, (unsigned)wd_required_mask(), hbuf,
(unsigned long)s_max_feed_gap,
(unsigned long)dbg_console_tx_fault(),
(unsigned long)s_wd_seg_trail[1]);
if (n > 0) {
/* 裸 UART 直发(不经环形缓冲/不依赖 defaultTask 排空):停喂窗口里
* 环形缓冲的排空本身可能停摆,裸发是本行证据的可靠通道(与盘上
* 证据互补——卷锁被阻塞方持有时盘上通道会失败,见 §0.5.4 判据缺口) */
dbg_console_write_raw(line, (unsigned)strlen(line));
dbg_console_write_raw("\r\n", 2u);
(void)sys_log_evidence(line);
}
}
/* ==================== round 8 取证:复位不丢的"进段打点" ====================
* 见 task_watchdog.h 的说明(round 7 初判"poller 自身停摆"经 round 8b 更正:
* 真因是 IWDG 超时被静默配成复位默认值,见 docs/子系统设计/看门狗设计说明.md
* 2026-10-02 变更记录)→ 需定位 defaultTask 主循环冻结在哪个调用点。
* 槽放 .noinit(scatter UNINIT_CCM):IWDG/引脚复位不改变 SRAM,复位后仍可读。 */
/*
* 函数功能:记录"即将进入的主循环段"到复位不丢的 .noinit 槽(round 8 取证)
* 入口参数:seg - 段号(task_wd_seg_t) uint32_t
* 返回值:无
* 限定条件:defaultTask 主循环调用(任意上下文亦可,纯 SRAM 写)
* 函数说明:写 [magic, 段号, 当前 ms, 循环序号];复位后由 task_wd_seg_report()
* 读出"上一轮最后进段"。纯 4 字写,无阻塞、无内核对象依赖。
*/
void task_wd_seg(uint32_t seg) {
s_wd_seg_trail[0] = WD_SEG_MAGIC;
s_wd_seg_trail[1] = seg;
s_wd_seg_trail[2] = wd_platform_tick_ms();
s_wd_seg_trail[3] = s_wd_seg_trail[3] + 1u;
}
/*
* 函数功能:开机快照上一轮的"时间源存活性"(round 8b 取证)
* 入口参数:无
* 返回值:无
* 限定条件:**必须在 HAL_Init() 之前调用**(main() 的 USER CODE BEGIN 1)
* 函数说明:把 s_wd_tick_cur(上一轮的终值,此刻尚未被本轮的 TIM7 中断覆盖)
* 搬到 s_wd_tick_prev,再把 cur 清零开始本轮累积。
* ⚠ 时序是本函数存在的唯一理由:TIM7 在 HAL_Init 里即开始 1ms 中断,
* 而开机上报要到 app_main_init(≈117ms)才跑——不先搬走,上一轮终值
* 会被本轮开机瞬间的 tick 覆盖(2026-10-02 台架实测踩坑)。
*/
void task_wd_tick_snapshot(void) {
if (s_wd_tick_cur[0] == WD_TICK_MAGIC) {
s_wd_tick_prev[0] = WD_TICK_MAGIC;
s_wd_tick_prev[1] = s_wd_tick_cur[1]; /* 上一轮最后一次 uwTick */
s_wd_tick_prev[2] = s_wd_tick_cur[2]; /* 上一轮 TIM7 中断总数 */
s_wd_tick_prev[3] = s_wd_tick_cur[3];
} else {
s_wd_tick_prev[0] = 0u; /* 上电首启:无上一轮数据 */
}
s_wd_tick_cur[0] = 0u; /* 本轮重新累积 */
s_wd_tick_cur[1] = 0u;
s_wd_tick_cur[2] = 0u;
s_wd_tick_cur[3] = 0u;
}
/*
* 函数功能:记录"时间源存活性"到复位不丢的 .noinit 槽(round 8b 取证)
* 入口参数:无
* 返回值:无
* 限定条件:TIM7 中断上下文调用(HAL_TIM_PeriodElapsedCallback 内,每 1ms)
* 函数说明:更新 s_wd_tick_cur = [magic, uwTick, 本轮中断计数, 0]。纯 3 字 SRAM 写
* + 读 uwTick,**不调用任何 FreeRTOS/CMSIS-RTOS API**——TIM7 在 HAL_Init
* 阶段即开始中断,远早于 xPortStartScheduler(),此时任何 ISR 版内核 API
* 都会触发 port.c 的优先级断言(ulMaxPRIGROUPValue 尚为 0)并把系统卡死
* 在"USART1 未初始化"的断言打印路径上(2026-10-02 台架实测根因)。
*/
void task_wd_tick_probe(void) {
s_wd_tick_cur[0] = WD_TICK_MAGIC;
s_wd_tick_cur[1] = wd_platform_tick_ms();
s_wd_tick_cur[2] = s_wd_tick_cur[2] + 1u;
}
/*
* 函数功能:开机上报上一轮最后进段 + 时间源存活性(round 8 取证)
* 入口参数:无
* 返回值:无
* 限定条件:启动早期调用(内核启动前亦可,裸 UART 直发)
* 函数说明:magic 命中才打一行 `PREVRUN seg=<段> seq=<序号> t=<ms>`(裸 UART,
* 不依赖环形缓冲写者),随后清 magic(一次复位只报一次)。
* round 8b:紧接着打 `TICKRUN uw= n= duw=`——uw 为 TIM7 最后一次记下的
* HAL tick、n 为 TIM7 中断计数、duw = uw - t(时间源是否活到复位)。
*/
void task_wd_seg_report(void) {
char m[128];
int n;
uint32_t t_seg;
if (s_wd_seg_trail[0] != WD_SEG_MAGIC) {
return; /* 上电首启或已被清:无上一轮轨迹 */
}
t_seg = s_wd_seg_trail[2];
/* 裸 UART 直发:启动早期环形缓冲写者尚未注册,不能走 DBG_* */
n = snprintf(m, sizeof(m), "PREVRUN seg=%lu seq=%lu t=%lu\r\n",
(unsigned long)s_wd_seg_trail[1],
(unsigned long)s_wd_seg_trail[3],
(unsigned long)t_seg);
if (n > 0) {
dbg_console_write_raw(m, (unsigned)strlen(m));
}
s_wd_seg_trail[0] = 0u; /* 一次复位只报一次 */
/* round 8b:时间源存活性(取自开机快照 prev,增量相对上一行的 t = 最后进段时刻)。
* duw≈0 → TIM7 与 defaultTask 同刻停摆(HAL 时基停摆);duw≈2000+ → TIM7 活到
* 复位(冻结另有阻塞点);n = 上一轮 TIM7 中断总数(≈该轮存活毫秒数)。 */
if (s_wd_tick_prev[0] == WD_TICK_MAGIC) {
uint32_t uw = s_wd_tick_prev[1];
n = snprintf(m, sizeof(m), "TICKRUN uw=%lu n=%lu duw=%lu\r\n",
(unsigned long)uw,
(unsigned long)s_wd_tick_prev[2],
(unsigned long)(uint32_t)(uw - t_seg));
if (n > 0) {
dbg_console_write_raw(m, (unsigned)strlen(m));
}
}
}
#endif /* !TASK_WATCHDOG_UNIT_TEST */
/* Public functions ----------------------------------------------------------*/
/*
* 函数功能:任务心跳置位
* 入口参数:id - 任务 ID(见 task_watchdog.h)
* 返回值:无
* 限定条件:可在任意上下文调用(单字写,无内核对象依赖)
* 函数说明:各任务在自己的主循环里调用。仅 1~2 条指令。
*/
void task_wd_beat(task_wd_id_t id) {
if ((uint32_t)id < (uint32_t)TASK_WD_ID_MAX) {
s_hb_bits |= (1u << (uint32_t)id);
}
}
/*
* 函数功能:启动 IWDG(延后启动,由 defaultTask 在 bring-up 完成后调用)
* 入口参数:无
* 返回值:无
* 限定条件:仅 defaultTask 调用;须在 osKernelStart 之后(本函数只用 HAL/寄存器)
* 函数说明:MX_IWDG_Init() 只把参数填进全局 hiwdg(Prescaler/Reload 由 CubeMX
* 在 Src/iwdg.c 生成区维护),**既不写寄存器也不启动**(见该文件
* IWDG-DEFER-START-ANCHOR 注释)。此处才真正启动——使"启动段不喂狗"
* 成立(§4.5)。IWDG 一旦启动不可停,只能靠复位终止。
* ⚠ PR/RLR 的写入由 wd_platform_start() 一并完成(v1.4 修复):
* 生成的 HAL_IWDG_Init() 被改名成空实现后它是唯一写这两只寄存器的路径,
* 否则寄存器停在复位默认值、超时只有 ~400ms(详见该函数说明)。
*/
void task_wd_start(void) {
if (s_started != 0u) {
return;
}
s_started = 1u;
/* 启动前先喂一次:让首个抽样窗口从"启动时刻"起算,
* 而非从 MX_IWDG_Init 的时刻起算(避免启动后立即超时) */
wd_platform_feed(); /* 重装计数器(喂狗) */
wd_platform_start(); /* 启动看门狗 */
DBG_INFO("[WD] armed: timeout ~2498ms (worst ~1701ms), miss_tol=800ms");
}
/*
* 函数功能:心跳抽样与喂狗判定(defaultTask 主循环调用)
* 入口参数:无
* 返回值:无
* 限定条件:仅 defaultTask 主循环调用(唯一喂狗者);内部节流到 100ms 一次
* 函数说明:取走并清零位图 → 与必需掩码比较:
* 全中 → 清零缺失计数并喂狗;
* 有缺 → 累计;连续达到 WD_MISS_TOLERANCE 次后**停止喂狗**,
* 让 IWDG 自然溢出复位(这样 RCC_CSR 记录的是权威的
* IWDGRSTF,而不是 NVIC_SystemReset 留下的 SFTRSTF —— §5.3)。
*/
void task_wd_poll(void) {
uint32_t now;
uint32_t snapshot;
if (s_started == 0u) {
return; /* 未启动:不做任何判定 */
}
now = wd_platform_tick_ms();
if ((now - s_last_tick) < WD_POLL_PERIOD_MS) {
return; /* 节流:非 100ms 边界直接返回 */
}
s_last_tick = now;
snapshot = s_hb_bits; /* 读 */
s_hb_bits = 0u; /* 清:语义 = "自上次抽样以来谁动过" */
s_last_mask = snapshot;
#ifndef TASK_WATCHDOG_UNIT_TEST
/* round 7 取证:留存近 8 次抽样位图(环形),停喂时随证据落盘 */
s_snap_hist[s_snap_hist_idx] = snapshot;
s_snap_hist_idx = (s_snap_hist_idx + 1u) % WD_SNAP_HIST_DEPTH;
#endif
if (snapshot == wd_required_mask()) {
s_miss_streak = 0u;
s_stall_evidenced = 0u; /* 恢复:允许下次停摆再落一次证据 */
wd_platform_feed(); /* 全员健康:喂狗 */
return;
}
/* 有任务缺席:累计(不喂狗) */
s_miss_total++;
if (s_miss_streak < WD_MISS_TOLERANCE) {
s_miss_streak++;
}
#ifndef TASK_WATCHDOG_UNIT_TEST
if (s_miss_streak == 1u) {
/* 停喂起点裸 UART 标记(每次缺席 episode 仅一条):证明 poller 仍在
* 抽样、并给出**首次**缺席的位图——定位是哪一任务先停摆(round 7) */
char m[96];
int mn = snprintf(m, sizeof(m),
"MISS start snap=0x%08X req=0x%08X t=%lu\r\n",
(unsigned)snapshot, (unsigned)wd_required_mask(),
(unsigned long)now);
if (mn > 0) {
dbg_console_write_raw(m, (unsigned)strlen(m));
}
}
#endif
if (s_miss_streak >= WD_MISS_TOLERANCE) {
/* 连续 800ms 有任务未动过 → 已停喂 → 约 2.5s 后 IWDG 复位。
* 最后遗言走裸 UART(不依赖内核、不遍历指针、有界长度)。 */
DBG_ERROR("[WD] task stall: mask=0x%08X req=0x%08X miss=%lu",
(unsigned)snapshot,
(unsigned)wd_required_mask(),
(unsigned long)s_miss_total);
#ifndef TASK_WATCHDOG_UNIT_TEST
/* round 7 取证:把现场落 /log/wdev.log(掉电不丢)。
* 串口 stall 行经僵尸通道可能丢失,故此处独立留盘上证据。
* ⚠ 本 episode 只写**一次**(s_stall_evidenced 门闩;注意 s_miss_streak
* 达上限后会**饱和**在容忍值,不能用它判"首次"):证据落盘经
* sys_log_evidence() 走 NAND 写 → FTL 路径调 nand_ftl_wd_feed()
* **直接喂狗**。若每轮 100ms 都写,等于在"停喂"窗口里持续喂狗 →
* IWDG 永不溢出 → 停喂语义被证据机制自身抵消(2026-10-03 台架 V2
* 实测:stall 已声明、miss 累至 1157≈115s 仍不复位,见
* docs/测试说明/实机测试实施指南.md §4)。写一次即可留证,此后不再
* 触碰 NAND,恢复"停喂 → IWDG 溢出复位"(§5.3 的权威归因)。 */
if (s_stall_evidenced == 0u) {
s_stall_evidenced = 1u;
wd_stall_evidence(snapshot);
}
#endif
}
}
/*
* 函数功能:读上次抽样到的位图(观测/自检用)
*/
uint32_t task_wd_last_mask(void) {
return s_last_mask;
}
/*
* 函数功能:读累计未达标轮数(观测/自检用)
*/
uint32_t task_wd_miss_count(void) {
return s_miss_total;
}
/*
* 函数功能:查询 IWDG 是否已启动(观测/自检用)
*/
uint32_t task_wd_is_armed(void) {
return (s_started != 0u) ? 1u : 0u;
}
/* Platform layer (MCU defaults, weak) ---------------------------------------*/
/*
* 以下三个 __weak 为 MCU 端默认实现,行为与重构前逐字一致。
* 主机侧单测提供同名强符号覆盖(链接器优先取强符号)以实现离线可观测。
* 声明见 task_watchdog.h 末段"平台后端(内部)";详见
* docs/测试说明/看门狗单元测试说明.md。
*
* 主机侧单测(同编译单元 #include 本文件)需回避这组默认定义与自身桩重名,
* 故由 TASK_WATCHDOG_UNIT_TEST 守卫:该宏下不编入默认实现,仅留桩。
*/
#ifndef TASK_WATCHDOG_UNIT_TEST
/*
* 函数功能:重装 IWDG 计数器(喂狗)——MCU 端默认实现
* 入口参数:无
* 返回值:无
* 函数说明:写 IWDG->KR = WD_IWDG_KEY_FEED(0xAAAA)。100ms 级高频路径,
* 故不经 HAL 封装。
*/
__weak void wd_platform_feed(void) {
IWDG->KR = WD_IWDG_KEY_FEED; /* 重装计数器(喂狗) */
wd_feed_track(); /* round 7 取证:记录喂狗时刻/最大间隔 */
}
/* ==== 为什么 IWDG 参数必须在这里写(2026-10-02 台架实测,P0 根因)====
* `Src/iwdg.c` 为实现"延后启动"把生成的 `HAL_IWDG_Init(&hiwdg)` 改名成空实现
* (见该文件 `USER CODE BEGIN 0` 里的 IWDG-DEFER-START-ANCHOR;2026-10-09 起
* 不再靠"每次重生成后删掉那一句"),而 `HAL_IWDG_Init()` 是 HAL 里**唯一**会把
* `hiwdg.Init.Prescaler/Reload` 写进寄存器的路径——改名后 `MX_IWDG_Init()`
* 只填结构体,PR/RLR 保持**复位默认值**(PR=0 → /4、RLR=4095)。
* 实测读数:`IWDG->PR = 0`、`IWDG->RLR = 4095` → 实际超时 4×4096/LSI
* ≈ 349~512ms,比设计的 1701~2498ms **短约 5 倍**。后果有二:
* ① `WD_MISS_TOLERANCE`(8×100ms=800ms)**永远等不到**就被 IWDG 复位
* → 停喂判定分支成为死代码(这正是 round 7 观察到"STALL 从未执行"
* 的原因,当时误判为"poller 自身停摆");
* ② 任何 >400ms 的心跳缺口都直接 IWDG 误复位。空 STOR(0 字节截断)
* → dhara/FTL flush 使 NET/FTP/UI 心跳缺 ~400ms → 复位(round 3~8
* 全部"空 STOR 必复位"的共同根因,P0-B 出口阻塞点)。
* 实测验证:SWD 现场把 PR/RLR 改成 4/1249 后,同一个空 STOR 由 5/5 复位
* 变为 100% 成功(`226 Closing data connection.`)。
* ==================================================================== */
/*
* 函数功能:按设计参数配置并启动 IWDG——MCU 端默认实现
* 入口参数:无
* 返回值:无
* 函数说明:解锁 PR/RLR → 写 WD_IWDG_PRESCALER / WD_IWDG_RELOAD(/64 + 1249
* → 1701~2498ms)→ 写 0xCCCC 启动(不可逆,只能靠复位停止)。
* 为何必须在此配置、否则超时短 5 倍,见上方长注释。
* ⚠ 顺序不可省:PR/RLR 有写保护,须先写 0x5555 解锁;每只寄存器写后要等
* `SR.PVU`/`SR.RVU` 清零(配置同步到 LSI 域,≤5 个 LSI 周期),否则**后写
* 的那只会被静默丢弃**(2026-10-02 SWD 实测:不等同步直接连写,RLR 未生效)。
*/
__weak void wd_platform_start(void) {
uint32_t spin;
IWDG->KR = WD_IWDG_KEY_UNLOCK; /* 解锁 PR/RLR 写访问 */
IWDG->PR = WD_IWDG_PRESCALER; /* 4 → /64 */
spin = WD_IWDG_SYNC_SPIN;
while (((IWDG->SR & IWDG_SR_PVU) != 0u) && (spin != 0u)) {
spin--; /* 等 PVU 清零:PR 已同步到 LSI 域 */
}
IWDG->RLR = WD_IWDG_RELOAD; /* 1249 */
spin = WD_IWDG_SYNC_SPIN;
while (((IWDG->SR & IWDG_SR_RVU) != 0u) && (spin != 0u)) {
spin--; /* 等 RVU 清零:RLR 已同步到 LSI 域 */
}
IWDG->KR = WD_IWDG_KEY_START; /* 启动看门狗(不可逆) */
}
/*
* 函数功能:读单调毫秒时基——MCU 端默认实现
* 入口参数:无
* 返回值:HAL 时基毫秒计数(32 位回绕安全,调用方用无符号差比较)
* 函数说明:HAL 时基 = TIM7(1ms),非 SysTick。
*/
__weak uint32_t wd_platform_tick_ms(void) {
return (uint32_t)HAL_GetTick();
}
/*
* 函数功能:lftpd 看门狗心跳钩子——MCU 端强符号实现
* 入口参数:无
* 返回值:无
* 限定条件:无(IWDG 启动前置位只写位图,不影响判定状态机)
* 函数说明:ftpTask 主循环 = lftpd 事件循环 / 传输等待点(埋点见 lftpd.c /
* lftpd_inet.c);驱动层只调用弱钩子 lftpd_task_wd_beat()
* (声明于 lftpd.h,弱空实现于 lftpd_inet.c),在此路由到任务
* 心跳位图的 FTP 位,使驱动层不向上依赖应用层(分层铁律)。
* 强符号覆盖同名弱实现(与 wd_platform_* 钩子同机制)。
* 背景:2026-10-02 台架实机——FTP 位无埋点 → 必需掩码永不满足
* → IWDG 启动后 800ms 停喂 → 约每秒一次 IWDG 复位、无限重启,
* 设备无法启动(P0-B 基线测试失败根因)。
*/
void lftpd_task_wd_beat(void) {
task_wd_beat(TASK_WD_FTP);
}
/* FTL 心跳路由表:任务句柄(0 = 未登记),下标 = 任务 ID */
static TaskHandle_t s_wd_bind[(uint32_t)TASK_WD_ID_MAX];
/*
* 函数功能:NAND FTL 看门狗心跳钩子 —— MCU 端强符号实现
* 入口参数:无
* 返回值:无
* 限定条件:任意上下文可调(内核启动前 / 未登记任务为空操作)
* 函数说明:gd5f_wait_busy 的长耗时轮询(块擦除等待最长 5 s、GC 页拷贝、
* 日志写)都运行在调用者任务上下文(default/ftp/ui/timesync 等),
* 此处按 task_wd_bind() 登记的句柄路由到调用者任务的心跳位,
* 防止长耗时窗口心跳缺席导致 IWDG 误复位。
* 背景:2026-10-02 台架——0 字节 STOR 截断 → dhara 日志块 GC
* (> 2 s,期间无埋点)→ FTP 位缺席 800 ms → 停喂 → IWDG 误复位。
* testTask 刻意不绑定:它不在必需掩码内(抽样为精确匹配),
* 其上下文置位会引入多余位反而误杀。
*/
void nand_ftl_wd_beat(void) {
TaskHandle_t h;
uint32_t i;
h = xTaskGetCurrentTaskHandle();
if (h == 0) {
return; /* 内核启动前:IWDG 未启动,无需心跳 */
}
for (i = 0u; i < (uint32_t)TASK_WD_ID_MAX; i++) {
if ((s_wd_bind[i] != 0) && (s_wd_bind[i] == h)) {
task_wd_beat((task_wd_id_t)i);
return;
}
}
/* 未登记任务(如 testTask):不置任何位(精确匹配抽样禁多余位) */
}
/*
* 函数功能:把当前任务句柄登记进 FTL 心跳路由表
* 入口参数:id - 任务 ID
* 返回值:无
* 限定条件:任务上下文调用(内核启动后)
* 函数说明:default/net/ftp/ui/timesync 五任务入口各调一次;
* 每任务 ID 一个句柄槽,重复登记覆盖(幂等)。
*/
void task_wd_bind(task_wd_id_t id) {
s_wd_bind[(uint32_t)id] = xTaskGetCurrentTaskHandle();
}
/*
* 函数功能:NAND FTL 看门狗直接喂狗钩子 —— MCU 端强符号实现
* 入口参数:无
* 返回值:无
* 限定条件:任意上下文可调(IWDG 未启动时为空操作)
* 函数说明:心跳钩子只维持心跳位,喂狗动作在 task_wd_poll(defaultTask 主
* 循环)。长耗时 FTL 操作(GC/整卷格式化)发生在 defaultTask 上下文
* 时,主循环阻塞在 FTL 调用内 → 喂狗点停摆 → 心跳位再新也无人喂 →
* IWDG 误复位(2026-10-02 台架:console tx fault 条目落盘触发 GC,
* poller 饥饿 ~2 s → IWDG 复位、无 stall 日志)。此处让 FTL 长路径
* 在操作进行中直接写 IWDG->KR,把"喂狗点自身被阻塞"的误复位形态
* 一并覆盖。语义边界(显式声明):NAND 侧真实卡死(wait_busy 自旋
* 超时后 dhara 恢复重试再卡死)时本路径持续喂狗 = 设备存活但功能
* 冻结(与 console ORE 自愈同类边界,V5 长跑观察)。
*/
void nand_ftl_wd_feed(void) {
if (s_started != 0u) {
wd_platform_feed();
}
}
#endif /* !TASK_WATCHDOG_UNIT_TEST */
/* Test-only hooks (compiled out unless TASK_WATCHDOG_UNIT_TEST) --------------*/
/*
* 函数功能:复位判定状态机到"刚上电"(仅主机侧单元测试使用)
* 入口参数:无
* 返回值:无
* 限定条件:仅当定义 TASK_WATCHDOG_UNIT_TEST 时存在
* 函数说明:把 s_started / 位图 / 计数 / 节流基线全部清零,使各用例互相独立。
* MCU 构建不定义该宏 → 零代码、零符号。
*/
#ifdef TASK_WATCHDOG_UNIT_TEST
void task_wd_test_reset(void) {
s_hb_bits = 0u;
s_miss_streak = 0u;
s_miss_total = 0u;
s_last_mask = 0u;
s_started = 0u;
s_last_tick = 0u;
s_stall_evidenced = 0u;
}
#endif
+248
View File
@@ -0,0 +1,248 @@
/*
* 模块名称:Task Watchdog(IWDG + 任务心跳位图)
* 模块功能:独立看门狗与"全部任务存活"绑定的对外接口
* 适用平台:STM32F407ZGTx + FreeRTOS(CMSIS-RTOS V2)
* 作者:王建锋
* 创建日期:2026-09-29
* 修改记录:
* v1.0 2026-09-29 王建锋 创建
*
* 设计依据:docs/子系统设计/看门狗设计说明.md
*/
#ifndef TASK_WATCHDOG_H
#define TASK_WATCHDOG_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* 任务 ID(位序号 = 心跳位图上的位置,与必需掩码一一对应)
* 顺序一旦确定不要插空/改动,掩码是编译期常量
*/
typedef enum {
TASK_WD_DEFAULT = 0,
TASK_WD_NET = 1,
TASK_WD_FTP = 2,
TASK_WD_TEST = 3,
TASK_WD_UI = 4,
TASK_WD_TIMESYNC = 5,
TASK_WD_ADC = 6, /* adcTask:1ms 采样节拍消费者(B1,2026-10-10) */
TASK_WD_ID_MAX
} task_wd_id_t;
/*
* 函数功能:任务心跳置位(各任务在自己主循环里调用)
* 入口参数:id - 任务 ID
* 返回值:无
* 限定条件:任意上下文可调(单字原子写、无内核对象依赖)
* 函数说明:仅 1~2 条指令,无锁、无阻塞,可放在中断外任意位置。
*/
void task_wd_beat(task_wd_id_t id);
/*
* 函数功能:把当前任务句柄登记进 FTL 心跳路由表(任务入口调用一次)
* 入口参数:id - 任务 ID
* 返回值:无
* 限定条件:任务上下文调用(内核启动后)
* 函数说明:nand_ftl_wd_beat() 强符号(本模块)按此表把 FTL 长耗时操作
* 的心跳路由到调用者任务的心跳位(见 gd5f2gq5ue.h 钩子说明)。
* testTask 刻意不登记:它不在必需掩码内(抽样为精确匹配),
* 其上下文置位会引入多余位反而误杀。
*/
void task_wd_bind(task_wd_id_t id);
/*
* 函数功能:启动 IWDG(**延后启动**)
* 入口参数:无
* 返回值:无
* 限定条件:仅 defaultTask 在存储 bring-up 完成后调用一次
* 函数说明:MX_IWDG_Init() 只填参数不启动(原因见 Src/iwdg.c 的
* IWDG_DEFER_START 注释:HAL_IWDG_Init 内部会立即启动,而它被
* CubeMX 插在 app_main_init 之前,那段无喂狗点且耗时可达数秒)。
* 本函数才真正启动,使"启动段不喂狗、主循环段才喂"成立。
*/
void task_wd_start(void);
/*
* 函数功能:心跳抽样与喂狗判定
* 入口参数:无
* 返回值:无
* 限定条件:仅 defaultTask 主循环调用(唯一喂狗者)
* 函数说明:内部节流至 100ms 一次。全员到位才喂狗;连续 800ms 有任务
* 缺席则停止喂狗,由 IWDG 自然溢出复位(保留 IWDGRSTF 权威归因)。
*/
void task_wd_poll(void);
/* 观测接口(自检 / 串口报告用) */
uint32_t task_wd_last_mask(void); /* 上次抽样到的位图(0 位 = 缺席任务) */
uint32_t task_wd_miss_count(void); /* 累计未达标轮数 */
uint32_t task_wd_is_armed(void); /* 1 = IWDG 已启动 */
/*
* ==================== round 8 取证:复位不丢的"进段打点" ====================
* 背景(2026-10-02 台架 round 7~8 取证,完整链条见
* docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录):误复位时判定分支(STALL)从未
* 执行,当时判为**喂狗点 task_wd_poll 自身停摆**(round 8b 已更正:真因是 IWDG 超时
* 被静默配成复位默认值),而非某个被监视任务饿死。RAM 态随复位消失、串口又可能来不及打,
* 故需要一条**复位后仍可读**的"defaultTask 主循环最后走到哪一段"轨迹。
*
* 实现:轨迹槽放在 .noinit(scatter 的 UNINIT 执行区,见
* MDK-ARM/STM32F407-Demo/STM32F407-Demo.sct 的 UNINIT_CCM)——IWDG/引脚复位
* 不改变 SRAM 内容,故复位后仍能读出上一轮的最后进段。槽布局:
* [0]=magic(校验是否为本模块写过)[1]=段号 [2]=进段时刻(ms) [3]=循环序号
* round 8b 追加"时间源存活性"槽(同样在 .noinit):
* cur = [magic, uwTick, 本轮 TIM7 计数, 0](TIM7 ISR 每 1ms 更新)
* prev = [magic, uwTick, 上一轮 TIM7 计数, 0](main() 在 HAL_Init 之前快照)
* 注意:**仅 MCU 构建存在**(TASK_WATCHDOG_UNIT_TEST 下不编入,主机单测不受影响)。
* ==========================================================================
*/
/* defaultTask 主循环"进段"编号(task_wd_seg 的入参;复位后从 PREVRUN 行读出) */
typedef enum {
TASK_WD_SEG_IDLE = 0, /* 未进入任何段(初值) */
TASK_WD_SEG_POLL = 1, /* task_wd_poll(喂狗判定) */
TASK_WD_SEG_EVTWAIT = 2, /* osEventFlagsWait(50ms 兜底等待) */
TASK_WD_SEG_DRAIN = 3, /* dbg_console_drain(控制台排空) */
TASK_WD_SEG_CFGSAVE = 4, /* net_cfg_save_run(配置保存) */
TASK_WD_SEG_CFGPOLL = 5, /* cfg_file_poll(2s 配置轮询) */
TASK_WD_SEG_LED = 6, /* LED 心跳 */
TASK_WD_SEG_SYSLOG = 7, /* syslog_drain(运行期日志落盘) */
TASK_WD_SEG_STACK = 8 /* stack_probe_poll(栈水位观测) */
} task_wd_seg_t;
/*
* 函数功能:记录"即将进入的主循环段"(round 8 取证;**仅 MCU 构建**)
* 入口参数:seg - 段号(task_wd_seg_t)
* 返回值:无
* 限定条件:defaultTask 主循环调用(任意上下文亦可,纯 SRAM 写)
* 函数说明:4 字写(magic/段/时刻/序号),进 .noinit 槽;复位不丢。
*/
void task_wd_seg(uint32_t seg);
/*
* 函数功能:开机上报上一轮最后进段 + 时间源存活性(round 8 取证;**仅 MCU 构建**)
* 入口参数:无
* 返回值:无
* 限定条件:启动早期调用(内核启动前亦可,裸 UART 直发)
* 函数说明:magic 命中才打一行 `PREVRUN seg=<段> seq=<序号> t=<ms>`(裸 UART,
* 不依赖环形缓冲写者),随后清 magic(一次复位只报一次)。
* round 8b:紧接着打一行 `TICKRUN uw= n= duw=`——uw 为 TIM7 在**上一轮**
* 最后一次记下的 HAL tick、n 为上一轮 TIM7 中断总数、duw = uw - t
* (相对进段时刻的增量),据此判定 TIM7 是否在冻结同刻停摆。
*/
void task_wd_seg_report(void);
/*
* 函数功能:开机快照上一轮的"时间源存活性"(round 8b 取证;**仅 MCU 构建**)
* 入口参数:无
* 返回值:无
* 限定条件:**必须在 HAL_Init() 之前调用**(main() 的 USER CODE BEGIN 1)
* 函数说明:TIM7 在 HAL_Init 里即开始 1ms 中断,而开机上报要到 app_main_init
* (≈117ms)才跑;本函数把上一轮的终值先搬到 prev 槽再清空 cur,
* 否则终值会被本轮开机瞬间的 tick 覆盖(2026-10-02 台架实测踩坑)。
*/
void task_wd_tick_snapshot(void);
/*
* 函数功能:记录"时间源存活性"到复位不丢的 .noinit 槽(round 8b 取证;**仅 MCU 构建**)
* 入口参数:无
* 返回值:无
* 限定条件:TIM7 中断上下文调用(`HAL_TIM_PeriodElapsedCallback` 内,每 1ms)
* 函数说明:更新 cur 槽 = [magic, uwTick, 本轮 TIM7 中断计数, 0](纯 SRAM 写,无阻塞)。
* 与 task_wd_seg 的"进段打点"配合,复位后由 task_wd_seg_report() 上报。
* 背景(2026-10-02 台架 round 8b 取证,见 docs/子系统设计/看门狗设计说明.md
* 2026-10-02 变更记录):HAL_UART_Transmit 的超时判据
* 依赖 uwTick,时间源停摆即死等——必须先把"TIM7 是否停摆"钉死再定向修复。
* ⚠ **本函数内严禁调用任何 FreeRTOS/CMSIS-RTOS API**:TIM7 在 HAL_Init
* 阶段即开始中断(远早于 xPortStartScheduler),此时 ISR 版内核 API 会触发
* port.c 的优先级断言并把系统卡死在 TIM7 ISR 内(2026-10-02 台架实测根因)。
*/
void task_wd_tick_probe(void);
/*
* ============================ 平台后端(内部) ============================
* 把判定状态机中的硬件访问收敛到三个薄钩子,使"取走清零 / 掩码判定 / 缺失
* 累计 / 节流"成为纯逻辑,可在主机侧用桩替换后离线穷举验证
* (见 test/host/watchdog/ 与 docs/测试说明/看门狗单元测试说明.md)。
*
* 与 App/cfg/cfg_file_store.h 的"共享核心 + 平台后端"同款做法:
* - 三者由 task_watchdog.c 以 __weak 提供 MCU 默认实现(读写 IWDG 寄存器 /
* 调 HAL_GetTick),**行为与不抽钩子时逐字一致**;
* - 主机侧单测提供同名**强符号**覆盖 → 不碰 MCU 代码即可观测/注入;
* - MCU 构建零行为变化。
*
* ⚠ 非模块对外接口,业务代码**不要**直接调用(由 task_wd_* 间接使用)。
* ========================================================================
*/
/*
* 函数功能:重装 IWDG 计数器(喂狗)
* 返回值:无
* 函数说明:MCU 默认实现 = `IWDG->KR = 0x0000AAAAu;`
* 主机侧单测覆盖此符号以记录"何时被喂"。
*/
void wd_platform_feed(void);
/*
* 函数功能:按设计参数配置并启动 IWDG(不可逆,启动后只能靠复位停止)
* 返回值:无
* 函数说明:MCU 默认实现 = 解锁 PR/RLR → 写 WD_IWDG_PRESCALER/WD_IWDG_RELOAD
* (/64 + 1249 → 1701~2498ms,等 SR.PVU/RVU 同步)→ `IWDG->KR = 0xCCCC`。
* ⚠ 配置**必须**在此完成:`Src/iwdg.c` 为实现延后启动,把生成的
* `HAL_IWDG_Init(&hiwdg)` 经宏改名成空实现(见该文件 `USER CODE BEGIN 0`),
* 而 `HAL_IWDG_Init()` 是 HAL 里唯一把 `hiwdg.Init.*` 写进寄存器的路径;
* 缺了这一步 PR/RLR 停在复位默认值(PR=0/RLR=4095 ≈ 400ms),
* 超时比设计短约 5 倍 → 800ms 容忍分支成死代码、易 IWDG 误复位。
* 主机侧单测以强符号覆盖本钩子(不碰寄存器),故其行为不受影响。
*/
void wd_platform_start(void);
/*
* 函数功能:读单调毫秒时基(用于 100ms 抽样节流)
* 返回值:自启动以来的毫秒计数(32 位回绕安全,用无符号差比较)
* 函数说明:MCU 默认实现 = `HAL_GetTick()`
* 主机侧单测覆盖此符号以驱动时间推进。
*/
uint32_t wd_platform_tick_ms(void);
/*
* 函数功能:lftpd 看门狗心跳钩子(**本模块提供强符号实现**)
* 入口参数:无
* 返回值:无
* 限定条件:任意上下文(IWDG 启动前只置位图,不影响判定)
* 函数说明:驱动层(lftpd.c / lftpd_inet.c 的节拍点)只调用弱钩子,本模块以
* 强符号覆盖并路由到 TASK_WD_FTP 心跳位——驱动层不向上依赖应用层
* (分层铁律)。声明于 lftpd.h 供驱动侧引用,此处为"本模块提供"侧的声明。
*/
void lftpd_task_wd_beat(void);
/*
* 函数功能:NAND FTL 直接喂狗钩子(**本模块提供强符号实现**)
* 入口参数:无
* 返回值:无
* 限定条件:任意上下文(IWDG 未启动时为空操作)
* 函数说明:FTL 长耗时路径(GC/块擦除等待)在调用者任务上下文**直接写 IWDG->KR**,
* 覆盖"喂狗点(defaultTask 主循环)自身被长 I/O 阻塞"的误复位形态。
* 声明于 gd5f2gq5ue.h 供驱动侧引用,此处为"本模块提供"侧的声明。
*/
void nand_ftl_wd_feed(void);
/*
* 函数功能:复位判定状态机(**仅主机侧单元测试使用**)
* 返回值:无
* 函数说明:仅当定义 TASK_WATCHDOG_UNIT_TEST 时编入(MCU 构建无此符号)。
* 用于在用例之间清空位图/计数/节流基线,保证用例互不污染。
*/
#ifdef TASK_WATCHDOG_UNIT_TEST
void task_wd_test_reset(void);
#endif
#ifdef __cplusplus
}
#endif
#endif /* TASK_WATCHDOG_H */
+34
View File
@@ -0,0 +1,34 @@
# Drivers/BSP/CH395F/ — CH395F 以太网控制器驱动
> CH395F 是 SPI 接口的 10/100M 以太网 MAC+PHY 控制器。本目录是其**底层寄存器级驱动**,
> 上层的 BSD Socket 抽象见 `../NET/`。
## 文件一览
| 文件 | 作用 |
|---|---|
| `ch395f.c/h` | SPI 命令收发、寄存器读写、芯片初始化、通道/中断管理 |
## 关键点
- **SPI 接口**:SPI2(PB12 CS / PB13 SCK / PB14 MISO / PB15 MOSI)
- **每次 SPI 事务必须 `ch395f_spi_begin()` / `ch395f_spi_end()` 包裹**
- **SPI 分频系数必须为 8**(PCB 信号完整性约束,**勿"优化"改快**)
- 芯片最多 8 个 socket,本层用**事件中断 + 周期轮询**驱动
- PHY 强制 **100M 全双工**(`net_task` 中配置)
## ⚠️ 改本驱动前必读陷阱(Trap Records)
| Trap | 问题 | 状态 |
|---|---|---|
| Trap 21 | TC-NET-604 "RX 窗口冻结" 判定为**非缺陷**(原报告系测试程序自吞数据 + 判读工具误判) | ✅ 结案 |
| Trap 22 | **快速复连丢 CONNECT/DISCONNECT 中断 → 监听静默死亡**;修复 = `net_reconcile_tick()` 周期对账自愈 | ✅ 已修 |
| Trap 23 | **上传中止后首笔复连被静默截断(≤2048B)并误报成功**;修复 = 异常释放后复监听隔离排空 | ✅ 已修 |
> 完整台账与复现配方见 `docs/问题记录/CH395F_Trap_Records.md`(**改驱动前必读**)。
## 相关文档
- 测试指南:`docs/测试说明/CH395F_Test_Guide.md`
- 上层抽象:`../NET/README.md`
- 台架探针:`MDK-ARM/tcp_reconnect_probe.py`、`MDK-ARM/ftp_abort_probe.py`
+97 -29
View File
@@ -8,6 +8,11 @@
* 修改记录:
* 2026-03-14 王建锋 创建初始版本,按代码规范重构
* 2026-07-18 王建锋 改进write_send_buf/read_recv_buf使用SPI2 DMA批量传输
* 2026-10-02 王建锋 E-34:3 处字节级 SPI 调用由 HAL_MAX_DELAY 改有限超时(100ms)
* + 返回值检查 + 故障锁存(沿程只记一条日志);原 SPI 异常时调用方永久挂死
* 2026-10-04 王建锋 P2-2:ch395f_spi_dma_transfer 由 void 改为返回状态(0 成功/-1 失败),
* DMA 初始化失败/超时中止均锁存通道故障;read_recv_buf 在 DMA 失败时不再把
* rx_buf 内容拷入调用方缓冲(改零填充),消除"失败当成功"的静默数据损坏
*/
/* 头文件包含区 - 先包含系统头文件,再包含自定义头文件 */
@@ -19,7 +24,12 @@
/*
* 调试输出配置
* 深调用链模块(ch395f_spi_fault_set 等出现在 netTask/ftpTask/timeSyncTask
* 最坏链尾部):日志行缓冲收到 128 B(默认 192)。本模块最长行
* "HAL_SPI_TransmitReceive_DMA FAIL: %d, SPI_State=%d, SPI_Error=0x%lX, DMA_State=%d"
* 渲染后 ≈ 102 B(含时间戳 21 + 标签)< 128 → 不截断
*/
#define DBG_LINE_BUF_MAX 128U
#define DBG_TAG "[CH395]"
#include "dbg_log.h"
@@ -30,6 +40,7 @@
#define CH395F_RESET_DELAY_MS 20U /* 复位后等待延时(ms) */
#define CH395F_INIT_TIMEOUT_MS 500U /* 初始化超时时间(ms) */
#define CH395F_SPI_BEGIN_DELAY_MS 1U /* SPI事务开始延时(ms) */
#define CH395F_SPI_BYTE_TIMEOUT_MS 100U /* E-34:字节级SPI有限超时(原 HAL_MAX_DELAY) */
/* DMA缓冲大小使用ch395f.h中公开的CH395F_SPI_DMA_BUF_SIZE */
#define CH395F_DMA_BUF_SIZE CH395F_SPI_DMA_BUF_SIZE
@@ -37,6 +48,7 @@
* 静态变量定义区 - DMA传输相关
*/
static volatile uint8_t s_spi2_dma_done = 0U; /* DMA传输完成标志 */
static uint8_t s_spi_fault = 0U; /* E-34/P2-2:SPI 通道故障锁存(字节级或 DMA 级超时/出错置位,MCU复位清除) */
__attribute__((aligned(4)))
static uint8_t s_spi2_dma_tx_buf[CH395F_DMA_BUF_SIZE]; /* DMA发送缓冲区 */
@@ -48,7 +60,8 @@ static uint8_t s_spi2_dma_rx_buf[CH395F_DMA_BUF_SIZE]; /* DMA接收缓冲区 */
*/
static void ch395f_spi_begin(void);
static void ch395f_spi_end(void);
static void ch395f_spi_dma_transfer(uint16_t len);
static int ch395f_spi_dma_transfer(uint16_t len);
static void ch395f_spi_fault_set(const char *kind);
/*
* 私有函数定义区
@@ -76,17 +89,35 @@ static void ch395f_spi_end(void) {
HAL_GPIO_WritePin(CH395_SCS_GPIO_Port, CH395_SCS_Pin, GPIO_PIN_SET);
}
/*
* 函数功能:锁存一次 SPI 通道故障(E-34/P2-2:仅首条故障记日志)
* 入口参数:kind - 故障类别标签("byte"/"dma-hal"/"dma-timeout") const char*
* 返回值:无
* 限定条件:无
* 函数说明:任一字节级或 DMA 级 SPI 调用超时/失败时调用;置位 s_spi_fault 并记一条日志,
* 后续故障静默(防串口刷屏);上层经 ch395f_spi_byte_fault() 查询
*/
static void ch395f_spi_fault_set(const char *kind) {
if (s_spi_fault == 0U) {
s_spi_fault = 1U;
DBG_ERROR("SPI channel fault latched: %s", kind);
}
}
/*
* 函数功能:DMA全双工传输并等待完成
* 入口参数:len - 传输长度(字节) uint16_t 1 - CH395F_DMA_BUF_SIZE
* 返回值:0 - 传输完成且无错误 -1 - 传输失败(HAL 启动失败 / 超时中止)
* 限定条件:1. s_spi2_dma_tx_buf中已填充待发送数据
* 2. 需确保DMA传输缓冲区已对齐到4字节边界
* 函数说明:1. 发送s_spi2_dma_tx_buf内容,同时接收数据存入s_spi2_dma_rx_buf
* 2. 阻塞等待DMA完成回调置标志位
* 3. 超时(1秒)时强制中止SPI DMA并打印错误信息
* 4. 传输完成后检查SPI错误码并清除溢出标志
* 5. P2-2(2026-10-04):失败路径锁存通道故障并返回 -1,调用方据此
* 判定 rx_buf 内容不可信,不再"失败当成功"继续消费
*/
static void ch395f_spi_dma_transfer(uint16_t len) {
static int ch395f_spi_dma_transfer(uint16_t len) {
HAL_StatusTypeDef status;
uint32_t tickstart;
volatile uint32_t loop_cnt = 0U;
@@ -99,7 +130,8 @@ static void ch395f_spi_dma_transfer(uint16_t len) {
(int)hspi2.State,
hspi2.ErrorCode,
(int)hspi2.hdmarx->State);
return;
ch395f_spi_fault_set("dma-hal");
return -1;
}
tickstart = HAL_GetTick();
@@ -122,7 +154,8 @@ static void ch395f_spi_dma_transfer(uint16_t len) {
SPI2->CR1, SPI2->CR2, SPI2->SR);
/* 强制中止SPI2 DMA状态,避免死锁 */
HAL_SPI_Abort(&hspi2);
return;
ch395f_spi_fault_set("dma-timeout");
return -1;
}
}
@@ -132,6 +165,8 @@ static void ch395f_spi_dma_transfer(uint16_t len) {
hspi2.ErrorCode, (int)hspi2.State);
__HAL_SPI_CLEAR_OVRFLAG(&hspi2);
}
return 0;
}
/*
@@ -175,10 +210,13 @@ void HAL_SPI_ErrorCallback(SPI_HandleTypeDef *hspi) {
* 函数说明:SPI模式下命令和数据字节之间无需间隔
*/
uint8_t ch395f_write_cmd(uint8_t cmd) {
uint8_t rx_data;
uint8_t rx_data = 0xFFU;
/* 通过SPI发送命令并接收回显 */
HAL_SPI_TransmitReceive(&hspi2, &cmd, &rx_data, 1, HAL_MAX_DELAY);
/* 通过SPI发送命令并接收回显(E-34:有限超时+返回值检查;失败返回0xFF占位并锁存故障) */
if (HAL_SPI_TransmitReceive(&hspi2, &cmd, &rx_data, 1,
CH395F_SPI_BYTE_TIMEOUT_MS) != HAL_OK) {
ch395f_spi_fault_set("byte");
}
return rx_data;
}
@@ -189,10 +227,13 @@ uint8_t ch395f_write_cmd(uint8_t cmd) {
* 限定条件:必须在ch395f_spi_begin()和ch395f_spi_end()之间调用
*/
uint8_t ch395f_write_data(uint8_t data) {
uint8_t rx_data;
uint8_t rx_data = 0xFFU;
/* 通过SPI发送数据并接收回显 */
HAL_SPI_TransmitReceive(&hspi2, &data, &rx_data, 1, HAL_MAX_DELAY);
/* 通过SPI发送数据并接收回显(E-34:有限超时+返回值检查) */
if (HAL_SPI_TransmitReceive(&hspi2, &data, &rx_data, 1,
CH395F_SPI_BYTE_TIMEOUT_MS) != HAL_OK) {
ch395f_spi_fault_set("byte");
}
return rx_data;
}
@@ -204,13 +245,27 @@ uint8_t ch395f_write_data(uint8_t data) {
*/
uint8_t ch395f_read_data(void) {
uint8_t tx_dummy = 0xFFU;
uint8_t rx_data;
uint8_t rx_data = 0xFFU;
/* 发送0xFF作为时钟源,读取芯片返回数据 */
HAL_SPI_TransmitReceive(&hspi2, &tx_dummy, &rx_data, 1, HAL_MAX_DELAY);
/* 发送0xFF作为时钟源,读取芯片返回数据(E-34:有限超时+返回值检查) */
if (HAL_SPI_TransmitReceive(&hspi2, &tx_dummy, &rx_data, 1,
CH395F_SPI_BYTE_TIMEOUT_MS) != HAL_OK) {
ch395f_spi_fault_set("byte");
}
return rx_data;
}
/*
* 函数功能:查询 SPI 通道故障锁存(E-34 / P2-2)
* 返回值:0 - 正常 1 - 曾发生 SPI 超时/错误(字节级或 DMA 级)
* 限定条件:无
* 函数说明:锁存式(不自动清除,MCU 复位前一直有效);上层据此判定
* "SPI 通道异常"(区别于"无芯片"/"PHY 未链接");仅首条故障记日志
*/
uint8_t ch395f_spi_byte_fault(void) {
return s_spi_fault;
}
/*
* 函数功能:检测CH395芯片是否存在且通信正常
* 返回值:CH395F_STATUS_OK - 芯片检测成功 ch395f_status_t
@@ -556,20 +611,13 @@ void ch395f_set_retrans_period(uint16_t period) {
* 函数说明:复位耗时约15ms(TE0),函数内部包含延时等待
*/
ch395f_status_t ch395f_reset(void) {
DBG_INFO("ch395f_reset: spi_begin");
ch395f_spi_begin();
DBG_INFO("ch395f_reset: write_cmd RESET_ALL");
ch395f_write_cmd(CH395F_CMD_RESET_ALL);
DBG_INFO("ch395f_reset: spi_end");
ch395f_spi_end();
DBG_INFO("ch395f_reset: HAL_Delay(%d)", CH395F_RESET_DELAY_MS);
DBG_INFO("ch395f_reset: tick=%lu basepri=0x%02X", HAL_GetTick(), __get_BASEPRI());
HAL_Delay(CH395F_RESET_DELAY_MS);
DBG_INFO("ch395f_reset: done");
return CH395F_STATUS_OK;
}
@@ -649,7 +697,7 @@ ch395f_status_t ch395f_init(void) {
* 函数功能:设置CH395的IP地址(低字节在前)
* 入口参数:p_ip - 4字节IP地址指针 uint8_t* 192.168.x.x格式
* 限定条件:1. p_ip不可为NULL
* 2. 芯片已初始化
* 2. 必须在 ch395f_init() 之前调用(手册 §9.2.1:SET_IP 先于 INIT_CH395)
*/
void ch395f_set_ip_addr(uint8_t *p_ip) {
uint8_t i;
@@ -678,7 +726,7 @@ void ch395f_set_ip_addr(uint8_t *p_ip) {
* 函数功能:设置CH395的网关IP地址(低字节在前)
* 入口参数:p_gwip - 4字节网关IP指针 uint8_t*
* 限定条件:1. p_gwip不可为NULL
* 2. 芯片已初始化
* 2. 必须在 ch395f_init() 之前调用(手册 §9.2.1:SET_GWIP 先于 INIT_CH395)
*/
void ch395f_set_gwip_addr(uint8_t *p_gwip) {
uint8_t i;
@@ -707,7 +755,7 @@ void ch395f_set_gwip_addr(uint8_t *p_gwip) {
* 函数功能:设置CH395的子网掩码(低字节在前)
* 入口参数:p_mask - 4字节子网掩码指针 uint8_t*
* 限定条件:1. p_mask不可为NULL
* 2. 芯片已初始化
* 2. 必须在 ch395f_init() 之前调用(手册 §9.2.1:SET_MASK 先于 INIT_CH395)
*/
void ch395f_set_mask_addr(uint8_t *p_mask) {
uint8_t i;
@@ -736,8 +784,9 @@ void ch395f_set_mask_addr(uint8_t *p_mask) {
* 函数功能:设置CH395的MAC地址(低字节在前,存入EEPROM)
* 入口参数:p_mac - 6字节MAC地址指针 uint8_t*
* 限定条件:1. p_mac不可为NULL
* 2. 芯片已初始化
* 函数说明:执行耗时约30us(TE5)
* 2. 必须在 ch395f_init() 之前调用(手册 §9.2.1 步骤①:SET_MAC 写 EEPROM,
* INIT_CH395 加载 MAC;顺序反了则本次上电不生效,回落芯片内置/上次值)
* 函数说明:执行耗时约30us(TE5);写芯片 EEPROM,每上电重放同值幂等
*/
void ch395f_set_mac_addr(uint8_t *p_mac) {
uint8_t i;
@@ -1140,9 +1189,10 @@ void ch395f_write_send_buf(uint8_t sock, uint8_t *p_data, uint16_t len) {
/* 数据同步屏障:确保memcpy写入对DMA可见 */
__DSB();
/* CS低 → DMA批量传输 → CS高 */
/* CS低 → DMA批量传输 → CS高(P2-2:失败已由 transfer 锁存通道故障并记日志,
* 调用方经 ch395f_spi_byte_fault() 查询;此处无后续处理,失败即放弃本次发送) */
ch395f_spi_begin();
ch395f_spi_dma_transfer(total);
(void)ch395f_spi_dma_transfer(total);
ch395f_spi_end();
}
@@ -1160,6 +1210,7 @@ void ch395f_write_send_buf(uint8_t sock, uint8_t *p_data, uint16_t len) {
*/
void ch395f_read_recv_buf(uint8_t sock, uint8_t *p_data, uint16_t len) {
uint16_t total;
int rc;
/* 检查输入参数合法性 */
if (p_data == NULL) {
@@ -1169,6 +1220,11 @@ void ch395f_read_recv_buf(uint8_t sock, uint8_t *p_data, uint16_t len) {
/* 计算总长度:1字节命令 + 1字节Socket + 2字节长度 + 数据 */
total = 4U + len;
if (total > CH395F_DMA_BUF_SIZE) {
/* 异常金丝雀:接收读不应超过整个接收缓冲(2KB,recv_len 不可能超过缓冲大小);
* 触发即调用方参数异常。静默丢弃会让数据与错误一起消失(无从诊断),
* 故必须留痕。正常路径不会到达此处。 */
DBG_ERROR("read_recv_buf OVERSIZE: sock %u req %u > max %u, read dropped",
(unsigned)sock, (unsigned)len, (unsigned)(CH395F_DMA_BUF_SIZE - 4U));
return;
}
@@ -1183,9 +1239,17 @@ void ch395f_read_recv_buf(uint8_t sock, uint8_t *p_data, uint16_t len) {
/* CS低 → DMA全双工传输(发送命令头 + 接收数据) → CS高 */
ch395f_spi_begin();
ch395f_spi_dma_transfer(total);
rc = ch395f_spi_dma_transfer(total);
ch395f_spi_end();
/* P2-2(2026-10-04):DMA 失败时 rx_buf 内容不可信,禁止拷入调用方缓冲
* (否则把总线残留/半截数据当有效载荷 → 静默数据损坏);改零填充并返回,
* 调用方据 ch395f_spi_byte_fault() 判定通道异常。 */
if (rc != 0) {
memset(p_data, 0, len);
return;
}
/* 从DMA接收缓冲区偏移4处拷贝有效数据到用户缓冲区 */
memcpy(p_data, &s_spi2_dma_rx_buf[4], len);
}
@@ -1272,12 +1336,16 @@ uint8_t ch395f_get_sock_int_status(uint8_t sock) {
* 入口参数:sock - Socket索引 uint8_t 0 - 7
* p_status - 2字节输出缓冲区指针 uint8_t*
* [0] = Socket状态, [1] = TCP状态
* 限定条件:芯片已初始化
* 限定条件:1. p_status 不可为 NULL
* 2. 芯片已初始化
* 函数说明:Socket状态: 00H=CLOSED, 05H=OPEN
* TCP状态: 00H=CLOSED, 01H=LISTEN, 02H=SYN_SENT,
* 03H=SYN_RCVD, 04H=ESTABLISHED
*/
void ch395f_get_socket_status(uint8_t sock, uint8_t *p_status) {
if (p_status == NULL) {
return;
}
ch395f_spi_begin();
ch395f_write_cmd(CH395F_CMD_GET_SOCKET_STATUS_SN);
ch395f_write_data(sock);
+31 -9
View File
@@ -112,17 +112,25 @@ extern "C" {
/*
* TCP MSS(CH395F_CMD_SET_TCP_MSS 参数)
* 由 net_init 经 ch395f_set_tcp_mss() 下发;此处作为单一真值源,
* DMA 缓冲大小亦由此推导(见下)。当前选取依据:8 Socket 共享 24KB 缓冲时,
* 各 Socket 接收缓冲 = 2×MSS,故 MSS 取 1024 为最大值(详见 net_socket.c 分配注释)。
* 由 net_init 经 ch395f_set_tcp_mss() 下发(须在 ch395f_init 之前);此处为单一真值源。
* 维持 1024:8 Socket 共享 24KB 缓冲时,单 Socket 接收缓冲 = 2×MSS = 2KB(手册 §9.2.8
* 满足上限的最大值)。MSS=512 裕量方案未采纳;当年支撑该决策的台架判据不可信,
* 已随 TC-NET-604 结案为非缺陷一并撤回(2026-09-23,见 Trap 21)——维持 1024 的
* 现有理由 = 手册允许的最大值、满缓冲周期段数少。MSS 只影响芯片分包与窗口动态,
* 与下方缓冲块布局解耦。
*/
#define CH395F_TCP_MSS 1024U
/* 内部缓冲 RAM 每块字节数(手册 §5.45:48 块 × 512B = 24KB) */
#define CH395F_RAM_BLOCK_SIZE 512U
/* 单 Socket 缓冲块数由 MSS 推导:接收 = 2×MSS(满足手册 §9.2.8 接收 ≥ 2×MSS),发送 = 1×MSS;向上取整 */
#define CH395F_RECV_BLOCKS ((2U * CH395F_TCP_MSS + CH395F_RAM_BLOCK_SIZE - 1U) / CH395F_RAM_BLOCK_SIZE)
#define CH395F_SEND_BLOCKS ((CH395F_TCP_MSS + CH395F_RAM_BLOCK_SIZE - 1U) / CH395F_RAM_BLOCK_SIZE)
/* 单 Socket 缓冲块数:固定布局(2026-09-22 与 MSS 解耦;此前由 MSS 推导,改 MSS 会
* 连带静默缩小布局):接收 4 块=2KB、发送 2 块=1KB,8 Socket × 6 块 = 48 块恰好用满。
* 布局满足手册 §9.2.8:接收 2KB = 2×MSS(1024)(建议下沿,零裕量——用满 48 块的代价)/
* ≥ MSS(必须)/ ≤ 6×MSS(建议);
* 发送 1KB = MSS(WCH FAQ 要求"发送缓冲 > MSS",此处取等值——生产实测(FTP 大文件
* 上传/下载 + UI 推流)在缓冲边界可正常成包)。 */
#define CH395F_RECV_BLOCKS 4U
#define CH395F_SEND_BLOCKS 2U
/* 单 Socket 硬件收发缓冲字节数(= 块数 × 块大小)。写入发送 FIFO 的单次长度严禁超过此值,
* 否则会写穿有限的发送缓冲(见 Trap 20)。 */
#define CH395F_SEND_BUF_SIZE_BYTES (CH395F_SEND_BLOCKS * CH395F_RAM_BLOCK_SIZE)
@@ -131,10 +139,13 @@ extern "C" {
/*
* SPI DMA 事务限制
* CH395F_SPI_DMA_MAX_PAYLOAD: 单次 DMA 事务最大有效载荷(总缓冲 - 4 字节命令头)
* 接收方向需一次性读空整个 Socket 接收缓冲(= 2×MSS,避免 Trap 13 部分读导致数据不可见),
* 故总缓冲 = 2×MSS + 4(4 为命令头开销),有效载荷 = 2×MSS。
* 接收方向需一次性读空整个 Socket 接收缓冲(= 接收缓冲大小,避免 Trap 13 部分读
* 导致剩余数据不可见),故总缓冲 = 接收缓冲大小 + 4(4 为命令头开销),
* 有效载荷 = 接收缓冲大小(2KB)。
* 2026-09-22:与 MSS 解耦(此前 = 2×MSS+4,MSS 下调会把 DMA 缓冲缩到装不下整缓冲
* → 整缓冲读会被截断丢弃)。
*/
#define CH395F_SPI_DMA_BUF_SIZE (CH395F_TCP_MSS * 2U + 4U)
#define CH395F_SPI_DMA_BUF_SIZE (CH395F_RECV_BUF_SIZE_BYTES + 4U)
#define CH395F_SPI_DMA_MAX_PAYLOAD (CH395F_SPI_DMA_BUF_SIZE - 4U)
/* 其他命令 */
@@ -290,6 +301,15 @@ uint8_t ch395f_write_data(uint8_t data);
*/
uint8_t ch395f_read_data(void);
/*
* 函数功能:查询 SPI 通道故障锁存(E-34 / P2-2)
* 返回值:0 - 正常 1 - 曾发生 SPI 超时/错误(字节级或 DMA 级)
* 限定条件:无
* 函数说明:锁存式(MCU 复位前一直有效);上层据此判定"SPI 通道异常"
* (区别于"无芯片"/"PHY 未链接");仅首条故障记日志
*/
uint8_t ch395f_spi_byte_fault(void);
/*
* 函数声明区 - 芯片检测与版本
*/
@@ -569,6 +589,8 @@ uint8_t ch395f_get_sock_int_status(uint8_t sock);
* 限定条件:芯片已初始化
* 函数说明:Socket 状态: 00H=CLOSED, 05H=OPEN
* TCP 状态: 00H=CLOSED, 01H=LISTEN, 02H=SYN_SENT, 03H=SYN_RCVD, 04H=ESTABLISHED
* ⚠ 必须给 **2 字节**缓冲:只给 1 字节会栈越界 1 字节(2026-09-22 实测
* net_socket.c 两处调用曾如此,已修)。
*/
void ch395f_get_socket_status(uint8_t sock, uint8_t *status);
+46
View File
@@ -0,0 +1,46 @@
# Drivers/BSP/GD5F2GQ5UE/ — GD5F2GQ5UE SPI NAND 驱动 + FTL
> 256MB SPI NAND Flash 驱动,以及之上的 **FTL(Flash 转换层,`nand_ftl.c`)**——
> 提供逻辑块映射、坏块管理、磨损均衡。再上层是 dhara(坏块/磨损算法)与 FatFs。
## 文件一览
| 文件 | 作用 |
|---|---|
| `gd5f2gq5ue.c/h` | NAND 芯片底层驱动:读/写/擦除、页/块管理、ID 与状态查询 |
| `nand_ftl.c/h` | **FTL 层**:逻辑地址 → 物理地址映射、坏块表、磨损均衡 |
## 硬件接口
- **SPI1**:PE0 CS / PB3 SCK / PB4 MISO / PB5 MOSI / PB8 WP / PE1 HOLD
## 三层存储栈
```
FatFs(文件系统)
↓
dhara(坏块管理 + 磨损均衡算法,Lib/dhara/)
↓
nand_ftl.c(本目录,FTL 适配)
↓
gd5f2gq5ue.c(本目录,芯片驱动)
↓
GD5F2GQ5UE 芯片
```
## ⚠️ 改本驱动前必读陷阱
| 陷阱 | 说明 |
|---|---|
| **FatFs 打开模式** | `FA_OPEN_APPEND`(0x30) **不含 `FA_WRITE`(0x02)**——追加写必须 `FA_WRITE\|FA_OPEN_APPEND`,否则 `f_write` 恒返 `FR_DENIED`(7) |
| **FatFs 文件名** | 严格 8.3 短名、**不得以点开头**(`.fschk` 会 `FR_INVALID_NAME`,纯 RAM 判定不碰盘);自愈逻辑**严禁**把名字类错误归入"卷损坏"触发集 |
| **`f_write` 簇分配失败返回 `FR_OK` + 短写** | 判失败必须**校验写回字节数**(无错误码) |
| **禁用 C 库堆分配** | MicroLIB 堆仅 512B,`malloc` 必返回 NULL;用 `pvPortMalloc/vPortFree` |
> 完整台账见 `docs/问题记录/GD5F2GQ5UE_Trap_Records.md`。
## 相关文档
- 存储架构:`docs/架构设计/STM32F4-Base存储架构说明.md`
- 测试指南:`docs/测试说明/GD5F2GQ5UE_Test_Guide.md`
- 容量恢复:`docs/测试说明/存储维护与容量恢复.md`
+297 -48
View File
@@ -16,7 +16,11 @@
/*
* 调试输出配置
* 深调用链模块(NAND 驱动在 FTL 之下):日志行缓冲收到 128 B(默认 192)。
* 本模块最长行 "ID mismatch: expected MID=0x.. DID=0x.., got MID=0x.. DID=0x.."
* 渲染后 ≈ 103 B(含时间戳 21 + 标签)< 128 → 不截断
*/
#define DBG_LINE_BUF_MAX 128U
#define DBG_TAG "[NAND]"
#include "dbg_log.h"
@@ -35,7 +39,7 @@ static uint32_t s_usable_blocks = GD5F_TOTAL_BLOCKS; /* dhara 可见块数(=
static uint32_t s_bbt_write_slot = 0; /* 下一写入槽(轮转指针) */
static uint32_t s_bbt_version = 0; /* 当前持久化 BBT 版本号 */
static uint8_t s_bbt_slot_dead = 0; /* 池块损坏位掩码 */
static uint8_t s_bbt_page_buf[GD5F_PAGE_SIZE]; /* BBT 读写页缓冲(静态,避免占栈) */
static uint8_t s_bbt_page_buf[GD5F_BBT_HDR_SIZE + GD5F_BBT_SIZE]; /* BBT 读写缓冲(静态,避免占栈):实际载荷仅 16B 头+256B 位图,按实际传输长度声明(2026-09-21 由整页 2048 收紧,省 1776B) */
static uint8_t s_bbt_best[GD5F_BBT_SIZE]; /* 最高版本位图缓存 */
static const uint8_t s_bbt_magic[4] = { 'G', 'B', 'B', 'T' }; /* BBT 存储魔数 "GBBT" */
@@ -45,8 +49,21 @@ extern SPI_HandleTypeDef hspi1;
/* DMA 完成标志(ch395f.c 中的 HAL_SPI_TxRxCpltCallback 会设置此标志) */
volatile uint8_t g_spi1_dma_done;
/* DMA 阈值(字节数超过此值使用 DMA 传输) */
#define GD5F_DMA_THRESHOLD 32
/* DMA 阈值(字节数超过此值使用 DMA 传输):取值定义在头文件,理由如下——
* 阈值以下走 CPU 轮询。存在把**栈上小缓冲**直接传进本驱动做收发的大小
* (dhara meta 读 132B、dhara_nand_is_free 64B),任务栈随 FreeRTOS 堆驻留
* CCM(0x10000000),而 DMA1/DMA2 物理上不可达 CCM——此类传输必须留在轮询域,
* 否则静默失败(CCM 首次扩容实验实证的根因)。页级大块传输(2048)仍走 DMA,
* 源/目的全部为文件作用域静态缓冲。 */
/* DMA 可达内存区间:必须与分散加载文件 RW_IRAM1(0x20000000,128KB,
* SRAM1+SRAM2)一致。CCM(0x10000000,64KB)DMA1/DMA2 物理不可达,
* RW_CCM 区任何缓冲都不得进 DMA。阈值只能挡住"当前已知的小栈缓冲",
* 挡不住"将来新加的 >1024B 栈缓冲";gd5f_dma_buf_ok() 把这条约定
* 升级为运行时强制检查(2026-09-21 加固)。若将来新增其它 DMA 可达
* 内存(如外部 SDRAM),此处区间必须同步扩展。 */
#define GD5F_DMA_RAM_BASE 0x20000000UL
#define GD5F_DMA_RAM_END 0x20020000UL
/*
* 函数功能:SPI1 TX DMA 完成回调(PROGRAM_LOAD 数据发送用)
@@ -78,6 +95,7 @@ void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) {
static int gd5f_page_program(uint32_t page_addr, uint16_t column,
const uint8_t *p_buf, size_t size);
static int gd5f_set_feature(uint8_t addr, uint8_t data);
static int gd5f_private_get_feature(uint8_t addr, uint8_t *p_data);
/* BBT 持久化内部函数 */
static int gd5f_private_bbt_locate_pool(void);
@@ -86,34 +104,109 @@ static int gd5f_private_bbt_find_best(void);
static int gd5f_bbt_load(void);
static int gd5f_bbt_save(void);
/* DMA 可达性守卫(见下方实现) */
static int gd5f_dma_buf_ok(const void *p_buf, size_t size);
/* ======================== DMA 可达性守卫 ======================== */
/*
* 函数功能:判定缓冲是否位于 DMA 可达内存(SRAM1+SRAM2)
* 入口参数:p_buf - 传输缓冲首地址 const void*
* size - 传输字节数 size_t
* 返回值:1 - 整段缓冲 DMA 可达;0 - 不可达(调用方须回落 CPU 轮询)
* 限定条件:无
* 函数说明:DMA1/DMA2 物理不可达 CCM(0x10000000)——以 CCM 缓冲做 DMA
* 源/目的会静默不落地(2026-09-20 首轮 CCM 扩容实验实证根因)。
* 本函数把"栈/堆缓冲禁入 DMA"的约定升级为运行时强制约束:
* 起止地址任一越出 SRAM 区间即判不可达。
*/
static int gd5f_dma_buf_ok(const void *p_buf, size_t size) {
uint32_t start = (uint32_t)(uintptr_t)p_buf;
uint32_t end;
if (size == 0U) {
return 0;
}
end = start + (uint32_t)size;
if (end < start) { /* 地址回绕:不可能落在同一区间内 */
return 0;
}
if ((start < GD5F_DMA_RAM_BASE) || (end > GD5F_DMA_RAM_END)) {
return 0;
}
return 1;
}
/* ======================== SPI 原语定义 ======================== */
/*
* 函数功能:等待芯片操作完成(轮询 OIP 位)
* 入口参数:timeout_ms - 超时时间 uint32_t > 0
* 返回值:0 - 操作完成,-2 - 超时
* 返回值:GD5F_OK(0) - 操作完成;GD5F_BUSY_TIMEOUT(-2) - 超时(可重试);
* GD5F_ERROR(-1) - SPI 传输真故障(不可重试,须上报自检)
* 限定条件:SPI 已初始化
* 函数说明:循环读取状态寄存器直到 OIP 位清零或超时
* 函数说明:循环读取状态寄存器直到 OIP 位清零或超时。
* E-53(2026-10-03,GD5F 错误通道统一):三处 HAL 调用的返回值**必须**检查
* ——原实现全部丢弃,失败时 `status` 保持初值 0 → `(status & OIP) == 0`
* 立即成立 → **把"操作完成"伪造出来**,后续命令在未就绪的芯片上执行,
* 最终表现为"静默报成功但数据没落盘"。现区分两类失败:
* ① SPI 层失败(HAL≠OK)→ GD5F_ERROR(真故障);
* ② OIP 长时间不落 → GD5F_BUSY_TIMEOUT(可重试)。
* 调用点据此分级:超时重试 / 真故障上报自检并降级(见审计 §5 第二档 #1)。
*/
int gd5f_wait_busy(uint32_t timeout_ms) {
/* 看门狗心跳弱钩子:默认空实现(无看门狗/模拟器构建);强符号由
* App/util/task_watchdog.c 提供,路由到调用者任务的心跳位 */
__weak void nand_ftl_wd_beat(void) {
}
/* 看门狗直接喂狗弱钩子:默认空实现;强符号由 App/util/task_watchdog.c
* 提供(IWDG 已启动时写 KR)——覆盖 defaultTask(喂狗点)自身陷于长 FTL
* 操作、主循环喂狗停摆的形态(2026-10-02 台架实证) */
__weak void nand_ftl_wd_feed(void) {
}
int32_t gd5f_wait_busy(uint32_t timeout_ms) {
uint8_t cmd = GD5F_CMD_GET_FEATURE;
uint8_t addr = GD5F_REG_STATUS;
uint8_t status = 0;
uint32_t tick_start = HAL_GetTick();
uint32_t last_beat = tick_start;
nand_ftl_wd_beat(); /* 看门狗:入口心跳(长操作在调用者任务上下文;内核前调用为空操作) */
nand_ftl_wd_feed(); /* 看门狗:入口直接喂狗(覆盖 defaultTask 长 I/O 阻塞主循环喂狗点的形态) */
while (1) {
HAL_StatusTypeDef hs;
GD5F_CS_LOW();
HAL_SPI_Transmit(&hspi1, &cmd, 1, GD5F_SPI_TIMEOUT);
HAL_SPI_Transmit(&hspi1, &addr, 1, GD5F_SPI_TIMEOUT);
HAL_SPI_Receive(&hspi1, &status, 1, GD5F_SPI_TIMEOUT);
hs = HAL_SPI_Transmit(&hspi1, &cmd, 1, GD5F_SPI_TIMEOUT);
if (hs == HAL_OK) {
hs = HAL_SPI_Transmit(&hspi1, &addr, 1, GD5F_SPI_TIMEOUT);
}
if (hs == HAL_OK) {
hs = HAL_SPI_Receive(&hspi1, &status, 1, GD5F_SPI_TIMEOUT);
}
GD5F_CS_HIGH();
if (hs != HAL_OK) {
/* E-53:SPI 真故障——绝不伪造成"操作完成"(详见函数说明) */
return GD5F_ERROR;
}
if ((status & GD5F_STATUS_OIP) == 0) {
return GD5F_OK;
}
if ((HAL_GetTick() - tick_start) >= timeout_ms) {
return GD5F_BUSY_TIMEOUT;
}
if ((uint32_t)(HAL_GetTick() - last_beat) >= 100u) {
last_beat = HAL_GetTick();
nand_ftl_wd_beat(); /* 看门狗:循环内每 100 ms 心跳(擦除等待最长 5 s) */
nand_ftl_wd_feed(); /* 看门狗:同步直接喂狗(同节奏) */
}
}
}
@@ -123,7 +216,7 @@ int gd5f_wait_busy(uint32_t timeout_ms) {
* 返回值:0 - 成功
* 限定条件:SPI 已初始化
* 函数说明:CS# 拉低后发 06h 命令再拉高 */
int gd5f_write_enable(void) {
int32_t gd5f_write_enable(void) {
uint8_t cmd = GD5F_CMD_WRITE_ENABLE;
GD5F_CS_LOW();
@@ -139,7 +232,7 @@ int gd5f_write_enable(void) {
* 返回值:0 - 成功
* 限定条件:SPI 已初始化
* 函数说明:发 0Fh + C0h 地址后读 1 字节状态 */
int gd5f_read_status(uint8_t *p_status) {
int32_t gd5f_read_status(uint8_t *p_status) {
uint8_t cmd = GD5F_CMD_GET_FEATURE;
uint8_t addr = GD5F_REG_STATUS;
@@ -153,13 +246,13 @@ int gd5f_read_status(uint8_t *p_status) {
}
/*
* 函数功能:页读取(将数据从存储阵列加载到内部缓存)
* 函数功能:发送一次 13h 页读取命令(gd5f_page_read 的装载单元)
* 入口参数:page_addr - 页地址 uint32_t 0 ~ GD5F_TOTAL_BLOCKS*64-1
* 返回值:0 - 成功,其它 - 错误
* 返回值:无
* 限定条件:SPI 已初始化
* 函数说明:发 13h + 3字节行地址,等待 OIP 清零
* 函数说明:发 13h + 3字节行地址
*/
int gd5f_page_read(uint32_t page_addr) {
static void gd5f_page_read_issue(uint32_t page_addr) {
uint8_t cmd[4] = {0};
cmd[0] = GD5F_CMD_PAGE_READ;
@@ -170,8 +263,29 @@ int gd5f_page_read(uint32_t page_addr) {
GD5F_CS_LOW();
HAL_SPI_Transmit(&hspi1, cmd, 4, GD5F_SPI_TIMEOUT);
GD5F_CS_HIGH();
}
return gd5f_wait_busy(100);
/*
* 函数功能:页读取(将数据从存储阵列加载到内部缓存)
* 入口参数:page_addr - 页地址 uint32_t 0 ~ GD5F_TOTAL_BLOCKS*64-1
* 返回值:0 - 成功,其它 - 错误
* 限定条件:SPI 已初始化
* 函数说明:13h 单次发令 + WIP 轮询(与 Linux 主线 / GigaDevice 厂商驱动
* 形状完全一致:13h×1 → OIP 轮询 → 0Bh)。
* 2026-09-20 台架异常史实(归因、证伪、沉降/二次发令的加与除)
* 统一记于 docs/问题记录/GD5F2GQ5UE_Trap_Records.md Trap 05,
* 复现手段见测试 Phase 11 用例。
*/
int32_t gd5f_page_read(uint32_t page_addr) {
int ret;
gd5f_page_read_issue(page_addr);
ret = gd5f_wait_busy(100);
if (ret != GD5F_OK) {
return ret;
}
return GD5F_OK;
}
/*
@@ -182,9 +296,20 @@ int gd5f_page_read(uint32_t page_addr) {
* 返回值:0 - 成功
* 限定条件:必须先调用 gd5f_page_read 完成数据加载
* 函数说明:发 0Bh + 2字节列地址 +1字节 dummy 后读取数据 */
int gd5f_read_from_cache(uint16_t column, uint8_t *p_buf,
int32_t gd5f_read_from_cache(uint16_t column, uint8_t *p_buf,
size_t size) {
uint8_t cmd[4] = {0};
uint8_t use_dma = 0U;
/* 守卫判定须在 CS 拉低之前完成:DBG_ERROR 走串口,不能占着 CS 窗口 */
if (size > GD5F_DMA_THRESHOLD) {
use_dma = (uint8_t)gd5f_dma_buf_ok(p_buf, size);
if (use_dma == 0U) {
DBG_ERROR("RX DMA buffer not DMA-reachable "
"(p=0x%08lX size=%lu), fallback poll",
(unsigned long)(uintptr_t)p_buf, (unsigned long)size);
}
}
cmd[0] = GD5F_CMD_READ_FROM_CACHE;
cmd[1] = (column >> 8) & 0xFF;
@@ -194,7 +319,7 @@ int gd5f_read_from_cache(uint16_t column, uint8_t *p_buf,
GD5F_CS_LOW();
HAL_SPI_Transmit(&hspi1, cmd, 4, GD5F_SPI_TIMEOUT);
if (size > GD5F_DMA_THRESHOLD) {
if (use_dma != 0U) {
uint32_t tick_start = HAL_GetTick();
g_spi1_dma_done = 0U;
if (HAL_SPI_Receive_DMA(&hspi1, p_buf, (uint16_t)size) != HAL_OK) {
@@ -243,8 +368,31 @@ static int gd5f_page_program(uint32_t page_addr, uint16_t column,
* 限定条件:SPI 已初始化,目标区域已擦除
* 函数说明:发 02h + 2字节列地址后发送数据;超过 DMA 阈值走 DMA
*/
int gd5f_program_load(uint16_t column, const uint8_t *p_buf, size_t size) {
/*
* 函数功能:将数据加载到芯片内部缓存(PROGRAM LOAD)
* 入口参数:column - 列(页内)偏移 uint16_t 本驱动恒为 0
* p_buf - 数据缓冲区 const uint8_t* 不为 NULL
* size - 数据长度 size_t > 0
* 返回值:0 - 成功,其它 - 错误
* 限定条件:SPI 已初始化;目标区域已擦除
* 函数说明:发 02h + 2字节列地址后发送数据;超过 DMA 阈值走 DMA。
* (buffer 由 SPI 数据流直接填充,无 array→buffer 竞态,
* 无需二次装载;program exec 的落盘校验由 FTL 回读完成)
*/
int32_t gd5f_program_load(uint16_t column, const uint8_t *p_buf, size_t size) {
uint8_t cmd[3];
uint8_t use_dma = 0U;
/* 守卫判定须在 CS 拉低之前完成:DBG_ERROR 走串口,不能占着 CS 窗口 */
if (size > GD5F_DMA_THRESHOLD) {
use_dma = (uint8_t)gd5f_dma_buf_ok(p_buf, size);
if (use_dma == 0U) {
DBG_ERROR("TX DMA buffer not DMA-reachable "
"(p=0x%08lX size=%lu), fallback poll",
(unsigned long)(uintptr_t)p_buf, (unsigned long)size);
}
}
cmd[0] = GD5F_CMD_PROGRAM_LOAD;
cmd[1] = (uint8_t)(column >> 8);
cmd[2] = (uint8_t)(column);
@@ -252,7 +400,7 @@ int gd5f_program_load(uint16_t column, const uint8_t *p_buf, size_t size) {
GD5F_CS_LOW();
HAL_SPI_Transmit(&hspi1, cmd, 3, GD5F_SPI_TIMEOUT);
if (size > GD5F_DMA_THRESHOLD) {
if (use_dma != 0U) {
uint32_t tick_start = HAL_GetTick();
g_spi1_dma_done = 0U;
if (HAL_SPI_Transmit_DMA(&hspi1, (uint8_t *)p_buf, (uint16_t)size)
@@ -282,7 +430,7 @@ int gd5f_program_load(uint16_t column, const uint8_t *p_buf, size_t size) {
* 限定条件:已先调用 gd5f_program_load 加载数据
* 函数说明:发 10h + 3字节页地址触发编程,等待完成后检查 P_FAIL 位
*/
int gd5f_program_exec(uint32_t page_addr) {
int32_t gd5f_program_exec(uint32_t page_addr) {
int ret;
uint8_t cmd[4];
uint8_t status;
@@ -312,7 +460,7 @@ int gd5f_program_exec(uint32_t page_addr) {
* 限定条件:SPI 已初始化,ECC 已使能
* 函数说明:读取状态寄存器 ECCS[5:4] 位,0x02 表示不可纠正错误
*/
int gd5f_check_ecc(void) {
int32_t gd5f_check_ecc(void) {
uint8_t status;
gd5f_read_status(&status);
uint8_t ecc = (status >> 4) & 0x03;
@@ -324,11 +472,15 @@ int gd5f_check_ecc(void) {
* 函数功能:设置 Feature 寄存器
* 入口参数:addr - 寄存器地址 uint8_t
* data - 写入数据 uint8_t
* 返回值:0 - 成功
* 返回值:GD5F_OK - 成功;其它 - SPI 传输/等待失败错误码
* 限定条件:SPI 已初始化
* 函数说明:先写使能,发 1Fh + 地址 + 数据,等待操作完成 */
* 函数说明:先写使能,发 1Fh + 地址 + 数据,等待操作完成。
* E-53(2026-10-03):HAL 传输与 wait_busy 的返回值必须检查并上报——
* 原实现恒返回 GD5F_OK,"配置未生效"对上层不可见(E-10 同族问题)。
*/
static int gd5f_set_feature(uint8_t addr, uint8_t data) {
uint8_t cmd[3];
int ret;
gd5f_write_enable();
@@ -337,14 +489,48 @@ static int gd5f_set_feature(uint8_t addr, uint8_t data) {
cmd[2] = data;
GD5F_CS_LOW();
HAL_SPI_Transmit(&hspi1, cmd, 3, GD5F_SPI_TIMEOUT);
if (HAL_SPI_Transmit(&hspi1, cmd, 3, GD5F_SPI_TIMEOUT) != HAL_OK) {
GD5F_CS_HIGH();
return GD5F_ERROR;
}
GD5F_CS_HIGH();
gd5f_wait_busy(100);
ret = gd5f_wait_busy(100);
if (ret != GD5F_OK) {
return ret;
}
return GD5F_OK;
}
/*
* 函数功能:读取任意 Feature 寄存器(0Fh + 地址 + 读 1 字节)
* 入口参数:addr - feature 寄存器地址 uint8_t
* 出口参数:p_data - 读到的字节输出指针 uint8_t* 不为 NULL
* 返回值:GD5F_OK - 成功;GD5F_ERROR - SPI 传输失败
* 限定条件:SPI 已初始化
* 函数说明:E-10(2026-10-03):init 需**回读** B0h 校验 ECC 使能位真正生效
* (原实现只写不读,"init 返回 OK 但 ECC 未打开"→ 存储栈在无 ECC
* 保护下静默降级)。与 gd5f_read_status 同构(后者固定 C0h),
* 此处参数化地址;同样检查 HAL 返回值(E-53)。
*/
static int gd5f_private_get_feature(uint8_t addr, uint8_t *p_data) {
uint8_t cmd = GD5F_CMD_GET_FEATURE;
HAL_StatusTypeDef hs;
GD5F_CS_LOW();
hs = HAL_SPI_Transmit(&hspi1, &cmd, 1, GD5F_SPI_TIMEOUT);
if (hs == HAL_OK) {
hs = HAL_SPI_Transmit(&hspi1, &addr, 1, GD5F_SPI_TIMEOUT);
}
if (hs == HAL_OK) {
hs = HAL_SPI_Receive(&hspi1, p_data, 1, GD5F_SPI_TIMEOUT);
}
GD5F_CS_HIGH();
return (hs == HAL_OK) ? GD5F_OK : GD5F_ERROR;
}
/*
* 函数功能:块擦除(擦除128KB块)
* 入口参数:block_addr - 块编号 uint32_t 0 - 2047
@@ -352,7 +538,7 @@ static int gd5f_set_feature(uint8_t addr, uint8_t data) {
* 限定条件:gd5f2gq5ue_init() 已调用
* 函数说明:1. 写使能 - D8h + 3字节页地址 - 等待完成
* 2. 擦除完成后检查 E_FAIL */
int gd5f_block_erase(uint32_t block_addr) {
int32_t gd5f_block_erase(uint32_t block_addr) {
int ret = GD5F_OK;
uint8_t cmd[4];
uint8_t status = 0;
@@ -385,10 +571,16 @@ int gd5f_block_erase(uint32_t block_addr) {
/*
* 函数功能:扫描所有块构建 BBT(在 ECC 使能前调用)
* 入口参数:无
* 返回值:0 - 成功
* 返回值:GD5F_OK - 扫描完成;其它 - 读失败错误码(扫描已中止,s_bbt 为部分结果)
* 限定条件:SPI 已初始化,ECC 尚未使能
* 函数说明:读取每个块首页(page 0)的 spare byte 0,
* 若不为 0xFF 则为出厂坏块(符合 datasheet §12.4)
* 若不为 0xFF 则为出厂坏块(符合 datasheet §12.4)。
* E-42(2026-10-03,GD5F 错误通道统一):原实现丢弃 page_read /
* read_from_cache 的返回值且对外恒返回 GD5F_OK——SPI 读失败时
* `spare[8]` 保持上一轮的**陈旧内容**,且"整片扫描全失败也返回成功",
* 上层以为坏块表可用(rebuild 路径还会把错表持久化覆盖好副本)。
* 现:① 每轮先清零 spare(防陈旧内容误判);② 任一读失败即**中止**
* 并返回错误码,由调用方决定"放弃持久化 / 让 init 失败"。
*/
static int gd5f_bbt_scan(void) {
uint32_t block;
@@ -399,9 +591,21 @@ static int gd5f_bbt_scan(void) {
for (block = 0; block < GD5F_TOTAL_BLOCKS; block++) {
uint32_t page = block << 6;
int ret;
gd5f_page_read(page);
gd5f_read_from_cache(GD5F_PAGE_SIZE, spare, 1);
memset(spare, 0, sizeof(spare)); /* 每轮清零:防读失败时用陈旧内容误判 */
ret = gd5f_page_read(page);
if (ret != GD5F_OK) {
DBG_ERROR("BBT scan: page_read fail at block %lu (ret=%d)",
(unsigned long)block, ret);
return ret; /* 扫描失败即中止,不得对外报"扫描完成" */
}
ret = gd5f_read_from_cache(GD5F_PAGE_SIZE, spare, 1);
if (ret != GD5F_OK) {
DBG_ERROR("BBT scan: read_from_cache fail at block %lu (ret=%d)",
(unsigned long)block, ret);
return ret;
}
if (spare[0] != 0xFF) {
s_bbt[block >> 3] |= (1 << (block & 7));
@@ -433,7 +637,9 @@ void gd5f2gq5ue_bbt_clear(void) {
* 再写回持久化 BBT 池(版本 +1,掉电安全),从而清除 TC-STO-03 等运行时
* 标记的坏块。调用后建议再执行 nand_ftl_format() 清空 dhara map。
*/
int gd5f2gq5ue_bbt_rebuild(void) {
int32_t gd5f2gq5ue_bbt_rebuild(void) {
int ret;
DBG_INFO("Rebuilding BBT from factory scan...");
/* 1. 关闭 ECC 以读取原始出厂坏块标记(与 gd5f2gq5ue_init 的扫描前提一致) */
@@ -441,12 +647,21 @@ int gd5f2gq5ue_bbt_rebuild(void) {
gd5f_wait_busy(100);
/* 2. 重新扫描出厂坏块(覆盖 RAM BBT,剔除运行时注入的坏块) */
gd5f_bbt_scan();
ret = gd5f_bbt_scan();
/* 3. 重新使能 ECC */
/* 3. 重新使能 ECC(无论扫描成败都须恢复,否则后续 I/O 在无 ECC 下运行) */
gd5f_set_feature(0xB0, 0x10);
gd5f_wait_busy(100);
/* E-42(2026-10-03,数据安全优先):扫描失败 → **放弃持久化**(保留持久区
* 既有好副本),并从盘上回读 BBT 恢复 RAM 视图。原实现无论扫描成败都照常
* save(),会把"读失败拼出来的错表"覆盖掉正确副本。 */
if (ret != GD5F_OK) {
DBG_ERROR("BBT rebuild: scan FAIL (ret=%d), persist abandoned", ret);
(void)gd5f_bbt_load();
return ret;
}
/* 4. 持久化重建后的 BBT(覆盖持久化区中的脏副本) */
if (gd5f_bbt_save() != GD5F_OK) {
DBG_ERROR("BBT rebuild: persist failed");
@@ -496,7 +711,7 @@ void gd5f2gq5ue_print_bbt(void) {
* 5. 解除所有块保护 (A0h = 0x00)
* 6. 定位保留块池并加载持久化 BBT(ECC 使能后)
*/
int gd5f2gq5ue_init(void) {
int32_t gd5f2gq5ue_init(void) {
int ret = GD5F_OK;
uint8_t mid = 0;
uint8_t did = 0;
@@ -532,21 +747,55 @@ int gd5f2gq5ue_init(void) {
}
DBG_INFO("Scanning bad blocks...");
gd5f_bbt_scan();
ret = gd5f_bbt_scan();
if (ret != GD5F_OK) {
/* E-10:扫坏块失败 → init 失败,不静默继续(上层据此上报自检并降级) */
DBG_ERROR("BBT scan failed: %d", ret);
return ret;
}
DBG_INFO("Enabling ECC...");
gd5f_set_feature(0xB0, 0x10);
gd5f_wait_busy(100);
ret = gd5f_set_feature(0xB0, 0x10);
if (ret != GD5F_OK) {
DBG_ERROR("Enable ECC failed: %d", ret);
return ret;
}
/* E-10:回读 B0h 校验 ECC 使能位(bit4)**真正生效**——原实现只写不读,
* "init 返回 OK 但 ECC 未真正打开"会使存储栈在无 ECC 保护下静默降级。 */
{
uint8_t feat = 0;
ret = gd5f_private_get_feature(GD5F_REG_FEATURE, &feat);
if (ret != GD5F_OK) {
DBG_ERROR("Read B0h failed: %d", ret);
return ret;
}
if ((feat & GD5F_FEATURE_ECC_EN) == 0u) {
DBG_ERROR("ECC not enabled (B0h=0x%02X)", feat);
return GD5F_ERROR;
}
}
DBG_INFO("Unlocking block protection...");
gd5f_set_feature(GD5F_REG_PROTECT, 0x00);
gd5f_wait_busy(100);
ret = gd5f_set_feature(GD5F_REG_PROTECT, 0x00);
if (ret != GD5F_OK) {
DBG_ERROR("Unlock block protection failed: %d", ret);
return ret;
}
DBG_INFO("Locating BBT pool...");
gd5f_private_bbt_locate_pool();
ret = gd5f_private_bbt_locate_pool();
if (ret != GD5F_OK) {
DBG_ERROR("Locate BBT pool failed: %d", ret);
return ret;
}
DBG_INFO("Loading persisted BBT...");
gd5f_bbt_load();
ret = gd5f_bbt_load();
if (ret != GD5F_OK) {
/* BBT 持久副本缺失/损坏不致命(RAM BBT 已由扫描建立),仅告警 */
DBG_ERROR("Load persisted BBT failed: %d (use scanned BBT)", ret);
}
DBG_INFO("NAND init OK");
return GD5F_OK;
@@ -560,7 +809,7 @@ int gd5f2gq5ue_init(void) {
* 限定条件:SPI 已初始化
* 函数说明:发 9Fh 命令后接 1 字节 dummy + MID + DID
*/
int gd5f2gq5ue_read_id(uint8_t *p_mid, uint8_t *p_did) {
int32_t gd5f2gq5ue_read_id(uint8_t *p_mid, uint8_t *p_did) {
uint8_t cmd = GD5F_CMD_READ_ID;
uint8_t id_buf[3] = {0};
@@ -584,7 +833,7 @@ int gd5f2gq5ue_read_id(uint8_t *p_mid, uint8_t *p_did) {
* 限定条件:gd5f2gq5ue_init() 已成功调用
* 函数说明:自动处理跨页读取;offset+size 越界返回错误
*/
int gd5f2gq5ue_read(long offset, uint8_t *p_buf, size_t size) {
int32_t gd5f2gq5ue_read(long offset, uint8_t *p_buf, size_t size) {
if (offset < 0 || (uint32_t)offset + (uint32_t)size > GD5F_TOTAL_SIZE) {
return GD5F_ERROR;
@@ -627,7 +876,7 @@ int gd5f2gq5ue_read(long offset, uint8_t *p_buf, size_t size) {
* 限定条件:gd5f2gq5ue_init() 已成功调用,目标区域已擦除
* 函数说明:自动处理跨页写入;offset+size 越界返回错误
*/
int gd5f2gq5ue_write(long offset, const uint8_t *p_buf, size_t size) {
int32_t gd5f2gq5ue_write(long offset, const uint8_t *p_buf, size_t size) {
if (offset < 0 || (uint32_t)offset + (uint32_t)size > GD5F_TOTAL_SIZE) {
return GD5F_ERROR;
@@ -664,7 +913,7 @@ int gd5f2gq5ue_write(long offset, const uint8_t *p_buf, size_t size) {
* 限定条件:gd5f2gq5ue_init() 已成功调用
* 函数说明:循环擦除 [offset, offset+size) 覆盖的每个块,偏移/长度不对齐返回错误
*/
int gd5f2gq5ue_erase(long offset, size_t size) {
int32_t gd5f2gq5ue_erase(long offset, size_t size) {
if (offset % GD5F_BLOCK_SIZE != 0) {
return GD5F_ERROR;
@@ -695,7 +944,7 @@ int gd5f2gq5ue_erase(long offset, size_t size) {
* 限定条件:SPI 已初始化
* 函数说明:发 FFh 复位命令后等待 5ms
*/
int gd5f2gq5ue_reset(void) {
int32_t gd5f2gq5ue_reset(void) {
uint8_t cmd = GD5F_CMD_RESET;
GD5F_CS_LOW();
@@ -714,7 +963,7 @@ int gd5f2gq5ue_reset(void) {
* 限定条件:gd5f2gq5ue_init() 已成功调用
* 函数说明:通过 BBT 查询
*/
int gd5f2gq5ue_is_block_bad(uint32_t block) {
int32_t gd5f2gq5ue_is_block_bad(uint32_t block) {
if (block >= GD5F_TOTAL_BLOCKS) {
return 1;
}
+66 -14
View File
@@ -58,9 +58,25 @@ extern "C" {
#define GD5F_TOTAL_PAGE_SIZE 2112
#define GD5F_PAGES_PER_BLOCK 64
#define GD5F_BLOCK_SIZE (GD5F_PAGES_PER_BLOCK * GD5F_PAGE_SIZE)
/*
* 页读取就绪判定 = WIP 轮询(与 Linux 主线 / GigaDevice 厂商驱动完全一致,
* 单发令形状)。2026-09-20 沉降 / 13h 二次发令史实统一记于
* GD5F2GQ5UE_Trap_Records.md Trap 05。
*/
#define GD5F_TOTAL_BLOCKS 2048
#define GD5F_TOTAL_SIZE (GD5F_TOTAL_BLOCKS * GD5F_BLOCK_SIZE)
/*
* SPI DMA 传输阈值:超过该字节数走 DMA,否则走 CPU 轮询。
* 取值约束:① 必须 >= 存储路径上可能出现在任务栈的最大缓冲(dhara meta 132B、
* dhara_nand_is_free 64B)——任务栈随 FreeRTOS 堆驻留 CCM(0x10000000),
* 而 DMA1/DMA2 物理不可达 CCM,栈缓冲一旦进 DMA 即静默失败(2026-09-20 实证);
* ② 必须 < 页大小(2048),页级传输保留 DMA。
* 公开此宏供测试用例对齐阈值上下界,避免测试取值与驱动漂移。
*/
#define GD5F_DMA_THRESHOLD 1024
/* 坏块表持久化:保留块池数量(从 NAND 顶部向下挑选出厂好块存储 BBT) */
#define GD5F_BBT_POOL_COUNT 4
@@ -109,35 +125,41 @@ int32_t gd5f2gq5ue_read_id(uint8_t *p_mid, uint8_t *p_did);
/*
* 函数功能:从 NAND 读取数据(支持跨页)
* 入口参数:offset - 起始字节偏移 int32_t 0 - GD5F_TOTAL_SIZE-1
* 入口参数:offset - 起始字节偏移 long 0 - GD5F_TOTAL_SIZE-1
* buf - 数据缓冲区 uint8_t* 不为 NULL
* size - 读取字节数 uint32_t > 0
* 返回值:0 - 成功,其他 - 错误码
* size - 读取字节数 size_t > 0
* 返回值:GD5F_OK(0) - SPI 传输完成;其它 - 错误码
* 限定条件:gd5f2gq5ue_init() 已成功调用
* 函数说明:自动处理跨页读取,每次读取不超过当前页剩余空间
* 函数说明:自动处理跨页读取,每次读取不超过当前页剩余空间。
* ⚠ **raw 契约(E-56,2026-10-03 显式文档化)**:返回 0 只表示 **SPI 传输完成**,
* **不代表数据无位翻转**——ECC 不可纠正页(ECCS 非零)在 SPI 层属正常完成,
* 本函数仍返回 0。数据正确性由 **ECC 层**保证:生产主路径经 dhara glue
* (`nand_ftl.c`)自有 ECC 检查保护;**直接使用本 raw API** 的调用方
* (测试/诊断路径)须自行处理位翻转。详见
* docs/问题记录/GD5F2GQ5UE_Trap_Records.md。
*/
int gd5f2gq5ue_read(long offset, uint8_t *buf, size_t size);
int32_t gd5f2gq5ue_read(long offset, uint8_t *buf, size_t size);
/*
* 函数功能:向 NAND 写入数据(支持跨页)
* 入口参数:offset - 起始字节偏移 int32_t 0 - GD5F_TOTAL_SIZE-1
* 入口参数:offset - 起始字节偏移 long 0 - GD5F_TOTAL_SIZE-1
* buf - 数据缓冲区 uint8_t* 不为 NULL
* size - 写入字节数 uint32_t > 0
* 返回值:0 - 成功,其他 - 错误码
* size - 写入字节数 size_t > 0
* 返回值:GD5F_OK(0) - 成功;其它 - 错误码
* 限定条件:gd5f2gq5ue_init() 已成功调用,目标区域已擦除
* 函数说明:自动处理跨页写入,每次写入不超过当前页剩余空间
*/
int gd5f2gq5ue_write(long offset, const uint8_t *buf, size_t size);
int32_t gd5f2gq5ue_write(long offset, const uint8_t *buf, size_t size);
/*
* 函数功能:擦除块(按块擦除,最小单位 128KB)
* 入口参数:offset - 起始字节偏移 int32_t 必须 GD5F_BLOCK_SIZE 对齐
* size - 擦除字节数 uint32_t 必须 GD5F_BLOCK_SIZE 整数倍
* 返回值:0 - 成功,其他 - 错误码
* 入口参数:offset - 起始字节偏移 long 必须 GD5F_BLOCK_SIZE 对齐
* size - 擦除字节数 size_t 必须 GD5F_BLOCK_SIZE 整数倍
* 返回值:GD5F_OK(0) - 成功;其它 - 错误码
* 限定条件:gd5f2gq5ue_init() 已成功调用
* 函数说明:擦除操作以块为单位,offset 和 size 必须块对齐
*/
int gd5f2gq5ue_erase(long offset, size_t size);
int32_t gd5f2gq5ue_erase(long offset, size_t size);
/*
* 函数功能:复位芯片
@@ -179,7 +201,7 @@ void gd5f2gq5ue_print_bbt(void);
* 再写回持久化 BBT 池(版本 +1,掉电安全),从而清除运行时 mark 的坏块。
* 调用后建议再执行 nand_ftl_format() 清空 dhara map。
*/
int gd5f2gq5ue_bbt_rebuild(void);
int32_t gd5f2gq5ue_bbt_rebuild(void);
/*
* 函数功能:获取 dhara 可用块数(总块数减去保留块池)
@@ -231,6 +253,36 @@ int32_t gd5f_program_exec(uint32_t page_addr);
int32_t gd5f_block_erase(uint32_t block_addr);
int32_t gd5f_check_ecc(void);
/*
* 函数功能:NAND 长耗时操作看门狗心跳弱钩子
* 入口参数:无
* 返回值:无
* 限定条件:任意上下文可调(内核启动前调用为空操作)
* 函数说明:gd5f_wait_busy 的长轮询(块擦除等待最长 5 s、GC 页拷贝、
* 日志写)都运行在调用者任务上下文(default/ftp/ui/timesync 等);
* 本钩子默认空实现,App/util/task_watchdog.c 提供强符号,按调用者
* 任务句柄路由到该任务的心跳位,防止长耗时窗口心跳缺席导致 IWDG
* 误复位(2026-10-02 台架:0 字节 STOR 截断 → dhara GC > 800 ms
* 无心跳 → 停喂 → IWDG 误复位)。
*/
void nand_ftl_wd_beat(void);
/*
* 函数功能:NAND 长耗时操作看门狗直接喂狗弱钩子
* 入口参数:无
* 返回值:无
* 限定条件:任意上下文可调(内核启动前 / IWDG 未启动时为空操作)
* 函数说明:心跳钩子(nand_ftl_wd_beat)只能维持"心跳位",喂狗动作本身在
* defaultTask 主循环(task_wd_poll)——若长耗时 FTL 操作(GC/整卷
* 格式化)发生在 defaultTask 上下文,主循环被阻塞在 FTL 调用里,
* 喂狗点停摆 → 心跳位再新也无人喂 → IWDG 误复位(2026-10-02 台架
* 实证:console tx fault 条目落盘触发 GC,poller 饥饿 ~2 s → IWDG
* 复位、无任何 stall 日志)。本钩子让 FTL 长路径在操作进行中直接
* 喂狗,覆盖"喂狗点自身被长 I/O 阻塞"的形态。强符号由
* App/util/task_watchdog.c 提供(仅 IWDG 已启动时生效)。
*/
void nand_ftl_wd_feed(void);
#ifdef __cplusplus
}
#endif
+70 -13
View File
@@ -6,6 +6,12 @@
* 创建日期:2026-07-20
* 修改记录:
* 2026-07-20 王建锋 创建初始版本
* 2026-09-21 E-31 resume 失败按错误分类:仅真空白判新卷,其余失败
* 返回 mount 层(禁止静默清 map 触发整卷重格式化)
* 2026-10-02 王建锋 resume 成功后增打 journal head/tail/epoch:复盘
* "复位后所有写入丢失"时 sectors used 不足以判定
* journal 回退(覆盖写不改 count),head/tail/epoch 是
* 唯一能暴露 resume 锚错 checkpoint 的证据
*/
/* 头文件包含区 */
@@ -20,6 +26,11 @@
#define FTL_FATFS_OFFSET 0U
/* 调试输出配置 */
/* 深调用链模块(FatFS → 本 FTL → dhara → NAND 是 defaultTask 最坏链的主干):
* 日志行缓冲收到 128 B(默认 192)。本模块最长行 "[2026-10-10 08:00:00][ERR][NAND_FTL]
* Cache flush failed: LPN=4294967295 err=-2147483648" ≈ 84 B < 128 → 不截断。
* 核算 scripts/stack_report.py,见 docs/内存管理/RAM使用分析与优化方案.md */
#define DBG_LINE_BUF_MAX 128U
#define DBG_TAG "[NAND_FTL]"
#include "dbg_log.h"
@@ -131,26 +142,40 @@ int dhara_nand_prog(const struct dhara_nand *n, dhara_page_t p, const uint8_t *d
return -1;
}
/* 编程正确性以 P_FAIL 状态位(gd5f_program_exec 内检查)+ 读时 ECC +
* journal 读时门(magic/CRC)为准;数据路径不做整页回读比对——
* 2026-09-20 曾加回读校验(GD5F_PROG_VERIFY),2026-09-21 经评估整体
* 移除(依据见 docs/内存管理/存储栈RAM优化调研.md 与 GD5F Trap Records Trap 05) */
return 0;
}
int dhara_nand_is_free(const struct dhara_nand *n, dhara_page_t p) {
(void)n;
uint32_t i;
uint8_t buf[64];
uint32_t phy_pg = ftl_phy_page(p);
int fre;
if (gd5f_page_read(phy_pg) != GD5F_OK) {
return 0;
}
gd5f_read_from_cache(0, buf, sizeof(buf));
fre = 0;
} else {
uint8_t buf[64];
for (i = 0; i < sizeof(buf); i++) {
if (buf[i] != 0xFF) {
return 0;
if (gd5f_read_from_cache(0, buf, sizeof(buf)) != GD5F_OK) {
/* E-42 同族(2026-10-03):读失败 → buf 可能为陈旧/垃圾内容,
* 保守判为非空(块不可复用),避免把"没读到内容的块"判成空闲。 */
fre = 0;
} else {
uint32_t i;
fre = 1;
for (i = 0; i < sizeof(buf); i++) {
if (buf[i] != 0xFF) {
fre = 0;
break;
}
}
}
}
return 1;
return fre;
}
int dhara_nand_read(const struct dhara_nand *n, dhara_page_t p, size_t offset, size_t length, uint8_t *data, dhara_error_t *err) {
@@ -167,7 +192,12 @@ int dhara_nand_read(const struct dhara_nand *n, dhara_page_t p, size_t offset, s
return -1;
}
gd5f_read_from_cache((uint16_t)offset, data, length);
/* E-42 同族(2026-10-03):read_from_cache 返回值必须检查——失败时 data 内容
* 不可信,原实现仍返回 0(成功),会把未读到的数据当有效数据交给上层。 */
if (gd5f_read_from_cache((uint16_t)offset, data, length) != GD5F_OK) {
dhara_set_error(err, DHARA_E_ECC);
return -1;
}
return 0;
}
@@ -210,13 +240,40 @@ DSTATUS disk_initialize(BYTE pdrv) {
s_cache_dirty = 0;
{
dhara_error_t err;
dhara_error_t err = DHARA_E_NONE;
if (dhara_map_resume(&s_map, &err) < 0) {
DBG_INFO("No valid map found, creating fresh (err=%d)", err);
dhara_map_clear(&s_map);
if (err == DHARA_E_TOO_BAD) {
/* 扫描全程 I/O 干净且无 checkpoint = 真空白卷(出厂首启 /
* 显式格式化后);失败的 resume 已把 journal 复位为空态 →
* 按新卷处理,由上层自动建卷(此处 dhara_map_clear 为空操作:
* resume 失败时 count 已清零,map 本已为空)。 */
DBG_INFO("No valid map found (err=%d), creating fresh", err);
dhara_map_clear(&s_map);
} else {
/* E-31(2026-09-21 审计 P0 修复):非"空白"错误(ECC /
* BAD_BLOCK / CORRUPT_MAP / 瞬态 I/O 等)= 真实完整性故障——
* 芯片上 journal 可能仍持有有效 checkpoint。旧行为把任何失败
* 当新卷清 map,上层随即整卷重格式化 → 单次瞬态毛刺即静默销毁
* 全部用户数据。现把失败返回给 mount 层:f_mount 失败 →
* defaultTask 自检上报 "filesystem fault" 并保留卷;瞬态故障
* 由主循环重试/下次上电重新 resume;重格式化只走显式命令
* (nand_ftl_format)。 */
DBG_ERROR("Map resume failed (err=%d), volume kept, "
"storage unavailable", err);
return STA_NOINIT;
}
} else {
DBG_INFO("Map resumed: %lu/%lu sectors used",
dhara_map_size(&s_map), dhara_map_capacity(&s_map));
/* journal 头/尾/epoch 一并打印:2026-10-02 台架复盘"复位后写入全部
* 丢失"时,只看 sectors used 判断不出 journal 是否回退(覆盖写场景
* count 不变),必须看 head/tail/epoch 才能发现 resume 锚错了
* checkpoint(详见 Lib/dhara/journal.c 的 2026-10-02 本地修复注释)。
* 纯内存字段、零 I/O,保留作长期可观测性。 */
DBG_INFO("Map head=%lu tail=%lu epoch=%u",
(unsigned long)s_map.journal.head,
(unsigned long)s_map.journal.tail,
(unsigned)s_map.journal.epoch);
}
}
+65
View File
@@ -0,0 +1,65 @@
# Drivers/BSP/NET/ — 网络抽象层(BSD Socket API)
> 在 CH395F 硬件驱动之上提供 **BSD Socket 风格 API**,使上层(FTP 服务、时间同步、UI 推流)
> 以标准 socket 语义编程,无需关心芯片细节。核心是**阻塞模型 + 事件驱动 + 周期对账自愈**。
## 文件一览
| 文件 | 作用 |
|---|---|
| `net_socket.c/h` | **BSD Socket API 实现**:`net_socket` / `net_connect` / `net_send` / `net_recv` / `net_close` / `net_listen` / `net_accept`,含 L3 挂起(pending)与错误码 |
| `net_select.c/h` | `select`/`poll` 多路复用(基于芯片中断事件 + 周期轮询) |
| `net_types.h` | 网络层公共类型定义 |
| `net_config.h` | 网络层编译期配置(缓冲区大小、超时、对账周期等) |
| `lftpd/` | **轻量 FTP 服务器**(第三方移植,见其子目录) |
## 核心机制
### 阻塞模型(L3 pending)
- 一期(RECV)已落地:`net_recv` 等支持带超时的阻塞(`pending` 挂起 + tick 唤醒)
- 二期(ACCEPT + SEND)待做 → `docs/待处理内容.md` N-4
### 周期对账自愈(Trap 22 修复)
- `net_reconcile_tick()`(200ms)回读芯片状态修正三类失同步:
本层 ESTABLISHED 而芯片已断 → 断开;本层 LISTENING 而芯片已握手 → 补 accept;
本层 LISTENING 而芯片监听消失 → 重建
- **去抖(连续 2 次)+ 冷却(1s)**——单次状态读的重建动作**必须去抖**,
否则"刚 listen 未生效"瞬态会反复重建直至 `relisten give up`(三监听口全死)
### 异常复监听隔离(Trap 23 修复)
- 异常释放(TIMEOUT)的 socket 复监听前隔离:排空 RX 残渣 + 主动 close + 等 CLOSED
- 400ms 起、800ms 上限强制放行(上限须 < 重试放弃时限 2000ms)
- 仅异常路径生效,正常 FIN 路径零改动
### 错误码(E-03 修复)
- **按任务隔离**:`s_net_errno_slots[8]`(每槽 `{任务句柄, err}`),替代原全局 `s_net_errno`
- `net_get_errno()` 只读本任务槽位,**对外语义不变**(成功不清零)
## 依赖关系
```
上层(FTP / timeSync / UI 推流)
↓ BSD Socket API
net_socket.c ←→ net_select.c
↓ ch395f_* 接口
Drivers/BSP/CH395F/
```
## ⚠️ 改本模块前必读
- 就绪屏障位(`SYS_EVT_NET_READY` 等)等待必须 `osFlagsNoClear`,**不能用默认消费型**
- 每次 CH395F SPI 事务须 `ch395f_spi_begin()/end()` 包裹
- **改任何自愈/重建逻辑必须去抖**,否则会引发重建风暴
- 相关陷阱:`docs/问题记录/CH395F_Trap_Records.md` Trap 21/22/23
## 相关文档
- 上层用法:`docs/网络/BSD_Socket_API_使用指南.md`
- 内部调用关系:`docs/网络/BSD_Socket_API_函数调用关系.md`
- 架构设计:`docs/网络/Net层阻塞模型架构设计.md`(阻塞等待 / pending 请求模型)
- 二期设计基线:`docs/待处理内容.md` §5.1
- 测试:`docs/测试说明/CH395F_Test_Guide.md`、`docs/测试说明/FTP_Test_Guide.md`
+41
View File
@@ -0,0 +1,41 @@
# Drivers/BSP/NET/lftpd/ — 轻量 FTP 服务器(第三方移植)
> ⚠️ **第三方开源库移植**(lftpd,许可证见 [`LICENSE.txt`](LICENSE.txt)),**非自研代码**。
> 本目录 README 仅说明其在本项目中的**定位与对接方式**;库内部逻辑请参考上游。
## 文件一览
| 文件 | 作用 |
|---|---|
| `lftpd.c/h` | FTP 服务器主体(命令解析、状态机、数据通道) |
| `lftpd_io.c` | I/O 适配层(**本项目对接 FatFs 的关键所在**) |
| `lftpd_inet.c` | 网络适配(对接本项目的 BSD Socket API) |
| `lftpd_log.c` | 日志输出适配 |
| `lftpd_string.c` | 字符串工具 |
| `private/*.h` | 内部头文件 |
| `LICENSE.txt` | 上游许可证 |
## 在本项目中的对接
```
ftpTask(App/ 侧创建)
↓ 等待 SYS_EVT_NET_READY + SYS_EVT_STORAGE_READY(osFlagsNoClear!)
lftpd(本目录)
├── lftpd_inet.c → Drivers/BSP/NET/net_socket.c(BSD Socket API)
└── lftpd_io.c → Lib/FatFs(文件读写)
```
## ⚠️ 项目相关注意
- **ftpTask 就绪屏障**:`osEventFlagsWait` 等两位就绪必须加 `osFlagsNoClear`,
否则位被先到的消费者清掉 → FTP 21 端口静默不监听(无日志无报错)
- **lftpd 大局部缓冲**:已按 `docs/内存管理/RAM使用分析与优化方案.md` §1.4 静态化 9 处
大局部缓冲(BSS +4.6KB),使 ftpTask 栈峰值从 3480B 降到 1184B——**勿改回局部变量**
- **上传中止截断**:FTP STOR 中途断开后首笔复连被截断(Trap 23)已在 `net_socket.c` 侧修复
- 本目录受 pre-commit 的**部分排除**(第三方代码)
## 相关文档
- 测试指南:`docs/测试说明/FTP_Test_Guide.md`
- 台架探针:`MDK-ARM/ftp_abort_probe.py`、`MDK-ARM/ftp_freeze_probe.py`
- 内存优化:`docs/内存管理/RAM使用分析与优化方案.md` §1.4
+71 -29
View File
@@ -6,6 +6,8 @@
#include <stdbool.h>
#include <ctype.h>
#include "FreeRTOS.h"
#include "lftpd.h"
#include "private/lftpd_status.h"
#include "private/lftpd_inet.h"
@@ -64,8 +66,8 @@ static int send_response(int socket, int code, bool include_code,
bool multiline_start, const char *format, ...)
{
va_list args;
char message[256];
char response[512];
static char message[256]; /* static: off-stack, single-client ftpTask */
static char response[512]; /* static: off-stack, single-client ftpTask */
va_start(args, format);
vsnprintf(message, sizeof(message), format, args);
@@ -106,7 +108,7 @@ static int send_list(int socket, const char *path)
return -1;
}
char name[256];
static char name[256]; /* static: off-stack, single-client ftpTask */
uint32_t size;
int is_dir;
@@ -131,7 +133,7 @@ static int send_nlst(int socket, const char *path)
return -1;
}
char name[256];
static char name[256]; /* static: off-stack, single-client ftpTask */
uint32_t size;
int is_dir;
@@ -153,10 +155,11 @@ static int send_file(int socket, const char *path)
return -1;
}
unsigned char buffer[1024];
static unsigned char buffer[1024]; /* static: 1KB off-stack, single-client ftpTask */
int read_len;
while ((read_len = lftpd_io_read(fh, buffer, sizeof(buffer))) > 0) {
lftpd_task_wd_beat(); /* 看门狗:每分片喂心跳(文件传输分片处,RETR) */
unsigned char *p = buffer;
while (read_len > 0) {
int write_len = lftpd_inet_write(socket, p, read_len);
@@ -182,11 +185,12 @@ static int receive_file(int socket, const char *path)
return -1;
}
unsigned char buffer[1024];
static unsigned char buffer[1024]; /* static: 1KB off-stack, single-client ftpTask */
int err;
int total = 0;
while ((err = lftpd_inet_read(socket, buffer, sizeof(buffer))) > 0) {
lftpd_task_wd_beat(); /* 看门狗:每分片喂心跳(文件传输分片处,STOR) */
total += err;
if (lftpd_io_write(fh, buffer, err) != err) {
err = -1;
@@ -197,13 +201,13 @@ static int receive_file(int socket, const char *path)
lftpd_io_close(fh);
if (err < 0) {
/* 异常结束(连接重置/超时/预算耗尽):删除截断文件防残留并返回错误码
* → STOR 回报 450 而非误报 226(Trap 23 待办 #3) */
lftpd_io_unlink(path);
return err;
}
if (total == 0) {
lftpd_log_error("received 0 bytes");
lftpd_io_unlink(path);
return -1;
}
/* EOF 正常结束:total==0 是合法空文件上传(如以空内容 STOR 清台账),
* 回报 226 成功(2026-10-02 台架:0 字节 STOR 曾被误判故障并删文件) */
return 0;
}
@@ -222,14 +226,14 @@ static int cmd_cwd(lftpd_client_t *client, const char *arg)
int is_dir;
if (lftpd_io_stat(path, &size, &is_dir) != 0 || !is_dir) {
send_simple_response(client->socket, 550, STATUS_550);
free(path);
vPortFree(path);
return -1;
}
}
strncpy(client->directory, path, sizeof(client->directory) - 1);
client->directory[sizeof(client->directory) - 1] = '\0';
free(path);
vPortFree(path);
send_simple_response(client->socket, 250, STATUS_250);
return 0;
@@ -248,18 +252,18 @@ static int cmd_dele(lftpd_client_t *client, const char *arg)
int is_dir;
if (lftpd_io_stat(path, &size, &is_dir) != 0) {
send_simple_response(client->socket, 550, STATUS_550);
free(path);
vPortFree(path);
return -1;
}
if (is_dir) {
send_simple_response(client->socket, 550, STATUS_550);
free(path);
vPortFree(path);
return -1;
}
lftpd_io_unlink(path);
free(path);
vPortFree(path);
send_simple_response(client->socket, 250, STATUS_250);
return 0;
}
@@ -316,6 +320,9 @@ static int cmd_list(lftpd_client_t *client, const char *arg)
}
int err = send_list(data_sock, client->directory);
/* 传输结束必须关闭数据连接:对端以 EOF 判定"数据传完"(LIST/RETR);
* 不关会让客户端一直读到超时(曾试"优雅断开+复用监听器"方案,因该语义限制
* 与芯片行为未能稳定工作,见 链路延迟测试说明.md §8.2)。 */
lftpd_inet_close(data_sock);
client->data_socket = -1;
@@ -345,6 +352,9 @@ static int cmd_nlst(lftpd_client_t *client, const char *arg)
}
int err = send_nlst(data_sock, client->directory);
/* 传输结束必须关闭数据连接:对端以 EOF 判定"数据传完"(LIST/RETR);
* 不关会让客户端一直读到超时(曾试"优雅断开+复用监听器"方案,因该语义限制
* 与芯片行为未能稳定工作,见 链路延迟测试说明.md §8.2)。 */
lftpd_inet_close(data_sock);
client->data_socket = -1;
@@ -449,6 +459,9 @@ static int cmd_retr(lftpd_client_t *client, const char *arg)
char *path = lftpd_io_canonicalize_path(client->directory, arg);
lftpd_log_debug("send '%s'", path);
int err = send_file(data_sock, path);
/* 传输结束必须关闭数据连接:对端以 EOF 判定"数据传完"(LIST/RETR);
* 不关会让客户端一直读到超时(曾试"优雅断开+复用监听器"方案,因该语义限制
* 与芯片行为未能稳定工作,见 链路延迟测试说明.md §8.2)。 */
lftpd_inet_close(data_sock);
client->data_socket = -1;
@@ -459,7 +472,7 @@ static int cmd_retr(lftpd_client_t *client, const char *arg)
lftpd_log_error("DOWNLOAD '%s' FAILED", path);
send_simple_response(client->socket, 450, STATUS_450);
}
free(path);
vPortFree(path);
return 0;
}
@@ -481,7 +494,7 @@ static int cmd_size(lftpd_client_t *client, const char *arg)
send_simple_response(client->socket, 550, STATUS_550);
}
free(path);
vPortFree(path);
return 0;
}
@@ -505,6 +518,9 @@ static int cmd_stor(lftpd_client_t *client, const char *arg)
char *path = lftpd_io_canonicalize_path(client->directory, arg);
lftpd_log_debug("receive '%s'", path);
int err = receive_file(data_sock, path);
/* 传输结束必须关闭数据连接:对端以 EOF 判定"数据传完"(LIST/RETR);
* 不关会让客户端一直读到超时(曾试"优雅断开+复用监听器"方案,因该语义限制
* 与芯片行为未能稳定工作,见 链路延迟测试说明.md §8.2)。 */
lftpd_inet_close(data_sock);
client->data_socket = -1;
@@ -518,7 +534,7 @@ static int cmd_stor(lftpd_client_t *client, const char *arg)
lftpd_log_error("UPLOAD '%s' FAILED", path);
send_simple_response(client->socket, 450, STATUS_450);
}
free(path);
vPortFree(path);
return 0;
}
@@ -548,7 +564,7 @@ static int handle_control_channel(lftpd_client_t *client)
goto cleanup;
}
char read_buffer[512];
static char read_buffer[512]; /* static: off-stack, single-client ftpTask */
while (err == 0) {
int line_len = lftpd_inet_read_line(client->socket, read_buffer,
@@ -582,7 +598,7 @@ static int handle_control_channel(lftpd_client_t *client)
bool matched = false;
for (int i = 0; commands[i].command; i++) {
if (strcmp(commands[i].command, command_tmp) == 0) {
char arg_buf[512];
static char arg_buf[512]; /* static: off-stack, single-client ftpTask */
const char *arg = NULL;
if (index < (int)strlen(read_buffer)) {
@@ -604,7 +620,15 @@ static int handle_control_channel(lftpd_client_t *client)
}
cleanup:
lftpd_inet_close(client->socket);
/* 数据监听器在复用模式下会跨命令保留,会话结束必须显式释放(否则每次会话泄漏一个 Socket) */
if (client->data_socket >= 0) {
lftpd_inet_close(client->data_socket);
client->data_socket = -1;
}
/* 控制通道不得 close:单连接模型下它即监听器(fd 与监听一体),EOF/断连时
* net 层已 auto_relisten 回 LISTEN;此处 close 会连监听一起毁掉并触发
* close→重建竞态(2026-09-24 台架 F1:快速重连在芯片沉降窗内被拒)。
* 直接返回外层循环,在同一监听 fd 上再次 accept。 */
return 0;
}
@@ -614,14 +638,31 @@ int lftpd_start(const char *directory, int port, lftpd_t *lftpd)
lftpd->directory = directory;
lftpd->port = port;
lftpd->server_socket = -1; /* memset 清零后显式置"无监听器"(0 是合法 fd) */
while (true) {
lftpd->server_socket = lftpd_inet_listen(port);
lftpd_task_wd_beat(); /* 看门狗:事件循环每轮喂心跳(ftpTask 主循环节拍点) */
if (lftpd->server_socket < 0) {
lftpd_log_error("error creating listener");
vTaskDelay(pdMS_TO_TICKS(3000));
continue;
/* 首次启动 / 网络重建后:全量创建监听器(socket+bind+listen) */
lftpd->server_socket = lftpd_inet_listen(port);
if (lftpd->server_socket < 0) {
lftpd_log_error("error creating listener");
/* 看门狗:3s 重试等待拆短片喂心跳(单次 3s 挂起超 IWDG 最坏窗
* ~1.7s → 误复位;与 time_sync.c 监听重试同模式) */
uint32_t wd_left = 3000U;
while (wd_left >= LFTPD_WAIT_SLICE_MS) {
lftpd_task_wd_beat();
vTaskDelay(pdMS_TO_TICKS(LFTPD_WAIT_SLICE_MS));
wd_left -= LFTPD_WAIT_SLICE_MS;
}
continue;
}
}
/* 后续会话:单连接模型下控制通道 fd 即监听器 fd,会话断连后 net 层已
* auto_relisten 回 LISTEN(同 fd,实测 ~50ms,D10 机制),直接复用;
* 不得 close→重建——close 会毁掉已就绪的监听器,重建的 open 落在芯片
* 沉降窗内返回假 OK → 窗内快速重连被拒(2026-09-24 台架 F1 发现;
* 铁律:EOF 时应用层不得 net_close 监听 fd)。 */
lftpd_log_info("waiting for connection...");
@@ -629,6 +670,7 @@ int lftpd_start(const char *directory, int port, lftpd_t *lftpd)
if (client_socket < 0) {
lftpd_log_error("error accepting client socket");
lftpd_inet_close(lftpd->server_socket);
lftpd->server_socket = -1; /* 监听器已不可用(网络重建):下轮重建 */
continue;
}
@@ -647,7 +689,7 @@ int lftpd_start(const char *directory, int port, lftpd_t *lftpd)
}
}
lftpd_client_t client;
static lftpd_client_t client; /* static: off-stack session context (single session) */
memset(&client, 0, sizeof(client));
strncpy(client.directory, directory, sizeof(client.directory) - 1);
client.directory[sizeof(client.directory) - 1] = '\0';
@@ -658,8 +700,8 @@ int lftpd_start(const char *directory, int port, lftpd_t *lftpd)
handle_control_channel(&client);
lftpd->client = NULL;
/* standalone mode: socket was closed in handle_control_channel,
* loop back to create a new listener */
/* 控制 Socket 不关闭:断连时 net 层已 auto_relisten(同 fd),
* 外层循环直接复用同一监听 fd 再次 accept */
}
}
+10
View File
@@ -17,3 +17,13 @@ typedef struct {
int lftpd_start(const char *directory, int port, lftpd_t *lftpd);
int lftpd_stop(lftpd_t *lftpd);
/*
* 看门狗心跳钩子(弱符号,默认空实现见 lftpd_inet.c):
* lftpd 事件循环与长等待点周期调用,为 ftpTask 喂任务心跳;
* 应用层(App/util/task_watchdog.c)提供强符号路由到
* task_wd_beat(TASK_WD_FTP),驱动层因此不向上依赖应用层
* (分层铁律);未接线的构建(如模拟器)空实现生效、行为不变。
* 2026-10-02 台架:FTP 心跳缺失致 IWDG 复位循环,本钩子为修复的一部分。
*/
void lftpd_task_wd_beat(void);
+97 -13
View File
@@ -5,11 +5,39 @@
#include "net_socket.h"
#include <string.h>
/*
* E-02 头阻塞修复 + L3 pending 化(2026-09-22,M3 台架实证):
* netTask 是全部 socket 操作的唯一执行者;lftpd 原先用默认 30s 的**阻塞**
* net_recv 等待对端下一行/下一块数据 → 只要客户端连上却不发完整行,netTask
* 就被长期占用,其余任务(含远端显示)的网络操作整体冻结(实测一次空闲 FTP
* 登录让 LCDMEM 整帧等待 17.7s)。
* 2026-09-22 先落地分片有界读(5ms 片;实测 100ms 片下受压帧仍达 ~750ms,
* 收到 5ms 后降到 ~10ms 量级,见 m3_data 修复后数据),同日 L3 pending 化
* 后改为"挂起请求":每次等待 = 一次挂起登记(netTask 零占用,完成按主循环
* 节拍检查),调用方单次超时 = 剩余总预算(总等待 ≤ LFTPD_TOTAL_BUDGET_MS,
* **总超时语义与旧实现一致**),旧分片协议退役。
*/
#define LFTPD_TOTAL_BUDGET_MS 30000U
/*
* 看门狗心跳钩子弱默认实现:空(未接应用层的构建——如模拟器——不喂心跳,
* 看门狗为 MCU 构建专属概念)。MCU 构建下 App/util/task_watchdog.c 提供
* 同名强符号(路由到 task_wd_beat(TASK_WD_FTP)),链接器取强符号
* (与 wd_platform_* 钩子同机制)。
*/
#if defined(__CC_ARM)
__weak
#endif
void lftpd_task_wd_beat(void)
{
}
int lftpd_inet_listen(int port)
{
int retry;
for (retry = 0; retry < 5; retry++) {
lftpd_task_wd_beat(); /* 看门狗:每轮喂心跳(重试间隔 500ms 在 800ms 容差内) */
int s = net_socket(NET_AF_INET, NET_SOCK_STREAM);
if (s < 0) {
if (retry == 0) DBG_ERROR("create socket");
@@ -57,11 +85,20 @@ int lftpd_inet_get_socket_port(int socket)
int lftpd_inet_accept(int socket)
{
/* 网络重建(net_init)后旧 Socket 已失效:检测到代次变化即返回 -1,
* 由 lftpd_start 外层循环重建监听(网络未就绪时其 listen 会重试) */
uint32_t gen = net_get_generation();
while (1) {
int s = net_accept(socket, NULL, NULL);
if (s >= 0) {
return s;
}
if (net_get_generation() != gen) {
DBG_ERROR("network re-init: abort accept, relisten");
return -1;
}
lftpd_task_wd_beat(); /* 看门狗:自旋轮次喂心跳(无限等待,可能长时间无客户端) */
vTaskDelay(pdMS_TO_TICKS(100));
}
}
@@ -81,6 +118,7 @@ int lftpd_inet_accept_timeout(int socket, int timeout_ms)
DBG_ERROR("data socket closed, accept aborted");
return -1;
}
lftpd_task_wd_beat(); /* 看门狗:每轮喂心跳(数据连接等待最长 5s) */
vTaskDelay(pdMS_TO_TICKS(100));
elapsed += 100;
}
@@ -91,19 +129,36 @@ int lftpd_inet_accept_timeout(int socket, int timeout_ms)
int lftpd_inet_read_line(int socket, char *buffer, size_t buffer_len)
{
TickType_t t0 = xTaskGetTickCount();
memset(buffer, 0, buffer_len);
int total_read_len = 0;
while (total_read_len < (int)buffer_len) {
int read_len = net_recv(socket,
buffer + total_read_len,
buffer_len - total_read_len - 1,
0);
/* 单次预算 = 剩余总预算(L3 pending:等待期 netTask 零占用,调用方
* 在自己上下文睡任务通知,旧片间 vTaskDelay(1) 让出退役) */
TickType_t elapsed = xTaskGetTickCount() - t0;
uint32_t rem = (elapsed >= (TickType_t)pdMS_TO_TICKS(LFTPD_TOTAL_BUDGET_MS)) ? 0U
: (uint32_t)((TickType_t)LFTPD_TOTAL_BUDGET_MS - elapsed);
if (rem == 0U) {
return -1; /* 总预算耗尽(语义同旧 30s 超时) */
}
lftpd_task_wd_beat(); /* 看门狗:每片前喂心跳(长等待无数据期保持节奏) */
if (rem > LFTPD_WAIT_SLICE_MS) {
rem = LFTPD_WAIT_SLICE_MS; /* 分片:单次 30s 挂起拆为 ≤200ms 片 */
}
int read_len = net_recv_to(socket,
buffer + total_read_len,
(int)(buffer_len - total_read_len - 1),
rem);
if (read_len == 0) {
return -1;
return -1; /* 对端关闭 */
}
if (read_len == -2) {
continue; /* 本次预算等满无数据 → 下轮 rem=0 退出 */
}
if (read_len < 0) {
return read_len;
return read_len; /* 真错误 */
}
total_read_len += read_len;
@@ -125,9 +180,12 @@ int lftpd_inet_write_string(int socket, const char *message)
while (length > 0) {
int write_len = net_send(socket, p, length, 0);
if (write_len < 0) {
DBG_ERROR("write error");
return write_len;
/* write_len == 0:发送超时且零进展(对端未消费数据);不判为致命则
* length 永不减少,将在此空转(任务死锁)。返回错误交会话层拆除
* 连接(lftpd.c handle_control_channel 的 cleanup 路径)。 */
if (write_len <= 0) {
DBG_ERROR("write error, ret=%d", write_len);
return -1;
}
p += write_len;
length -= write_len;
@@ -143,8 +201,12 @@ int lftpd_inet_write(int socket, const void *buf, int len)
while (remaining > 0) {
int n = net_send(socket, p, remaining, 0);
if (n < 0) {
return n;
/* n == 0:发送超时且零进展(对端停滞/断链),判为致命:否则 remaining
* 永不减少 → 死循环。调用方(lftpd.c send_file)收到 < 0 后终止传输
* 并向上报 450 错误,会话保持可用。 */
if (n <= 0) {
DBG_ERROR("write error, ret=%d", n);
return -1;
}
p += n;
remaining -= n;
@@ -155,8 +217,30 @@ int lftpd_inet_write(int socket, const void *buf, int len)
int lftpd_inet_read(int socket, void *buf, int len)
{
int n = net_recv(socket, buf, len, 0);
return n;
/* L3 pending 读:总预算 30s 不变,等待期 netTask 零占用
* (空闲数据连接不再冻结全网;旧分片协议随 L3 pending 化退役) */
TickType_t t0 = xTaskGetTickCount();
for (;;) {
TickType_t elapsed = xTaskGetTickCount() - t0;
uint32_t rem = (elapsed >= (TickType_t)pdMS_TO_TICKS(LFTPD_TOTAL_BUDGET_MS)) ? 0U
: (uint32_t)((TickType_t)LFTPD_TOTAL_BUDGET_MS - elapsed);
if (rem == 0U) {
return -1; /* 总预算耗尽(语义同旧 30s 超时) */
}
lftpd_task_wd_beat(); /* 看门狗:每片前喂心跳(长等待无数据期保持节奏) */
if (rem > LFTPD_WAIT_SLICE_MS) {
rem = LFTPD_WAIT_SLICE_MS; /* 分片:单次 30s 挂起拆为 ≤200ms 片 */
}
int n = net_recv_to(socket, buf, len, rem);
if (n >= 0) {
return n; /* >0 数据 / 0 EOF */
}
if (n == -1) {
return -1; /* 真错误 */
}
/* n == -2:本次预算等满无数据 → 下轮 rem=0 退出 */
}
}
int lftpd_inet_close(int socket)
+45 -9
View File
@@ -3,8 +3,12 @@
#include <string.h>
#include <ctype.h>
#include "FreeRTOS.h"
#include "ff.h"
#include "private/lftpd_io.h"
#include "lftpd.h"
#include "vol_lock.h"
#define DBG_TAG "[LFT_IO]"
#include "dbg_log.h"
@@ -30,6 +34,24 @@ static const char *to_fatfs_path(const char *path)
static int s_file_open = 0;
static FIL s_ftp_file;
/*
* 卷锁获取(分片阻塞 + 心跳):FatFs/FTL 卷状态非线程安全,文件操作序列
* 必须与 defaultTask 的 syslog/配置盘写互斥(2026-10-02 台架实证:并发
* FTL 写 → dhara journal 竞争 → 长耗时恢复旋转饥饿 defaultTask 主循环
* (看门狗喂狗点)→ IWDG 误复位)。等待按 100ms 分片、每片置 FTP 心跳位
* (与就绪屏障等待同模式),防长等待造成 FTP 心跳位缺席误复位。
* 持有约定:open...close / opendir...closedir 全程持锁;lftpd_io_read/
* write 假定锁已由 open 持有;stat/unlink 自行短持。
*/
static void lftpd_io_vol_lock_take(void) {
for (;;) {
if (vol_lock_wait(100u) != 0) {
return;
}
lftpd_task_wd_beat(); /* 看门狗:分片心跳(卷锁等待中) */
}
}
char *lftpd_io_canonicalize_path(const char *base, const char *name)
{
if (base == NULL) {
@@ -41,19 +63,19 @@ char *lftpd_io_canonicalize_path(const char *base, const char *name)
char *path;
if (name[0] == '/') {
path = (char *)malloc(strlen(name) + 1);
path = (char *)pvPortMalloc(strlen(name) + 1);
if (path == NULL) return NULL;
strcpy(path, name);
} else {
size_t len = strlen(base) + 1 + strlen(name) + 1;
path = (char *)malloc(len);
path = (char *)pvPortMalloc(len);
if (path == NULL) return NULL;
snprintf(path, len, "%s/%s", base, name);
}
char *abs_path = (char *)malloc(strlen(path) + 2);
char *abs_path = (char *)pvPortMalloc(strlen(path) + 2);
if (abs_path == NULL) {
free(path);
vPortFree(path);
return NULL;
}
abs_path[0] = '\0';
@@ -83,7 +105,7 @@ char *lftpd_io_canonicalize_path(const char *base, const char *name)
*p = saved;
}
free(path);
vPortFree(path);
if (strlen(abs_path) == 0) {
strcpy(abs_path, "/");
@@ -98,10 +120,12 @@ int lftpd_io_open_read(const char *path)
DBG_ERROR("open_read '%s': already open", path);
return -1;
}
lftpd_io_vol_lock_take();
const char *fpath = to_fatfs_path(path);
FRESULT fr = f_open(&s_ftp_file, fpath, FA_READ);
if (fr != FR_OK) {
DBG_ERROR("open_read '%s': f_open err=%d", fpath, (int)fr);
vol_lock_release();
return -1;
}
s_file_open = 1;
@@ -114,10 +138,12 @@ int lftpd_io_open_write(const char *path)
DBG_ERROR("open_write '%s': already open", path);
return -1;
}
lftpd_io_vol_lock_take();
const char *fpath = to_fatfs_path(path);
FRESULT fr = f_open(&s_ftp_file, fpath, FA_WRITE | FA_CREATE_ALWAYS);
if (fr != FR_OK) {
DBG_ERROR("open_write '%s': f_open err=%d", fpath, (int)fr);
vol_lock_release();
return -1;
}
s_file_open = 1;
@@ -153,6 +179,7 @@ void lftpd_io_close(int handle)
if (s_file_open) {
f_close(&s_ftp_file);
s_file_open = 0;
vol_lock_release(); /* 卷锁:open...close 序列结束 */
}
}
@@ -161,7 +188,9 @@ int lftpd_io_stat(const char *path, uint32_t *p_size, int *p_is_dir)
FILINFO fno;
const char *fpath = to_fatfs_path(path);
lftpd_io_vol_lock_take();
if (f_stat(fpath, &fno) != FR_OK) {
vol_lock_release();
return -1;
}
@@ -172,30 +201,36 @@ int lftpd_io_stat(const char *path, uint32_t *p_size, int *p_is_dir)
*p_is_dir = (fno.fattrib & AM_DIR) ? 1 : 0;
}
vol_lock_release();
return 0;
}
int lftpd_io_unlink(const char *path)
{
const char *fpath = to_fatfs_path(path);
lftpd_io_vol_lock_take();
if (f_unlink(fpath) == FR_OK) {
vol_lock_release();
return 0;
}
vol_lock_release();
return -1;
}
void *lftpd_io_opendir(const char *path)
{
DIR *dp = (DIR *)malloc(sizeof(DIR));
DIR *dp = (DIR *)pvPortMalloc(sizeof(DIR));
if (dp == NULL) {
return NULL;
}
const char *fpath = to_fatfs_path(path);
lftpd_io_vol_lock_take();
if (f_opendir(dp, fpath) != FR_OK) {
free(dp);
vPortFree(dp);
vol_lock_release();
return NULL;
}
return (void *)dp;
return (void *)dp; /* 卷锁持有至 lftpd_io_closedir */
}
int lftpd_io_readdir(void *dp, char *name, int name_max, uint32_t *p_size, int *p_is_dir)
@@ -230,6 +265,7 @@ void lftpd_io_closedir(void *dp)
{
if (dp != NULL) {
f_closedir((DIR *)dp);
free(dp);
vPortFree(dp);
vol_lock_release(); /* 卷锁:opendir...closedir 序列结束 */
}
}
+11 -2
View File
@@ -3,10 +3,12 @@
#include <stdio.h>
#include <stdlib.h>
#include <stdarg.h>
#include "dbg_log.h"
void lftpd_log_internal(const char* level, const char* format, ...) {
char buffer[256];
static char buffer[256]; /* static: called at depth in ftpTask, keep off-stack */
static char line[288]; /* static: 组装含前缀的整行,交给控制台单写者 */
va_list args;
va_start(args, format);
int err = vsnprintf(buffer, sizeof(buffer), format, args);
@@ -14,5 +16,12 @@ void lftpd_log_internal(const char* level, const char* format, ...) {
if (err >= sizeof(buffer)) {
return;
}
printf("%s %s\n", level, buffer);
/* 走控制台单写者(App/log/console_log.c):整行一次入缓冲,避免逐字符 fputc 交错 */
int n = snprintf(line, sizeof(line), "%s %s\r\n", level, buffer);
if (n > 0) {
if (n > (int)sizeof(line) - 1) {
n = (int)sizeof(line) - 1;
}
(void)dbg_console_write_line(line, (unsigned)n);
}
}
@@ -3,6 +3,15 @@
#include <stdlib.h>
#include <stdint.h>
/*
* 看门狗分片等待:单次 net_recv_to 等待片长上限。须显著小于看门狗抽样窗
* (100ms)的容错窗口(8 窗 = 800ms),200ms 留足余量;语义上仅把一次长
* 等待拆成多次短等待,总预算不变(与 time_sync.c 同款,见看门狗设计说明
* §4.4 长耗时白名单)。2026-10-02 台架:FTP 心跳缺失 + 30s 不分片等待 →
* IWDG 复位循环,本分片为修复的一部分。
*/
#define LFTPD_WAIT_SLICE_MS 200U
int lftpd_inet_listen(int port);
int lftpd_inet_get_socket_port(int socket);
int lftpd_inet_accept(int socket);
+27 -54
View File
@@ -1,11 +1,16 @@
/*
* 模块名称:Network Configuration
* 模块功能:网络子系统配置宏定义,包括最大Socket数量、缓冲区大小、
* 超时时间等参数配置
* 模块功能:网络子系统编译期参数——Socket 数量、收发超时、TCP KeepAlive。
* 仅保留**实际被引用**的宏;未使用项已清理。
* 适用平台:STM32F4 系列(CH395F 以太网芯片)
* 作者:王建锋
* 创建日期:2026-07-18
* 修改记录:
* 2026-09-10 王建锋 清理未引用宏(TCP Server 监听 Socket 索引、收发缓冲、
* 连接/接受超时、select/队列/轮询);KeepAlive 三宏改为
* 实际生效值 60s/5s/3 并在 net_socket.c 引用(原宏为无效遗留值)
* 2026-09-22 新增 NET_RETRANS_COUNT/NET_RETRANS_PERIOD_MS(P0-a:重传周期
* 500→200ms 治 D1 症状),net_socket.c 的字面量 8/500 移入本文件
*/
#ifndef NET_CONFIG_H
@@ -22,37 +27,10 @@ extern "C" {
#define NET_MAX_SOCKETS 8
#endif
/*
* TCP Server 多连接模式配置
* 监听 Socket 索引和数据 Socket 起始索引
*/
#ifndef NET_TCP_SERVER_LISTEN_SOCK
#define NET_TCP_SERVER_LISTEN_SOCK 0 /* 监听 Socket 索引 */
#endif
/*
* 缓冲区大小配置
*/
#ifndef NET_SEND_BUF_SIZE
#define NET_SEND_BUF_SIZE 4096 /* 发送缓冲区大小(字节) */
#endif
#ifndef NET_RECV_BUF_SIZE
#define NET_RECV_BUF_SIZE 4096 /* 接收缓冲区大小(字节) */
#endif
/*
* 超时时间配置(毫秒)
* 0 = 无限等待(阻塞模式)
*/
#ifndef NET_CONNECT_TIMEOUT_MS
#define NET_CONNECT_TIMEOUT_MS 5000 /* 连接超时 */
#endif
#ifndef NET_ACCEPT_TIMEOUT_MS
#define NET_ACCEPT_TIMEOUT_MS 0 /* 接受连接超时(0=无限等待) */
#endif
#ifndef NET_SEND_TIMEOUT_MS
#define NET_SEND_TIMEOUT_MS 1000 /* 发送超时 */
#endif
@@ -62,39 +40,34 @@ extern "C" {
#endif
/*
* TCP KeepAlive 配置
* TCP 重传配置(由 net_socket.c 的 net_init() 在 ch395f_init() 之前下发)
* P0-a(2026-09-22):周期 500→200ms——单次丢包代价 500→200ms(治 D1 症状),
* 总窗口 8×200=1.6s,链路真断时判失败更快(对远端显示重连有利)。
* 手册 §5.18 仅标注上限(≤1000ms)未标注下限;200ms 已经台架验证芯片接受
*(GET_CMD_STATUS=0x00 + 全套件无回归,见《链路延迟测试说明.md》§10.2 记录)。
*/
#ifndef NET_RETRANS_COUNT
#define NET_RETRANS_COUNT 8 /* 重传次数(手册:最大 20,默认 12) */
#endif
#ifndef NET_RETRANS_PERIOD_MS
#define NET_RETRANS_PERIOD_MS 200 /* 重传周期 ms(手册:最大 1000,默认 500) */
#endif
/*
* TCP KeepAlive 配置(单位 ms;须为 500 的倍数且 IDLE > INTVL)
* 由 net_socket.c 的 net_init() 在 ch395f_init() 之前下发
*/
#ifndef NET_KEEPALIVE_IDLE_MS
#define NET_KEEPALIVE_IDLE_MS 7200000 /* KeepAlive 空闲时间(默认2小时) */
#define NET_KEEPALIVE_IDLE_MS 60000 /* 空闲 60 秒后开始探测 */
#endif
#ifndef NET_KEEPALIVE_INTVL_MS
#define NET_KEEPALIVE_INTVL_MS 75000 /* KeepAlive 探测间隔(默认75秒) */
#define NET_KEEPALIVE_INTVL_MS 5000 /* 5 秒探测一次 */
#endif
#ifndef NET_KEEPALIVE_CNT
#define NET_KEEPALIVE_CNT 9 /* KeepAlive 探测次数(默认9次) */
#endif
/*
* select/poll 最大 fd 数量
*/
#ifndef NET_SELECT_MAX_FDS
#define NET_SELECT_MAX_FDS NET_MAX_SOCKETS
#endif
/*
* 轮询延时配置(毫秒)
*/
#ifndef NET_POLL_DELAY_MS
#define NET_POLL_DELAY_MS 1 /* 非阻塞轮询延时 */
#endif
/*
* 消息队列配置
*/
#ifndef NET_MSG_QUEUE_LENGTH
#define NET_MSG_QUEUE_LENGTH 8 /* 消息队列深度 */
#define NET_KEEPALIVE_CNT 3 /* 最多探测 3 次 */
#endif
#ifdef __cplusplus

Some files were not shown because too many files have changed in this diff Show More