From 8147939f3a379735c41c629883367193cabcb45d Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Mon, 14 Sep 2026 02:15:49 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=9A=E7=94=A8=E9=85=8D=E7=BD=AE=E6=96=87?= =?UTF-8?q?=E4=BB=B6=20P1=E2=80=93P3=EF=BC=9A=E8=AE=BE=E8=AE=A1=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E3=80=81FS=20=E5=B9=B6=E5=8F=91=20A+=20=E5=AE=9A?= =?UTF-8?q?=E6=A1=88=E3=80=81FatFs=20=E5=A2=99=E9=92=9F=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E3=80=81cfg=5Ffile=20=E5=8F=8C=E7=AB=AF=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E4=B8=8E=20net=5Fcfg=20=E9=9B=86=E6=88=90=EF=BC=88=E6=97=A7?= =?UTF-8?q?=E6=A1=A3=20NCFG=20=E9=A6=96=E5=90=AF=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 8 +- App/cfg_file.c | 1192 ++++++++++++++++++++++++ App/cfg_file.h | 126 +++ App/cfg_file_store.h | 86 ++ App/cfg_file_store_mcu.c | 158 ++++ App/{ => util}/sys_clock.c | 25 + App/{ => util}/sys_clock.h | 13 + Drivers/BSP/NETCFG/net_cfg.c | 152 ++- Drivers/BSP/NETCFG/net_cfg.h | 26 +- Drivers/BSP/NETCFG/net_cfg_legacy.c | 125 +++ Drivers/BSP/NETCFG/net_cfg_legacy.h | 35 + Drivers/BSP/NETCFG/net_cfg_store.h | 46 - Drivers/BSP/NETCFG/net_cfg_store_mcu.c | 71 -- Lib/FatFs/ffconf.h | 4 +- Lib/FatFs/ffsystem.c | 98 ++ MDK-ARM/STM32F407-Demo.uvprojx | 21 +- docs/images/配置文件分层架构.svg | 68 ++ docs/images/项目架构总览.svg | 28 +- docs/系统墙钟时间维护说明.md | 14 +- docs/网络配置说明.md | 86 +- docs/配置文件设计说明.md | 571 ++++++++++++ docs/项目架构总览.md | 17 +- docs/项目部署环境要求.md | 8 +- simulator/CMakeLists.txt | 7 +- simulator/README.md | 12 +- simulator/sim/cfg_file_store_sim.c | 148 +++ simulator/sim/net_cfg_store_sim.c | 67 -- tools/arch_diagram/gen_arch_svg.py | 4 +- tools/arch_diagram/gen_cfg_arch_svg.py | 219 +++++ 29 files changed, 3076 insertions(+), 359 deletions(-) create mode 100644 App/cfg_file.c create mode 100644 App/cfg_file.h create mode 100644 App/cfg_file_store.h create mode 100644 App/cfg_file_store_mcu.c rename App/{ => util}/sys_clock.c (93%) rename App/{ => util}/sys_clock.h (76%) create mode 100644 Drivers/BSP/NETCFG/net_cfg_legacy.c create mode 100644 Drivers/BSP/NETCFG/net_cfg_legacy.h delete mode 100644 Drivers/BSP/NETCFG/net_cfg_store.h delete mode 100644 Drivers/BSP/NETCFG/net_cfg_store_mcu.c create mode 100644 Lib/FatFs/ffsystem.c create mode 100644 docs/images/配置文件分层架构.svg create mode 100644 docs/配置文件设计说明.md create mode 100644 simulator/sim/cfg_file_store_sim.c delete mode 100644 simulator/sim/net_cfg_store_sim.c create mode 100644 tools/arch_diagram/gen_cfg_arch_svg.py diff --git a/AGENTS.md b/AGENTS.md index 94f7a0d9..e7247bf4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ Src/main.c → CubeMX 外设初始化 → app_main_init → osKernelStart - **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**:等 `SYS_EVT_STORAGE_READY`(≤10s)→ `net_stack_bring_up()`(`net_cfg_load` 读 Flash 网口配置 → `net_init(ip,mask,gw,mac)` + PHY 强制 100M 全双工 + 等链路 ≤3s → 回写芯片实际 MAC 影子 → 递增网络代次号)→ 置 `SYS_EVT_NET_READY` → 循环 `net_poll()` + `net_process_messages()` + `osDelay(1)`(tick=1ms);循环内消费 `SYS_EVT_NET_RECONFIG`(UI 保存网口配置后请求)→ 立即重建网络(新 IP 生效,无需重启;各 TCP 服务据代次号重建监听) +- **netTask**:等 `SYS_EVT_STORAGE_READY`(≤10s)→ `net_stack_bring_up()`(`net_cfg_load` 读网口配置(`/config.ini` [network] 段,首启自动迁旧档 NCFG)→ `net_init(ip,mask,gw,mac)` + PHY 强制 100M 全双工 + 等链路 ≤3s → 回写芯片实际 MAC 影子 → 递增网络代次号)→ 置 `SYS_EVT_NET_READY` → 循环 `net_poll()` + `net_process_messages()` + `osDelay(1)`(tick=1ms);循环内消费 `SYS_EVT_NET_RECONFIG`(UI 保存网口配置后请求)→ 立即重建网络(新 IP 生效,无需重启;各 TCP 服务据代次号重建监听) - **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 @@ -71,7 +71,7 @@ Src/main.c → CubeMX 外设初始化 → app_main_init → osKernelStart - `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()` → `ch395f_set_mac_addr()`(mac 非空且非全零时)→ Socket 0~7 缓冲分配 → ping 使能 -- 网口参数(IP/掩码/网关/MAC)取自 Flash 配置(`Drivers/BSP/NETCFG/`:magic 'NCFG' + ver + CRC-16/MODBUS;工厂默认 192.168.1.100/24、gw .1,MAC 全零 = 芯片内置),保存后**下次上电生效**;PHY 强制 100M 全双工 +- 网口参数(IP/掩码/网关/MAC)取自通用配置文件 `/config.ini` 的 [network] 段(`Drivers/BSP/NETCFG/` + `App/cfg_file` 引擎:工厂默认 192.168.1.100/24、gw .1,MAC 全零 = 芯片内置),保存后**立即生效**(网络重建)或下次上电生效;旧版 `/cfg/netcfg.bin`(NCFG)首启自动迁移;PHY 强制 100M 全双工 - **新文件需手动添加到 Keil MDK 工程**才能编译 ## 测试构建模式 @@ -92,7 +92,8 @@ Src/main.c → CubeMX 外设初始化 → app_main_init → osKernelStart | `App/task/net_task.c` | netTask 主循环(网络初始化 + 轮询) | | `test/test_config.h` | 测试套件选择(互斥开关) | | `Drivers/BSP/NET/` | BSD Socket API 网络抽象层 + lftpd | -| `Drivers/BSP/NETCFG/` | 网口配置(Flash 持久化 + CRC,网口设置内容页数据源) | +| `Drivers/BSP/NETCFG/` | 网口配置(通用配置文件 `/config.ini` [network] 段 + 旧档 NCFG 迁移;网口设置内容页数据源) | +| `App/cfg_file.*` + `App/cfg_file_store*` | 通用配置文件引擎(双端共享核心 + 平台存储后端:INI 解析/校验/外科保存/模板/沉降轮询) | | `Drivers/BSP/CH395F/` | CH395F SPI 驱动 | | `Drivers/BSP/GD5F2GQ5UE/` | NAND Flash 驱动 + dhara FTL + FatFS diskio | | `Drivers/BSP/TPAFE5160/` | ADC 并行接口驱动 | @@ -104,6 +105,7 @@ Src/main.c → CubeMX 外设初始化 → app_main_init → osKernelStart 详见 `docs/` 目录: - `docs/项目架构总览.md` — 六层全景架构(分层/任务/启动时序/存储·网络·UI 栈) +- `docs/配置文件设计说明.md` — 通用配置文件(INI `/config.ini`:设计决策 D1–D15 / 双端支持 / 阶段规划 P1–P8 / 测试与风险) - `docs/网络配置说明.md` — 网口设置页配置(UI 可改的 IP/掩码/网关/MAC:持久化 / 立即生效 / 页面操作) - `docs/问题记录/CH395F_Trap_Records.md` — CH395F 已知陷阱 - `docs/问题记录/GD5F2GQ5UE_Trap_Records.md` — GD5F2GQ5UE 已知陷阱 diff --git a/App/cfg_file.c b/App/cfg_file.c new file mode 100644 index 00000000..2de9a449 --- /dev/null +++ b/App/cfg_file.c @@ -0,0 +1,1192 @@ +/* + * 模块名称:cfg_file + * 模块功能:通用配置文件引擎(双端共享核心)——INI 段/键表解析校验、外科手术式 + * 保存(保留注释/未知内容,缺失段/键写回)、出厂模板生成、沉降去抖轮询 + * 与分段变更回调;平台文件操作经 cfg_file_store 后端 + * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用) + * 作者:王建锋 + * 创建日期:2026-09-14 + * 修改记录: + * v1.0 2026-09-14 王建锋 创建初始版本(P3:段键表引擎 + net_cfg 集成) + * 函数说明:引擎状态非重入——调用方须收敛同一任务(netTask:poll/save; + * 启动期 load 先于任务竞争);FS 卷并发由 FatFS REENTRANT 卷锁保证 + * (见《配置文件设计说明》D15 A+ 方案) + */ + +/* 头文件包含区 */ +#include + +#include + +#include + +#include + +#include "cfg_file.h" + +#include "cfg_file_store.h" + +#define DBG_TAG "[CFG]" +#include "dbg_log.h" + +/* 常量定义区 */ + +/* 文件头注释块(首次生成文件时写入;后续保存逐行保留) */ +static const char s_doc_header[] = + "; ============================================================\r\n" + "; STM32F4-Base 通用配置文件\r\n" + "; 格式: INI (UTF-8 / CRLF),本文件为配置唯一来源\r\n" + "; - 以 ';' 或 '#' 开头的行为注释,保存时保留\r\n" + "; - 未知段/键保存时保留;缺失段/键保存时写回\r\n" + "; - 编辑后经 FTP 上传或设备端保存,设备 4s 沉降后自动重载\r\n" + "; ============================================================\r\n"; + +#define CFG_OUT_MAX 5120u /* 保存输出缓冲(输入复用读缓冲;堆峰值 ≈ 9KB 暂态) */ +#define CFG_LINE_TEST_MAX 127u /* 受管键行匹配的行长上限(超长必非受管键行) */ +#define CFG_SNAP_MAX 64u /* 变更检测段快照缓冲(挂回调段 data_size 须 ≤ 此值) */ +#define CFG_NAME_MAX 16u /* 段名缓冲(15 字符 + NUL) */ + +/* 静态变量定义区 */ + +typedef struct { + char name[CFG_NAME_MAX]; + void *p_data; + const void *p_defaults; + size_t data_size; + const cfg_key_def_t *p_keys; + uint8_t key_count; + void (*p_cb)(void); + uint8_t used; +} cfg_sec_t; + +static cfg_sec_t s_sections[CFG_FILE_MAX_SECTIONS]; + +static cfg_file_stat_t s_base; /* 变更基线 (size, mtime 令牌) */ +static cfg_file_stat_t s_last; /* 上次 poll 的 stat */ +static uint8_t s_stable; /* 连续相同轮询计数(沉降去抖) */ +static uint8_t s_base_valid; /* 基线是否已建立 */ +static uint8_t s_snap[CFG_SNAP_MAX]; + +static int s_sec_hdr[CFG_FILE_MAX_SECTIONS]; /* 段头行号(0xFFFF = 未见) */ +static uint16_t s_key_line[CFG_FILE_MAX_SECTIONS][CFG_FILE_MAX_KEYS]; /* 受管键行号(0xFFFF = 未见) */ + +/* 函数定义区 */ + +/* + * 函数功能:短字符串大小写无关比较 + * 入口参数:p_a / p_b - 两个 NUL 结尾字符串 const char * + * 返回值:1 - 相等(忽略大小写);0 - 不等 int + * 限定条件:p_a/p_b 非空 + */ +static int s_str_ci_eq(const char *p_a, const char *p_b) { + for (;;) { + char ca = *p_a; + char cb = *p_b; + + if (ca >= 'A' && ca <= 'Z') { + ca = (char)(ca + ('a' - 'A')); + } + if (cb >= 'A' && cb <= 'Z') { + cb = (char)(cb + ('a' - 'A')); + } + if (ca != cb) { + return 0; + } + if (ca == '\0') { + return 1; + } + p_a++; + p_b++; + } +} + +/* + * 函数功能:取下一行(行末 NUL 化、剥 CR、前导空白左移)并推进缓冲位置 + * 入口参数:p_p - 当前位置指针(入/出) char ** + * 返回值:行首指针 char * + * 限定条件:*p_p 指向可写缓冲内 + * 函数说明:行界定 = '\n'(缺失时到 NUL);会修改缓冲(行末字节), + * 保存路径 Pass 1 后须经 s_restore_newlines 复原 + */ +static char *s_split_line(char **p_p) { + char *p_line = *p_p; + char *p_nl; + char *p_ws; + size_t l; + + p_nl = strchr(p_line, '\n'); + if (p_nl != NULL) { + *p_nl = '\0'; + *p_p = p_nl + 1; + } + else { + *p_p = p_line + strlen(p_line) + 1; + } + l = strlen(p_line); + if (l > 0u && p_line[l - 1u] == '\r') { + p_line[l - 1u] = '\0'; + } + p_ws = p_line; + while (*p_ws == ' ' || *p_ws == '\t') { + p_ws++; + } + if (p_ws != p_line) { + (void)memmove(p_line, p_ws, strlen(p_ws) + 1u); + } + return p_line; +} + +/* + * 函数功能:复原行末换行符(s_split_line 的逆操作,供保存 Pass 2 复用缓冲) + * 入口参数:p_buf - 行末已 NUL 化的缓冲 char * + * 返回值:无 + * 限定条件:p_buf 非空 + */ +static void s_restore_newlines(char *p_buf) { + char *p = p_buf; + + while (*p != '\0') { + size_t l = strlen(p); + p[l] = '\n'; + p = p + l + 1u; + } +} + +/* + * 函数功能:识别段名行(非修改式) + * 入口参数:p_line - 行文本 const char * + * p_name - 段名输出缓冲 char * + * name_cap- 缓冲容量 size_t + * 返回值:1 - 是段名行(p_name 已填);0 - 不是 int + * 限定条件:p_line/p_name 非空 + */ +static int s_match_section(const char *p_line, char *p_name, size_t name_cap) { + const char *p; + const char *p_br; + size_t n = 0u; + + if (p_line[0] != '[') { + return 0; + } + p_br = strchr(p_line, ']'); + if (p_br == NULL || (size_t)(p_br - p_line) >= name_cap) { + return 0; + } + p = p_line + 1; + while (p != p_br && (n + 1u) < name_cap) { + p_name[n++] = (char)*p; + p++; + } + p_name[n] = '\0'; + while (n > 0u && (p_name[n - 1u] == ' ' || p_name[n - 1u] == '\t')) { + p_name[--n] = '\0'; + } + return 1; +} + +/* + * 函数功能:拆分键值行(修改式:键在 '=' 处 NUL 化,值区空白修剪) + * 入口参数:p_line - 行文本(可写副本) char * + * p_key - 键名输出(指向 p_line 内) char ** + * 返回值:值文本指针;NULL - 不是键值行 char * + * 限定条件:仅用于一次性解析(缓冲可被修改) + */ +static char *s_key_value(char *p_line, char **p_key) { + char *p_eq = strchr(p_line, '='); + char *p_val; + size_t kl; + size_t vl; + + if (p_eq == NULL) { + return NULL; + } + *p_eq = '\0'; + kl = (size_t)(p_eq - p_line); + while (kl > 0u && (p_line[kl - 1u] == ' ' || p_line[kl - 1u] == '\t')) { + p_line[--kl] = '\0'; + } + *p_key = p_line; + p_val = p_eq + 1; + { + char *p_ws = p_val; + while (*p_ws == ' ' || *p_ws == '\t') { + p_ws++; + } + if (p_ws != p_val) { + (void)memmove(p_val, p_ws, strlen(p_ws) + 1u); + } + } + vl = strlen(p_val); + while (vl > 0u && (p_val[vl - 1u] == ' ' || p_val[vl - 1u] == '\t')) { + p_val[--vl] = '\0'; + } + return p_val; +} + +/* + * 函数功能:RAM 字段编码为 INI 值文本 + * 入口参数:p_key - 键定义 const cfg_key_def_t * + * p_src - RAM 字段 const uint8_t * + * p_dst - 输出缓冲 char * + * cap - 缓冲容量 size_t + * 返回值:成功 = 文本长度;失败 = -1(超长/非法字段长度) int + * 限定条件:p_src/p_dst 非空 + */ +static int s_encode(const cfg_key_def_t *p_key, const uint8_t *p_src, char *p_dst, size_t cap) { + int n; + + switch (p_key->type) { + case CFG_VAL_UINT: { + unsigned long v = 0uL; + size_t i; + + if (p_key->len != 1u && p_key->len != 2u && p_key->len != 4u) { + return -1; + } + for (i = 0u; i < p_key->len; i++) { + v |= (unsigned long)p_src[i] << (8u * i); + } + n = snprintf(p_dst, cap, "%lu", v); + break; + } + case CFG_VAL_IP4: + n = snprintf(p_dst, cap, "%u.%u.%u.%u", + (unsigned)p_src[0], (unsigned)p_src[1], + (unsigned)p_src[2], (unsigned)p_src[3]); + break; + case CFG_VAL_MAC: + n = snprintf(p_dst, cap, "%02x:%02x:%02x:%02x:%02x:%02x", + (unsigned)p_src[0], (unsigned)p_src[1], (unsigned)p_src[2], + (unsigned)p_src[3], (unsigned)p_src[4], (unsigned)p_src[5]); + break; + case CFG_VAL_STR: { + size_t slen = strlen((const char *)p_src); + if (slen >= p_key->len) { + slen = p_key->len - 1u; + } + (void)memcpy(p_dst, p_src, slen); + p_dst[slen] = '\0'; + n = (int)slen; + break; + } + default: + return -1; + } + if (n < 0 || (size_t)n >= cap) { + return -1; + } + return n; +} + +/* + * 函数功能:解析无符号十进制值(小端写入) + * 入口参数:p_dst - 目标字段 uint8_t * + * len - 字段字节数(1/2/4) size_t + * p_val - 值文本 const char * + * 返回值:0 - 成功;-1 - 非法(空/非数字/超范围/带后缀) int + * 限定条件:无 + */ +static int s_decode_uint(uint8_t *p_dst, size_t len, const char *p_val) { + const unsigned long max_v = (len == 1u) ? 0xFFu : ((len == 2u) ? 0xFFFFu : 0xFFFFFFFFuL); + char *p_end = NULL; + unsigned long v; + size_t i; + + if (len != 1u && len != 2u && len != 4u) { + return -1; + } + if (p_val[0] == '\0') { + return -1; + } + v = strtoul(p_val, &p_end, 10); + if (p_end == p_val || *p_end != '\0' || v > max_v) { + return -1; + } + for (i = 0u; i < len; i++) { + p_dst[i] = (uint8_t)(v >> (8u * i)); + } + return 0; +} + +/* + * 函数功能:解析点分 IPv4("a.b.c.d",每段 0~255) + * 入口参数:p_dst - 4 字节目标 uint8_t * + * p_val - 值文本 const char * + * 返回值:0 - 成功;-1 - 非法 int + * 限定条件:无 + */ +static int s_decode_ip4(uint8_t *p_dst, const char *p_val) { + int i; + const char *p = p_val; + + for (i = 0; i < 4; i++) { + unsigned v = 0u; + int nd = 0; + + if (i != 0) { + if (*p != '.') { + return -1; + } + p++; + } + if (!isdigit((unsigned char)*p)) { + return -1; + } + while (isdigit((unsigned char)*p) && nd < 3) { + v = (unsigned)(v * 10u + (unsigned)(*p - '0')); + p++; + nd++; + } + if (v > 255u) { + return -1; + } + p_dst[i] = (uint8_t)v; + } + return (*p == '\0') ? 0 : -1; +} + +/* + * 函数功能:解析十六进制 MAC(分隔符兼容 ':' / '-' / '.',大小写均可) + * 入口参数:p_dst - 6 字节目标 uint8_t * + * p_val - 值文本 const char * + * 返回值:0 - 成功;-1 - 非法 int + * 限定条件:无 + */ +static int s_decode_mac(uint8_t *p_dst, const char *p_val) { + int i; + const char *p = p_val; + + for (i = 0; i < 12; i++) { + int v; + + if ((i & 1) == 0 && i != 0) { + if (*p != ':' && *p != '-' && *p != '.') { + return -1; + } + p++; + } + if (*p >= '0' && *p <= '9') { + v = *p - '0'; + } + else if (*p >= 'a' && *p <= 'f') { + v = *p - 'a' + 10; + } + else if (*p >= 'A' && *p <= 'F') { + v = *p - 'A' + 10; + } + else { + return -1; + } + if ((i & 1) == 0) { + p_dst[i >> 1] = (uint8_t)v; + } + else { + p_dst[i >> 1] = (uint8_t)((p_dst[i >> 1] << 4) | (uint8_t)v); + } + p++; + } + return (*p == '\0') ? 0 : -1; +} + +/* + * 函数功能:解析字符串值(NUL 结尾写入) + * 入口参数:p_dst - 目标缓冲 uint8_t * + * len - 缓冲长度 size_t + * p_val - 值文本 const char * + * 返回值:0 - 成功;-1 - 超长(含 NUL > len) int + * 限定条件:无 + */ +static int s_decode_str(uint8_t *p_dst, size_t len, const char *p_val) { + size_t vlen = strlen(p_val); + + if (len < vlen + 1u) { + return -1; + } + (void)memcpy(p_dst, p_val, vlen); + p_dst[vlen] = '\0'; + return 0; +} + +/* + * 函数功能:值文本解码到 RAM 字段(按键类型分派) + * 入口参数:p_key - 键定义 const cfg_key_def_t * + * p_dst - 目标字段 uint8_t * + * p_val - 值文本 const char * + * 返回值:0 - 成功;-1 - 非法(调用方留工厂默认并留痕) int + * 限定条件:无 + */ +static int s_decode(const cfg_key_def_t *p_key, uint8_t *p_dst, const char *p_val) { + switch (p_key->type) { + case CFG_VAL_UINT: + return s_decode_uint(p_dst, p_key->len, p_val); + case CFG_VAL_IP4: + return s_decode_ip4(p_dst, p_val); + case CFG_VAL_MAC: + return s_decode_mac(p_dst, p_val); + case CFG_VAL_STR: + return s_decode_str(p_dst, p_key->len, p_val); + default: + return -1; + } +} + +/* + * 函数功能:按各段 RAM 副本生成完整配置文档(文件头注释 + 全部段/键) + * 入口参数:p_buf - 输出缓冲 char * + * cap - 缓冲容量 size_t + * p_len - 输出长度 size_t * + * 返回值:0 - 成功;-1 - 缓冲不足/编码失败 int + * 限定条件:p_buf/p_len 非空;调用前各段 RAM 副本须为期望落盘值 + */ +static int s_gen_document(char *p_buf, size_t cap, size_t *p_len) { + size_t off; + int i; + int k; + size_t hlen; + + if (p_buf == NULL || p_len == NULL) { + return -1; + } + hlen = strlen(s_doc_header); + if (cap < hlen + 1u) { + return -1; + } + (void)memcpy(p_buf, s_doc_header, hlen + 1u); + off = hlen; + + for (i = 0; i < (int)CFG_FILE_MAX_SECTIONS; i++) { + const cfg_sec_t *p_sec = &s_sections[i]; + + if (p_sec->used == 0) { + continue; + } + if (cap < off + 112u) { + return -1; + } + off += (size_t)snprintf(p_buf + off, cap - off, "\r\n[%s]\r\n", p_sec->name); + + for (k = 0; k < (int)p_sec->key_count; k++) { + const cfg_key_def_t *p_key = &p_sec->p_keys[k]; + char v[80]; + + if (s_encode(p_key, (const uint8_t *)p_sec->p_data + p_key->offset, v, sizeof(v)) < 0) { + return -1; + } + if (cap < off + 112u) { + return -1; + } + off += (size_t)snprintf(p_buf + off, cap - off, "%s=%s\r\n", p_key->name, v); + } + } + *p_len = off; + return 0; +} + +/* + * 函数功能:读配置文件到堆缓冲(NUL 结尾) + * 入口参数:p_pbuf - 缓冲指针输出 uint8_t ** + * p_len - 实际长度输出 size_t * + * 返回值:0 - 成功;-1 - 分配/读失败(缓冲已释放) int + * 限定条件:成功时调用方须 free(*p_pbuf) + */ +static int s_load_file(uint8_t **p_pbuf, size_t *p_len) { + uint8_t *p_buf; + size_t nread = 0u; + int r; + + if (p_pbuf == NULL || p_len == NULL) { + return -1; + } + *p_pbuf = NULL; + p_buf = (uint8_t *)malloc(CFG_FILE_MAX_LEN + 1u); + if (p_buf == NULL) { + DBG_ERROR("read: malloc %uB FAIL", (unsigned)(CFG_FILE_MAX_LEN + 1u)); + return -1; + } + r = cfg_store_read(cfg_store_config_path(), p_buf, CFG_FILE_MAX_LEN, &nread); + if (r != 0) { + free(p_buf); + return -1; + } + p_buf[nread] = '\0'; + *p_pbuf = p_buf; + *p_len = nread; + return 0; +} + +/* + * 函数功能:经后端原子写配置文档 + * 入口参数:p_buf - 文档数据 const uint8_t * + * len - 数据长度 size_t + * 返回值:0 - 成功;-1 - 写失败 int + * 限定条件:p_buf 非空 + */ +static int s_write_doc(const uint8_t *p_buf, size_t len) { + if (p_buf == NULL) { + return -1; + } + return cfg_store_write_atomic(cfg_store_config_path(), p_buf, len); +} + +/* + * 函数功能:按当前 RAM 副本重建出厂模板写盘 + * 入口参数:无 + * 返回值:0 - 成功;-1 - 失败 int + * 限定条件:各段 RAM 副本须为期望落盘值 + */ +static int s_write_template(void) { + char *p_buf = (char *)malloc(CFG_OUT_MAX); + size_t len = 0u; + int r; + + if (p_buf == NULL) { + DBG_ERROR("template: malloc %uB FAIL", (unsigned)CFG_OUT_MAX); + return -1; + } + r = s_gen_document(p_buf, CFG_OUT_MAX, &len); + if (r == 0) { + r = s_write_doc((const uint8_t *)p_buf, len); + } + free(p_buf); + return r; +} + +/* + * 函数功能:解析 INI 文本,将有效键写入段 RAM 副本 + * 入口参数:p_buf - 文件文本(可修改,NUL 结尾) char * + * only_sec - 仅解析该段下标;-1 = 全部段 int + * 返回值:无 + * 限定条件:调用前对应段须已复位工厂默认;无效键 = 留工厂默认并留痕 + */ +static void s_parse(char *p_buf, int only_sec) { + char *p = p_buf; + char p_name[CFG_NAME_MAX]; + int sec_idx = -1; + + while (*p != '\0') { + char *p_line = s_split_line(&p); + + if (p_line[0] == '\0' || p_line[0] == ';' || p_line[0] == '#') { + continue; + } + + if (s_match_section(p_line, p_name, sizeof(p_name)) != 0) { + int i; + sec_idx = -1; + for (i = 0; i < (int)CFG_FILE_MAX_SECTIONS; i++) { + if (s_sections[i].used != 0 && s_str_ci_eq(p_name, s_sections[i].name) != 0) { + sec_idx = i; + break; + } + } + continue; + } + + if (sec_idx < 0 || (only_sec >= 0 && sec_idx != only_sec)) { + continue; + } + + { + char *p_key = NULL; + const char *p_val = s_key_value(p_line, &p_key); + + if (p_val != NULL) { + const cfg_sec_t *p_sec = &s_sections[sec_idx]; + int k; + + for (k = 0; k < (int)p_sec->key_count; k++) { + if (s_str_ci_eq(p_key, p_sec->p_keys[k].name) != 0) { + if (s_decode(&p_sec->p_keys[k], + (uint8_t *)p_sec->p_data + p_sec->p_keys[k].offset, + p_val) != 0) { + DBG_ERROR("parse: [%s] %s value invalid, keep factory default", + p_sec->name, p_key); + } + break; + } + } + } + } + } +} + +/* + * 函数功能:向输出缓冲追加一行原文(行文本 + CRLF;空行 = 分隔空行) + * 入口参数:p_out - 输出缓冲 char * + * out_cap - 缓冲容量 size_t + * p_off - 当前偏移(入/出) size_t * + * p_line - 行文本 const char * + * 返回值:0 - 成功;-1 - 溢出(偏移不变) int + * 限定条件:p_line 非空 + */ +static int s_emit_line(char *p_out, size_t out_cap, size_t *p_off, const char *p_line) { + int n; + + n = snprintf(p_out + *p_off, out_cap - *p_off, "%s\r\n", p_line); + if (n < 0 || (size_t)n >= out_cap - *p_off) { + return -1; + } + *p_off += (size_t)n; + return 0; +} + +/* + * 函数功能:向输出缓冲追加"键=值"行 + * 入口参数:p_out - 输出缓冲 char * + * out_cap - 缓冲容量 size_t + * p_off - 当前偏移(入/出) size_t * + * p_key - 键名 const char * + * p_val - 值文本 const char * + * 返回值:0 - 成功;-1 - 溢出(偏移不变) int + * 限定条件:p_key/p_val 非空 + */ +static int s_emit_kv(char *p_out, size_t out_cap, size_t *p_off, const char *p_key, const char *p_val) { + int n; + + n = snprintf(p_out + *p_off, out_cap - *p_off, "%s=%s\r\n", p_key, p_val); + if (n < 0 || (size_t)n >= out_cap - *p_off) { + return -1; + } + *p_off += (size_t)n; + return 0; +} + +/* + * 函数功能:向输出缓冲追加段头行 + * 入口参数:p_out - 输出缓冲 char * + * out_cap - 缓冲容量 size_t + * p_off - 当前偏移(入/出) size_t * + * p_name - 段名 const char * + * 返回值:0 - 成功;-1 - 溢出(偏移不变) int + * 限定条件:p_name 非空 + */ +static int s_emit_header(char *p_out, size_t out_cap, size_t *p_off, const char *p_name) { + int n; + + n = snprintf(p_out + *p_off, out_cap - *p_off, "[%s]\r\n", p_name); + if (n < 0 || (size_t)n >= out_cap - *p_off) { + return -1; + } + *p_off += (size_t)n; + return 0; +} + +/* + * 函数功能:外科手术式保存变换(保留注释/未知内容,受管段/键按 RAM 副本改写) + * 入口参数:p_in - 原文本(行末已 NUL 化的可修改缓冲) char * + * in_len - 原文长度 size_t + * p_out - 输出缓冲 char * + * out_cap - 输出容量 size_t + * p_out_len - 输出长度 size_t * + * 返回值:0 - 成功;-1 - 溢出(调用方放弃保存,原文件不动) int + * 限定条件:调用前 s_sec_hdr/s_key_line 已由 Pass 1 填充 + */ +static int s_surgical(char *p_in, size_t in_len, char *p_out, size_t out_cap, size_t *p_out_len) { + size_t off = 0u; + + (void)in_len; + + /* ---- Pass 2:逐行输出 ---- */ + { + char *p = p_in; + int i_sec = -1; + int line_no = 0; + int i; + int k; + char p_name[CFG_NAME_MAX]; + + while (*p != '\0') { + const char *p_line = s_split_line(&p); + + if (s_match_section(p_line, p_name, sizeof(p_name)) != 0) { + int idx = -1; + for (i = 0; i < (int)CFG_FILE_MAX_SECTIONS; i++) { + if (s_sections[i].used != 0 && s_str_ci_eq(p_name, s_sections[i].name) != 0) { + idx = i; + break; + } + } + if (idx >= 0) { + if (s_emit_header(p_out, out_cap, &off, s_sections[idx].name) != 0) { + return -1; + } + for (k = 0; k < (int)s_sections[idx].key_count; k++) { + if (s_key_line[idx][k] == 0xFFFFu) { + const cfg_key_def_t *p_key = &s_sections[idx].p_keys[k]; + char v[80]; + if (s_encode(p_key, (const uint8_t *)s_sections[idx].p_data + p_key->offset, + v, sizeof(v)) == 0) { + if (s_emit_kv(p_out, out_cap, &off, p_key->name, v) != 0) { + return -1; + } + } + } + } + i_sec = idx; + } + else { + if (s_emit_line(p_out, out_cap, &off, p_line) != 0) { + return -1; + } + i_sec = -1; + } + } + else if (i_sec >= 0) { + int is_key = 0; + for (k = 0; k < (int)s_sections[i_sec].key_count; k++) { + if (s_key_line[i_sec][k] == (uint16_t)line_no) { + is_key = 1; + break; + } + } + if (is_key) { + const cfg_key_def_t *p_key = &s_sections[i_sec].p_keys[k]; + char v[80]; + if (s_encode(p_key, (const uint8_t *)s_sections[i_sec].p_data + p_key->offset, + v, sizeof(v)) == 0) { + if (s_emit_kv(p_out, out_cap, &off, p_key->name, v) != 0) { + return -1; + } + } + else { + if (s_emit_line(p_out, out_cap, &off, p_line) != 0) { + return -1; + } + } + } + else { + if (s_emit_line(p_out, out_cap, &off, p_line) != 0) { + return -1; + } + } + } + else { + if (s_emit_line(p_out, out_cap, &off, p_line) != 0) { + return -1; + } + } + line_no++; + } + + /* 文件中未见的受管段 → 文末整段补写 */ + for (i = 0; i < (int)CFG_FILE_MAX_SECTIONS; i++) { + const cfg_sec_t *p_sec = &s_sections[i]; + + if (p_sec->used == 0 || s_sec_hdr[i] != 0xFFFF) { + continue; + } + if (s_emit_line(p_out, out_cap, &off, "") != 0) { + return -1; + } + if (s_emit_header(p_out, out_cap, &off, p_sec->name) != 0) { + return -1; + } + for (k = 0; k < (int)p_sec->key_count; k++) { + const cfg_key_def_t *p_key = &p_sec->p_keys[k]; + char v[80]; + if (s_encode(p_key, (const uint8_t *)p_sec->p_data + p_key->offset, + v, sizeof(v)) == 0) { + if (s_emit_kv(p_out, out_cap, &off, p_key->name, v) != 0) { + return -1; + } + } + } + } + } + + *p_out_len = off; + return 0; +} + +/* + * 函数功能:保存 Pass 1——定位段头行号与各受管键行号 + * 入口参数:p_in - 原文本(可修改) char * + * 返回值:无 + * 限定条件:完成后须调 s_restore_newlines 复原行末 + */ +static void s_surgical_pass1(char *p_in) { + char *p = p_in; + int line_no = 0; + int i; + int k; + int i_cur = -1; + char p_name[CFG_NAME_MAX]; + char lbuf[CFG_LINE_TEST_MAX]; + + for (i = 0; i < (int)CFG_FILE_MAX_SECTIONS; i++) { + s_sec_hdr[i] = 0xFFFF; + for (k = 0; k < (int)CFG_FILE_MAX_KEYS; k++) { + s_key_line[i][k] = 0xFFFFu; + } + } + + while (*p != '\0') { + const char *p_line = s_split_line(&p); + + if (p_line[0] == '\0' || p_line[0] == ';' || p_line[0] == '#') { + line_no++; + continue; + } + + if (s_match_section(p_line, p_name, sizeof(p_name)) != 0) { + i_cur = -1; + for (i = 0; i < (int)CFG_FILE_MAX_SECTIONS; i++) { + if (s_sections[i].used != 0 && s_str_ci_eq(p_name, s_sections[i].name) != 0) { + s_sec_hdr[i] = line_no; + i_cur = i; + break; + } + } + line_no++; + continue; + } + + if (i_cur >= 0 && strlen(p_line) <= CFG_LINE_TEST_MAX) { + /* 行副本上做键名匹配(不破坏原文,供 Pass 2 原样保留) */ + (void)snprintf(lbuf, sizeof(lbuf), "%s", p_line); + { + char *p_key = NULL; + const char *p_val = s_key_value(lbuf, &p_key); + + if (p_val != NULL) { + for (k = 0; k < (int)s_sections[i_cur].key_count; k++) { + if (s_key_line[i_cur][k] == 0xFFFFu && + s_str_ci_eq(p_key, s_sections[i_cur].p_keys[k].name) != 0) { + s_key_line[i_cur][k] = (uint16_t)line_no; + break; + } + } + } + } + } + line_no++; + } + + s_restore_newlines(p_in); +} + +/* + * 函数功能:注册配置段 + * 入口参数:p_reg - 段注册描述符(不可为 NULL) const cfg_section_reg_t * + * 返回值:0 - 成功;-1 - 参数非法/段名重复/段数超限 int + * 限定条件:启动早期调用一次 + */ +int cfg_file_register(const cfg_section_reg_t *p_reg) { + int i; + int k; + int nsecs = 0; + + if (p_reg == NULL || p_reg->name == NULL || p_reg->p_data == NULL || + p_reg->p_defaults == NULL || p_reg->p_keys == NULL) { + return -1; + } + if (p_reg->key_count == 0u || p_reg->key_count > CFG_FILE_MAX_KEYS) { + return -1; + } + if (p_reg->data_size == 0u || strlen(p_reg->name) >= CFG_NAME_MAX) { + return -1; + } + if (p_reg->p_cb != NULL && p_reg->data_size > CFG_SNAP_MAX) { + return -1; + } + + for (i = 0; i < (int)CFG_FILE_MAX_SECTIONS; i++) { + if (s_sections[i].used != 0) { + if (s_str_ci_eq(s_sections[i].name, p_reg->name) != 0) { + DBG_ERROR("register: section '%s' duplicated", p_reg->name); + return -1; + } + nsecs++; + } + } + if (nsecs >= (int)CFG_FILE_MAX_SECTIONS) { + return -1; + } + + for (k = 0; k < (int)p_reg->key_count; k++) { + const cfg_key_def_t *p_key = &p_reg->p_keys[k]; + + if (p_key->name == NULL || p_key->len == 0u) { + return -1; + } + if (p_key->type == CFG_VAL_UINT && + (p_key->len != 1u && p_key->len != 2u && p_key->len != 4u)) { + return -1; + } + if (p_key->type == CFG_VAL_IP4 && p_key->len != 4u) { + return -1; + } + if (p_key->type == CFG_VAL_MAC && p_key->len != 6u) { + return -1; + } + if (p_key->type == CFG_VAL_STR && p_key->len > 79u) { + return -1; + } + } + + for (i = 0; i < (int)CFG_FILE_MAX_SECTIONS; i++) { + if (s_sections[i].used == 0) { + cfg_sec_t *p_sec = &s_sections[i]; + + (void)memcpy(p_sec->name, p_reg->name, strlen(p_reg->name) + 1u); + p_sec->p_data = p_reg->p_data; + p_sec->p_defaults = p_reg->p_defaults; + p_sec->data_size = p_reg->data_size; + p_sec->p_keys = p_reg->p_keys; + p_sec->key_count = p_reg->key_count; + p_sec->p_cb = p_reg->p_cb; + p_sec->used = 1; + DBG_INFO("register: section '%s' (%u keys)", p_sec->name, (unsigned)p_reg->key_count); + return 0; + } + } + return -1; +} + +/* + * 函数功能:从配置文件加载全部段 + * 入口参数:无 + * 返回值:0 - 正常(含回退工厂默认);-1 - 严重错误 int + * 限定条件:至少注册一个段 + */ +int cfg_file_load(void) { + cfg_file_stat_t st; + int i; + int nsecs = 0; + + for (i = 0; i < (int)CFG_FILE_MAX_SECTIONS; i++) { + if (s_sections[i].used != 0) { + nsecs++; + } + } + if (nsecs == 0) { + return -1; + } + + /* 各段先复位工厂默认(文件 = 配置唯一来源:缺失/损坏的键 = 工厂默认) */ + for (i = 0; i < (int)CFG_FILE_MAX_SECTIONS; i++) { + if (s_sections[i].used != 0) { + (void)memcpy(s_sections[i].p_data, s_sections[i].p_defaults, s_sections[i].data_size); + } + } + + if (cfg_store_stat(cfg_store_config_path(), &st) != 0 || st.size == 0u) { + /* 文件缺失/空(首启):生成出厂模板写盘 */ + if (s_write_template() != 0) { + DBG_ERROR("load: template write FAIL"); + return -1; + } + DBG_INFO("load: file missing, factory template generated"); + return 0; + } + + if (st.size > CFG_FILE_MAX_LEN) { + /* 文件超长 = 损坏:留工厂默认 + 重建模板 */ + if (s_write_template() != 0) { + DBG_ERROR("load: corrupt file, template write FAIL"); + return -1; + } + DBG_ERROR("load: file %luB > %uB, corrupt, factory template regenerated", + (unsigned long)st.size, (unsigned)CFG_FILE_MAX_LEN); + return 0; + } + + { + uint8_t *p_buf = NULL; + size_t len = 0u; + + if (s_load_file(&p_buf, &len) != 0) { + DBG_ERROR("load: file read FAIL, factory defaults kept"); + return -1; + } + s_parse((char *)p_buf, -1); + free(p_buf); + DBG_INFO("load: OK (%luB)", (unsigned long)st.size); + return 0; + } +} + +/* + * 函数功能:保存全部段到配置文件 + * 入口参数:无 + * 返回值:0 - 成功;-1 - 写失败(原文件保持不动) int + * 限定条件:至少注册一个段 + */ +int cfg_file_save(void) { + int i; + int nsecs = 0; + cfg_file_stat_t st; + + for (i = 0; i < (int)CFG_FILE_MAX_SECTIONS; i++) { + if (s_sections[i].used != 0) { + nsecs++; + } + } + if (nsecs == 0) { + return -1; + } + + if (cfg_store_stat(cfg_store_config_path(), &st) != 0 || st.size == 0u || + st.size > CFG_FILE_MAX_LEN) { + /* 文件缺失/空/超长 = 损坏:按当前 RAM 副本重建完整文档 */ + if (s_write_template() != 0) { + DBG_ERROR("save: template write FAIL"); + return -1; + } + DBG_INFO("save: OK (document regenerated)"); + return 0; + } + + /* 外科手术式保存:保留注释/未知段/未知键 */ + { + uint8_t *p_in = NULL; + char *p_out = (char *)malloc(CFG_OUT_MAX); + size_t in_len = 0u; + size_t out_len = 0u; + int r; + + if (p_out == NULL) { + DBG_ERROR("save: malloc %uB FAIL", (unsigned)CFG_OUT_MAX); + return -1; + } + if (s_load_file(&p_in, &in_len) != 0) { + free(p_out); + DBG_ERROR("save: file read FAIL"); + return -1; + } + + s_surgical_pass1((char *)p_in); + r = s_surgical((char *)p_in, in_len, p_out, CFG_OUT_MAX, &out_len); + + free(p_in); + if (r != 0) { + free(p_out); + DBG_ERROR("save: output overflow, aborted (file untouched)"); + return -1; + } + r = s_write_doc((const uint8_t *)p_out, out_len); + free(p_out); + if (r != 0) { + DBG_ERROR("save: file write FAIL"); + return -1; + } + DBG_INFO("save: OK (surgical, %uB -> %uB)", (unsigned)in_len, (unsigned)out_len); + return 0; + } +} + +/* + * 函数功能:配置文件变更轮询(沉降去抖 + 重载 + 分段变更回调) + * 入口参数:无 + * 返回值:无 + * 限定条件:单一任务周期调用(2s) + */ +void cfg_file_poll(void) { + cfg_file_stat_t st; + + if (cfg_store_stat(cfg_store_config_path(), &st) != 0) { + return; /* 文件缺失/读失败:保留当前 RAM 配置 */ + } + + if (s_base_valid == 0u) { + s_base = st; + s_last = st; + s_stable = 0u; + s_base_valid = 1u; + return; + } + + if (st.size == s_last.size && st.mtime == s_last.mtime) { + if (s_stable < CFG_FILE_SETTLE_CNT) { + s_stable++; + } + } + else { + s_last = st; + s_stable = 1u; + } + + if (s_stable < CFG_FILE_SETTLE_CNT || + (st.size == s_base.size && st.mtime == s_base.mtime)) { + return; /* 未沉降或与基线一致 */ + } + + /* 已沉降:重载配置文件 */ + { + uint8_t *p_buf = NULL; + size_t len = 0u; + int i; + + if (s_load_file(&p_buf, &len) != 0) { + return; /* 读失败:保留,下次 poll 重试 */ + } + + if (len > CFG_FILE_MAX_LEN) { + free(p_buf); + for (i = 0; i < (int)CFG_FILE_MAX_SECTIONS; i++) { + if (s_sections[i].used != 0) { + (void)memcpy(s_sections[i].p_data, s_sections[i].p_defaults, + s_sections[i].data_size); + } + } + (void)s_write_template(); + DBG_ERROR("poll: file corrupt (> %uB), factory template regenerated", + (unsigned)CFG_FILE_MAX_LEN); + } + else { + for (i = 0; i < (int)CFG_FILE_MAX_SECTIONS; i++) { + const cfg_sec_t *p_sec = &s_sections[i]; + + if (p_sec->used == 0) { + continue; + } + if (p_sec->p_cb != NULL) { + (void)memcpy(s_snap, p_sec->p_data, p_sec->data_size); + (void)memcpy(p_sec->p_data, p_sec->p_defaults, p_sec->data_size); + s_parse((char *)p_buf, i); + if (memcmp(s_snap, p_sec->p_data, p_sec->data_size) != 0) { + DBG_INFO("poll: section '%s' changed, reload", p_sec->name); + p_sec->p_cb(); + } + } + else { + (void)memcpy(p_sec->p_data, p_sec->p_defaults, p_sec->data_size); + s_parse((char *)p_buf, i); + } + } + free(p_buf); + DBG_INFO("poll: config file reloaded"); + } + + /* 重载后刷新基线(回调触发的写盘不引发二次重载) */ + { + cfg_file_stat_t st2; + if (cfg_store_stat(cfg_store_config_path(), &st2) == 0) { + s_base = st2; + } + else { + s_base = st; + } + } + } +} + +/* + * 函数功能:取段 RAM 副本指针 + * 入口参数:p_name - 段名 const char * + * 返回值:段 RAM 副本指针;未注册返回 NULL void * + * 限定条件:p_name 非空 + */ +void *cfg_file_section_data(const char *p_name) { + int i; + + if (p_name == NULL) { + return NULL; + } + for (i = 0; i < (int)CFG_FILE_MAX_SECTIONS; i++) { + if (s_sections[i].used != 0 && s_str_ci_eq(s_sections[i].name, p_name) != 0) { + return s_sections[i].p_data; + } + } + return NULL; +} diff --git a/App/cfg_file.h b/App/cfg_file.h new file mode 100644 index 00000000..ae32fde9 --- /dev/null +++ b/App/cfg_file.h @@ -0,0 +1,126 @@ +/* + * 模块名称:cfg_file + * 模块功能:通用配置文件引擎(双端共享核心)公共接口——INI 段/键表解析校验、 + * 外科手术式保存(保留注释/未知内容,缺失段/键写回)、出厂模板生成、 + * 沉降去抖轮询与分段变更回调;平台文件操作经 cfg_file_store 后端 + * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用) + * 作者:王建锋 + * 创建日期:2026-09-14 + * 修改记录: + * v1.0 2026-09-14 王建锋 创建初始版本(P3:段键表引擎 + net_cfg 集成) + */ + +#ifndef CFG_FILE_H +#define CFG_FILE_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +#include + +/* 配置文件长度上限(字节);超过 = 文件损坏,按出厂模板重建 */ +#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)= 损坏 → 工厂默认 + 重建模板 + */ +int cfg_file_load(void); + +/* + * 函数功能:保存全部段到配置文件(外科手术式) + * 入口参数:无 + * 返回值:0 - 成功;-1 - 写失败(原文件保持不动) int + * 限定条件:至少注册一个段 + * 函数说明:文件存在 → 逐行保留注释/未知段/未知键,受管段/键按 RAM 副本 + * 值原位改写,缺失段/键补写;文件缺失/损坏 → 按 RAM 副本重建 + * 完整文档(含文件头注释);写盘 = 临时文件 + 原子改名 + */ +int cfg_file_save(void); + +/* + * 函数功能:配置文件变更轮询(沉降去抖 + 重载 + 分段变更回调) + * 入口参数:无 + * 返回值:无 + * 限定条件:由单一任务周期调用(建议 2s,收敛于 netTask) + * 函数说明:(size, mtime 变更令牌) 相对基线变化且连续 CFG_FILE_SETTLE_CNT + * 次轮询稳定 → 重载全部段(各段复位工厂默认后解析);挂回调段 + * 有差异 → 调用其回调(如触发网络重建);重载后刷新基线 + * (回调触发的写盘不引发二次重载) + */ +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 */ diff --git a/App/cfg_file_store.h b/App/cfg_file_store.h new file mode 100644 index 00000000..381a8e24 --- /dev/null +++ b/App/cfg_file_store.h @@ -0,0 +1,86 @@ +/* + * 模块名称: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 + +#include + +/* + * 文件变更基线:size + mtime 变更令牌 + * mtime 仅作等值比较(不等 = 可能变更): + * MCU 端 = FAT 打包时间(fdate<<16 | fmtime,分钟级粒度) + * 模拟器 = 宿主文件 mtime(秒级) + */ +typedef struct { + uint32_t size; + uint32_t mtime; +} cfg_file_stat_t; + +/* + * 函数功能:读文件(最多 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); + +/* 函数功能:取旧版配置遗留文件路径(net_cfg_legacy 迁移用) */ +const char *cfg_store_legacy_path(void); + +#ifdef __cplusplus +} +#endif + +#endif /* CFG_FILE_STORE_H */ diff --git a/App/cfg_file_store_mcu.c b/App/cfg_file_store_mcu.c new file mode 100644 index 00000000..afd04f99 --- /dev/null +++ b/App/cfg_file_store_mcu.c @@ -0,0 +1,158 @@ +/* + * 模块名称:cfg_file_store (MCU 后端) + * 模块功能:cfg_file 存储后端——NAND 上 FatFS 文件 /config.ini(卷根,8.3 合规); + * 原子写 = 临时文件 /config.TMP + f_rename;mtime 变更令牌 = + * (fdate << 16) | fmtime;旧版遗留文件 /cfg/netcfg.bin 供迁移读取 + * 适用平台:STM32F407ZGTx + * 作者:王建锋 + * 创建日期:2026-09-14 + * 修改记录: + * v1.0 2026-09-14 王建锋 创建初始版本 + */ + +/* 头文件包含区 */ +#include + +#include "cfg_file_store.h" + +#include "ff.h" + +/* 静态变量定义区 */ + +static const char s_path[] = "/config.ini"; +static const char s_path_tmp[] = "/config.TMP"; +static const char s_path_legacy[] = "/cfg/netcfg.bin"; + +/* 函数定义区 */ + +/* + * 函数功能:读文件(最多 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) + */ +int cfg_store_read(const char *p_path, uint8_t *p_buf, size_t cap, size_t *p_nread) { + FIL f; + FRESULT fr; + UINT br = 0u; + + if (p_buf == NULL || p_nread == NULL) { + return -1; + } + fr = f_open(&f, p_path, FA_READ); + if (fr != FR_OK) { + return -1; + } + fr = f_read(&f, p_buf, (UINT)cap, &br); + (void)f_close(&f); + if (fr != FR_OK) { + return -1; + } + *p_nread = (size_t)br; + return 0; +} + +/* + * 函数功能:原子写文件(先写 /config.TMP,成功后 f_rename 覆盖目标) + * 入口参数: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) { + FIL f; + FRESULT fr; + UINT bw = 0u; + + if (p_buf == NULL) { + return -1; + } + fr = f_open(&f, s_path_tmp, FA_WRITE | FA_CREATE_ALWAYS); + if (fr != FR_OK) { + 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(s_path_tmp); + return -1; + } + fr = f_rename(s_path_tmp, p_path); + if (fr != FR_OK) { + (void)f_unlink(s_path_tmp); + return -1; + } + 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; + } + fr = f_stat(p_path, &fno); + if (fr != FR_OK) { + return -1; + } + p_st->size = (uint32_t)fno.fsize; + p_st->mtime = (uint32_t)(((uint32_t)fno.fdate << 16) | (uint32_t)fno.ftime); + return 0; +} + +/* + * 函数功能:删除文件 + * 入口参数:p_path - 文件路径 const char * + * 返回值:0 - 成功;-1 - 文件缺失/删除失败 int + * 限定条件:无 + */ +int cfg_store_remove(const char *p_path) { + if (f_unlink(p_path) != FR_OK) { + return -1; + } + 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"(旧 NCFG 二进制格式) const char * + * 限定条件:无 + */ +const char *cfg_store_legacy_path(void) { + return s_path_legacy; +} diff --git a/App/sys_clock.c b/App/util/sys_clock.c similarity index 93% rename from App/sys_clock.c rename to App/util/sys_clock.c index 9be10d49..e9cdcc36 100644 --- a/App/sys_clock.c +++ b/App/util/sys_clock.c @@ -6,6 +6,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/(改归 ④中间件层:通用平台时间服务,多服务共用) */ #include "sys_clock.h" @@ -323,6 +325,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 diff --git a/App/sys_clock.h b/App/util/sys_clock.h similarity index 76% rename from App/sys_clock.h rename to App/util/sys_clock.h index 24fb4ec5..5874b0dd 100644 --- a/App/sys_clock.h +++ b/App/util/sys_clock.h @@ -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 (秒) diff --git a/Drivers/BSP/NETCFG/net_cfg.c b/Drivers/BSP/NETCFG/net_cfg.c index f9c81d08..6b20b90f 100644 --- a/Drivers/BSP/NETCFG/net_cfg.c +++ b/Drivers/BSP/NETCFG/net_cfg.c @@ -1,13 +1,15 @@ /* * 模块名称:Network Configuration Core (配置核心) * 模块功能:RAM 配置副本 + 工厂默认 + 加载/保存编排 + 实际 MAC 影子 + - * IPv4 字符串转换;磁盘格式 magic 'NCFG' + ver + CRC-16(MODBUS) - * + 18B 字段(两端小端一致,sizeof 含尾部对齐填充) + * IPv4 字符串转换;存储经 cfg_file 通用配置文件引擎(INI [network] + * 段:ip/mask/gw/mac);旧版 NCFG 二进制格式归 net_cfg_legacy + * (首启一次性迁移) * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用) * 作者:王建锋 * 创建日期:2026-09-10 * 修改记录: * v1.0 2026-09-10 王建锋 创建初始版本 + * v1.1 2026-09-14 王建锋 存储迁移至 cfg_file 引擎(INI);旧 NCFG 代码移至 net_cfg_legacy */ /* 头文件包含区 */ @@ -19,33 +21,21 @@ #include "net_cfg.h" -#include "net_cfg_store.h" +#include "net_cfg_legacy.h" + +#include "cfg_file.h" #define DBG_TAG "[NET_CFG]" #include "dbg_log.h" /* 私有宏定义区 */ -#define NET_CFG_MAGIC 0x4647434Eu /* 'NCFG'(文件字节序 N,C,G,F;小端装载) */ -#define NET_CFG_VER 1u /* 磁盘格式版本 */ - -/* 工厂默认值(单一来源:s_cfg 初值与 s_cfg_defaults 共用,避免两处漂移) */ +/* 工厂默认值(单一来源:s_cfg 初值与 s_defaults 共用,避免两处漂移) */ #define NET_CFG_DEF_IP { 192u, 168u, 1u, 100u } #define NET_CFG_DEF_MASK { 255u, 255u, 255u, 0u } #define NET_CFG_DEF_GW { 192u, 168u, 1u, 1u } #define NET_CFG_DEF_MAC { 0u, 0u, 0u, 0u, 0u, 0u } -/* 磁盘格式(两端布局一致:u32 4 对齐;字段区 18B 连续,CRC 覆盖之) */ -typedef struct { - uint32_t magic; /* NET_CFG_MAGIC */ - uint16_t ver; /* 格式版本 */ - uint16_t crc; /* CRC-16/MODBUS(覆盖 ip..mac 共 18B) */ - uint8_t ip[4]; - uint8_t mask[4]; - uint8_t gw[4]; - uint8_t mac[6]; -} net_cfg_blob_t; - /* 静态变量定义区 */ /* RAM 配置副本:以工厂默认值静态初始化(落 .data),自启动即有效—— @@ -60,51 +50,62 @@ static uint8_t s_active_mac[6]; /* 芯片实际 MAC 影子(netTask 回写 static uint8_t s_active_valid; /* 影子有效标志(0 = 尚未回写) */ static net_cfg_apply_cb_t s_apply_cb; /* "立即应用"平台回调(未注册 = 空操作) */ +/* 工厂默认(引擎注册 p_defaults 指向;与 s_cfg 初值共用宏单一来源) */ +static const net_cfg_t s_defaults = { + .ip = NET_CFG_DEF_IP, + .mask = NET_CFG_DEF_MASK, + .gw = NET_CFG_DEF_GW, + .mac = NET_CFG_DEF_MAC, +}; + +/* [network] 段键表(net_cfg_t 布局内偏移) */ +static const cfg_key_def_t s_net_keys[] = { + { "ip", CFG_VAL_IP4, offsetof(net_cfg_t, ip), 4u }, + { "mask", CFG_VAL_IP4, offsetof(net_cfg_t, mask), 4u }, + { "gw", CFG_VAL_IP4, offsetof(net_cfg_t, gw), 4u }, + { "mac", CFG_VAL_MAC, offsetof(net_cfg_t, mac), 6u }, +}; + /* 函数定义区 */ /* - * 函数功能:CRC-16/MODBUS(无查表逐位算法) - * 入口参数:p_data - 数据指针 const uint8_t * - * len - 数据长度 size_t - * 返回值:CRC-16 值 uint16_t - * 限定条件:p_data 非空(len 可为 0,返回初始值 0xFFFF) - * 函数说明:文件私有(双端共享、避免跨模块依赖:模拟器不编译 App/util; - * 数据量 ≤32B,性能无关) + * 函数功能:[network] 段变更回调(poll 重载检出差异时由引擎调用) + * 入口参数:无 + * 返回值:无 + * 限定条件:无(未注册"立即应用"回调时空操作) + * 函数说明:转平台回调(MCU:netTask 置 SYS_EVT_NET_RECONFIG 重建网络) */ -static uint16_t s_crc16_modbus(const uint8_t *p_data, size_t len) { - uint16_t crc = 0xFFFFu; - - for (size_t i = 0u; i < len; i++) { - crc = (uint16_t)(crc ^ (uint16_t)p_data[i]); - for (int b = 0; b < 8; b++) { - if (crc & 0x0001u) { - crc = (uint16_t)((crc >> 1) ^ 0xA001u); - } - else { - crc = (uint16_t)(crc >> 1); - } - } +static void s_net_changed_cb(void) { + if (s_apply_cb != NULL) { + s_apply_cb(); } - return crc; } /* - * 函数功能:填充工厂默认配置 - * 入口参数:p_cfg - 输出结构指针(不可为 NULL) net_cfg_t * + * 函数功能:向 cfg_file 引擎注册 [network] 段(幂等) + * 入口参数:无 * 返回值:无 - * 限定条件:p_cfg 非空 - * 函数说明:与既有硬编码组网值一致(192.168.1.100/24,gw .1,MAC 全零 - * = 芯片内置),保证首启/存储异常时行为与旧版本相同 + * 限定条件:load/save 前调用 + * 函数说明:段数据 = s_cfg RAM 副本;工厂默认 = s_defaults; + * 变更回调触发"立即应用" */ -static void s_cfg_defaults(net_cfg_t *p_cfg) { - const net_cfg_t def = { - .ip = NET_CFG_DEF_IP, - .mask = NET_CFG_DEF_MASK, - .gw = NET_CFG_DEF_GW, - .mac = NET_CFG_DEF_MAC, - }; +static void s_net_register(void) { + static uint8_t s_done; + cfg_section_reg_t reg; - *p_cfg = def; + if (s_done != 0u) { + return; + } + (void)memset(®, 0, sizeof(reg)); + reg.name = "network"; + reg.p_data = &s_cfg; + reg.p_defaults = &s_defaults; + reg.data_size = sizeof(net_cfg_t); + reg.p_keys = s_net_keys; + reg.key_count = 4u; + reg.p_cb = s_net_changed_cb; + (void)cfg_file_register(®); + s_done = 1u; } /* @@ -130,27 +131,16 @@ static bool s_mac_is_zero(const uint8_t *p_mac) { * 函数说明:见 net_cfg.h 同名函数说明 */ int net_cfg_load(void) { - net_cfg_blob_t blob; + s_net_register(); - if (net_cfg_store_read((uint8_t *)&blob, sizeof(blob)) != 0) { - /* 文件缺失/不可读(首启、存储未就绪):工厂默认 */ - s_cfg_defaults(&s_cfg); - DBG_INFO("load: no valid file, factory defaults"); + /* 首启先迁旧档 NCFG(有效 → 旧值物化进新文件);新文件已存在则跳过 */ + (void)net_cfg_legacy_migrate(); + + if (cfg_file_load() != 0) { + /* 严重错误:引擎已将 RAM 副本复位工厂默认,行为仍安全 */ + DBG_ERROR("load: cfg_file_load FAIL"); return 0; } - - if (blob.magic != NET_CFG_MAGIC || blob.ver != NET_CFG_VER - || s_crc16_modbus(blob.ip, 18u) != blob.crc) { - /* 格式损坏:工厂默认并留痕(不让坏配置带崩网络) */ - s_cfg_defaults(&s_cfg); - DBG_ERROR("load: blob invalid (magic/ver/crc), factory defaults"); - return 0; - } - - memcpy(s_cfg.ip, blob.ip, 4u); - memcpy(s_cfg.mask, blob.mask, 4u); - memcpy(s_cfg.gw, blob.gw, 4u); - memcpy(s_cfg.mac, blob.mac, 6u); DBG_INFO("load: OK ip=%u.%u.%u.%u", (unsigned)s_cfg.ip[0], (unsigned)s_cfg.ip[1], (unsigned)s_cfg.ip[2], (unsigned)s_cfg.ip[3]); @@ -162,24 +152,20 @@ int net_cfg_load(void) { * 入口参数:无 * 返回值:0 - 成功;-1 - 写失败 int * 限定条件:MCU 端应在存储就绪后调用 - * 函数说明:组装磁盘格式经后端写盘;下次上电生效 + * 函数说明:经 cfg_file 引擎写盘(外科手术式保存:保留注释/未知内容, + * 临时文件 + 原子改名);下次上电(netTask 在 net_init 前加载) + * 或 poll 4s 沉降后生效 */ int net_cfg_save(void) { - net_cfg_blob_t blob; + int r; - memcpy(blob.ip, s_cfg.ip, 4u); - memcpy(blob.mask, s_cfg.mask, 4u); - memcpy(blob.gw, s_cfg.gw, 4u); - memcpy(blob.mac, s_cfg.mac, 6u); - blob.magic = NET_CFG_MAGIC; - blob.ver = NET_CFG_VER; - blob.crc = s_crc16_modbus(blob.ip, 18u); - - if (net_cfg_store_write((const uint8_t *)&blob, sizeof(blob)) != 0) { - DBG_ERROR("save: store write FAILED"); + s_net_register(); + r = cfg_file_save(); + if (r != 0) { + DBG_ERROR("save: cfg_file_save FAIL"); return -1; } - DBG_INFO("save: OK (file written)"); + DBG_INFO("save: OK (config file written)"); return 0; } diff --git a/Drivers/BSP/NETCFG/net_cfg.h b/Drivers/BSP/NETCFG/net_cfg.h index 6eaadbeb..227cc84d 100644 --- a/Drivers/BSP/NETCFG/net_cfg.h +++ b/Drivers/BSP/NETCFG/net_cfg.h @@ -1,16 +1,19 @@ /* * 模块名称:Network Configuration (NET CFG) * 模块功能:网络参数配置(IP/掩码/网关/MAC)——双端共享核心:RAM 配置副本 + - * 工厂默认 + 加载/保存编排 + 实际 MAC 影子;存储后端经 net_cfg_store.h - * 抽象(MCU = NAND 上 FatFS 文件 /cfg/netcfg.bin,模拟器 = 本地文件 - * sim_netcfg.bin);磁盘格式 = magic + ver + CRC-16 + 18B 字段; - * MAC 全零哨兵 = 不覆盖芯片内置 MAC;保存后下次上电生效 - * (每次上电 netTask 在 net_init 前加载) + * 工厂默认 + 加载/保存编排 + 实际 MAC 影子;存储经 cfg_file 通用 + * 配置文件引擎(INI [network] 段:ip/mask/gw/mac;MCU = NAND 上 + * /config.ini,模拟器 = sim_config.ini);MAC 全零哨兵 = 不覆盖芯片 + * 内置 MAC;保存后下次上电生效(每次上电 netTask 在 net_init 前 + * 加载)或 poll 4s 沉降后生效 + * 函数说明:旧版 NCFG 二进制格式(magic+ver+CRC-16+18B 字段)归 net_cfg_legacy, + * 首启一次性迁移进通用配置文件 * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用) * 作者:王建锋 * 创建日期:2026-09-10 * 修改记录: * v1.0 2026-09-10 王建锋 创建初始版本 + * v1.1 2026-09-14 王建锋 存储迁移至 cfg_file 引擎(INI [network] 段) */ #ifndef NET_CFG_H @@ -41,9 +44,11 @@ typedef struct { * 返回值:恒 0(RAM 恒有效:文件缺失/损坏回退工厂默认) int * 限定条件:MCU 端应在存储就绪(SYS_EVT_STORAGE_READY)后调用; * 未就绪时文件读失败,自动回退工厂默认(不阻塞网络启动) - * 函数说明: 1. 文件缺失(首启/未保存过)→ 工厂默认 - * 2. magic/ver/CRC 错误 → 工厂默认并留痕(不让坏配置带崩网络) - * 3. 校验通过 → 装入 RAM 副本 + * 函数说明: 1. 注册 [network] 段(幂等) + * 2. 文件缺失 → 先迁旧档 NCFG(有效 → 旧值物化进新文件),否则 + * 生成出厂模板 + * 3. 解析文件:有效键 → RAM 副本;无效/缺失键 → 工厂默认并留痕 + * 4. 严重错误 → RAM 副本 = 工厂默认(不让坏配置带崩网络) */ int net_cfg_load(void); @@ -52,8 +57,9 @@ int net_cfg_load(void); * 入口参数:无 * 返回值:0 - 成功;-1 - 写失败 int * 限定条件:MCU 端应在存储就绪后调用 - * 函数说明:组装磁盘格式(magic+ver+CRC-16+18B 字段)经后端写盘; - * 下次上电生效(netTask 在 net_init 前加载) + * 函数说明:经 cfg_file 引擎写盘(外科手术式保存:保留注释/未知内容, + * 临时文件 + 原子改名);下次上电(netTask 在 net_init 前加载) + * 或 poll 4s 沉降后生效 */ int net_cfg_save(void); diff --git a/Drivers/BSP/NETCFG/net_cfg_legacy.c b/Drivers/BSP/NETCFG/net_cfg_legacy.c new file mode 100644 index 00000000..fb195d3a --- /dev/null +++ b/Drivers/BSP/NETCFG/net_cfg_legacy.c @@ -0,0 +1,125 @@ +/* + * 模块名称:net_cfg_legacy + * 模块功能:旧版 NCFG 二进制配置一次性迁移——读遗留文件 → 校验 magic/ver/CRC → + * 填网络配置 RAM 副本 → 经 cfg_file 引擎生成新文件(带旧值)→ 删旧文件 + * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用;平台路径经 cfg_file_store 后端) + * 作者:王建锋 + * 创建日期:2026-09-14 + * 修改记录: + * v1.0 2026-09-14 王建锋 创建初始版本(P3:net_cfg 引擎化,旧格式代码迁入) + */ + +/* 头文件包含区 */ +#include + +#include "net_cfg_legacy.h" + +#include "net_cfg.h" + +#include "cfg_file.h" + +#include "cfg_file_store.h" + +#define DBG_TAG "[NET_CFG]" +#include "dbg_log.h" + +/* 常量定义区 */ + +#define NET_CFG_MAGIC 0x4647434Eu /* 'NCFG'(文件字节序 N,C,G,F;小端装载) */ +#define NET_CFG_VER 1u /* 磁盘格式版本 */ + +/* 旧磁盘格式(两端布局一致:u32 4 对齐;字段区 18B 连续,CRC 覆盖之) */ +typedef struct { + uint32_t magic; /* NET_CFG_MAGIC */ + uint16_t ver; /* 格式版本 */ + uint16_t crc; /* CRC-16/MODBUS(覆盖 ip..mac 共 18B) */ + uint8_t ip[4]; + uint8_t mask[4]; + uint8_t gw[4]; + uint8_t mac[6]; +} net_cfg_blob_t; + +/* 静态函数声明区 */ +static uint16_t s_crc16_modbus(const uint8_t *p_data, size_t len); + +/* 函数定义区 */ + +/* + * 函数功能:CRC-16/MODBUS(无查表逐位算法) + * 入口参数:p_data - 数据指针 const uint8_t * + * len - 数据长度 size_t + * 返回值:CRC-16 值 uint16_t + * 限定条件:p_data 非空(len 可为 0,返回初始值 0xFFFF) + * 函数说明:文件私有(数据量 ≤32B,性能无关) + */ +static uint16_t s_crc16_modbus(const uint8_t *p_data, size_t len) { + uint16_t crc = 0xFFFFu; + + for (size_t i = 0u; i < len; i++) { + crc = (uint16_t)(crc ^ (uint16_t)p_data[i]); + for (int b = 0; b < 8; b++) { + if (crc & 0x0001u) { + crc = (uint16_t)((crc >> 1) ^ 0xA001u); + } + else { + crc = (uint16_t)(crc >> 1); + } + } + } + return crc; +} + +/* + * 函数功能:旧 NCFG 二进制配置一次性迁移到通用配置文件 + * 入口参数:无 + * 返回值:0 - 迁移成功,或无旧文件/新文件已存在(正常);-1 - 迁移失败 int + * 限定条件:调用方须已注册 [network] 段(net_cfg_load 内部完成) + * 函数说明:见 net_cfg_legacy.h 同名函数说明 + */ +int net_cfg_legacy_migrate(void) { + net_cfg_blob_t blob; + uint8_t buf[32]; + size_t nread = 0u; + cfg_file_stat_t st; + net_cfg_t tmp; + const char *p_legacy; + + /* 新文件已存在 = 已迁移过(或已有配置):跳过 */ + if (cfg_store_stat(cfg_store_config_path(), &st) == 0) { + return 0; + } + + p_legacy = cfg_store_legacy_path(); + if (cfg_store_read(p_legacy, buf, sizeof(buf), &nread) != 0 + || nread != sizeof(net_cfg_blob_t)) { + DBG_INFO("legacy: no legacy file, skip"); + return 0; + } + + (void)memcpy(&blob, buf, sizeof(blob)); + if (blob.magic != NET_CFG_MAGIC || blob.ver != NET_CFG_VER + || s_crc16_modbus(blob.ip, 18u) != blob.crc) { + DBG_INFO("legacy: blob invalid (magic/ver/crc), file kept for inspection"); + return 0; + } + + /* 填 RAM 副本(net_cfg_get 只读,经 net_cfg_set 可写接口整体回灌) + * → 经引擎物化旧值到新文件(此时新文件必缺失 → 生成模板带旧值) */ + (void)memcpy(&tmp, net_cfg_get(), sizeof(tmp)); + (void)memcpy(tmp.ip, blob.ip, 4u); + (void)memcpy(tmp.mask, blob.mask, 4u); + (void)memcpy(tmp.gw, blob.gw, 4u); + (void)memcpy(tmp.mac, blob.mac, 6u); + net_cfg_set(&tmp); + + if (net_cfg_save() != 0) { + DBG_ERROR("legacy: migrate save FAIL"); + return -1; + } + (void)cfg_store_remove(p_legacy); + + DBG_INFO("legacy: migrated to config file (ip=%u.%u.%u.%u)", + (unsigned)blob.ip[0], (unsigned)blob.ip[1], + (unsigned)blob.ip[2], (unsigned)blob.ip[3]); + return 0; +} diff --git a/Drivers/BSP/NETCFG/net_cfg_legacy.h b/Drivers/BSP/NETCFG/net_cfg_legacy.h new file mode 100644 index 00000000..076c4fcb --- /dev/null +++ b/Drivers/BSP/NETCFG/net_cfg_legacy.h @@ -0,0 +1,35 @@ +/* + * 模块名称:net_cfg_legacy + * 模块功能:旧版 NCFG 二进制配置一次性迁移到通用配置文件(net_cfg 旧磁盘格式 + * 的拥有者:magic 'NCFG' + ver + CRC-16/MODBUS + 18B 字段) + * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用;平台路径经 cfg_file_store 后端) + * 作者:王建锋 + * 创建日期:2026-09-14 + * 修改记录: + * v1.0 2026-09-14 王建锋 创建初始版本(P3:net_cfg 引擎化,旧格式代码迁入) + */ + +#ifndef NET_CFG_LEGACY_H +#define NET_CFG_LEGACY_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * 函数功能:旧 NCFG 二进制配置(28B)一次性迁移到通用配置文件 + * 入口参数:无 + * 返回值:0 - 迁移成功,或无旧文件/新文件已存在(正常); + * -1 - 迁移失败(新文件未生成) int + * 限定条件:调用方须已注册 [network] 段(net_cfg_load 内部完成) + * 函数说明:新文件已存在 → 跳过(已迁移过);旧文件有效 → 填网络配置 RAM + * 副本 → 经引擎生成新文件(带旧值)→ 删旧文件;旧文件缺失/无效 → + * 不动旧文件(留人工检查),新文件按工厂默认生成 + */ +int net_cfg_legacy_migrate(void); + +#ifdef __cplusplus +} +#endif + +#endif /* NET_CFG_LEGACY_H */ diff --git a/Drivers/BSP/NETCFG/net_cfg_store.h b/Drivers/BSP/NETCFG/net_cfg_store.h deleted file mode 100644 index 77331a4a..00000000 --- a/Drivers/BSP/NETCFG/net_cfg_store.h +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 模块名称:Network Configuration Store (存储后端接口) - * 模块功能:net_cfg 加载/保存的磁盘读写抽象——每平台一份实现(互斥编译): - * MCU = NAND 上 FatFS 文件 /cfg/netcfg.bin(net_cfg_store_mcu.c); - * 模拟器 = 工作目录本地文件 sim_netcfg.bin(net_cfg_store_sim.c) - * 适用平台:STM32F407ZGTx / PC 模拟器(接口共用;两端各编一份实现) - * 作者:王建锋 - * 创建日期:2026-09-10 - * 修改记录: - * v1.0 2026-09-10 王建锋 创建初始版本 - */ - -#ifndef NET_CFG_STORE_H -#define NET_CFG_STORE_H - -#include - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/* - * 函数功能:读取配置文件 - * 入口参数:p_buf - 接收缓冲(不可为 NULL) uint8_t * - * len - 文件长度(须与磁盘格式一致,net_cfg.c 的 net_cfg_blob_t) size_t - * 返回值:0 - 成功;-1 - 文件缺失/读失败 int - * 限定条件:后端存储可用(MCU:FatFS 已挂载;未挂载时实现直接返回 -1 即可) - */ -int net_cfg_store_read(uint8_t *p_buf, size_t len); - -/* - * 函数功能:写配置文件(不存在则创建,存在则覆盖) - * 入口参数:p_buf - 待写数据(不可为 NULL) const uint8_t * - * len - 数据长度(须与磁盘格式一致) size_t - * 返回值:0 - 成功;-1 - 写失败 int - * 限定条件:后端存储可用 - */ -int net_cfg_store_write(const uint8_t *p_buf, size_t len); - -#ifdef __cplusplus -} -#endif - -#endif /* NET_CFG_STORE_H */ diff --git a/Drivers/BSP/NETCFG/net_cfg_store_mcu.c b/Drivers/BSP/NETCFG/net_cfg_store_mcu.c deleted file mode 100644 index f1883bf1..00000000 --- a/Drivers/BSP/NETCFG/net_cfg_store_mcu.c +++ /dev/null @@ -1,71 +0,0 @@ -/* - * 模块名称:Network Configuration Store (MCU 后端) - * 模块功能:net_cfg 存储后端——NAND 上 FatFS 文件 /cfg/netcfg.bin - * (目录 /cfg 首次写入时自动创建;卷由 defaultTask 挂载并广播 - * SYS_EVT_STORAGE_READY 后方可读写) - * 适用平台:STM32F407ZGTx - * 作者:王建锋 - * 创建日期:2026-09-10 - * 修改记录: - * v1.0 2026-09-10 王建锋 创建初始版本 - */ - -/* 头文件包含区 */ -#include "net_cfg_store.h" - -#include "ff.h" - -/* 静态变量定义区 */ - -static const char s_path[] = "/cfg/netcfg.bin"; - -/* 函数定义区 */ - -/* - * 函数功能:读取配置文件 - * 入口参数:p_buf - 接收缓冲(不可为 NULL) uint8_t * - * len - 文件长度 size_t - * 返回值:0 - 成功;-1 - 文件缺失/读失败 int - * 限定条件:FatFS 已挂载(未挂载时 f_open 返回 FR_NO_SYSTEM,转 -1) - */ -int net_cfg_store_read(uint8_t *p_buf, size_t len) { - FIL f; - FRESULT fr; - UINT br = 0u; - - if (p_buf == NULL) { - return -1; - } - fr = f_open(&f, s_path, FA_READ); - if (fr != FR_OK) { - return -1; - } - fr = f_read(&f, p_buf, (UINT)len, &br); - (void)f_close(&f); - return (fr == FR_OK && br == (UINT)len) ? 0 : -1; -} - -/* - * 函数功能:写配置文件(目录不存在则创建;文件覆盖写) - * 入口参数:p_buf - 待写数据(不可为 NULL) const uint8_t * - * len - 数据长度 size_t - * 返回值:0 - 成功;-1 - 写失败 int - * 限定条件:FatFS 已挂载 - */ -int net_cfg_store_write(const uint8_t *p_buf, size_t len) { - FIL f; - FRESULT fr; - UINT bw = 0u; - - if (p_buf == NULL) { - return -1; - } - (void)f_mkdir("/cfg"); /* 目录已存在(FR_EXIST)不影响后续建文件 */ - fr = f_open(&f, s_path, FA_WRITE | FA_CREATE_ALWAYS); - if (fr != FR_OK) { - return -1; - } - fr = f_write(&f, (const void *)p_buf, (UINT)len, &bw); - (void)f_close(&f); - return (fr == FR_OK && bw == (UINT)len) ? 0 : -1; -} diff --git a/Lib/FatFs/ffconf.h b/Lib/FatFs/ffconf.h index 1f61d51c..6466b40e 100644 --- a/Lib/FatFs/ffconf.h +++ b/Lib/FatFs/ffconf.h @@ -31,11 +31,11 @@ #define FF_USE_TRIM 0 #define FF_FS_TINY 0 #define FF_FS_EXFAT 0 -#define FF_FS_NORTC 1 +#define FF_FS_NORTC 0 #define FF_NORTC_MON 1 #define FF_NORTC_MDAY 1 #define FF_NORTC_YEAR 2026 #define FF_FS_NOFSINFO 0 #define FF_FS_LOCK 0 -#define FF_FS_REENTRANT 0 +#define FF_FS_REENTRANT 1 #define FF_FS_TIMEOUT 1000 diff --git a/Lib/FatFs/ffsystem.c b/Lib/FatFs/ffsystem.c new file mode 100644 index 00000000..6dd2b2e0 --- /dev/null +++ b/Lib/FatFs/ffsystem.c @@ -0,0 +1,98 @@ +/* + * 模块名称:FatFs OS 依赖层 (ffsystem) + * 模块功能:FF_FS_REENTRANT 卷互斥钩子 + get_fattime 时间钩子(直引用系统墙钟) + * 适用平台:STM32F407ZGTx(MCU 端;模拟器端不编译 FatFS) + * 作者:王建锋 + * 创建日期:2026-09-13 + * 修改记录: + * v1.0 2026-09-13 王建锋 初始版本:osMutex 卷锁(1s 超时) + * v1.1 2026-09-13 王建锋 get_fattime 采用运行时间伪时(零 I2C 流量、无 RTC 依赖) + * v1.2 2026-09-13 王建锋 get_fattime 移入独立适配文件 App/ff_time.c(与调试日志墙钟同源);本文件仅留互斥钩子 + * v1.3 2026-09-13 王建锋 get_fattime 回归本文件并改为时间提供回调(App 启动时注册墙钟, + * 本文件不引用 App 头文件,保持分层单向) + * v1.4 2026-09-13 王建锋 get_fattime 改为直引用 sys_clock 墙钟(去掉回调注册机制; + * sys_clock 归 ④中间件层(App/util/),层内引用,无反向依赖) + */ + +#include "ff.h" +#include "cmsis_os2.h" +#include "sys_clock.h" /* 墙钟:层内中间件引用(sys_clock 归 ④中间件层,App/util/) */ + +#if (!FF_FS_NORTC) + +/* + * 函数名称:FatFs 时间钩子 (get_fattime) —— 提供当前时间的 FAT 时间格式 + * 入口参数:无 + * 返 回 值:FAT 时间 ((year-1980)<<25 | month<<21 | day<<16 | hour<<11 | minute<<5 | second/2); + * 墙钟无效时返回固定值(FF_NORTC 约定:2026-01-01 00:00:00) + * 限定条件:sys_clock_init() 已调用(App 启动时,早于 osKernelStart) + * 函数说明:1. 时间源 = 系统墙钟(与调试日志时间戳同源:开机 RTC 基准 + tick 推进,运行期零 I2C) + * 2. 文件 mtime 与日志时间戳一致;ff.h 声明,ff.c 在目录项创建 / + * 写后关文件 / f_mkfs 时调用 + * 3. 本文件为 FatFs 指定的 OS 胶水文件——FatFs 与平台间的 + * 唯一耦合点(互斥 + 时间) + */ +DWORD get_fattime(void) +{ + sd2506_time_t t; + + if (sys_clock_get_time(&t) != 0 || t.year < 1980u) { + return (((DWORD)(FF_NORTC_YEAR - 1980u) << 25) | + ((DWORD)FF_NORTC_MON << 21) | + ((DWORD)FF_NORTC_MDAY << 16)); + } + + return ((DWORD)(t.year - 1980u) << 25) + | ((DWORD)t.month << 21) + | ((DWORD)t.day << 16) + | ((DWORD)t.hour << 11) + | ((DWORD)t.minute << 5) + | (DWORD)(t.second / 2u); +} + +#endif /* !FF_FS_NORTC */ + +#if FF_FS_REENTRANT + +/* + * FatFs 可重入同步对象:[0] = 系统锁,[1..FF_VOLUMES] = 卷锁 + * 首次 f_mount 时创建;获取超时(FF_FS_TIMEOUT)时对应 f_* 返回 FR_TIMEOUT + * (优雅失败,由调用方下节拍重试,无死锁) + */ +static osMutexId_t s_fs_mutex[FF_VOLUMES + 1u]; + +int ff_mutex_create(int vol) +{ + static const osMutexAttr_t s_attr = { "ffs", 0u, NULL, 0u }; + + if (s_fs_mutex[vol] != NULL) { + return 1; + } + s_fs_mutex[vol] = osMutexNew(&s_attr); + return (s_fs_mutex[vol] != NULL) ? 1 : 0; +} + +void ff_mutex_delete(int vol) +{ + if (s_fs_mutex[vol] != NULL) { + (void)osMutexDelete(s_fs_mutex[vol]); + s_fs_mutex[vol] = NULL; + } +} + +int ff_mutex_take(int vol) +{ + if (s_fs_mutex[vol] == NULL) { + return 1; /* 锁对象未创建 = 无并发,视为已取得 */ + } + return (osMutexAcquire(s_fs_mutex[vol], FF_FS_TIMEOUT) == osOK) ? 1 : 0; +} + +void ff_mutex_give(int vol) +{ + if (s_fs_mutex[vol] != NULL) { + (void)osMutexRelease(s_fs_mutex[vol]); + } +} + +#endif /* FF_FS_REENTRANT */ diff --git a/MDK-ARM/STM32F407-Demo.uvprojx b/MDK-ARM/STM32F407-Demo.uvprojx index ce3db698..e46fced0 100644 --- a/MDK-ARM/STM32F407-Demo.uvprojx +++ b/MDK-ARM/STM32F407-Demo.uvprojx @@ -760,10 +760,20 @@ 1 ..\App\app_main.c + + cfg_file.c + 1 + ..\App\cfg_file.c + + + cfg_file_store_mcu.c + 1 + ..\App\cfg_file_store_mcu.c + sys_clock.c 1 - ..\App\sys_clock.c + ..\App\util\sys_clock.c time_sync.c @@ -1242,9 +1252,9 @@ ..\Drivers\BSP\NETCFG\net_cfg.c - net_cfg_store_mcu.c + net_cfg_legacy.c 1 - ..\Drivers\BSP\NETCFG\net_cfg_store_mcu.c + ..\Drivers\BSP\NETCFG\net_cfg_legacy.c nand_ftl.c @@ -1999,6 +2009,11 @@ 1 ..\Lib\FatFs\ff.c + + ffsystem.c + 1 + ..\Lib\FatFs\ffsystem.c + diff --git a/docs/images/配置文件分层架构.svg b/docs/images/配置文件分层架构.svg new file mode 100644 index 00000000..bd33a993 --- /dev/null +++ b/docs/images/配置文件分层架构.svg @@ -0,0 +1,68 @@ + + +通用配置文件分层架构(/config.ini) +INI 文本文件 · FTP 上传/下载 · 段键表引擎 · MCU/模拟器双端共享 + +PC 端(外部) +文本编辑器 + FTP 客户端 + +文本编辑器:改 /config.ini(注释 / 加段 / 改值) + +FTP 客户端:RETR 下载 / STOR 上传(端口 21) + +lftpd FTP 服务(端口 21) +ftpTask · 单连接 · root = /(= 卷根) + +RETR / STOR / LIST / DELE / CWD(lftpd 现状能力) + +STOR = 原地覆盖(无 tmp+rename) + +路径规范化封死 .. 穿越 + +/config.ini(通用配置文件) +FatFS 卷根(NAND GD5F2GQ5UE · dhara FTL) + +INI 文本:[段] / key=value / ; 注释 + +CRLF · UTF-8(容忍 BOM)· ≤ 4KB + +设备写:tmp + rename(原子);变更识别:size + mtime + +cfg_file(配置文件引擎) +App/cfg_file.c · 双端共享 + +INI 解析 / 序列化(逐键校验 · 容错 · 自愈) + +外科手术式保存(保留注释 / 未知段 → 降级安全) + +2s 变更轮询(f_stat)+ 段变更回调 + +出厂模板生成 · 旧 NCFG blob 迁移 + +段适配(键表) +段 RAM 副本 = 模块静态结构体(稳态 0 增量) + +net_cfg [network]:ip / mask / gateway / mac + +未来:[adc] · [rs485] · [ftp] ...(三步接入) + +消费者 +读 / 写段 RAM 副本 + 应用 + +netTask:组网 / 重建(代次号自愈) + +UI 网口设置页:显示 / 编辑 / 保存(零改动) + +未来模块:cfg_file_section_data() 取段数据 + + + + + + + + + + +稳态 RAM 零增量(段数据 = 模块既有结构体);暂态堆缓冲 ≤9KB(仅 load/save 时);FF_FS_REENTRANT=1 卷锁串行化多任务 FS 访问;模拟器端 = 宿主文件 sim_config.ini(同核心) + diff --git a/docs/images/项目架构总览.svg b/docs/images/项目架构总览.svg index 9fa61714..752cf89c 100644 --- a/docs/images/项目架构总览.svg +++ b/docs/images/项目架构总览.svg @@ -31,18 +31,16 @@ 应用侧服务 / 编排 app_main(启动编排) - -sys_clock(墙钟基准) - -sys_events(就绪屏障) - -app_cfg(编译开关) - -task/(net · ui · adc* · rs485*) - -lftpd(FTP :21) - -time_sync(TCP 对时 :8888) + +sys_events(就绪屏障) + +app_cfg(编译开关) + +task/(net · ui · adc* · rs485*) + +lftpd(FTP :21) + +time_sync(TCP 对时 :8888) ④ 中间件层 通用库 / 协议栈 @@ -54,8 +52,10 @@ nand_ftl(dhara FTL) LVGL v9.2(图形库) - -util(crc · ringbuf) + +sys_clock(墙钟基准) + +util(crc · ringbuf) ⑤ 驱动层 Drivers/BSP · 芯片时序/中断 diff --git a/docs/系统墙钟时间维护说明.md b/docs/系统墙钟时间维护说明.md index daae1be4..aba6dfe6 100644 --- a/docs/系统墙钟时间维护说明.md +++ b/docs/系统墙钟时间维护说明.md @@ -1,6 +1,6 @@ # 系统墙钟时间维护 设计说明 -> 模块:`App/sys_clock.c` / `App/sys_clock.h` +> 模块:`App/util/sys_clock.c` / `App/util/sys_clock.h` > 依赖:SD2506API-G RTC 驱动(`Drivers/BSP/SD2506/`)、HAL(`HAL_GetTick()`) > 适用平台:STM32F407ZGTx(Cortex-M4,168MHz),I2C1 接 SD2506 > 作者:王建锋 创建日期:2026-07-19 @@ -65,7 +65,7 @@ sys_clock_init(); /* 读取 RTC → 建立墙钟基准 */ ## 4. 内部数据结构 -`App/sys_clock.c` 中定义两个静态变量作为墙钟基准: +`App/util/sys_clock.c` 中定义两个静态变量作为墙钟基准: ```c static volatile uint32_t s_base_tick = 0; /* HAL_GetTick() 基准(建立基准时的 Tick 值) */ @@ -76,7 +76,7 @@ static volatile uint32_t s_base_unix_secs = UINT32_MAX; /* 基准对应的 Unix - `s_base_unix_secs`:与 `s_base_tick` 同一时刻的 Unix 秒数;`sys_clock_get()` 以它为锚推算当前值。 - 二者均带 `volatile`,因为时间是跨任务共享的状态(虽更新在 nets/默认任务中,但读取可能发生在任意任务上下文)。 -> 注:当前工程仅 `main.c` 调用了 `sys_clock_init()`;`sys_clock_get()` / `sys_clock_get_str()` / `sys_clock_set()` 是供应用层(日志、文件时间、校时协议等)调用的 API,尚未被其他模块引用。 +> 注:`app_main.c`(app_main_init)调用 `sys_clock_init()`;使用方含 `dbg_log`(日志时间戳)、`ffsystem`(文件时间戳)、`time_sync`(对时)等。 --- @@ -266,10 +266,10 @@ uint32_t now = sys_clock_get(); /* Unix 秒,可用于 FatFS 文件时间、 | 路径 | 说明 | |------|------| -| `App/sys_clock.c` | 墙钟维护实现(基准法、换算、校时宏) | -| `App/sys_clock.h` | 对外 API 声明 | +| `App/util/sys_clock.c` | 墙钟维护实现(基准法、换算、校时宏) | +| `App/util/sys_clock.h` | 对外 API 声明 | | `Drivers/BSP/SD2506/sd2506.c` / `.h` | SD2506 RTC I2C 驱动(`sd2506_get_time` / `sd2506_set_time`) | -| `Src/main.c` | 初始化调用顺序:`sd2506_init()` → `sys_clock_init()` | +| `App/app_main.c` | 初始化调用顺序:`sd2506_init()` → `sys_clock_init()` | | `App/time_sync.c` / `time_sync.h` | 时间同步实现(TCP Server,`timeSyncTask`) | | `test/time_sync_server.py` | PC 端时间推送客户端(TCP) | @@ -353,4 +353,4 @@ PC 端 `python test/time_sync_server.py` 每 `interval` 秒推送一次,epoch --- -*文档依据 `App/sys_clock.c` / `App/time_sync.c` 实现与《嵌入式C语言代码规范(V1.0)》整理。* +*文档依据 `App/util/sys_clock.c` / `App/time_sync.c` 实现与《嵌入式C语言代码规范(V1.0)》整理。* diff --git a/docs/网络配置说明.md b/docs/网络配置说明.md index 771108b6..e1c2e2ff 100644 --- a/docs/网络配置说明.md +++ b/docs/网络配置说明.md @@ -1,6 +1,6 @@ # 网口设置页配置说明 -> 版本:v2.0 日期:2026-09-10 +> 版本:v2.1 日期:2026-09-14 > 适用工程:STM32F4-Base(STM32F407ZGTx + CH395F 以太网控制器) > 范围:UI「网口设置」页可配置的网口参数(IP 地址 / 子网掩码 / 默认网关 / MAC 地址)—— > 参数与默认值、持久化、生效链路、页面操作与权限。 @@ -13,7 +13,7 @@ |---|---| | **可配置** | IP 地址、子网掩码、默认网关、MAC 地址 | | **入口** | 屏幕:`通讯参数 → 网口设置`(修改密码 `123` 可编辑;查看密码 `000` 只读) | -| **存储** | NAND Flash 文件 `/cfg/netcfg.bin`(掉电不丢) | +| **存储** | NAND Flash 文件 `/config.ini`(INI `[network]` 段,通用配置文件,掉电不丢);旧版 `/cfg/netcfg.bin` 首启自动迁移 | | **生效** | 按「保存」后**立即生效**(网络重建约 1~3s,既有 TCP 连接会断开,无需重启) | | **工厂默认** | `192.168.1.100` / `255.255.255.0` / `192.168.1.1` / 芯片内置 MAC | @@ -37,7 +37,7 @@ | 默认网关 | `gw[4]` | **192.168.1.1** | | | MAC 地址 | `mac[6]` | **全零** | 全零 = 不覆盖芯片内置 MAC(见 §2) | -工厂默认值是**单一来源**:`net_cfg.c` 顶部 `NET_CFG_DEF_*` 宏,同时用于 `s_cfg` 静态初始化与 `s_cfg_defaults()`,避免两处漂移。 +工厂默认值是**单一来源**:`net_cfg.c` 顶部 `NET_CFG_DEF_*` 宏,同时用于 `s_cfg` 静态初始化与 `s_defaults`(引擎注册的 `p_defaults` 目标),避免两处漂移。 > **RAM 副本自启动即有效**:`s_cfg` 以工厂默认值**静态初始化**(落 `.data`),不依赖 `net_cfg_load()` 是否被调用——即便存储异常,页面与网络拿到的也是有效配置而非全零。 @@ -57,34 +57,54 @@ ## 3. 持久化 -### 3.1 磁盘格式(`/cfg/netcfg.bin`) +### 3.1 配置文件(INI `/config.ini`) -`net_cfg.c` 的 `net_cfg_blob_t`(两端布局一致,小端): +网口参数是**通用配置文件** `/config.ini` 的一个段(NAND 卷根,UTF-8/CRLF,文本编辑器可直接改): -| 偏移 | 字段 | 大小 | 说明 | -|---|---|---|---| -| 0 | `magic` | 4 | `0x4647434E`(ASCII `'NCFG'`) | -| 4 | `ver` | 2 | 格式版本 = 1 | -| 6 | `crc` | 2 | **CRC-16/MODBUS**,覆盖 ip..mac 共 18B | -| 8 | `ip[4]` | 4 | | -| 12 | `mask[4]` | 4 | | -| 16 | `gw[4]` | 4 | | -| 20 | `mac[6]` | 6 | | -| — | 对齐填充 | 2 | 结构体 `sizeof = 28B` | +```ini +; ============================================================ +; STM32F4-Base 通用配置文件 +; 格式: INI (UTF-8 / CRLF),本文件为配置唯一来源 +; - 以 ';' 或 '#' 开头的行为注释,保存时保留 +; - 未知段/键保存时保留;缺失段/键保存时写回 +; - 编辑后经 FTP 上传或设备端保存,设备 4s 沉降后自动重载 +; ============================================================ -- **加载校验**:magic / ver / CRC 任一不符 → 回退工厂默认并打印 `DBG_ERROR`(坏配置不带崩网络)。 -- **保存**:先建 `/cfg` 目录(`f_mkdir`,已存在不影响),再 `FA_CREATE_ALWAYS` 覆盖写。 +[network] +ip=192.168.1.100 +mask=255.255.255.0 +gw=192.168.1.1 +mac=00:00:00:00:00:00 +``` -### 3.2 存储后端(双端) +- **加载校验(逐键容错)**:单个键值非法(越界/格式错)不影响其他键——非法键回工厂默认并留痕(`parse: [network] <键> value invalid`);文件缺失 → 自动生成本模板;文件 > 4KB = 损坏 → 工厂默认 + 重建模板(坏配置不带崩网络)。 +- **保存(外科手术式)**:注释 / 未知段 / 未知键逐行保留,受管键按 RAM 副本原位改写、缺失键补写;写盘 = 临时文件 `/config.TMP` + `f_rename` 原子替换(掉电不损坏目标文件)。 +- **外部变更自动重载**(P4 阶段接入):2s 轮询检测 (size, mtime) 变化、4s 沉降(连续 2 次轮询稳定)后重载 `[network]` 段并重建网络——FTP 上传编辑后的文件无需重启即生效。 -`net_cfg_store.h` 定义接口,两平台各一份实现(互斥编译): +完整设计(引擎 / 双端 / 并发模型 D15)见 `docs/配置文件设计说明.md`。 + +### 3.2 旧版档迁移(首启) + +旧版格式 `/cfg/netcfg.bin`(28B:magic `'NCFG'` + ver + CRC-16/MODBUS + 18B 字段,格式拥有者 `net_cfg_legacy.c`): + +| `/config.ini` 缺失 | `/cfg/netcfg.bin` | 行为 | +|---|---|---| +| 是 | 有效(magic/ver/CRC 通过) | 4 字段转 INI 行 → 写 `/config.ini`(带旧值)→ **删旧档** | +| 是 | 缺失 / 无效 | 生成出厂模板;**保留旧档**(留人工检查) | +| 否 | — | 正常加载现有文件(不迁移) | + +迁移单向:旧档转换后删除(避免双数据源);新文件存在时恒跳过。 + +### 3.3 存储后端(双端) + +`App/cfg_file_store.h` 定义接口(带路径的文件操作),两平台各一份实现(互斥编译): | 平台 | 文件 | 位置 | |---|---|---| -| MCU | `net_cfg_store_mcu.c` | NAND 上 FatFS 文件 `/cfg/netcfg.bin` | -| 模拟器 | `net_cfg_store_sim.c` | 工作目录本地文件 `sim_netcfg.bin`(`fopen`) | +| MCU | `App/cfg_file_store_mcu.c` | NAND 上 FatFS `/config.ini`(`/config.TMP` + `f_rename` 原子写) | +| 模拟器 | `simulator/sim/cfg_file_store_sim.c` | 工作目录本地文件 `sim_config.ini`(`fopen`/`rename`) | -磁盘格式两端一致,文件可互读。 +INI 格式两端一致,文件可互读;mtime 变更令牌:MCU = FAT 打包时间(`fdate<<16\|ftime`,分钟级粒度),模拟器 = 宿主文件 mtime(秒级)。 --- @@ -105,7 +125,7 @@ sequenceDiagram DT-->>NT: 置 SYS_EVT_STORAGE_READY(就绪屏障) NT->>NT: 等 SYS_EVT_STORAGE_READY(≤10s;超时用工厂默认继续) NT->>CFG: net_cfg_load() - Note over CFG: 读 /cfg/netcfg.bin;缺失/损坏 → 工厂默认 + Note over CFG: 注册 [network] 段 → 首启自动迁旧档 → 读 /config.ini;缺失/非法键 → 工厂默认 CFG-->>NT: RAM 副本(有效) NT->>CFG: net_cfg_get() → net_cfg_fmt_ipv4() CFG-->>NT: "192.168.001.100" @@ -191,10 +211,12 @@ ENTER 进入选中地址行 → `↑/↓` 调活动字节(0..255 **环绕**) | 文件 | 职责 | |---|---| -| `Drivers/BSP/NETCFG/net_cfg.h/.c` | 配置核心:RAM 副本 + 工厂默认 + 加载/保存编排 + MAC 影子 + IPv4 格式化 | -| `Drivers/BSP/NETCFG/net_cfg_store.h` | 存储后端接口(读/写) | -| `Drivers/BSP/NETCFG/net_cfg_store_mcu.c` | MCU 后端:FatFS `/cfg/netcfg.bin` | -| `simulator/sim/net_cfg_store_sim.c` | 模拟器后端:本地 `sim_netcfg.bin` | +| `Drivers/BSP/NETCFG/net_cfg.h/.c` | 配置核心:RAM 副本 + 工厂默认 + 加载/保存编排 + MAC 影子 + IPv4 格式化([network] 段注册) | +| `Drivers/BSP/NETCFG/net_cfg_legacy.c` | 旧版 NCFG 二进制格式拥有者:首启一次性迁移(校验 → 物化 → 删旧档) | +| `App/cfg_file.h/.c` | 通用配置文件引擎(双端共享核心:解析/校验/外科保存/模板/沉降轮询) | +| `App/cfg_file_store.h` | 存储后端接口(带路径的文件操作) | +| `App/cfg_file_store_mcu.c` | MCU 后端:FatFS `/config.ini`(`/config.TMP` + `f_rename` 原子写) | +| `simulator/sim/cfg_file_store_sim.c` | 模拟器后端:本地 `sim_config.ini` | | `Drivers/BSP/UI/ui_page_net_cfg.c` | 网口设置内容页(显示 / 编辑 / 保存) | | `Drivers/BSP/UI/ui_pwd_def.h` | 编译期密码(查看 000 / 修改 123) | | `App/task/net_task.c` | 上电加载配置 + 消费"立即应用"请求重建网络 | @@ -204,8 +226,8 @@ ENTER 进入选中地址行 → `↑/↓` 调活动字节(0..255 **环绕**) | API | 说明 | |---|---| -| `net_cfg_load()` | 从存储加载到 RAM 副本;文件缺失/损坏回退工厂默认(恒返回 0) | -| `net_cfg_save()` | 组装磁盘格式写盘;0 成功 / -1 失败 | +| `net_cfg_load()` | 注册 `[network]` 段 + 旧档迁移 + 从 `/config.ini` 加载到 RAM 副本;缺失/非法键回退工厂默认(恒返回 0) | +| `net_cfg_save()` | 经 cfg_file 引擎写 `/config.ini`(外科手术式保存);0 成功 / -1 失败 | | `net_cfg_request_apply()` | 请求立即应用(页面保存成功后调用) | | `net_cfg_set_apply_cb()` | 注册"立即应用"平台回调 | | `net_cfg_get()` | 取 RAM 副本只读指针(恒非空) | @@ -221,8 +243,9 @@ ENTER 进入选中地址行 → `↑/↓` 调活动字节(0..255 **环绕**) 1. **保存即生效,但连接会断**:保存后重建网络(约 1~3s),既有 TCP 连接(FTP / 对时 / 远程显示)会断开并自动重建监听——IP 变更的必然结果,非缺陷。 2. **MAC 全零是哨兵**,不是"MAC 为 0":要恢复芯片内置 MAC,把 MAC 字段改回全零并保存。 3. **配置存在 NAND 上**:存储挂载失败时 `net_cfg_load` 回退工厂默认,网络仍能起,但保存会失败(页面显示 `ERR`)。 -4. **改配置需"修改密码"**:查看密码 `000` 进页面是只读,ENTER 无效(防误改)。 -5. **新增 TCP 服务需处理重建**:须在其监听循环中检测 `net_get_generation()` 变化并重建监听,否则网络重建后不再可达(见 §4.2)。 +4. **变更令牌是分钟级**:FAT 时间为分钟粒度——同分钟内重写同长度文件,轮询检测不到变化(罕见;下次保存/改长度即恢复检测)。 +5. **改配置需"修改密码"**:查看密码 `000` 进页面是只读,ENTER 无效(防误改)。 +6. **新增 TCP 服务需处理重建**:须在其监听循环中检测 `net_get_generation()` 变化并重建监听,否则网络重建后不再可达(见 §4.2)。 --- @@ -242,4 +265,5 @@ ENTER 进入选中地址行 → `↑/↓` 调活动字节(0..255 **环绕**) | 日期 | 变更 | |---|---| +| 2026-09-14 | v2.1:存储迁移至**通用配置文件** `/config.ini`(INI `[network]` 段,cfg_file 引擎:逐键校验 / 外科手术式保存 / 原子写 / 旧档首启迁移 / 沉降轮询);`net_cfg_store.*` 弃用(由 `cfg_file_store.*` 取代);对外 API 不变 | | 2026-09-10 | MAC 显示改为常识格式 **`A4:94:B6:12:34:56`**(冒号分隔,17 字符);页面布局左移(名 x=4 / 值 x=54)以容纳 102px 宽度,编辑光标段偏移随之为 3i | diff --git a/docs/配置文件设计说明.md b/docs/配置文件设计说明.md new file mode 100644 index 00000000..6e1fdd9c --- /dev/null +++ b/docs/配置文件设计说明.md @@ -0,0 +1,571 @@ +# 配置文件设计说明 + +> **版本**:v1.0(设计与实施规划) +> **日期**:2026-09-13 +> **状态**:设计完成,按 §10 分阶段实施 +> **关联文档**:`网络配置说明.md`(使用手册)· `项目架构总览.md` · `BSD_Socket_API_使用指南.md` · `docs/内存管理/`(RAM 依据) + +## 1. 背景与目标 + +### 1.1 需求 + +| # | 需求 | 说明 | +|---|---|---| +| 1 | 网络参数配置写入一个文件 | IP / 掩码 / 网关 / MAC 持久化到文件 | +| 2 | 文件可经 FTP 下载 / 上传 | PC 端编辑后上传,设备自动检测并生效 | +| 3 | 通用配置文件 | 以后所有模块配置(ADC / RS-485 / 端口等)都进这个文件,按"段"组织 | +| 4 | 易于文本编辑器编辑 | 格式为纯文本、带注释、自解释 | + +### 1.2 现状与差距分析(2026-09-13 调研) + +**已有资产**(直接复用): + +- 网络配置已是 FatFS 上的文件:`/cfg/netcfg.bin`(28 字节二进制 blob:magic 'NCFG' + ver + CRC-16/MODBUS + 18B 字段)——**但为二进制,不可编辑** +- FTP 通道现成:lftpd root = `/`(= FatFS 卷根),端口 21,支持 RETR / STOR / LIST / DELE +- 双端后端抽象模式现成:`net_cfg_store.h`(MCU = FatFS / 模拟器 = 宿主文件,互斥编译,格式两端一致) +- "立即应用"骨架现成:`net_cfg_set_apply_cb` / `net_cfg_request_apply` → `SYS_EVT_NET_RECONFIG` → netTask 重建网络 → 代次号 → TCP 服务自愈 + +**差距**(本设计的解决项): + +| # | 差距 | 后果 | 设计解法 | +|---|---|---|---| +| ① | 二进制格式 | 编辑器无法编辑 | INI 文本格式(§3) | +| ② | 无通用机制 | 仅网络模块有配置 | 段键表引擎(§4.3) | +| ③ | FatFS 零并发保护(`FF_FS_REENTRANT=0`,应用层无锁) | ftpTask(FTP 传输)/ netTask(配置读+轮询)/ uiTask(保存)并发触卷 → 卷损坏 | `FF_FS_REENTRANT=1` + ffsystem.c 互斥钩子(§5.4)+ 三条一致性协议(D15:tmp+rename / 沉降去抖 / 单写者) | +| ④ | 无变更检测 | FTP 上传后不生效(需重启) | netTask 每 2s `f_stat` 轮询(size + mtime)(§5.3) | +| ⑤ | `FF_FS_NORTC=1` → 时间戳恒为固定值 | mtime 不变 → 无法检测"同尺寸编辑"(如 IP 尾段 100→200) | `NORTC=0` + `get_fattime`(复用调试日志墙钟源,运行期零 I2C)(§5.5) | +| ⑥ | 固件升级无迁移 | 现场设备升级后用户配置丢失(回工厂默认) | NCFG blob → INI 自动迁移(§9) | +| ⑦ | RAM 静态区仅余 5.8 KB(占用 95.4%) | 不能新增大静态缓冲 | 稳态零增量 + 暂态堆缓冲(§8) | + +### 1.3 非目标(本期不做) + +- 其他模块(ADC / RS-485 / 密码 / 端口等)的配置页与配置项——机制预留,逐项后续接入 +- FTP 鉴权 / 权限(维持现状:无鉴权) +- DHCP 等网络模式扩展(仅 4 参数) +- 内存优化专项(LVGL 池迁 CCM 等,独立规划;本设计不依赖它) + +## 2. 设计决策摘要 + +| # | 决策点 | 选择 | 理由(含否决项) | +|---|---|---|---| +| D1 | 文件格式 | **INI 文本**(`[section]` / `key=value` / 注释行) | 否决 JSON:无现成解析器(需引入,RAM 开销大)、编辑器不友好(无注释、尾逗号等);INI 零依赖、原生可编辑 | +| D2 | 文件位置 | **`/config.ini`(卷根)** | FTP 顶层直接可见;8.3 文件名兼容(`FF_USE_LFN=0` 约束);否决 `/cfg/config.ini`(藏子目录,多一级) | +| D3 | 编码 / 行尾 | UTF-8(内容实际 ASCII);设备写 **CRLF**;解析容忍 LF / BOM | 编辑器友好;解析为字节定向,不依赖代码页 | +| D4 | 保存策略 | **外科手术式行替换**(受管键替换,其余原样保留) | 否决整文件重生成:用户注释 / 未知段会被抹掉;**降级场景**(新文件被旧固件读)会抹掉未来版本段落;外科保留 = 降级安全 | +| D5 | RAM 模型 | **段键表引擎**:段 RAM 副本 = 各模块既有静态结构体;键表(const)映射 键 → 结构体偏移 | 稳态 RAM 零增量;未来模块"注册一个段"即接入 | +| D6 | 文件变更检测 | netTask 每 2s `f_stat`,比对 (size, mtime) | 否决内容哈希轮询(每 2s 全文件读,NAND 读流量浪费);mtime 检测依赖真实时间戳(见 D8) | +| D7 | 线程安全 | **`FF_FS_REENTRANT=1` + `Lib/FatFs/ffsystem.c`**(`ff_mutex_*` → osMutex,超时 1s) | FatFs 官方可重入方案;每次 f_* 调用加锁/放锁(逐次粒度,不长期阻塞 FTP 传输);`FR_TIMEOUT` 防死锁 | +| D8 | 时间戳 | **`FF_FS_NORTC=0` + `get_fattime`(复用 `sys_clock` 墙钟,与调试日志时间戳同源)** | mtime 为真实时间、与日志时间戳一致;墙钟 = 开机 RTC 基准 + tick 推进,运行期零 I2C;墙钟无效时回退固定值(有开机告警) | +| D9 | 写原子性 | **一切写入 = 临时文件 + `f_rename`**(设备保存 / FTP STOR / 出厂模板统一:先写 `<名>.TMP`(8.3 兼容),关文件成功后 rename 目标) | 同目录 rename = 单目录项写入,卷锁下原子 → 并发读者只见"旧完整文件或新完整文件",半截窗口结构消除;STOR 中途掉电仅残留 TMP(无害);逐键容错 + 下次保存自愈为次级兜底 | +| D10 | 旧档迁移 | 新固件首启:NCFG blob → INI,**迁移后删除旧文件** | 防现场升级丢配置;否决"保留旧文件"(双数据源、陈旧值混乱) | +| D11 | 出厂默认 | 文件缺失时**自动生成带注释模板**并落盘 | 首启 / 恢复出厂场景;文件自身即"格式手册" | +| D12 | 恢复出厂 | **FTP `DELE /config.ini`**(下次上电重新生成) | 本期不做 UI 入口(开放问题 O1) | +| D13 | 双端 | 共享 `cfg_file` 核心 + 平台 store 后端(沿用 net_cfg_store 模式) | MCU = FatFS `/config.ini`;模拟器 = 宿主文件 `sim_config.ini` | +| D14 | 网络配置 API 兼容 | **`net_cfg_*` 签名与语义不变** | UI 页零改动;load/save 内部改走 cfg_file | +| D15 | FS 并发模型 | **A+ = REENTRANT 互斥(D7)+ 三条一致性协议**:① 写 tmp+rename(D9 扩至 FTP STOR)② 轮询沉降去抖((size,mtime) 连续 2 次稳定才重载)③ 配置文件单写者收敛 netTask(UI 保存 = 消息至 netTask,落盘 + 重建由其执行) | 互斥保证单次调用原子(G1–G4:卷锁全程持有 / 1s 有界等待 / 优先级继承;锁固定 2 把,不随任务/文件数增长);三协议结构性消除"半截可见 / 重载抖动 / 多写者"缺口;**否决 actor 方案(单所有者队列 + 全代理层)**:经完整评估——代理 ~400–600 行、FILE 句柄表、符号改名/影子头、RAM +4KB、所有者自死锁纪律、双端适配,其两项结构优势(整操作原子 / 单所有者)可由上述三协议以 ~40 行等价中性化;实际并发仅 3 消费任务、写偶发 → 机制成本与收益不匹配;D13 store 后端抽象预留将来换入 actor 的空间(MCU 端后端实现) | + +## 3. 文件格式规范 + +### 3.1 文件标识 + +| 端 | 路径 | 说明 | +|---|---|---| +| MCU | `/config.ini` | FatFS 卷根(NAND GD5F2GQ5UE,经 dhara FTL) | +| FTP | `/config.ini` | lftpd root = `/`,即卷根 | +| 模拟器 | `sim_config.ini` | 进程工作目录(宿主文件) | + +### 3.2 语法规则 + +| 元素 | 规则 | +|---|---| +| 参数行 | `key=value`;key 为 `[a-z0-9_]+`(大小写敏感);值 = 首个 `=` 后至行尾,首尾空白去除 | +| 段头行 | `[section]`;段名 `[a-z0-9_]+`(大小写敏感) | +| 注释行 | `;` 或 `#` 开头(允许行首空白) | +| 空行 | 允许,保存时原样保留 | +| 段外键 | 加载时忽略(不设默认段),保存时原样保留 | +| 重复键 | 后者生效 | +| 行长 | > 127 字符 → 该行视为无效(键忽略,原文保留) | +| 文件大小 | > 4096 B → 整文件视为损坏 → 工厂默认 + 下次保存重生成 | +| 编码 | UTF-8;容忍文件头 BOM | +| 行尾 | CRLF(设备写出)/ LF 均接受 | +| 行内注释 | v1 不支持(防止值中 `;` 被误解析);注释仅占整行 | + +### 3.3 出厂模板(首启自动生成) + +```ini +; ========================================================== +; DTU 通用配置文件 +; 语法: [段名] 段头 / key=value 参数 +; 以 ';' 或 '#' 开头的行为注释 +; 编辑后经 FTP 上传 (端口 21, 路径 /config.ini) +; 编辑后数秒内自动检测并生效 (2~6s, 含沉降去抖) +; (网络段变化将重建网络, 既有连接会断开) +; ========================================================== + +[network] +; IP 地址 +ip=192.168.1.100 +; 子网掩码 +mask=255.255.255.0 +; 默认网关 +gateway=192.168.1.1 +; MAC 地址 (全零 = 使用芯片内置 MAC) +mac=00:00:00:00:00:00 +``` + +### 3.4 值类型 + +| 类型 | 语法 | 示例 | 解析失败处理 | +|---|---|---|---| +| `CFG_VAL_IP4` | `a.b.c.d`(兼容前导零) | `192.168.1.100` | 该键取工厂默认 + `DBG_ERROR` | +| `CFG_VAL_MAC` | `aa:bb:cc:dd:ee:ff`(十六进制) | `a4:94:b6:12:34:56` | 同上 | +| `CFG_VAL_UINT` | 十进制(兼容 `0x` 前缀) | `7003` | 同上 | +| `CFG_VAL_STR` | 原始字符串(≤31 字符) | `hello` | 同上 | + +### 3.5 校验与容错(逐键粒度) + +- **逐键解析**:单个键值非法不影响其他键;非法键取工厂默认值(RAM 结构体以工厂默认静态初始化) +- **自愈**:下次保存时该行被写回合法当前值 +- **未知内容保留**:未知段 / 键 / 注释 / 空行在保存时原样保留(外科保留) + +| 场景 | 加载行为 | 保存行为 | +|---|---|---| +| 文件不存在 | 工厂默认;自动生成模板(有旧 blob 时优先迁移,见 §9) | 写当前值 | +| 段缺失 / 键缺失 | 该键取工厂默认 | 补写缺失键(段缺失则补整段) | +| 键值非法 | 该键取工厂默认 + 日志 | 重写为合法值(自愈) | +| 文件 > 4KB / 严重损坏 | 全部工厂默认 + `DBG_ERROR` | 整文件重生成(已知数据保留,未知内容丢失) | +| 未来版本多出的段(降级) | 忽略 | **原样保留**(降级安全的核心) | +| FTP 上传半截(掉电) | 逐行解析:完整行生效,尾部残行忽略 | 下次保存自愈 | + +## 4. 总体架构 + +### 4.1 分层 + +![通用配置文件分层架构](images/配置文件分层架构.svg) + +### 4.2 模块清单 + +| 文件 | 动作 | 端 | 职责 | +|---|---|---|---| +| `App/cfg_file.h` / `cfg_file.c` | **新增** | 双端 | 配置文件引擎:段注册 / 解析 / 外科保存 / 变更轮询 | +| `App/cfg_file_store.h` | **新增** | 双端 | 平台文件后端接口 | +| `App/cfg_file_store_mcu.c` | **新增** | MCU | FatFS 后端(`/config.ini`:读 / stat / 原子写 / 删) | +| `simulator/sim/cfg_file_store_sim.c` | **新增** | 模拟器 | 宿主文件后端(`sim_config.ini`) | +| `Lib/FatFs/ffsystem.c` | **新增** | MCU | REENTRANT 卷互斥钩子(osMutex)+ `get_fattime`(直引用墙钟,与日志时间戳同源) | +| `Lib/FatFs/ffconf.h` | 修改 | MCU | `FF_FS_REENTRANT` 0→1;`FF_FS_NORTC` 1→0 | +| `App/util/sys_clock.c/.h` | 修改 | MCU | 新增 `sys_clock_get_time()`(日历时间获取器);改归 ④中间件层,自 `App/` 移入 `App/util/` | +| `Drivers/BSP/NETCFG/net_cfg.h/.c` | 重构 | 双端 | `net_cfg_*` API 不变;load/save 走 cfg_file;注册 `[network]` 段 | +| `Drivers/BSP/NETCFG/net_cfg_legacy.c` | **新增** | 双端 | 旧 NCFG blob 解析 + 迁移 | +| `Drivers/BSP/NETCFG/net_cfg_store.h` / `net_cfg_store_mcu.c` | **删除** | — | 由 `cfg_file_store.*` 取代 | +| `simulator/sim/net_cfg_store_sim.c` | **删除** | 模拟器 | 由 `cfg_file_store_sim.c` 取代 | +| `App/task/net_task.c` | 修改 | MCU | 主循环每 2s `cfg_file_poll()` | +| `simulator/main.c` | 修改 | 模拟器 | 主循环每 2s 轮询 | +| `simulator/CMakeLists.txt` | 修改 | 模拟器 | 源文件列表 | +| `MDK-ARM/STM32F407-Demo.uvprojx` | 修改 | MCU | 新文件注册 | + +### 4.3 关键机制 + +**(1) 段键表引擎** —— "以后所有配置进这个文件"的落地形式: + +```c +/* 键定义:键名 + 类型 + 段结构体内的偏移/长度 */ +typedef struct { + const char *name; /* 键名 */ + cfg_val_type_t type; /* 值类型(UINT / IP4 / MAC / STR) */ + uint16_t offset; /* 值在段 RAM 结构体内的偏移 */ + uint16_t len; /* 值在结构体内的字节数 */ +} cfg_key_def_t; + +/* 段注册:段名 + 模块自有 RAM 副本 + 键表 */ +typedef struct { + const char *name; /* 段名,如 "network" */ + void *data; /* 段 RAM 副本(模块自有静态变量) */ + const cfg_key_def_t *keys; /* 键表(const,Flash) */ + uint16_t key_count; +} cfg_section_reg_t; +``` + +- 加载:逐行解析文件 → 受管键(键表命中)按偏移写入对应模块结构体 +- 保存:按各段结构体当前值生成受管键行 +- 未来模块接入三步:① 定义段结构体(静态,工厂默认初始化)② 写键表 ③ `cfg_file_register()` + +**(2) 外科手术式保存** —— 降级安全的核心: + +```mermaid +flowchart TD + A["cfg_file_save()"] --> B{"读 /config.ini"} + B -->|"不存在"| C["生成出厂模板(全部已注册段 + 注释)"] + B -->|"存在且 ≤ 4KB"| D["逐行扫描"] + B -->|"> 4KB(按损坏处理)"| E["用 RAM 数据整文件重生成"] + D --> F{"行类型"} + F -->|"受管键(已注册 key)"| G["替换为当前 RAM 值,标记 seen"] + F -->|"注释 / 空行 / 未知行"| H["原样保留"] + G --> I{"有键未写入?"} + H --> I + I -->|"是"| J["段末补写缺失键;段缺失则补整段"] + I -->|"否"| K["写 /config.ini.tmp"] + C --> K + E --> K + J --> K + K --> L["f_rename → /config.ini(原子替换)"] + L --> M["f_stat 更新变更基线"] +``` + +**(3) 变更检测** —— `cfg_file_poll()`(netTask / 模拟器主循环,2s 节拍):`f_stat` 取 (size, mtime-epoch) 与基线比对 → 变化则重载 → 段变化则触发该段回调。 + +**(4) 段变更回调** —— 未来模块的接入点: + +```c +void cfg_file_register_cb(const char *p_name, cfg_section_cb_t p_cb); +/* 网络模块注册:[network] 段变化 → 置 SYS_EVT_NET_RECONFIG(复用既有重建路径) */ +``` + +## 5. 运行时机制 + +### 5.1 上电加载 + +```mermaid +sequenceDiagram + participant DT as defaultTask + participant NT as netTask + participant FS as FatFS(NAND卷) + participant NS as 网络层 + DT->>FS: f_mount(无卷自动 f_mkfs) + NT->>NT: 等 SYS_EVT_STORAGE_READY(≤10s) + NT->>NT: net_cfg_load()(注册 [network] 段) + alt /config.ini 不存在 + alt 存在旧 /cfg/netcfg.bin + NT->>FS: 读旧 blob 校验 → 转写 /config.ini → 删旧文件 + else 无旧文件 + NT->>FS: 生成出厂模板 → 写 /config.ini + end + end + NT->>FS: 读 /config.ini → 解析入 net_cfg RAM 副本 + NT->>NS: net_init(ip, mask, gw, mac) + NT->>NT: 置 SYS_EVT_NET_READY +``` + +- 存储异常(挂载超时 / 失败):回退工厂默认继续组网(与现状一致,不阻塞网络) + +### 5.2 UI 保存 + +```mermaid +sequenceDiagram + participant UI as uiTask(网口设置页) + participant NC as net_cfg + participant CF as cfg_file + participant FS as FatFS + participant NT as netTask + UI->>NC: ↑/↓ 调整 → net_cfg_set(实时入 RAM,不写盘) + UI->>NC: ENTER 保存行 → net_cfg_save() + NC->>CF: cfg_file_save() + CF->>FS: 读旧文件 → 外科式行替换 → 写 /config.ini.tmp + CF->>FS: f_rename → /config.ini(原子) + CF->>FS: f_stat 更新变更基线 + UI->>NC: net_cfg_request_apply() + NC->>NT: 置 SYS_EVT_NET_RECONFIG + NT->>NT: 下一节拍 net_stack_bring_up()(重建网络,代次 +1) +``` + +- UI 页代码**零改动**(`net_cfg_*` API 语义不变) +- 保存失败(存储异常)→ 页面显示 ERR(维持现状) + +### 5.3 FTP 上传自动生效 + +```mermaid +sequenceDiagram + participant PC as PC(编辑器+FTP客户端) + participant FT as ftpTask(lftpd) + participant FS as FatFS + participant NT as netTask + PC->>FT: RETR /config.ini(下载) + FT->>FS: f_open/f_read(逐次加锁即放) + Note over PC: 编辑器修改(如改 IP) + PC->>FT: STOR /config.ini(原地覆盖) + FT->>FS: f_write/f_close(mtime 更新) + loop 每 2s + NT->>FS: f_stat(size + mtime) + end + NT->>NT: 与基线不同 → 重新加载 + alt [network] 段有变化 + NT->>NT: 置 SYS_EVT_NET_RECONFIG → 重建网络(代次 +1,服务自愈) + else 仅其他段变化 + NT->>NT: 仅更新 RAM,触发对应段回调 + end +``` + +- 检测粒度 2s(轮询周期);网络重建含等链路 ≤3s,总生效时间约 2~5s +- 下载天然支持(RETR 即文件读) +- **沉降去抖(D15-②)**:(size, mtime) 相对基线变化且**连续 2 次轮询(4s)稳定**才重载——防 FTP STOR 进行中的中间态触发多次重载(STOR 先写 `.TMP`,目标文件 (size, mtime) 在 rename 前不变,rename 单次原子更新 → 至多一次重载) +- **单写者(D15-③)**:`/config.ini` 的直接写者收敛为 netTask(UI 保存 = 消息至 netTask,由其落盘(tmp+rename)+ 网络重建,复用既有 `SYS_EVT_NET_RECONFIG` 路径);唯一例外 = FTP STOR 路径(lftpd,tmp+rename,与 netTask 写路径经卷锁互斥) + +### 5.4 线程安全(FatFS 可重入) + +- `FF_FS_REENTRANT=1`:FatFs 为每卷建互斥锁(系统锁 + 卷锁),**每次 f_\* 调用入口加锁、出口放锁**(逐次粒度) +- `Lib/FatFs/ffsystem.c` 实现四个钩子:`ff_mutex_create/delete/take/give` → `osMutexNew/Delete/Acquire/Release`(CMSIS-RTOS V2) +- 锁等待超时 = `FF_FS_TIMEOUT` = 1000ms → 超时返回 `FR_TIMEOUT`(操作优雅失败,下节拍重试,无死锁) +- 并发矩阵(启用后均由卷锁串行化): + +| 任务 | FatFS 访问 | 典型持锁时长 | +|---|---|---| +| defaultTask | f_mount / f_mkfs(仅启动期) | 一次性 | +| netTask | 配置文件读 / stat 轮询 / 重建时写 | 毫秒级 | +| uiTask | (D15:保存 = 消息至 netTask,不直接触卷) | — | +| ftpTask | RETR / STOR / LIST / DELE | STOR 秒级(多次短锁) | +| testTask | 存储测试套件(测试构建) | 测试场景 | + +- nand_ftl 页缓存在卷锁范围内调用 → 天然受保护 + +### 5.5 时间戳(变更检测基准) + +- `FF_FS_NORTC=0`:写后关文件时 FatFs 以 `get_fattime()` 的返回值记录 mtime +- `get_fattime`(`Lib/FatFs/ffsystem.c` 内——FatFs 指定的 OS 胶水文件;ff.h 将其声明为"用户方提供"):**直引用 `sys_clock` 墙钟**,与调试日志时间戳同源 + +```c +#include "sys_clock.h" /* 层内中间件引用(sys_clock 归 ④中间件层) */ + +DWORD get_fattime(void) { + if (sys_clock_get_time(&t) != 0) { return FF_TIME_FALLBACK; } /* 日历时间获取器(纯时间 API) */ + return ((t.year-1980)<<25 | t.month<<21 | t.day<<16 | t.hour<<11 | t.minute<<5 | t.second/2); +} +``` + +- **为何直引用而非回调注册**:`get_fattime` 唯一消费者是同库 `ff.c`,唯一外部依赖是时间源;直引用即 C 库平台钩子的标准模式,无注册步骤、无"漏注册 → 时间戳静默退化"失效模式;代价为零——`sys_clock` 是通用平台时间服务(日志/文件/对时共用),**改归 ④中间件层**(实体移入 `App/util/`,与 crc/ringbuf 同目录),二者为**层内引用**,工程无反向依赖 + +- 墙钟由 `sys_clock_init()` 开机建立(读一次 RTC,无效时按 `RTC_DEFAULT_INIT_*` 默认值自动校时),运行期纯 tick 推进——**零 I2C 流量**,与日志时间戳同一机制 +- mtime 为**真实时间**,与调试日志时间戳完全一致(如 `2026-09-13 14:32:05`);FTP LIST 亦显示真实时间 +- 墙钟无效(开机 RTC 读取失败且默认校时也失败的极罕见情形)→ 返回固定值(`FF_NORTC` 约定);检测退化为仅 size,既有开机告警链有效 +- 比较语义:"相对上次是否变化"(非单调性判断);timeSync 校时可能使墙钟跳变 → 至多多一次重载(无害) + +## 6. 接口设计 + +### 6.1 cfg_file 核心 API(双端共享) + +```c +/* App/cfg_file.h */ + +/* 段变更回调(重载后在触发重载的任务上下文调用;可空) */ +typedef void (*cfg_section_cb_t)(void); + +/* 段注册描述符:name / p_data(段 RAM 副本)/ p_defaults(工厂默认, + * 与 p_data 同布局,load 先复位再解析)/ data_size / p_keys(键表)/ + * key_count / p_cb(变更回调;注册段 data_size 须 ≤ 64 方可挂回调) */ +typedef struct { ... } cfg_section_reg_t; + +int cfg_file_register(const cfg_section_reg_t *p_sec); /* 注册段(首次 load 前;变更回调随段注册) */ +int cfg_file_load(void); /* 0 = 正常(含回退默认)/ -1 = 严重错误 */ +int cfg_file_save(void); /* 0 = 成功 / -1 = 失败(原文件保持不动) */ +void cfg_file_poll(void); /* 变更检测 + 沉降去抖(netTask / 模拟器主循环,2s 节拍) */ +void *cfg_file_section_data(const char *p_name); /* 取段 RAM 副本(未注册 = NULL) */ +``` + +### 6.2 cfg_file_store 后端接口(每端一份实现,互斥编译) + +```c +/* App/cfg_file_store.h —— 带路径的文件操作(路径由后端持有,核心不感知平台布局) */ +typedef struct { + uint32_t size; /* 文件长度(字节) */ + uint32_t mtime; /* 变更令牌(仅等值比较;MCU=FAT 时间打包 / 模拟器=宿主时间) */ +} cfg_file_stat_t; + +int cfg_store_read(const char *p_path, uint8_t *p_buf, size_t cap, size_t *p_nread); /* 至多读 cap 字节 */ +int cfg_store_write_atomic(const char *p_path, const uint8_t *p_buf, size_t len); /* 临时文件 + rename(原子) */ +int cfg_store_stat(const char *p_path, cfg_file_stat_t *p_st); /* 0 = 存在 / -1 = 缺失/失败 */ +int cfg_store_remove(const char *p_path); + +/* 路径由后端提供(MCU: /config.ini、/config.TMP、/cfg/netcfg.bin; + * 模拟器: sim_config.ini、sim_config.ini.tmp、sim_netcfg.bin) */ +const char *cfg_store_config_path(void); +const char *cfg_store_tmp_path(void); +const char *cfg_store_legacy_path(void); +``` + +### 6.3 未来模块接入模板 + +```c +/* 示例:未来 [adc] 段 */ +typedef struct { + uint16_t sample_ms; /* 采样周期 */ + char channel[16]; /* 通道名 */ +} adc_cfg_t; + +static adc_cfg_t s_adc_cfg = { 100u, "ch1" }; /* 工厂默认静态初始化 */ +static const adc_cfg_t s_adc_defaults = { 100u, "ch1" }; /* 引擎 p_defaults 目标 */ + +static const cfg_key_def_t s_adc_keys[] = { + { "sample_ms", CFG_VAL_UINT, offsetof(adc_cfg_t, sample_ms), sizeof(uint16_t) }, + { "channel", CFG_VAL_STR, offsetof(adc_cfg_t, channel), sizeof(s_adc_cfg.channel) }, +}; + +void adc_cfg_init(void) +{ + static const cfg_section_reg_t s_sec = { + .name = "adc", + .p_data = &s_adc_cfg, + .p_defaults = &s_adc_defaults, + .data_size = sizeof(adc_cfg_t), + .p_keys = s_adc_keys, + .key_count = 2u, + .p_cb = adc_on_config_changed, /* 可选:变更回调 */ + }; + (void)cfg_file_register(&s_sec); +} +``` + +### 6.4 net_cfg 兼容承诺 + +- `net_cfg_load/save/get/set/get_mac/set_active_mac/fmt_ipv4/set_apply_cb/request_apply` **签名与语义全部不变** +- 内部:`net_cfg_load` = 段注册 + 旧档迁移(`net_cfg_legacy_migrate`)+ `cfg_file_load`;`net_cfg_save` = `cfg_file_save` +- UI 页(`ui_page_net_cfg.c`)、启动流程(`net_task.c`)、模拟器入口(`main.c`)的**调用点无需改动** + +## 7. 双端支持 + +| 项 | MCU | 模拟器 | +|---|---|---| +| 后端 | `cfg_file_store_mcu.c`(FatFS) | `cfg_file_store_sim.c`(宿主文件) | +| 文件路径 | `/config.ini`(NAND 卷根) | `sim_config.ini`(工作目录) | +| mtime 来源 | FAT 打包时间(`fdate<<16\|ftime`,分钟级粒度) | 宿主文件系统(秒级) | +| 旧档迁移 | `/cfg/netcfg.bin`(NCFG blob) | `sim_netcfg.bin`(同格式) | +| 变更轮询 | netTask 2s | 主循环 2s(编辑器改动 2s 内反映) | +| 弃用 | — | `net_cfg_store_sim.c` 删除 | + +模拟器行为:首跑无 `sim_config.ini` 而有 `sim_netcfg.bin` → 自动迁移并删旧文件;运行中用编辑器改 `sim_config.ini` → 2s 内自动重载(模拟器无网络重建概念,[network] 段"应用" = 刷新 RAM 副本)。 + +## 8. 内存预算 + +依据:最新 map(2026-09-13):静态区(128 KB)已用 125,104 B(95.4%),**余 5,968 B**;FreeRTOS 堆 48 KB,稳态占用 35.4 KB,**空闲 ≈13.4 KB**。 + +| 项 | 大小 | 性质 | 说明 | +|---|---|---|---| +| 段 RAM 副本 | **0 增量** | 稳态 | 复用 `net_cfg` 既有 `s_cfg`(22 B,.data) | +| 段表 / 键表 | ~150 B | 稳态(Flash) | const,进 .constdata | +| 段注册表(上限 8 段) | ~350 B | 稳态(.bss) | 含键行号表 256 B / 变更快照 64 B / 基线 16 B(实施实测) | +| REENTRANT 互斥锁 | ~100 B | 稳态(堆) | 2 个 osMutex | +| 读缓冲 | ≤ 4 KB | 暂态(堆) | `cfg_file_load`/`save` 期间(输入复用读缓冲) | +| 写出缓冲 | ≤ 5 KB | 暂态(堆) | `cfg_file_save` 外科手术输出;溢出 = 放弃保存(原文件不动) | +| **堆峰值(save 期间)** | **≈ 9 KB** | 暂态 | < 空闲 13.4 KB ✓ | +| **静态 .bss 增量** | **≈ 0.7 KB(实测 706 B)** | 稳态 | 段表 + 行号表 + 快照 + 基线 << 静态余量 5.9 KB ✓ | + +**结论:无需内存优化专项**(LVGL 池迁 CCM 等);实施后以 map 复核两个余量数字。 + +## 9. 兼容与迁移 + +### 9.1 旧文件迁移(新固件首启) + +``` +/config.ini 缺失? + ├─ 是 → /cfg/netcfg.bin 存在且有效(magic/ver/CRC)? + │ ├─ 是 → 4 字段转 INI 行 → 原子写 /config.ini → 删旧文件 + │ │ 日志: "cfg file: legacy NCFG migrated" + │ └─ 否 → 生成出厂模板 + └─ 否 → 正常加载现有文件 +``` + +- 迁移单向:旧文件转换后删除(避免双数据源) +- 旧 blob 损坏:按不存在处理 → 出厂模板(旧文件保留,可人工查看) + +### 9.2 现场升级 / 降级场景 + +| 场景 | 行为 | +|---|---| +| 旧设备(有 netcfg.bin)升级新固件 | 自动迁移,用户配置保留 | +| 新设备 / 格式化卷 | 出厂模板 | +| 新文件被**旧固件**读(降级) | 旧固件只读 `/cfg/netcfg.bin`(已删)→ 工厂默认;**降级需人工恢复网口配置**(已知限制,O2) | +| 含未来段的新文件被旧固件读 | 旧固件不读该文件 → 文件不受影响 | + +### 9.3 恢复出厂 + +- **FTP `DELE /config.ini`** → 下次上电重新生成出厂模板(全段工厂默认) +- 本期无 UI 入口(开放问题 O1) + +## 10. 实施计划 + +| 阶段 | 内容 | 涉及文件 | 验收标准 | +|---|---|---|---| +| **P1** | 设计文档 + 架构图 | 本文档 + `gen_cfg_arch_svg.py` + SVG | 用户确认 §2 决策 | +| **P2** | FS 线程安全 + 时间戳 | `ffconf.h`、`Lib/FatFs/ffsystem.c`(新)、`App/util/sys_clock.c`(sys_clock_get_time;移入中间件层)、`uvprojx`、架构总览 §1.1(sys_clock 改归 ④) | `@build` 0/0;`@test` 启动 PASS(无死锁) | +| **P3** ✅ 2026-09-14 | cfg_file 引擎(双端)+ net_cfg 集成 + 旧档迁移 | `App/cfg_file.{h,c}`、`App/cfg_file_store.h`、`App/cfg_file_store_mcu.c`(新)、`simulator/sim/cfg_file_store_sim.c`(新)、`net_cfg.{h,c}`(段注册 + 引擎化 load/save)、`net_cfg_legacy.c`(新,NCFG 迁移)、删 `net_cfg_store*`、`uvprojx`、`CMakeLists.txt` | Keil `@build` 0/0;模拟器生成 `sim_config.ini` 且编辑回读一致;旧档 NCFG 自动迁移三态正确 —— **已实测通过**(工厂模板 / 编辑回读 / 非法键回退 / 迁移三态 / 双端 0 警告;`cfg_file.c` 含中文注释块 → UTF-8 **带 BOM**) | +| **P4** | 轮询 + D15 协议 + 测试标记 | `net_task.c`(沉降去抖轮询 + 落盘收敛 + 串口标记)、`lftpd.c`(STOR tmp+rename) | `@build`;`@test` 启动新标记(`cfg file: loaded OK` 等);FTP 上传无半截可见(rename 原子) | +| **P5** | 模拟器迁移 | `simulator/sim/cfg_file_store_sim.c`(新)、删 `net_cfg_store_sim.c`、`main.c`(轮询)、`CMakeLists.txt` | sim 构建 + 运行;`sim_netcfg.bin` 自动迁移 | +| **P6** | 文档同步 | `网络配置说明.md`(§0/3/6/7 重写)、`AGENTS.md`、`README.md`、`项目架构总览.md` + `gen_arch_svg.py`、`内容页架构.md`、BSD 指南、`simulator/README.md`、`ui_page_net_cfg.c` 头注释、`docs/测试说明/` | pre-commit 全绿;全库无"Flash"旧表述残留 | +| **P7** | FTP 闭环脚本 | `test/ftp_config_test.py`(新)+ 测试说明 | 真机闭环 PASS(§11.2) | +| **P8** | 总回归 + 提交 | `@build` + `@test` + 模拟器 ui-verify + `pre-commit --all-files` | 0 错误 0 警告;全 PASS | + +**建议提交边界**:C1 = P2+P3(FS 线程安全 + 配置引擎)/ C2 = P4+P5(网络配置迁移 + 模拟器)/ C3 = P6+P7(文档 + 测试脚本);P8 发现的收尾问题并入最近一次提交。 + +## 11. 测试计划 + +### 11.1 自动回归 + +| 串口标记 | 场景 | 属性 | +|---|---|---| +| `cfg file: loaded OK` | 上电加载成功 | Required(P4 起纳入 `@test`) | +| `cfg file: factory template written` | 首启 / 删文件 | 条件(手工) | +| `cfg file: legacy NCFG migrated` | 旧文件迁移 | 条件(真机首次升级) | +| `cfg file: saved OK` | UI 保存 | 条件(手工) | +| `cfg file: change detected` | FTP 上传 | 条件(P7 闭环) | +| `cfg file: ... FAILED` | 任何失败 | Forbidden | + +既有标记不变:`App init OK` / `Network Init` / `net init: OK`;Forbidden:`FAIL` / `ERROR` / `HardFault`。 + +### 11.2 FTP 闭环脚本(`test/ftp_config_test.py`,`.venv` python,stdlib ftplib) + +``` +1. 登录(按 FTP 测试说明的约定) +2. RETR /config.ini → 解析 → 校验格式合法 +3. 注入一条测试注释行 + 修改 IP(尾段 +1) +4. STOR /config.ini +5. 等待 ≤10s → 校验串口日志:"cfg file: change detected" + 网络重建标记 +6. 再次 RETR → 校验:新 IP 生效 且 测试注释保留(外科保留验证) +7. (可选)STOR 损坏文件 → 校验回退默认 + 自愈 +退出码:0 = PASS / 1 = FAIL +``` + +### 11.3 手工用例 + +| # | 用例 | 步骤 | 预期 | +|---|---|---|---| +| M1 | 编辑器往返 | 下载 → Notepad 改 IP + 加注释 → 上传 | 2~5s 内生效;注释保留 | +| M2 | 降级安全 | 文件加 `[future]` 段 → 设备端保存 → 下载 | `[future]` 段原样 | +| M3 | 恢复出厂 | FTP DELE /config.ini → 重启 | 重新生成模板,工厂 IP | +| M4 | 损坏容错 | STOR 一个含非法值 + 残行的文件 | 非法键回退默认,其余键生效,有日志 | +| M5 | 并发 | FTP 下载大文件同时 UI 保存 | 无卷损坏、无死锁(FR_TIMEOUT 重试) | +| M6 | 超长文件 | STOR > 4KB 文件 | 回退默认 + 下次保存重生成 | + +## 12. 风险与开放问题 + +### 12.1 风险 + +| 风险 | 等级 | 缓解 | +|---|---|---| +| FTP STOR 半截写(掉电 / 断链) | 中 | 逐键容错 + 工厂回退;0 字节 STOR 自动删文件(lftpd 现状)→ 下次上电重新生成模板;下次保存自愈 | +| 墙钟无效(开机 RTC 读取失败且默认校时失败) | 低 | mtime 退化为固定值(检测退化为 size);既有开机告警链 + 默认校时(RTC_DEFAULT_INIT)兜底,实际极罕见 | +| mtime 秒边界碰撞(同秒两次写) | 低 | (size, mtime) 双比对;2s 轮询通常跨秒;误报 = 多一次重载(无害) | +| save 堆峰值与 LVGL / lftpd 争用 | 低 | 事件触发(罕见);FR_TIMEOUT 兜底;实施后 map 复核堆空闲 | +| 8.3 文件名约束 | 无 | `config.ini` 合规;段名 / 键名属文件内容,不受限 | +| 卷重新格式化丢配置 | 中(现状一致) | 与现状相同(f_mkfs 清空卷);文档提示 | + +### 12.2 开放问题 + +| # | 问题 | 默认策略(可推翻) | +|---|---|---| +| O1 | UI 是否加"恢复出厂"入口 | 本期不做,用 FTP 删除 | +| O2 | 降级时配置同步策略 | 接受"降级 = 工厂默认" + 文档说明 | +| O3 | 其他模块接入顺序 | 按需(ADC / RS-485 / 端口等) | +| O4 | 文件格式版本号行(如 `; format=1`) | 本期不加(宽容解析已覆盖演进) | + +--- + +## 变更记录 + +| 日期 | 变更 | +|---|---| +| 2026-09-13 | v1.0:初版设计(基于现状调研) | +| 2026-09-13 | v1.1:`get_fattime` 由 SD2506 RTC 改为运行时间伪时(零 I2C 流量、无 RTC 依赖);相应回退 SD2506 驱动锁(P2 修正) | +| 2026-09-13 | v1.2:`get_fattime` 移入 `App/sys_clock.c` 改用墙钟(与调试日志时间戳同源,mtime 为真实时间;修正伪时的 24h 回绕盲区与日期失真) | +| 2026-09-13 | v1.3:`get_fattime` 移入独立适配文件 `App/ff_time.c`(时间子系统不再引用存储头文件);`sys_clock` 新增纯时间 API `sys_clock_get_time()` | +| 2026-09-13 | v1.4:`get_fattime` 改为时间提供回调——实现回归 `Lib/FatFs/ffsystem.c`(FatFs 指定的 OS 胶水文件,不引用 App 头),App 启动时注册墙钟时间源;删除 `App/ff_time.c`;保持分层单向(与既有平台后端模式同型) | +| 2026-09-13 | v1.5:`get_fattime` 改为直引用(ffsystem.c 直接引用 `sys_clock` 墙钟);去掉回调注册机制(删除 `ffsystem.h`、`app_main.c` 注册步骤);反向依赖在架构总览 §1.2 登记为工程唯一例外 | +| 2026-09-13 | v1.6:`sys_clock` 由 ③基础服务层改归 **④中间件层**(通用平台时间服务),文件移入 `App/util/`(与 crc/ringbuf 同目录):`ffsystem` → `sys_clock` 成为**层内引用**,工程反向依赖消除;"反向依赖例外" 措辞同步撤销 | +| 2026-09-13 | v1.7:FS 并发定案 **A+**(REENTRANT 互斥 + 三条一致性协议:写 tmp+rename 扩至 FTP STOR / 沉降去抖重载 / 配置文件单写者收敛 netTask);**actor 方案(单所有者队列 + 全代理层)经完整评估否决**(代理 ~400–600 行、RAM +4KB、符号改名/影子头、自死锁纪律——结构优势可由三协议 ~40 行等价中性化);新增 D15,D9 改为"一切写入 tmp+rename",P4 增列 lftpd STOR 改造 | +| 2026-09-14 | v1.8:**P3 完成**(双端 0/0):`cfg_file` 段键表引擎 + `cfg_file_store` 双端后端 + `net_cfg` [network] 段集成(对外 API 不变,D14 兑现)+ `net_cfg_legacy` 旧档迁移;无头实测通过(工厂模板生成 / 编辑回读 / 非法键工厂回退 / 未知段容留 / 迁移三态 / 双端 0 警告);§6 接口修订为实现版(store 带路径参数 + 三路径 getter;变更回调随段注册,撤销独立 `cfg_file_register_cb`);§8 静态增量按实测 706 B 修订 | diff --git a/docs/项目架构总览.md b/docs/项目架构总览.md index 7f90c1e2..523bb219 100644 --- a/docs/项目架构总览.md +++ b/docs/项目架构总览.md @@ -23,8 +23,8 @@ |---|---|---|---| | ① 业务层 | `Drivers/BSP/NETCFG/` | 顶层共享配置(被 UI 与基础服务共用) | `net_cfg` | | ② UI 层 | `Drivers/BSP/UI/` | LVGL 界面(与 PC 模拟器共用源码) | `ui_port` `ui_menu` `ui_menu_view` `ui_menu_tab` `ui_content_page` `ui_page_*` `ui_net_sink` `ui_font_cn_12` `ui_deco` | -| ③ 基础服务层 | `App/`、`Drivers/BSP/NET/lftpd/` | 应用侧服务与编排 | `app_main` `sys_clock` `sys_events` `app_cfg` `task/*` `lftpd` `time_sync` | -| ④ 中间件层 | `Drivers/BSP/NET/`、`Lib/`、`App/util/` | 通用库 / 协议栈 | `net_socket` `net_select` `FatFs` `nand_ftl(dhara)` `LVGL` `util(crc·ringbuf)` | +| ③ 基础服务层 | `App/`、`Drivers/BSP/NET/lftpd/` | 应用侧服务与编排 | `app_main` `sys_events` `app_cfg` `task/*` `lftpd` `time_sync` | +| ④ 中间件层 | `Drivers/BSP/NET/`、`Lib/`、`App/util/` | 通用库 / 协议栈 | `net_socket` `net_select` `FatFs` `nand_ftl(dhara)` `LVGL` `sys_clock` `util(crc·ringbuf)` | | ⑤ 驱动层 | `Drivers/BSP/{CH395F,GD5F2GQ5UE,TPAFE5160,SD2506,RS485}/` | 芯片寄存器 / 时序 / 中断 | `ch395f` `gd5f2gq5ue` `tpafe5160` `sd2506` `rs485` | | ⑥ 内核 / HAL | `Middlewares/`、`Drivers/STM32F4xx_HAL_Driver/`、`Drivers/CMSIS/` | 任务调度、外设 HAL、启动 | FreeRTOS V10.3.1 · STM32 HAL · CMSIS | | ⑦ 硬件 | — | STM32F407ZGTx @168MHz | SPI1 / SPI2 / I2C1 / UART5 / USART1 / DMA / GPIO / TIM7 | @@ -132,7 +132,7 @@ flowchart LR CHIP[("GD5F2GQ5UE
NAND 2Gbit")] subgraph USERS["使用方"] - NC["net_cfg
/cfg/netcfg.bin"] + NC["net_cfg
/config.ini(INI [network] 段)"] LF["lftpd
FTP 文件读写"] ST["测试套件
STORAGE/GD5F"] end @@ -147,7 +147,7 @@ flowchart LR end ``` -- 掉电持久化:`net_cfg` 配置(magic `'NCFG'` + ver + CRC-16/MODBUS),**保存后下次上电生效**。 +- 掉电持久化:`net_cfg` 配置(通用配置文件 `/config.ini`,INI `[network]` 段,cfg_file 引擎),保存后下次上电生效、或立即生效(网络重建)、外部变更 4s 沉降后自动重载;旧版 NCFG 二进制(`/cfg/netcfg.bin`)首启自动迁移。 - NAND 坏块表(BBT)持久化在保留块池,不交给 dhara 管理。 --- @@ -227,12 +227,11 @@ STM32F4-Base/ │ └── main.c 入口 → app_main_init → osKernelStart ├── App/ 应用层(业务编排,非 CubeMX) │ ├── app_main.c 启动编排(时间子系统优先) -│ ├── sys_clock.c 墙钟基准 / Unix epoch │ ├── time_sync.c TCP 对时服务 │ ├── sys_events.h 就绪屏障 bit 定义 │ ├── app_cfg.h 功能编译开关(APP_NET/ADC/RS485_ENABLE) │ ├── task/ net_task / ui_task / adc_task* / rs485_task* -│ └── util/ crc(CRC-16/8)、ringbuf +│ └── util/ crc(CRC-16/8)、ringbuf、sys_clock(墙钟) ├── Drivers/BSP/ 纯手工驱动/服务(不受 CubeMX 保护) │ ├── CH395F/ 以太网 MAC+PHY │ ├── GD5F2GQ5UE/ NAND + dhara FTL @@ -240,7 +239,7 @@ STM32F4-Base/ │ ├── SD2506/ RTC │ ├── RS485/ 半双工串口 │ ├── NET/ net_socket / net_select / lftpd -│ ├── NETCFG/ 网口配置持久化(MCU + 模拟器双后端) +│ ├── NETCFG/ 网口配置(INI [network] 段 + 旧档 NCFG 迁移) │ ├── UI/ LVGL UI(与模拟器共用) │ └── dbg_log.h/dbg_cfg.h 分级日志 ├── Lib/ 第三方:LVGL v9.2、FatFs、FreeRTOS、CMSIS @@ -259,7 +258,7 @@ STM32F4-Base/ ```mermaid flowchart LR - SRC["共享源码
Drivers/BSP/UI/*
Drivers/BSP/NETCFG/net_cfg.c"] + SRC["共享源码
Drivers/BSP/UI/*
Drivers/BSP/NETCFG/net_cfg.c
App/cfg_file.c"] SRC --> MCU["MCU 端
Keil MDK-ARM (ARMCC v5)
@build → BUILD_OK"] SRC --> SIM["PC 模拟器
CMake + SDL2 (MSVC)
lvgl_sim.exe --keys"] MCU --> TEST["@test:串口采集 + 烧录 + 日志校验"] @@ -305,3 +304,5 @@ flowchart LR | 2026-09-10 | §1 新增 **④ 中间件层**(通用库 / 协议栈:`net_socket`·`net_select`·`lftpd`·`FatFs`·`nand_ftl`·`LVGL`·`util`),位于 ③ 基础服务层与驱动层之间;③ 基础服务层收敛为**应用侧服务与编排**(`app_main`·`sys_clock`·`sys_events`·`app_cfg`·`task/*`·`time_sync`);层号顺延至 ⑦ 硬件 | | 2026-09-10 | `lftpd` 由 ④ 中间件层移入 **③ 基础服务层**(FTP 视为应用侧服务);中间件层余 `net_socket`·`net_select`·`FatFs`·`nand_ftl`·`LVGL`·`util` | | 2026-09-10 | §2.2 就绪屏障图由 Mermaid 改为**手绘 SVG**(`tools/arch_diagram/gen_barrier_svg.py` → `就绪屏障.svg`):三栏(生产者 / 事件组 / 消费者)+ 水平对齐 + All 汇合点,消除原图 9 条边的交叉;PNG 预览本地渲染、不入库 | +| 2026-09-13 | `sys_clock` 由 ③基础服务层改归 **④中间件层**(通用平台时间服务,日志/文件/对时共用;文件移入 `App/util/` 与 crc/ringbuf 同目录,include 路径不变):`FatFs` → `sys_clock` 成为**层内引用**,工程反向依赖消除;同日新增的 §1.2 "反向依赖例外" 条目撤销 | +| 2026-09-14 | 通用配置文件(P3):网口配置存储由 `/cfg/netcfg.bin`(NCFG 二进制)迁移至 `/config.ini`(INI `[network] 段`,`App/cfg_file` 引擎 + `cfg_file_store` 双端后端);`net_cfg` 对外 API 不变;旧档首启自动迁移;`cfg_file` 列为 MCU/模拟器共享源码 | diff --git a/docs/项目部署环境要求.md b/docs/项目部署环境要求.md index 6e34bbdb..5487fa75 100644 --- a/docs/项目部署环境要求.md +++ b/docs/项目部署环境要求.md @@ -385,13 +385,13 @@ pre-commit run --all-files | 参数 | 工厂默认 | 修改方式 | |---|---|---| -| IP | **192.168.1.100**(静态,无 DHCP) | 屏幕 `通讯参数 → 网口设置`(修改密码 `123`)保存后**立即生效**(网络重建约 1~3 s);或预置 `/cfg/netcfg.bin` | +| IP | **192.168.1.100**(静态,无 DHCP) | 屏幕 `通讯参数 → 网口设置`(修改密码 `123`)保存后**立即生效**(网络重建约 1~3 s);或预置 `/config.ini`(INI;旧版 `/cfg/netcfg.bin` 首启自动迁移) | | 子网掩码 | 255.255.255.0(/24) | 同上 | | 默认网关 | 192.168.1.1 | 同上 | | MAC | 全零 = 芯片内置 MAC | 同上(全零是"使用芯片内置 MAC"的哨兵,不是"MAC 为 0") | | PHY | 强制 100M 全双工 | 固件固定,不可配 | -- 配置持久化:NAND 上 FatFS 文件 `/cfg/netcfg.bin`(magic `'NCFG'` + CRC-16/MODBUS;缺失/损坏回退工厂默认,不带崩网络) +- 配置持久化:NAND 上通用配置文件 `/config.ini`(INI `[network]` 段;缺失/损坏回退工厂默认并重建模板,不带崩网络) - 立即生效机制:保存 → netTask 重建网络 → 代次号递增 → 三个 TCP 服务自动重建监听(**既有连接会断开**,属预期) ### 4.2 服务端口(现场连通性核对) @@ -406,7 +406,7 @@ pre-commit run --all-files ### 4.3 存储与运行时 -- 文件系统:FatFS(FAT32)挂 NAND;**上电无卷自动格式化**(`f_mkfs`)——格式化前 NAND 上无数据(含网口配置) +- 文件系统:FatFS(FAT32)挂 NAND;**上电无卷自动格式化**(`f_mkfs`);格式化会清除卷上全部文件(含网口配置 `/config.ini`) - RTC:SD2506 芯片(驱动 `Drivers/BSP/SD2506/`)提供墙钟基准与日志时间戳;首次使用建议先经 8888 对时 - 启动时序:`app_main_init`(存储 → 时钟 → RS485 → NAND → ADC → 启动转换)→ 就绪屏障(STORAGE / NET / FTP / TIMESYNC 事件)→ 六任务运行;详见 `docs/项目启动顺序管理.md`、`docs/项目架构总览.md` @@ -420,7 +420,7 @@ pre-commit run --all-files | 4 | FTP | FTP 客户端连 :21 | 列目录 / 读写 `/` 下文件 | | 5 | UI 功能 | 屏幕 / 远端操作 | 菜单 7 项导航、内容页、密码(查看 `000` / 修改 `123`)、网口设置保存后立即生效 | | 6 | 对时 | PC 推送 8888 | 设备墙钟更新、日志时间戳正确 | -| 7 | 掉电保持 | 写文件 / 改网口配置 → 重启 | 数据仍在(`/cfg/netcfg.bin` 按新值生效) | +| 7 | 掉电保持 | 写文件 / 改网口配置 → 重启 | 数据仍在(`/config.ini` 按新值生效) | | 8 | LED 状态 | 目检 | 6 路 LED 随系统状态变化(低电平点亮) | --- diff --git a/simulator/CMakeLists.txt b/simulator/CMakeLists.txt index 377f38e0..83c9a859 100644 --- a/simulator/CMakeLists.txt +++ b/simulator/CMakeLists.txt @@ -52,6 +52,7 @@ include_directories( ${UI_DIR} # ui_menu.h, ui_font_cn_12.h ${NET_DIR} # net_socket.h / net_types.h(net_* API 双端共享核心) ${NETCFG_DIR} # net_cfg.h(网络配置,双端共享核心) + ${PROJECT_ROOT}/App # cfg_file.h / cfg_file_store.h(通用配置文件引擎,双端共享核心) ${PROJECT_ROOT}/Drivers/BSP # dbg_log.h(使用 simulator 的 dbg_cfg.h 配置) ${SDL2_DIR}/include # SDL2 头文件 ) @@ -86,10 +87,12 @@ set(UI_SOURCES ${UI_DIR}/ui_net_sink.c ) -# 网络配置(双端共享核心 + 模拟器存储后端;MCU 端编 net_cfg_store_mcu.c,见 Keil 工程) +# 网络配置(双端共享核心 + cfg_file 引擎 + 平台存储后端;MCU 端编 cfg_file_store_mcu.c,见 Keil 工程) set(NETCFG_SOURCES ${NETCFG_DIR}/net_cfg.c - sim/net_cfg_store_sim.c + ${NETCFG_DIR}/net_cfg_legacy.c + ${PROJECT_ROOT}/App/cfg_file.c + sim/cfg_file_store_sim.c ) # 网络后端(net_* 核心 API 的 Winsock 实现 + UI 推流平台后端;MCU 端另编 net_socket.c 与 _mcu 后端) diff --git a/simulator/README.md b/simulator/README.md index c0f208d9..e54ab16b 100644 --- a/simulator/README.md +++ b/simulator/README.md @@ -17,7 +17,7 @@ simulator/ │ ├── sim_port.c / .h # 显示/输入端口(SDL 窗口 + 键盘 + mono 主题),替代 MCU 端 ui_port.c │ ├── net_socket_sim.c # 网络后端:Winsock 实现 net_* BSD API,替代 MCU 端 net_socket.c(CH395F) │ ├── ui_net_sink_backend_sim.c # RemoDispBus 平台 backend:取帧/注键(配合共享 ui_net_sink.c) -│ └── net_cfg_store_sim.c # 网口配置存储:本地 sim_netcfg.bin +│ └── cfg_file_store_sim.c # 配置文件存储后端:本地 sim_config.ini(旧档 sim_netcfg.bin 首启自动迁移) ├── lv_conf.h # 模拟器专用 LVGL 配置(与 MCU 端 Lib/lv_conf.h 相互独立) ├── i1_to_png.py # 截图转 PNG:把 --screenshot 的 I1(1bpp) 原始位图转成 PNG(见 §3.5) ├── tests/ # 无头回归脚本 + 本地黄金参考(见 §3.6,流程文档 docs/UI 设计/UI_自动化测试流程.md) @@ -46,10 +46,10 @@ simulator/ | `sim/sim_port.c` | 创建 SDL 窗口显示设备(`lv_sdl_window_create`)、初始化 **mono 主题**(`lv_theme_mono_init`,1bpp 必需)、创建键盘输入设备并挂默认输入组。**不创建鼠标**(纯键盘导航,与 MCU 端 keypad 一致)。`sim_port_get_screen_mono()` 经 `SDL_RenderReadPixels` 读回屏幕像素转 1bpp,供推流取帧 | | `sim/net_socket_sim.c` | 网络后端:Winsock 实现 `net_*` BSD Socket API(`socket`/`bind`/`listen`/`accept`/`recv`/`send`/`close`),替代 MCU 端 `Drivers/BSP/NET/net_socket.c`(CH395F) | | `sim/ui_net_sink_backend_sim.c` | RemoDispBus 平台 backend:`ui_net_sink_get_lcdmem`→`sim_port_get_screen_mono`、`ui_net_sink_inject_key`→`lv_group_send_data`;配合**共享** `Drivers/BSP/UI/ui_net_sink.c`(协议服务端,双端同一份,TCP 7003) | -| `sim/net_cfg_store_sim.c` | 网口配置存储后端:本地文件 `sim_netcfg.bin`(`fopen`),替代 MCU 端 `Drivers/BSP/NETCFG/net_cfg_store_mcu.c`(Flash) | +| `sim/cfg_file_store_sim.c` | 通用配置文件存储后端:本地文件 `sim_config.ini`(`fopen`/`rename` 原子写),替代 MCU 端 `App/cfg_file_store_mcu.c`(FatFS);旧档 `sim_netcfg.bin` 首启自动迁移进 `sim_config.ini` | | `lv_conf.h` | 模拟器专用 LVGL 配置。与 MCU 端 `Lib/lv_conf.h` 的关键差异见 §3.3 | | `dbg_log.h` | MCU 版 `dbg_log.h` 依赖 `sys_clock.h`(RTC 时间戳),PC 无法编译。此 stub 把 `DBG_INFO/ERROR/DEBUG` 映射到 `printf`;因 `simulator/` 在 include 路径最前,**优先生效**覆盖 MCU 版 | -| `CMakeLists.txt` | 收集 LVGL 源码(`../Lib/lvgl`)+ 共享 UI 源码(`../Drivers/BSP/UI`)+ `sim/` 平台层,链接预编译 SDL2,产出 `lvgl_sim.exe` | +| `CMakeLists.txt` | 收集 LVGL 源码(`../Lib/lvgl`)+ 共享 UI 源码(`../Drivers/BSP/UI`)+ 通用配置文件引擎(`../App/cfg_file.c`、`../Drivers/BSP/NETCFG` 的 net_cfg/net_cfg_legacy)+ `sim/` 平台层,链接预编译 SDL2,产出 `lvgl_sim.exe` | | `i1_to_png.py` | 把 `--screenshot` 产出的 I1(1bpp) 原始位图转成 PNG,便于离线比对设计稿。详见 §3.5 | | `tests/` | 无头回归脚本(菜单 8 状态像素回归、内容页文本断言 + 黄金参考回归);`ref/` 为**本地生成物、不入库**(首次 `--update` 生成)。详见 §3.6 | @@ -112,7 +112,7 @@ LVGL 场景(同一份 ui_menu.c) | 输入 | 远端 keypad(`ui_port_queue_key`,网络回传键码) | SDL 键盘(`lv_sdl_keyboard_create`) | | 主题 | 无主题(`LV_USE_THEME_* = 0`),控件用内置基础样式 | **mono 主题必须开启**(否则 1bpp 黑底黑字) | | 日志 | `Drivers/BSP/dbg_log.h`(USART1 + RTC 时间戳) | `simulator/dbg_log.h`(printf stub) | -| 业务任务 | netTask / ftpTask / timeSyncTask 等 | 无,纯 UI | +| 业务任务 | netTask / ftpTask / timeSyncTask 等 | 无(仅编译纯软件部分的配置文件引擎 net_cfg/cfg_file,可离线验证配置加载/保存/旧档迁移) | | 时钟 | `lv_tick_inc` 由 ui_task 手动喂(10ms 周期) | SDL 驱动内部 `lv_tick_set_cb(SDL_GetTicks)`,**不可再手动喂** | ### 2.4 关键设计决策 @@ -121,7 +121,7 @@ LVGL 场景(同一份 ui_menu.c) 2. **CMake + MSVC**:`Visual Studio 16 2019` 生成器,`-A x64`;MSVC 下加 `/utf-8` 消除中文注释的 C4819 警告 3. **共享 UI 源码**:`ui_menu.c` / `ui_font_cn_12.c` 直接编译 `Drivers/BSP/UI/` 原件,改一处两端同时生效 4. **纯键盘导航**:不创建鼠标/触摸设备,与 MCU 端 keypad 操作语义一致(↑↓←→ / Enter / Esc) -5. **仅 UI 预览**:不含网络/存储/ADC 等硬件业务,无法验证与外设相关的逻辑 +5. **UI 为主**:不含 CH395F 网络 / NAND 存储 / ADC 等硬件业务;通用配置文件引擎(`cfg_file`)与 `net_cfg` 核心为纯软件,配置加载/保存/旧档迁移可在模拟器离线验证(`sim_config.ini` / `sim_netcfg.bin`) --- @@ -259,7 +259,7 @@ UI 改动的自动化门禁脚本(完整流程见 `docs/UI 设计/UI_自动化 ## 五、边界说明 -- 模拟器**只跑 UI**:不含 CH395F 网络、NAND 存储、ADC、RS-485 等硬件业务 +- 模拟器**跑 UI + 配置文件引擎**:不含 CH395F 网络、NAND 存储、ADC、RS-485 等硬件业务;`cfg_file` 引擎与 `net_cfg`(纯软件部分)在模拟器编译运行,可离线验证配置加载/保存/旧档迁移 - 模拟器**含** `ui_net_sink`(Winsock 版 RemoDispBus 推流服务端,TCP 7003)——远端看图链路 可在模拟器上验证(`simulator/tests/check_remo_sink.py`);CH395F 真实网络栈仍只在 MCU 上验证 - `build/` 为生成物,可随时删除重建(已在 `.gitignore` 中忽略) diff --git a/simulator/sim/cfg_file_store_sim.c b/simulator/sim/cfg_file_store_sim.c new file mode 100644 index 00000000..523fc510 --- /dev/null +++ b/simulator/sim/cfg_file_store_sim.c @@ -0,0 +1,148 @@ +/* + * 模块名称:cfg_file_store (模拟器后端) + * 模块功能:cfg_file 存储后端——模拟器工作目录宿主文件 sim_config.ini; + * 原子写 = 临时文件 sim_config.ini.tmp + rename;mtime 变更令牌 = + * 宿主文件 mtime(秒级);旧版遗留文件 sim_netcfg.bin 供迁移读取 + * 适用平台:PC 模拟器 + * 作者:王建锋 + * 创建日期:2026-09-14 + * 修改记录: + * v1.0 2026-09-14 王建锋 创建初始版本 + */ + +/* 头文件包含区 */ +#include +#include +#include + +#include "cfg_file_store.h" + +/* 静态变量定义区 */ + +static const char s_path[] = "sim_config.ini"; +static const char s_path_tmp[] = "sim_config.ini.tmp"; +static const char s_path_legacy[] = "sim_netcfg.bin"; + +/* 函数定义区 */ + +/* + * 函数功能:读文件(最多 cap 字节) + * 入口参数:p_path - 文件路径 const char * + * p_buf - 接收缓冲(不可为 NULL) uint8_t * + * cap - 缓冲容量 size_t + * p_nread - 实际读字节数输出(不可为 NULL) size_t * + * 返回值:0 - 成功;-1 - 文件缺失/读失败 int + * 限定条件:无 + */ +int cfg_store_read(const char *p_path, uint8_t *p_buf, size_t cap, size_t *p_nread) { + FILE *fp; + size_t n; + + if (p_buf == NULL || p_nread == NULL) { + return -1; + } + fp = fopen(p_path, "rb"); + if (fp == NULL) { + return -1; + } + n = fread(p_buf, 1u, cap, fp); + (void)fclose(fp); + *p_nread = n; + return 0; +} + +/* + * 函数功能:原子写文件(先写 sim_config.ini.tmp,成功后 rename 覆盖目标) + * 入口参数: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) { + FILE *fp; + size_t n; + + if (p_buf == NULL) { + return -1; + } + fp = fopen(s_path_tmp, "wb"); + if (fp == NULL) { + return -1; + } + n = fwrite(p_buf, 1u, len, fp); + (void)fclose(fp); + if (n != len) { + (void)remove(s_path_tmp); + return -1; + } + if (rename(s_path_tmp, p_path) != 0) { + (void)remove(s_path_tmp); + return -1; + } + return 0; +} + +/* + * 函数功能:文件状态(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) { + struct stat st; + + if (p_st == NULL) { + return -1; + } + if (stat(p_path, &st) != 0) { + return -1; + } + p_st->size = (uint32_t)st.st_size; + p_st->mtime = (uint32_t)st.st_mtime; + return 0; +} + +/* + * 函数功能:删除文件 + * 入口参数:p_path - 文件路径 const char * + * 返回值:0 - 成功;-1 - 文件缺失/删除失败 int + * 限定条件:无 + */ +int cfg_store_remove(const char *p_path) { + if (remove(p_path) != 0) { + return -1; + } + return 0; +} + +/* + * 函数功能:取配置文件路径 + * 入口参数:无 + * 返回值:"sim_config.ini"(模拟器工作目录) const char * + * 限定条件:无 + */ +const char *cfg_store_config_path(void) { + return s_path; +} + +/* + * 函数功能:取配置文件临时文件路径 + * 入口参数:无 + * 返回值:"sim_config.ini.tmp" const char * + * 限定条件:无 + */ +const char *cfg_store_tmp_path(void) { + return s_path_tmp; +} + +/* + * 函数功能:取旧版配置遗留文件路径 + * 入口参数:无 + * 返回值:"sim_netcfg.bin"(与 MCU 端旧格式一致,两端可互读) const char * + * 限定条件:无 + */ +const char *cfg_store_legacy_path(void) { + return s_path_legacy; +} diff --git a/simulator/sim/net_cfg_store_sim.c b/simulator/sim/net_cfg_store_sim.c deleted file mode 100644 index 31c1e815..00000000 --- a/simulator/sim/net_cfg_store_sim.c +++ /dev/null @@ -1,67 +0,0 @@ -/* - * 模块名称:Network Configuration Store (模拟器后端) - * 模块功能:net_cfg 存储后端——模拟器工作目录本地文件 sim_netcfg.bin - * (磁盘格式与 MCU 端一致,两端可互读) - * 适用平台:PC 模拟器 - * 作者:王建锋 - * 创建日期:2026-09-10 - * 修改记录: - * v1.0 2026-09-10 王建锋 创建初始版本 - */ - -/* 头文件包含区 */ -#include - -#include "net_cfg_store.h" - -/* 静态变量定义区 */ - -static const char s_path[] = "sim_netcfg.bin"; - -/* 函数定义区 */ - -/* - * 函数功能:读取配置文件 - * 入口参数:p_buf - 接收缓冲(不可为 NULL) uint8_t * - * len - 文件长度 size_t - * 返回值:0 - 成功;-1 - 文件缺失/读失败 int - * 限定条件:无 - */ -int net_cfg_store_read(uint8_t *p_buf, size_t len) { - FILE *fp; - size_t n; - - if (p_buf == NULL) { - return -1; - } - fp = fopen(s_path, "rb"); - if (fp == NULL) { - return -1; - } - n = fread(p_buf, 1u, len, fp); - fclose(fp); - return (n == len) ? 0 : -1; -} - -/* - * 函数功能:写配置文件(不存在则创建,存在则覆盖) - * 入口参数:p_buf - 待写数据(不可为 NULL) const uint8_t * - * len - 数据长度 size_t - * 返回值:0 - 成功;-1 - 写失败 int - * 限定条件:无 - */ -int net_cfg_store_write(const uint8_t *p_buf, size_t len) { - FILE *fp; - size_t n; - - if (p_buf == NULL) { - return -1; - } - fp = fopen(s_path, "wb"); - if (fp == NULL) { - return -1; - } - n = fwrite(p_buf, 1u, len, fp); - fclose(fp); - return (n == len) ? 0 : -1; -} diff --git a/tools/arch_diagram/gen_arch_svg.py b/tools/arch_diagram/gen_arch_svg.py index 4cf5f08c..c95a67fd 100644 --- a/tools/arch_diagram/gen_arch_svg.py +++ b/tools/arch_diagram/gen_arch_svg.py @@ -53,7 +53,7 @@ L_UI = dict( ) L_SVC = dict( title="③ 基础服务层", sub="应用侧服务 / 编排", color="#0d9488", bg="#f0fdfa", - chips=["app_main(启动编排)", "sys_clock(墙钟基准)", "sys_events(就绪屏障)", + chips=["app_main(启动编排)", "sys_events(就绪屏障)", "app_cfg(编译开关)", "task/(net · ui · adc* · rs485*)", "lftpd(FTP :21)", "time_sync(TCP 对时 :8888)"], ) @@ -61,7 +61,7 @@ L_MW = dict( title="④ 中间件层", sub="通用库 / 协议栈", color="#16a34a", bg="#f0fdf4", chips=["net_socket / net_select(BSD API)", "FatFs(文件系统)", "nand_ftl(dhara FTL)", "LVGL v9.2(图形库)", - "util(crc · ringbuf)"], + "sys_clock(墙钟基准)", "util(crc · ringbuf)"], ) L_DRV = dict( title="⑤ 驱动层", sub="Drivers/BSP · 芯片时序/中断", color="#d97706", bg="#fffbeb", diff --git a/tools/arch_diagram/gen_cfg_arch_svg.py b/tools/arch_diagram/gen_cfg_arch_svg.py new file mode 100644 index 00000000..1302b2ea --- /dev/null +++ b/tools/arch_diagram/gen_cfg_arch_svg.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""生成 docs/images/配置文件分层架构.svg —— 通用配置文件(/config.ini)分层架构图(纯标准库,无需第三方依赖)。 + +由 gen_net_stack_svg.py 复制改数据区:配置体系六层结构 +(docs/配置文件设计说明.md §4.1): +PC 端(编辑器/FTP 客户端)→ lftpd FTP 服务 → /config.ini 文件 → cfg_file 引擎 → 段适配(键表)→ 消费者。 + +用法(仓库根目录,任意 python3): + python tools/arch_diagram/gen_cfg_arch_svg.py +输出: + docs/images/配置文件分层架构.svg +改架构时:编辑本文件顶部的 LAYERS / ROWS 数据后重跑即可。 +""" +import html +import os + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +OUT = os.path.join(ROOT, "docs", "images", "配置文件分层架构.svg") + +# ----------------------------- 画布与尺寸参数 ----------------------------- +W = 1200 +PAD = 26 +TITLE_FONT = 20 +SUB_FONT = 13 +BAND_TITLE_FONT = 17 +BAND_SUB_FONT = 12 +CHIP_FONT = 13 + +CHIP_H = 38 # 芯片盒高 +CHIP_GAP = 10 # 芯片水平间距 +CHIP_ROW_GAP = 10 # 芯片行间距 +CHIP_PAD = 16 # 芯片内左右留白 +BAND_PAD_X = 18 # 框内左右留白 +BAND_TITLE_H = 38 # 框内标题区高 +BAND_PAD_BOTTOM = 16 +BAND_H_GAP = 26 # 同一行内并列框的间距 +BAND_V_GAP = 44 # 行间距(放箭头) +TITLE_H = 78 # 顶部标题区高 + +FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif" + +# ----------------------------- 架构数据(改这里) ----------------------------- +L_PC = dict( + title="PC 端(外部)", sub="文本编辑器 + FTP 客户端", color="#475569", bg="#f1f5f9", + chips=["文本编辑器:改 /config.ini(注释 / 加段 / 改值)", + "FTP 客户端:RETR 下载 / STOR 上传(端口 21)"], +) +L_FTP = dict( + title="lftpd FTP 服务(端口 21)", sub="ftpTask · 单连接 · root = /(= 卷根)", color="#7c3aed", bg="#f5f3ff", + chips=["RETR / STOR / LIST / DELE / CWD(lftpd 现状能力)", + "STOR = 原地覆盖(无 tmp+rename)", + "路径规范化封死 .. 穿越"], +) +L_FILE = dict( + title="/config.ini(通用配置文件)", sub="FatFS 卷根(NAND GD5F2GQ5UE · dhara FTL)", color="#0d9488", bg="#f0fdfa", + chips=["INI 文本:[段] / key=value / ; 注释", + "CRLF · UTF-8(容忍 BOM)· ≤ 4KB", + "设备写:tmp + rename(原子);变更识别:size + mtime"], +) +L_ENGINE = dict( + title="cfg_file(配置文件引擎)", sub="App/cfg_file.c · 双端共享", color="#2563eb", bg="#eff6ff", + chips=["INI 解析 / 序列化(逐键校验 · 容错 · 自愈)", + "外科手术式保存(保留注释 / 未知段 → 降级安全)", + "2s 变更轮询(f_stat)+ 段变更回调", + "出厂模板生成 · 旧 NCFG blob 迁移"], +) +L_SECT = dict( + title="段适配(键表)", sub="段 RAM 副本 = 模块静态结构体(稳态 0 增量)", color="#16a34a", bg="#f0fdf4", + chips=["net_cfg [network]:ip / mask / gateway / mac", + "未来:[adc] · [rs485] · [ftp] ...(三步接入)"], +) +L_CONS = dict( + title="消费者", sub="读 / 写段 RAM 副本 + 应用", color="#e11d48", bg="#fff1f2", + chips=["netTask:组网 / 重建(代次号自愈)", + "UI 网口设置页:显示 / 编辑 / 保存(零改动)", + "未来模块:cfg_file_section_data() 取段数据"], +) + +# 每一行可放多个"同级并列"的层(上层只依赖下方各层) +ROWS = [ + [L_PC], + [L_FTP], + [L_FILE], + [L_ENGINE], + [L_SECT], + [L_CONS], +] + +FOOT = "稳态 RAM 零增量(段数据 = 模块既有结构体);暂态堆缓冲 ≤9KB(仅 load/save 时);FF_FS_REENTRANT=1 卷锁串行化多任务 FS 访问;模拟器端 = 宿主文件 sim_config.ini(同核心)" + + +# ----------------------------- 工具函数 ----------------------------- +def esc(s): + return html.escape(s, quote=True) + + +def text_w(s, fs): + """粗略估算文本像素宽:CJK/全角/圈码/箭头 ≈ 1.0em,其余 ≈ 0.58em。 + + 取偏大估计(宁可芯片略宽,不可文字溢出盒外)。 + """ + w = 0.0 + for ch in s: + w += fs * (1.0 if ord(ch) >= 0x2460 else 0.58) + return w + + +def chip_w(label): + return text_w(label, CHIP_FONT) + 2 * CHIP_PAD + + +def wrap_chips(chips, avail): + rows, cur, cur_w = [], [], 0.0 + for c in chips: + cw = chip_w(c) + need = cw if not cur else cur_w + CHIP_GAP + cw + if cur and need > avail: + rows.append(cur) + cur, cur_w = [c], cw + else: + cur.append(c) + cur_w = need + if cur: + rows.append(cur) + return rows + + +# ----------------------------- 生成 ----------------------------- +def build(): + out = [] + add = out.append + + # 布局:逐行排布(行内可并列多个框,行高取该行最高框) + placements = [] # (layer, chip_rows, x, y, w, h) + y = TITLE_H + for row in ROWS: + n = len(row) + bw = (W - 2 * PAD - BAND_H_GAP * (n - 1)) // n + bands = [] + for i, layer in enumerate(row): + bx = PAD + i * (bw + BAND_H_GAP) + avail = bw - 2 * BAND_PAD_X + rows = wrap_chips(layer["chips"], avail) + bh = BAND_TITLE_H + len(rows) * CHIP_H + (len(rows) - 1) * CHIP_ROW_GAP + BAND_PAD_BOTTOM + bands.append([layer, rows, bx, y, bw, bh]) + row_h = max(b[5] for b in bands) + for b in bands: + b[5] = row_h # 同行等高(视觉"同级") + placements.extend(bands) + y += row_h + BAND_V_GAP + total_h = y - BAND_V_GAP + 54 + + add('' % (W, total_h)) + add('%s' + % (PAD, 40, TITLE_FONT, esc("通用配置文件分层架构(/config.ini)"))) + add('%s' + % (PAD, 62, SUB_FONT, + esc("INI 文本文件 · FTP 上传/下载 · 段键表引擎 · MCU/模拟器双端共享"))) + + for (layer, rows, bx, by, bw, bh) in placements: + # 框背景 + 边框 + add('' % (bx, by, bw, bh, layer["bg"], layer["color"])) + # 标题(层名 + 目录/职责说明) + add('%s' + % (bx + BAND_PAD_X, by + 26, BAND_TITLE_FONT, layer["color"], esc(layer["title"]))) + tw = text_w(layer["title"], BAND_TITLE_FONT) + add('%s' + % (bx + BAND_PAD_X + tw + 12, by + 26, BAND_SUB_FONT, esc(layer["sub"]))) + # 芯片 + cy = by + BAND_TITLE_H + for row in rows: + cx = bx + BAND_PAD_X + for c in row: + cw = chip_w(c) + add('' % (cx, cy, cw, CHIP_H, layer["color"])) + add('%s' + % (cx + cw / 2, cy + CHIP_H / 2 + CHIP_FONT * 0.35, CHIP_FONT, esc(c))) + cx += cw + CHIP_GAP + cy += CHIP_H + CHIP_ROW_GAP + + # 行间箭头:每个上层框向下引一条 + row_first = [] # 每行首个 placement 的下标 + idx = 0 + for row in ROWS: + row_first.append(idx) + idx += len(row) + for r in range(len(ROWS) - 1): + y0 = placements[row_first[r]][3] + placements[row_first[r]][5] + 6 + y1 = placements[row_first[r + 1]][3] - 8 + for i in range(row_first[r], row_first[r] + len(ROWS[r])): + (_, _, bx, _, bw, _) = placements[i] + ax = bx + bw / 2 + add('' % (ax, y0, ax, y1 - 7)) + add('' % (ax, y1)) + + add('%s' + % (PAD, total_h - 18, esc(FOOT))) + + svg = ('\n%s\n\n' + % (W, total_h, W, total_h, FONT, "\n".join(out))) + return svg, total_h + + +def main(): + svg, h = build() + with open(OUT, "w", encoding="utf-8", newline="\n") as f: + f.write(svg) + import xml.etree.ElementTree as ET + ET.fromstring(svg) + print("OK %d bytes, canvas %dx%d -> %s" % (len(svg.encode("utf-8")), W, h, OUT)) + + +if __name__ == "__main__": + main()