diff --git a/.gitignore b/.gitignore
index 7ce84d4a..47459a7d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -98,6 +98,9 @@ simulator/SDL2/
# 内容页黄金参考(ref/*.bin)与目检 PNG 均为本地生成物,不入库:
# 首次/换机后先跑 check_content_pages.py --update 生成 ref/*.bin,再跑 regen_ref_png.py 出 PNG
simulator/tests/ref/
+# 架构图 PNG 预览(源为 docs/*.svg,PNG 本地渲染生成,不入库)
+docs/项目架构总览.png
+docs/就绪屏障.png
*.pyc
# Python venv (unified env, see AGENTS.md "Python 环境"; rebuild via requirements.txt)
diff --git a/AGENTS.md b/AGENTS.md
index 1951f0a3..6e04d248 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -41,12 +41,13 @@ 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)
+- **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 服务据代次号重建监听)
- **ftpTask**:`osEventFlagsWait(NET_READY | STORAGE_READY, WaitAll)` 后 `lftpd_start("/", 21, &ftp)`(已无 7s 魔法延时)
- **testTask**:测试套件入口(`test/test_config.h` 选择;产品构建下空转)
- **uiTask**:等 `SYS_EVT_NET_READY` 后 `ui_port_init` → `ui_menu_init` → 启动 UI net sink
- **timeSyncTask**:TCP 墙钟时间同步(PC 推送)
- 功能编译开关:`App/app_cfg.h`(`APP_NET_ENABLE` / `APP_ADC_ENABLE` / `APP_RS485_ENABLE`)
+- **全景架构图**:`docs/项目架构总览.md`(六层分层 / 任务与就绪屏障 / 启动时序 / 存储·网络·UI 栈 / 目录组织)
### 任务栈
@@ -102,6 +103,8 @@ Src/main.c → CubeMX 外设初始化 → app_main_init → osKernelStart
## 驱动关键点
详见 `docs/` 目录:
+- `docs/项目架构总览.md` — 六层全景架构(分层/任务/启动时序/存储·网络·UI 栈)
+- `docs/网络配置说明.md` — 网口设置页配置(UI 可改的 IP/掩码/网关/MAC:持久化 / 立即生效 / 页面操作)
- `docs/问题记录/CH395F_Trap_Records.md` — CH395F 已知陷阱
- `docs/问题记录/GD5F2GQ5UE_Trap_Records.md` — GD5F2GQ5UE 已知陷阱
- `docs/问题记录/SD2506_Trap_Records.md` — SD2506 RTC 已知陷阱
diff --git a/App/sys_events.h b/App/sys_events.h
index f930f3cd..11583cd3 100644
--- a/App/sys_events.h
+++ b/App/sys_events.h
@@ -27,6 +27,7 @@ extern osEventFlagsId_t sys_evtHandle;
#define SYS_EVT_STORAGE_READY (1u << 1) /* defaultTask:FTL/FatFS 挂载完成 */
#define SYS_EVT_FTP_READY (1u << 2) /* ftpTask:lftpd_start 完成 */
#define SYS_EVT_TIMESYNC_READY (1u << 3) /* timeSyncTask:首次时间同步完成 */
+#define SYS_EVT_NET_RECONFIG (1u << 4) /* UI:请求立即应用新网口配置(netTask 消费后重建网络) */
#ifdef __cplusplus
}
diff --git a/App/task/net_task.c b/App/task/net_task.c
index 59e7a691..bc5e9355 100644
--- a/App/task/net_task.c
+++ b/App/task/net_task.c
@@ -24,6 +24,20 @@
#define DBG_TAG "[NET_TASK]"
#include "dbg_log.h"
+/*
+ * 函数功能:"立即应用网口配置"平台回调(注册给 net_cfg)
+ * 入口参数:无
+ * 返回值:无
+ * 限定条件:UI 保存成功后经 net_cfg_request_apply() 调用(任意任务上下文)
+ * 函数说明:只置事件位;真正的重建在主循环内完成(CH395F SPI 归 netTask 独占,
+ * UI 不得直接操作)
+ */
+static void net_reconfig_request_cb(void) {
+ if (sys_evtHandle != NULL) {
+ (void)osEventFlagsSet(sys_evtHandle, SYS_EVT_NET_RECONFIG);
+ }
+}
+
/*
* 函数功能:CH395F 协议栈一次性初始化(两种构建模式共用)
* 入口参数:无
@@ -96,6 +110,10 @@ int net_stack_bring_up(void) {
(phy == CH395F_PHY_10M_HALF) ? "10M HALF" :
(phy == CH395F_PHY_DISCONN) ? "DISCONNECT" : "UNKNOWN");
+ /* 网络已重建完成、对外可用:递增代次号,通知各 TCP 服务(FTP/对时/远程显示)
+ * 旧 Socket 已随芯片复位失效、应重建监听 */
+ net_generation_inc();
+
return 0;
}
@@ -142,10 +160,26 @@ void net_task_func(void const *arg) {
osEventFlagsSet(sys_evtHandle, SYS_EVT_NET_READY);
}
+ /* 注册"立即应用网口配置"回调:UI 保存成功后经 net_cfg_request_apply() 触发 */
+ net_cfg_set_apply_cb(net_reconfig_request_cb);
+
DBG_INFO("netTask: started (single-socket mode)");
/* ==================== 主轮询循环 ==================== */
for (;;) {
+ /* UI 保存网口配置后请求立即应用:在此重建网络(新 IP/掩码/网关/MAC)。
+ * 重建完成后代次号已递增,各 TCP 服务据此重建监听;期间连接会断开(预期)。 */
+ if (sys_evtHandle != NULL
+ && (osEventFlagsGet(sys_evtHandle) & SYS_EVT_NET_RECONFIG) != 0u) {
+ (void)osEventFlagsClear(sys_evtHandle, SYS_EVT_NET_RECONFIG);
+ DBG_INFO("netTask: apply new net config (re-init)");
+ if (net_stack_bring_up() == 0) {
+ (void)osEventFlagsSet(sys_evtHandle, SYS_EVT_NET_READY);
+ } else {
+ DBG_ERROR("netTask: re-init FAIL");
+ }
+ }
+
net_poll();
net_process_messages();
osDelay(1);
diff --git a/App/time_sync.c b/App/time_sync.c
index 5ea15c22..9815f8ed 100644
--- a/App/time_sync.c
+++ b/App/time_sync.c
@@ -114,6 +114,9 @@ void time_sync_task(void *argument) {
}
for (;;) {
+ /* 记录本次监听对应的网络代次;代次变化(网络重建)则放弃旧监听重建 */
+ uint32_t gen = net_get_generation();
+
int ls = time_sync_open_listener();
if (ls < 0) {
DBG_ERROR("time_sync: open listener FAIL, retry later");
@@ -126,10 +129,19 @@ void time_sync_task(void *argument) {
do {
conn = net_accept(ls, NULL, NULL);
if (conn < 0) {
+ if (net_get_generation() != gen) {
+ net_close(ls); /* 网络重建:旧监听失效,回外层重建 */
+ break;
+ }
vTaskDelay(pdMS_TO_TICKS(200));
}
} while (conn < 0);
+ if (conn < 0) {
+ DBG_INFO("time_sync: network re-init, relisten");
+ continue;
+ }
+
time_sync_handle_conn(conn);
/* conn(单连接模式下即 ls)已被 net_close 释放,下次循环重建监听 */
diff --git a/Drivers/BSP/NET/lftpd/lftpd_inet.c b/Drivers/BSP/NET/lftpd/lftpd_inet.c
index ed557c09..9d43ca70 100644
--- a/Drivers/BSP/NET/lftpd/lftpd_inet.c
+++ b/Drivers/BSP/NET/lftpd/lftpd_inet.c
@@ -57,11 +57,19 @@ int lftpd_inet_get_socket_port(int socket)
int lftpd_inet_accept(int socket)
{
+ /* 网络重建(net_init)后旧 Socket 已失效:检测到代次变化即返回 -1,
+ * 由 lftpd_start 外层循环重建监听(网络未就绪时其 listen 会重试) */
+ uint32_t gen = net_get_generation();
+
while (1) {
int s = net_accept(socket, NULL, NULL);
if (s >= 0) {
return s;
}
+ if (net_get_generation() != gen) {
+ DBG_ERROR("network re-init: abort accept, relisten");
+ return -1;
+ }
vTaskDelay(pdMS_TO_TICKS(100));
}
}
diff --git a/Drivers/BSP/NET/net_config.h b/Drivers/BSP/NET/net_config.h
index 3d61cb85..0f1fdbe8 100644
--- a/Drivers/BSP/NET/net_config.h
+++ b/Drivers/BSP/NET/net_config.h
@@ -1,11 +1,14 @@
/*
* 模块名称:Network Configuration
- * 模块功能:网络子系统配置宏定义,包括最大Socket数量、缓冲区大小、
- * 超时时间等参数配置
+ * 模块功能:网络子系统编译期参数——Socket 数量、收发超时、TCP KeepAlive。
+ * 仅保留**实际被引用**的宏;未使用项已清理。
* 适用平台:STM32F4 系列(CH395F 以太网芯片)
* 作者:王建锋
* 创建日期:2026-07-18
* 修改记录:
+ * 2026-09-10 王建锋 清理未引用宏(TCP Server 监听 Socket 索引、收发缓冲、
+ * 连接/接受超时、select/队列/轮询);KeepAlive 三宏改为
+ * 实际生效值 60s/5s/3 并在 net_socket.c 引用(原宏为无效遗留值)
*/
#ifndef NET_CONFIG_H
@@ -22,37 +25,10 @@ extern "C" {
#define NET_MAX_SOCKETS 8
#endif
-/*
- * TCP Server 多连接模式配置
- * 监听 Socket 索引和数据 Socket 起始索引
- */
-#ifndef NET_TCP_SERVER_LISTEN_SOCK
-#define NET_TCP_SERVER_LISTEN_SOCK 0 /* 监听 Socket 索引 */
-#endif
-
-/*
- * 缓冲区大小配置
- */
-#ifndef NET_SEND_BUF_SIZE
-#define NET_SEND_BUF_SIZE 4096 /* 发送缓冲区大小(字节) */
-#endif
-
-#ifndef NET_RECV_BUF_SIZE
-#define NET_RECV_BUF_SIZE 4096 /* 接收缓冲区大小(字节) */
-#endif
-
/*
* 超时时间配置(毫秒)
* 0 = 无限等待(阻塞模式)
*/
-#ifndef NET_CONNECT_TIMEOUT_MS
-#define NET_CONNECT_TIMEOUT_MS 5000 /* 连接超时 */
-#endif
-
-#ifndef NET_ACCEPT_TIMEOUT_MS
-#define NET_ACCEPT_TIMEOUT_MS 0 /* 接受连接超时(0=无限等待) */
-#endif
-
#ifndef NET_SEND_TIMEOUT_MS
#define NET_SEND_TIMEOUT_MS 1000 /* 发送超时 */
#endif
@@ -62,39 +38,19 @@ extern "C" {
#endif
/*
- * TCP KeepAlive 配置
+ * TCP KeepAlive 配置(单位 ms;须为 500 的倍数且 IDLE > INTVL)
+ * 由 net_socket.c 的 net_init() 在 ch395f_init() 之前下发
*/
#ifndef NET_KEEPALIVE_IDLE_MS
-#define NET_KEEPALIVE_IDLE_MS 7200000 /* KeepAlive 空闲时间(默认2小时) */
+#define NET_KEEPALIVE_IDLE_MS 60000 /* 空闲 60 秒后开始探测 */
#endif
#ifndef NET_KEEPALIVE_INTVL_MS
-#define NET_KEEPALIVE_INTVL_MS 75000 /* KeepAlive 探测间隔(默认75秒) */
+#define NET_KEEPALIVE_INTVL_MS 5000 /* 5 秒探测一次 */
#endif
#ifndef NET_KEEPALIVE_CNT
-#define NET_KEEPALIVE_CNT 9 /* KeepAlive 探测次数(默认9次) */
-#endif
-
-/*
- * select/poll 最大 fd 数量
- */
-#ifndef NET_SELECT_MAX_FDS
-#define NET_SELECT_MAX_FDS NET_MAX_SOCKETS
-#endif
-
-/*
- * 轮询延时配置(毫秒)
- */
-#ifndef NET_POLL_DELAY_MS
-#define NET_POLL_DELAY_MS 1 /* 非阻塞轮询延时 */
-#endif
-
-/*
- * 消息队列配置
- */
-#ifndef NET_MSG_QUEUE_LENGTH
-#define NET_MSG_QUEUE_LENGTH 8 /* 消息队列深度 */
+#define NET_KEEPALIVE_CNT 3 /* 最多探测 3 次 */
#endif
#ifdef __cplusplus
diff --git a/Drivers/BSP/NET/net_socket.c b/Drivers/BSP/NET/net_socket.c
index 943f9fe4..b26ecc18 100644
--- a/Drivers/BSP/NET/net_socket.c
+++ b/Drivers/BSP/NET/net_socket.c
@@ -40,6 +40,7 @@ static int s_net_errno = 0; /* 最后错误码 */
static uint8_t s_net_initialized = 0; /* 初始化标志 */
static uint8_t s_local_ip[4]; /* 本机 IP 缓存(网络序,net_getsockname 用) */
static uint16_t s_dynamic_port = 49152; /* 动态端口分配起始值 */
+static volatile uint32_t s_net_generation = 0; /* 网络代次号:网络重建完成时递增(服务据此重建监听) */
/*
* 消息队列 - 线程安全核心
@@ -142,10 +143,11 @@ static bool s_mac_is_zero(const uint8_t *p_mac) {
ch395f_set_retrans_count(8); /* 重传 8 次*/
ch395f_set_retrans_period(500); /* 每次间隔 500ms,总计 4s 重传窗口 */
- /* 设置 TCP KeepAlive 参数(必须在 INIT_CH395 之前,单位 ms,必须为 500 的倍数,且 IDLE > INTVL)*/
- ch395f_set_keepalive_idle(60000); /* 空闲 60 秒后开始探测*/
- ch395f_set_keepalive_intvl(5000); /* 5 秒探测一次*/
- ch395f_set_keepalive_cnt(3); /* 最多探 3 次*/
+ /* 设置 TCP KeepAlive 参数(必须在 INIT_CH395 之前,单位 ms,必须为 500 的倍数,且 IDLE > INTVL)
+ * 值来自 net_config.h 的 NET_KEEPALIVE_*(60s/5s/3)*/
+ ch395f_set_keepalive_idle(NET_KEEPALIVE_IDLE_MS); /* 空闲 60 秒后开始探测*/
+ ch395f_set_keepalive_intvl(NET_KEEPALIVE_INTVL_MS); /* 5 秒探测一次*/
+ ch395f_set_keepalive_cnt(NET_KEEPALIVE_CNT); /* 最多探 3 次*/
/* 设置网络参数(必须在 INIT_CH395 之前调用 */
if (ip != NULL) {
@@ -207,6 +209,28 @@ static bool s_mac_is_zero(const uint8_t *p_mac) {
return 0;
}
+/*
+ * 函数功能:取网络代次号
+ * 入口参数:无
+ * 返回值:代次号(每次网络重建完成递增) uint32_t
+ * 限定条件:无
+ * 函数说明:见 net_socket.h 同名函数说明
+ */
+uint32_t net_get_generation(void) {
+ return s_net_generation;
+}
+
+/*
+ * 函数功能:递增网络代次号(网络重建完成、对外可用时调用)
+ * 入口参数:无
+ * 返回值:无
+ * 限定条件:net_init() 成功且 PHY/链路就绪后调用(当前仅 net_stack_bring_up)
+ * 函数说明:见 net_socket.h 同名函数说明
+ */
+void net_generation_inc(void) {
+ s_net_generation++;
+}
+
/*
* 函数功能:轮询 CH395F 中断状态,更新 Socket 连接状态并处理事件
* 返回值:本次轮询中发生事件的 Socket 数量 int >= 0
diff --git a/Drivers/BSP/NET/net_socket.h b/Drivers/BSP/NET/net_socket.h
index 0c55707d..d4b2f0ff 100644
--- a/Drivers/BSP/NET/net_socket.h
+++ b/Drivers/BSP/NET/net_socket.h
@@ -62,6 +62,27 @@ typedef struct {
*/
int net_init(const char *ip, const char *mask, const char *gateway, const uint8_t *mac);
+/*
+ * 函数功能:取网络代次号
+ * 入口参数:无
+ * 返回值:代次号(每次网络重建完成递增) uint32_t
+ * 限定条件:无
+ * 函数说明:TCP 服务(FTP / 对时 / 远程显示)据此感知"网络已重建"——代次变化
+ * 表示旧 Socket 已随芯片复位失效,服务应放弃旧监听并重建。
+ * 递增点在 net_stack_bring_up() 末尾(PHY/链路就绪后),避免服务在网络
+ * 尚未可用时抢先重建、也避免 uiTask 阻塞在消息队列上。
+ */
+uint32_t net_get_generation(void);
+
+/*
+ * 函数功能:递增网络代次号(网络重建完成、对外可用时调用)
+ * 入口参数:无
+ * 返回值:无
+ * 限定条件:net_init() 成功且 PHY/链路就绪后调用(当前仅 net_stack_bring_up)
+ * 函数说明:见 net_get_generation()
+ */
+void net_generation_inc(void);
+
/*
* 函数功能:轮询所有 Socket 状态(必须在主循环中调用)
* 返回值:有事件发生的 socket 数量
diff --git a/Drivers/BSP/NETCFG/net_cfg.c b/Drivers/BSP/NETCFG/net_cfg.c
index c1a514ce..f9c81d08 100644
--- a/Drivers/BSP/NETCFG/net_cfg.c
+++ b/Drivers/BSP/NETCFG/net_cfg.c
@@ -58,6 +58,7 @@ static net_cfg_t s_cfg = {
};
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; /* "立即应用"平台回调(未注册 = 空操作) */
/* 函数定义区 */
@@ -178,10 +179,34 @@ int net_cfg_save(void) {
DBG_ERROR("save: store write FAILED");
return -1;
}
- DBG_INFO("save: OK (effective at next power-on)");
+ DBG_INFO("save: OK (file written)");
return 0;
}
+/*
+ * 函数功能:注册"立即应用"回调
+ * 入口参数:cb - 回调函数(NULL = 注销) net_cfg_apply_cb_t
+ * 返回值:无
+ * 限定条件:应用启动早期注册一次即可
+ * 函数说明:见 net_cfg.h 同名函数说明
+ */
+void net_cfg_set_apply_cb(net_cfg_apply_cb_t cb) {
+ s_apply_cb = cb;
+}
+
+/*
+ * 函数功能:请求立即应用当前 RAM 配置
+ * 入口参数:无
+ * 返回值:无
+ * 限定条件:无(未注册回调时空操作)
+ * 函数说明:见 net_cfg.h 同名函数说明
+ */
+void net_cfg_request_apply(void) {
+ if (s_apply_cb != NULL) {
+ s_apply_cb();
+ }
+}
+
/*
* 函数功能:取 RAM 配置副本(只读指针)
* 入口参数:无
diff --git a/Drivers/BSP/NETCFG/net_cfg.h b/Drivers/BSP/NETCFG/net_cfg.h
index 5ebcd210..6eaadbeb 100644
--- a/Drivers/BSP/NETCFG/net_cfg.h
+++ b/Drivers/BSP/NETCFG/net_cfg.h
@@ -57,6 +57,35 @@ int net_cfg_load(void);
*/
int net_cfg_save(void);
+/*
+ * "立即应用"回调类型:平台挂钩(可选)
+ * 入口参数:无
+ * 返回值:无
+ * 函数说明:MCU 端由 netTask 注册——置 SYS_EVT_NET_RECONFIG 事件,由 netTask
+ * 重建网络;模拟器无网络栈,不注册(空操作)。经回调而非直接调用,
+ * 避免 NETCFG(驱动层)反向依赖 App 层。
+ */
+typedef void (*net_cfg_apply_cb_t)(void);
+
+/*
+ * 函数功能:注册"立即应用"回调
+ * 入口参数:cb - 回调函数(NULL = 注销) net_cfg_apply_cb_t
+ * 返回值:无
+ * 限定条件:应用启动早期注册一次即可
+ * 函数说明:见 net_cfg_apply_cb_t
+ */
+void net_cfg_set_apply_cb(net_cfg_apply_cb_t cb);
+
+/*
+ * 函数功能:请求立即应用当前 RAM 配置
+ * 入口参数:无
+ * 返回值:无
+ * 限定条件:无(未注册回调时空操作)
+ * 函数说明:UI"网口设置"页保存行写盘成功后调用;真正生效由平台回调完成
+ * (MCU:netTask 重建网络,无需重启)
+ */
+void net_cfg_request_apply(void);
+
/*
* 函数功能:取 RAM 配置副本(只读指针)
* 入口参数:无
diff --git a/Drivers/BSP/UI/ui_net_sink.c b/Drivers/BSP/UI/ui_net_sink.c
index fe172787..062930d7 100644
--- a/Drivers/BSP/UI/ui_net_sink.c
+++ b/Drivers/BSP/UI/ui_net_sink.c
@@ -35,6 +35,8 @@ static uint8_t g_rx[64];
static int g_listen = -1;
static int g_client = -1;
+static uint16_t g_port; /* 监听端口(网络重建后重监听用) */
+static uint32_t g_gen; /* 建立监听时的网络代次号(变化 = 网络已重建) */
/*
* 函数功能:计算 RemoDispBus 数据段异或校验值
@@ -204,6 +206,7 @@ static void remo_handle(const uint8_t *f, int n)
*/
int ui_net_sink_start(uint16_t port)
{
+ g_port = port;
g_listen = net_socket(NET_AF_INET, NET_SOCK_STREAM);
if (g_listen < 0) {
return -1;
@@ -225,6 +228,7 @@ int ui_net_sink_start(uint16_t port)
return -1;
}
g_client = -1;
+ g_gen = net_get_generation(); /* 成功才记录代次;失败保持旧值以便下轮重试 */
return 0;
}
@@ -236,9 +240,21 @@ int ui_net_sink_start(uint16_t port)
* 函数说明:1. 无客户端时尝试接受连接(不主动上报 INIT,由客户端按需请求)
* 2. 有客户端时非阻塞收帧并解析
* 3. 连接关闭或异常时复位,等待重监听,不阻塞界面刷新
+ * 4. 网络重建(net_init 代次变化)时关闭旧监听并重建(IP 变更后自愈)
*/
void ui_net_sink_poll(void)
{
+ /* 网络重建(net_init 代次变化):旧监听/客户端已随芯片复位失效,重建监听 */
+ if (net_get_generation() != g_gen) {
+ if (g_listen >= 0) {
+ net_close(g_listen);
+ g_listen = -1;
+ }
+ g_client = -1;
+ (void)ui_net_sink_start(g_port); /* 失败则下轮再试(g_gen 未更新) */
+ return;
+ }
+
if (g_listen < 0) {
return;
}
diff --git a/Drivers/BSP/UI/ui_page_net_cfg.c b/Drivers/BSP/UI/ui_page_net_cfg.c
index 0368a9ed..c50d2b1b 100644
--- a/Drivers/BSP/UI/ui_page_net_cfg.c
+++ b/Drivers/BSP/UI/ui_page_net_cfg.c
@@ -2,13 +2,14 @@
* 模块名称:UI Page Net Cfg (网口设置内容页)
* 模块功能:网口设置叶子节点的内容页数据 + 行为——5 行地址列表(IP 地址/子网
* 掩码/默认网关/MAC 地址 + 保存行,5 行每页共 1 页);
- * 行槽顶 = 24+14i、选中白带 x=2..156 高 13、名 x=6 / 值 x=60;
+ * 行槽顶 = 24+14i、选中白带 x=2..156 高 13、名 x=4 / 值 x=54;
* 值取自 net_cfg RAM 副本(netTask 上电自 Flash 加载并回写实际 MAC);
* 编辑模式:ENTER 进选中地址行(字节光标 = 活动字节,反显标记),
* ↑↓调字节 0..255(环绕、值列实时刷新)、←→移字节光标(首↔末环绕)、
* ENTER 确认退出编辑;
* 权限:edit_capable=1——查看密码 → 只读(编辑/保存键无效),
- * 修改密码 → 可编辑;保存行 ENTER → net_cfg_save()(下次上电生效);
+ * 修改密码 → 可编辑;保存行 ENTER → net_cfg_save() 写盘 +
+ * net_cfg_request_apply() 通知 netTask 立即重建网络(无需重启);
* 向框架 ui_content_page 提供页面描述符(零 LVGL 对象)
* 适用平台:STM32F407ZGTx / PC 模拟器(两端共用)
* 作者:王建锋
@@ -29,8 +30,9 @@
/*
* 布局常量(设备像素;1bpp 屏 160x160):
* 5 行共 1 页(行槽顶 y = 24+14i,末行止于 y=93,与页脚 y=143 无冲突);
- * 名 x=6 / 值 x=60(值列须 ≥60:4 汉字名占 48px + 间隙;值列宽——
- * IP/掩码/网关 11 ASCII 字符 = 66px 止于 126,MAC 12 hex 字符 = 72px 止于 132)
+ * 名 x=4 / 值 x=54(值列须 ≥54:4 汉字名占 48px + 间隙;值列宽——
+ * IP/掩码/网关 11 ASCII 字符 = 66px 止于 119,MAC 17 字符(冒号分隔)= 102px 止于 155,
+ * 均在选中白带 x=2..156 内)
*/
#define NC_ROW_COUNT 5u /* 4 地址行 + 1 保存行 */
#define NC_ADDR_ROW_MAX 4u /* 地址行范围 0..3(IP/掩码/网关/MAC) */
@@ -38,8 +40,8 @@
#define NC_TOP_Y 24 /* 首行槽顶 y */
#define NC_PITCH 14 /* 行距 */
#define NC_BAND_H 13 /* 选中白带高 */
-#define NC_LEFT_X 6 /* 左列 x(名) */
-#define NC_RIGHT_X 60 /* 右列 x(值) */
+#define NC_LEFT_X 4 /* 左列 x(名) */
+#define NC_RIGHT_X 54 /* 右列 x(值) */
#define NC_ROW_BUF_LEN 32u /* 行缓冲长度(与框架 CP_ROW_BUF_LEN 一致) */
/* 静态变量定义区 */
@@ -84,7 +86,7 @@ static uint8_t nc_octet_count(uint16_t row) {
* 返回值:无
* 限定条件:p_buf 非空
* 函数说明:IP/掩码/网关 = "192.168.001.100"(每段 3 位补零定长,字节 i 恒在
- * 字符 4i 起);MAC = "A494B6123456"(无分隔 hex,字节 i 恒在字符 2i 起)
+ * 字符 4i 起);MAC = "A4:94:B6:12:34:56"(冒号分隔,字节 i 恒在字符 3i 起)
*/
static void nc_fmt_value(uint16_t row, char *p_buf) {
const net_cfg_t *cfg = net_cfg_get();
@@ -93,7 +95,7 @@ static void nc_fmt_value(uint16_t row, char *p_buf) {
uint8_t mac[6];
net_cfg_get_mac(mac);
- (void)lv_snprintf(p_buf, NC_ROW_BUF_LEN, "%02X%02X%02X%02X%02X%02X",
+ (void)lv_snprintf(p_buf, NC_ROW_BUF_LEN, "%02X:%02X:%02X:%02X:%02X:%02X",
(unsigned)mac[0], (unsigned)mac[1], (unsigned)mac[2],
(unsigned)mac[3], (unsigned)mac[4], (unsigned)mac[5]);
return;
@@ -196,7 +198,7 @@ static void nc_draw_cb(lv_layer_t *layer, const lv_area_t *clip) {
}
nc_fmt_value(sel, value);
- start = (uint32_t)s_cursor * ((sel == 3u) ? 2u : 4u);
+ start = (uint32_t)s_cursor * ((sel == 3u) ? 3u : 4u); /* 字节 i 字符偏移:IP/掩码/网关 4i,MAC 3i(冒号分隔) */
seg_len = (sel == 3u) ? 2u : 3u;
slot_y = NC_TOP_Y + (int32_t)sel * NC_PITCH;
@@ -223,10 +225,17 @@ static void nc_draw_cb(lv_layer_t *layer, const lv_area_t *clip) {
{
lv_draw_label_dsc_t ld;
+ char seg[4]; /* 段文本:IP/掩码/网关 3 字符、MAC 2 字符,+ '\0' */
+
+ /* 只绘制"段文本"本身:若传整串(value + start),LVGL 在未置 EXPAND 标志时
+ * 按 coords 宽度(此处为段宽)折行,会逐行向盒下方绘制、溢出到其它行/空白区。
+ * 传段文本(宽度 ≤ 盒宽)则单行绘制,恰好落在光标盒内。 */
+ memcpy(seg, value + start, seg_len);
+ seg[seg_len] = '\0';
lv_draw_label_dsc_init(&ld);
ld.base.layer = layer;
- ld.text = value + start;
+ ld.text = seg;
ld.text_static = 0u; /* 栈缓冲(同帧有效) */
ld.color = lv_color_white();
ld.ofs_x = 1; /* box.x1 = x0-1,+1 = x0(与框架行文本原点一致) */
@@ -295,6 +304,9 @@ static bool nc_key_cb(lv_key_t key) {
}
if (sel == NC_SAVE_ROW) {
s_saved = (net_cfg_save() == 0) ? 1u : 2u;
+ if (s_saved == 1u) {
+ net_cfg_request_apply(); /* 立即应用:通知 netTask 重建网络(无需重启) */
+ }
ui_content_page_invalidate();
return true;
}
diff --git a/README.md b/README.md
index 2914856b..a75810b3 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,8 @@ flowchart TB
APP --> BSP --> MW --> HAL --> HW
```
+> 更详细的全景架构(六层分层 + 任务与就绪屏障 + 启动时序 + 存储/网络/UI 栈 + 目录组织)见 **`docs/项目架构总览.md`**。
+
### 启动流程
```
@@ -369,9 +371,11 @@ pre-commit run --all-files
| 文档 | 说明 |
|------|------|
+| `docs/项目架构总览.md` | **项目全景架构**(六层分层 + 任务/就绪屏障 + 启动时序 + 存储/网络/UI 栈 + 目录组织) |
| `docs/STM32F4-Base存储架构说明.md` | 三层存储软件栈详解(FatFS → dhara FTL → NAND) |
| `docs/项目启动顺序管理.md` | Event Flags 就绪屏障设计 |
| `docs/系统墙钟时间维护说明.md` | RTC + 墙钟时间子系统 |
+| `docs/网络配置说明.md` | 网口设置页配置(UI 可改的 IP/掩码/网关/MAC:持久化 + 立即生效 + 页面操作) |
| `docs/UI 设计/UI_设计架构.md` | LVGL UI 系统架构(菜单 / 内容页 / 内存预算) |
| `docs/UI 设计/菜单页面设计.md` | 菜单导航行为、数据流与内容页设计 |
| `docs/UI 设计/UI_界面制作流程.md` | 参考图 → HTML 样机 → LVGL 代码管线(P0~P5 门禁) |
diff --git a/docs/UI 设计/内容页架构.md b/docs/UI 设计/内容页架构.md
index 12d421eb..1dbeb164 100644
--- a/docs/UI 设计/内容页架构.md
+++ b/docs/UI 设计/内容页架构.md
@@ -216,6 +216,8 @@ flowchart TD
- **文本**:静态行 `text_static=1`(Flash 字符串不拷贝);动态行与页脚页码用栈缓冲
(`lv_draw_label` 在同一刷新周期内**同步**光栅化,指针仅需调用期间有效)
- **页脚**:`lv_snprintf` 现算(MSVC 对 `snprintf` 中文格式串误报 C4819,故用 LVGL 自带 `lv_snprintf`)
+
+> ⚠️ **`lv_draw_label` 折行陷阱**:`lv_draw_label` 的换行宽度取 **`coords` 宽度**(未置 `LV_TEXT_FLAG_EXPAND` 时 `w = lv_area_get_width(coords)`)。在**窄区域**(光标盒 / 小方框)里传入**长于该区域的文本**时,LVGL 会按该宽度**折行并逐行向下绘制**,溢出到下方其它行与空白区(表现为"下方弹出一串字符")。规则:窄区域只传"恰好该区域宽度的文本"(如网口设置光标段只传 3 个字符),长文本请在整行宽条带内绘制。
- 容器样式为**常量样式**(`LV_STYLE_CONST_INIT`,零 LVGL 池分配),见 `UI_设计架构.md` §5.6
## 四、页面描述符(content_page_def_t)
@@ -473,3 +475,5 @@ extern const content_page_def_t ui_page_device_info_desc; /* 描述符(Flash
| 2026-09-09 | 密码页落地(§4.1):框架描述符新增 title_y/hide_deco/hide_footer/key_intercept + 新 API `ui_content_page_invalidate`;新模块 `ui_page_password`(3 数字框反显光标 + arm API,draw_cb+key_cb);菜单受保护叶子路由(arm→开密码页,确认一致后 `menu_password_ok` **先关页再续做进入动作**);字体 97→102 汉字(198 字形);双端注册;回归扩至 7 页 + 往返水位,五门禁全绿 |
| 2026-09-09 | 权限模型(替代"每页专属密码",§4.1):表行尾注记 ` #P`(需密码,无类别);`ui_pwd_def.h` 两个编译期密码(查看 000 / 修改 123)+ 新 `ui_perm_t` 权限枚举——密码页输查看密码授只读权限、输修改密码授修改权限(只有修改权限进入后才能改参数);生成器同步改造并重新生成(pwd 列 = 0/1 布尔,`ui_menu_tab_has_pwd`);框架 `ui_content_page_open(id, perm)` 携授予权限 + 描述符 `edit_capable` + 新 API `ui_content_page_is_editable`(页面钩子据以判只读/可编辑);路由密码检查提到内容页判断之前(修复带内容页需密码叶子绕过密码的路由缺口);无密码叶子直接进、默认只读。§4.1 功能规则/待确认表/落地要点同步。验证:五门禁全绿(菜单 8 态 + 7 页像素一致、池 4784/4864 B 不变、Keil 0 错 0 警、代码规范 PASS) |
| 2026-09-10 | 网口设置内容页:新增 `Drivers/BSP/NETCFG/`(Flash 持久化 magic 'NCFG'+CRC-16/MODBUS,工厂默认 192.168.1.100/24、gw .1、MAC 全零=芯片内置)+ 页面插件 `ui_page_net_cfg`(5 行动态值 + 可编辑:↑↓调字节 0..255 环绕 / ←→移光标 / ENTER 确认,保存行 ENTER → `net_cfg_save` 下次上电生效);框架新增描述符钩子 `init_cb`(开页重置运行态)+ API `ui_content_page_selected_row`;`net_init` 增 `mac` 参数、netTask 改为等 `SYS_EVT_STORAGE_READY` → `net_cfg_load` → `net_init(ip,mask,gw,mac)` → 回写实际 MAC 影子;模拟器 main 调 `net_cfg_load` 使参考图显示工厂默认值;字体修复:lv_font_conv 丢弃低码位符号(U+FF0C 全角逗号 < 0x4E2D 范围起点 → 页脚逗号长期空白)改由 `merge_extra_symbols()` 补回 + 原本缺字(存/密/户/本/用/钥)→ 111 字 / 208 字形;回归扩至 11 页(网口设置 4 态:可编辑/只读/编辑光标反显/保存 OK),五门禁全绿 |
+| 2026-09-10 | 修复网口设置页编辑态渲染 bug:光标段原传整串 `value + start`,而 `lv_draw_label` 按 `coords`(光标盒,仅段宽)折行 → 逐行向盒下方绘制、溢出到其它行与空白区("下方弹出一串字符")。改为只传**段文本**(IP/掩码/网关 3 字符、MAC 2 字符);§3.5 补"`lv_draw_label` 折行陷阱"说明 |
+| 2026-09-10 | 网口设置页 MAC 值改常识格式 **`A4:94:B6:12:34:56`**(冒号分隔,17 字符 = 102px);布局调整 名 x=6→4、值 x=60→54(MAC ink 止于 155,仍在白带 2..157 内);编辑光标段偏移 MAC 2i→3i |
diff --git a/docs/UI 设计/菜单页面设计.pdf b/docs/UI 设计/菜单页面设计.pdf
deleted file mode 100644
index 88234b6b..00000000
Binary files a/docs/UI 设计/菜单页面设计.pdf and /dev/null differ
diff --git a/docs/就绪屏障.svg b/docs/就绪屏障.svg
new file mode 100644
index 00000000..ada0a81e
--- /dev/null
+++ b/docs/就绪屏障.svg
@@ -0,0 +1,89 @@
+
diff --git a/docs/网络配置说明.md b/docs/网络配置说明.md
new file mode 100644
index 00000000..4011206e
--- /dev/null
+++ b/docs/网络配置说明.md
@@ -0,0 +1,252 @@
+# 网口设置页配置说明
+
+> 版本:v2.0 日期:2026-09-10
+> 适用工程:STM32F4-Base(STM32F407ZGTx + CH395F 以太网控制器)
+> 范围:UI「网口设置」页可配置的网口参数(IP 地址 / 子网掩码 / 默认网关 / MAC 地址)——
+> 参数与默认值、持久化、生效链路、页面操作与权限。
+
+---
+
+## 0. 速览
+
+| 项目 | 内容 |
+|---|---|
+| **可配置** | IP 地址、子网掩码、默认网关、MAC 地址 |
+| **入口** | 屏幕:`通讯参数 → 网口设置`(修改密码 `123` 可编辑;查看密码 `000` 只读) |
+| **存储** | NAND Flash 文件 `/cfg/netcfg.bin`(掉电不丢) |
+| **生效** | 按「保存」后**立即生效**(网络重建约 1~3s,既有 TCP 连接会断开,无需重启) |
+| **工厂默认** | `192.168.1.100` / `255.255.255.0` / `192.168.1.1` / 芯片内置 MAC |
+
+**典型操作(改 IP)**:
+
+1. 屏幕进入 `通讯参数 → 网口设置`,输入修改密码 `123`
+2. 选中 `IP地址` 行 → 回车进编辑 → `↑/↓` 调数字、`←/→` 移光标 → 回车确认
+3. 选中 `保存` 行 → 回车 → 显示 `OK`
+4. 约 1~3 秒后设备在新 IP 上恢复服务(FTP / 对时 / 远程显示自动重建监听)
+
+---
+
+## 1. 可配置参数与工厂默认值
+
+定义在 `Drivers/BSP/NETCFG/net_cfg.h` 的 `net_cfg_t`:
+
+| 参数 | 字段 | 工厂默认 | 说明 |
+|---|---|---|---|
+| IP 地址 | `ip[4]` | **192.168.1.100** | 静态 IP(不启用 DHCP) |
+| 子网掩码 | `mask[4]` | **255.255.255.0** | /24 |
+| 默认网关 | `gw[4]` | **192.168.1.1** | |
+| MAC 地址 | `mac[6]` | **全零** | 全零 = 不覆盖芯片内置 MAC(见 §2) |
+
+工厂默认值是**单一来源**:`net_cfg.c` 顶部 `NET_CFG_DEF_*` 宏,同时用于 `s_cfg` 静态初始化与 `s_cfg_defaults()`,避免两处漂移。
+
+> **RAM 副本自启动即有效**:`s_cfg` 以工厂默认值**静态初始化**(落 `.data`),不依赖 `net_cfg_load()` 是否被调用——即便存储异常,页面与网络拿到的也是有效配置而非全零。
+
+---
+
+## 2. MAC 地址语义
+
+| 存储值 | `net_cfg_get_mac()` 返回 | 网络初始化时 |
+|---|---|---|
+| 非全零 | 存储的 MAC | 写入芯片 EEPROM |
+| 全零(默认) | 芯片实际 MAC 影子(已回写时);否则全零 | **不写**,保持芯片内置 MAC |
+
+- 芯片实际 MAC 由 netTask 在网络初始化成功后读回,经 `net_cfg_set_active_mac()` 存入**影子区**——供页面只读显示(页面不直接触 SPI)。
+- MAC 写芯片 EEPROM,**每上电重放同值幂等**。
+
+---
+
+## 3. 持久化
+
+### 3.1 磁盘格式(`/cfg/netcfg.bin`)
+
+`net_cfg.c` 的 `net_cfg_blob_t`(两端布局一致,小端):
+
+| 偏移 | 字段 | 大小 | 说明 |
+|---|---|---|---|
+| 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` |
+
+- **加载校验**:magic / ver / CRC 任一不符 → 回退工厂默认并打印 `DBG_ERROR`(坏配置不带崩网络)。
+- **保存**:先建 `/cfg` 目录(`f_mkdir`,已存在不影响),再 `FA_CREATE_ALWAYS` 覆盖写。
+
+### 3.2 存储后端(双端)
+
+`net_cfg_store.h` 定义接口,两平台各一份实现(互斥编译):
+
+| 平台 | 文件 | 位置 |
+|---|---|---|
+| MCU | `net_cfg_store_mcu.c` | NAND 上 FatFS 文件 `/cfg/netcfg.bin` |
+| 模拟器 | `net_cfg_store_sim.c` | 工作目录本地文件 `sim_netcfg.bin`(`fopen`) |
+
+磁盘格式两端一致,文件可互读。
+
+---
+
+## 4. 生效链路
+
+### 4.1 上电启动
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant DT as defaultTask
+ participant NT as netTask
+ participant CFG as net_cfg
+ participant CH as CH395F
+
+ Note over DT,NT: 上电:CubeMX 已创建任务与事件组,调度器启动
+ DT->>DT: FatFS 挂载(f_mount;无卷则 f_mkfs)
+ DT-->>NT: 置 SYS_EVT_STORAGE_READY(就绪屏障)
+ NT->>NT: 等 SYS_EVT_STORAGE_READY(≤10s;超时用工厂默认继续)
+ NT->>CFG: net_cfg_load()
+ Note over CFG: 读 /cfg/netcfg.bin;缺失/损坏 → 工厂默认
+ CFG-->>NT: RAM 副本(有效)
+ NT->>CFG: net_cfg_get() → net_cfg_fmt_ipv4()
+ CFG-->>NT: "192.168.001.100"
+ NT->>CH: 初始化网络(下发 IP / 掩码 / 网关 / MAC)
+ CH-->>NT: 芯片实际 MAC
+ NT->>CFG: net_cfg_set_active_mac()(MAC 影子)
+ NT->>NT: 置 SYS_EVT_NET_READY(广播网络就绪)
+```
+
+### 4.2 保存后立即生效
+
+保存网口参数后,设备立即按新参数重建网络,无需重启。
+
+**机制**:`请求 → netTask 重建 → 代次号 → 服务自愈`
+
+**约束与对策**:网络重建会复位 CH395F,所有监听 Socket 随之失效;而三个 TCP 服务(FTP :21 / 对时 :8888 / 远程显示 :7003)的监听由各自任务持有。为此引入"网络代次号"——各服务检测到代次变化后主动重建监听,在新 IP 上恢复服务。
+
+```mermaid
+sequenceDiagram
+ participant UI as 网口设置页
+ participant CFG as net_cfg
+ participant NT as netTask
+ participant NS as net_socket
+ participant SVC as FTP/对时/远程显示
+
+ UI->>CFG: 保存行 ENTER → net_cfg_save()(写盘)
+ CFG->>NT: net_cfg_request_apply() → 回调置 SYS_EVT_NET_RECONFIG
+ NT->>NT: 主循环检测到该位 → 重建网络(新 IP/掩码/网关/MAC)
+ NT->>NS: net_generation_inc()(代次号 +1,此刻网络已可用)
+ NT->>SVC: 重新广播 SYS_EVT_NET_READY
+ SVC->>SVC: 轮询发现代次变化 → 关闭旧监听 → 重建监听(新 IP)
+```
+
+**三个服务的自愈点**:
+
+| 服务 | 端口 | 检测位置 | 自愈方式 |
+|---|---|---|---|
+| FTP | 21 | `lftpd_inet_accept()` | 代次变化 → 返回 -1 → `lftpd_start` 外层循环重建监听 |
+| 对时 | 8888 | `time_sync_task()` 的 accept 循环 | 代次变化 → 关旧监听 → 回外层重建 |
+| 远程显示 | 7003 | `ui_net_sink_poll()` | 代次变化 → 关旧监听/客户端 → 重建 |
+
+**关键设计点**:
+
+1. **SPI 归 netTask 独占**:UI 只置事件位,不直接操作 CH395F。
+2. **代次号在链路就绪后才递增**:避免服务在网络未就绪时抢先重建,也避免 uiTask 阻塞在消息队列上冻结界面。
+3. **无回调 = 空操作**:模拟器不注册应用回调,行为退化为"仅写盘"。
+4. **既有连接必断**:IP 变更导致连接不可达,属网络层语义。
+
+---
+
+## 5. 页面操作(网口设置页)
+
+菜单路径:`通讯参数 → 网口设置`(菜单节点 30,行尾 ` #P` 需密码)。页面模块 `Drivers/BSP/UI/ui_page_net_cfg.c`。
+
+### 5.1 页面布局
+
+| 行 | 名称 | 值格式 |
+|---|---|---|
+| 0 | IP地址 | `192.168.001.100`(每段 `%03u` 补零,**定长 11 字符**) |
+| 1 | 子网掩码 | `255.255.255.000` |
+| 2 | 默认网关 | `192.168.001.001` |
+| 3 | MAC地址 | `A4:94:B6:12:34:56`(6 字节十六进制,冒号分隔) |
+| 4 | 保存 | `ENTER` → 保存后显示 `OK` / `ERR` |
+
+### 5.2 权限
+
+`ui_pwd_def.h` 编译期密码:
+
+| 输入的密码 | 授予权限 | 页面行为 |
+|---|---|---|
+| 查看密码 `000` | `UI_PERM_VIEW` | **只读**:ENTER 无效,仅可浏览 |
+| 修改密码 `123` | `UI_PERM_MODIFY` | **可编辑**:ENTER 进编辑模式 |
+
+### 5.3 编辑模式
+
+ENTER 进入选中地址行 → `↑/↓` 调活动字节(0..255 **环绕**)、`←/→` 移字节光标(首↔末环绕)、ENTER 确认退出。调整**实时落 RAM 副本**(`net_cfg_set`),**只有保存行 ENTER 才写盘并应用**(`net_cfg_save` + `net_cfg_request_apply`);ESC 退出不写盘、改动丢弃(重启后恢复上次保存值)。
+
+> 值格式与解析约定:页面写定长 `%03u` 字符串 → 网络初始化按十进制逐段解析、容忍前导零(如 `"001"` → 1)。
+
+---
+
+## 6. 文件与 API 索引
+
+| 文件 | 职责 |
+|---|---|
+| `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` |
+| `Drivers/BSP/NETCFG/net_cfg_store_sim.c` | 模拟器后端:本地 `sim_netcfg.bin` |
+| `Drivers/BSP/UI/ui_page_net_cfg.c` | 网口设置内容页(显示 / 编辑 / 保存) |
+| `Drivers/BSP/UI/ui_pwd_def.h` | 编译期密码(查看 000 / 修改 123) |
+| `App/task/net_task.c` | 上电加载配置 + 消费"立即应用"请求重建网络 |
+| `Drivers/BSP/NET/net_socket.h/.c` | 网络代次号(服务自愈依据) |
+
+**对外 API**(`net_cfg.h`):
+
+| API | 说明 |
+|---|---|
+| `net_cfg_load()` | 从存储加载到 RAM 副本;文件缺失/损坏回退工厂默认(恒返回 0) |
+| `net_cfg_save()` | 组装磁盘格式写盘;0 成功 / -1 失败 |
+| `net_cfg_request_apply()` | 请求立即应用(页面保存成功后调用) |
+| `net_cfg_set_apply_cb()` | 注册"立即应用"平台回调 |
+| `net_cfg_get()` | 取 RAM 副本只读指针(恒非空) |
+| `net_cfg_set()` | 更新 RAM 副本(不写盘) |
+| `net_cfg_set_active_mac()` | 回写芯片实际 MAC 影子(netTask 调) |
+| `net_cfg_get_mac()` | 取显示用 MAC(存储非零 → 存储;否则影子;否则全零) |
+| `net_cfg_fmt_ipv4()` | 4 字节 → `"192.168.001.100"` 定长字符串 |
+
+---
+
+## 7. 陷阱
+
+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)。
+
+---
+
+## 8. 相关文档
+
+| 主题 | 文档 |
+|---|---|
+| CH395F 已知陷阱 | `docs/问题记录/CH395F_Trap_Records.md` |
+| 网络测试指南 | `docs/测试说明/CH395F_Test_Guide.md` |
+| 启动顺序 / 就绪屏障 | `docs/项目启动顺序管理.md` |
+| 项目分层架构 | `docs/项目架构总览.md` |
+| 内容页框架(含网口设置页) | `docs/UI 设计/内容页架构.md` |
+
+---
+
+## 9. 变更记录
+
+| 日期 | 变更 |
+|---|---|
+| 2026-09-10 | 初版:网口参数 + 协议栈参数 + 陷阱 |
+| 2026-09-10 | `net_config.h` 收敛:删除 8 个从未引用的遗留宏;KeepAlive 三宏改为实际生效值并由 `net_socket.c` 引用 |
+| 2026-09-10 | **保存后立即生效**:新增"应用回调 + 网络代次号"机制 |
+| 2026-09-10 | §0 速览重写为并列对比表 + 两条生效时机 + 分步操作 |
+| 2026-09-10 | §1.7 改为客观描述当前行为,补"三个服务的自愈点"表 |
+| 2026-09-10 | **v2.0 聚焦 UI 页面配置**:删除"协议栈参数(② 编译期常量)"整章;标题 / 范围 / 速览 / 索引 / 陷阱相应收敛为"网口设置页"内容 |
+| 2026-09-10 | §4.1 上电启动由文本流改为**时序图**(与 §4.2 一致,任务先后关系与配置回退更直观) |
+| 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
index d28526e0..ce7f607e 100644
--- a/docs/项目启动顺序管理.md
+++ b/docs/项目启动顺序管理.md
@@ -39,6 +39,11 @@
这与 CubeMX"一次性创建全部任务"的模式完全兼容:任务仍由 `MX_FREERTOS_Init` 统一创建,但各自在入口 `wait`,相当于"创建即挂起、就绪才放行"。
+
+
+> 上图:**左 = 生产者(set)· 中 = 事件组 `sys_evtHandle` · 右 = 消费者(wait)**;任务卡与事件位对齐同一行,`bit1 → ftpTask` 的额外依赖用 **All 汇合点**(`osFlagsWaitAll`)表达。
+> 源码 `tools/arch_diagram/gen_barrier_svg.py`(改数据重跑即可),PNG 预览本地渲染、不入库。
+
### 为什么用 Event Flags 而非全局变量
| 维度 | 全局标志 `g_net_ready` | Event Flags |
|------|----------------------|-------------|
@@ -65,6 +70,7 @@ extern osEventFlagsId_t sys_evtHandle;
#define SYS_EVT_STORAGE_READY (1u << 1) /* defaultTask:FTL/FatFS 挂载完成 */
#define SYS_EVT_FTP_READY (1u << 2) /* ftpTask:lftpd_start 完成 */
#define SYS_EVT_TIMESYNC_READY (1u << 3) /* timeSyncTask:首次时间同步完成 */
+#define SYS_EVT_NET_RECONFIG (1u << 4) /* UI:请求立即应用新网口配置(netTask 消费后重建网络) */
/* 后续新增子系统继续左移分配 bit,32 位上限内即可 */
```
@@ -75,6 +81,7 @@ extern osEventFlagsId_t sys_evtHandle;
| `SYS_EVT_STORAGE_READY` | `defaultTask`(挂载成功后) | 依赖文件系统的测试套件(如 STORAGE/GD5F) |
| `SYS_EVT_FTP_READY` | `ftpTask`(`lftpd_start` 成功后) | 无(示例,预留给需要 FTP 就绪的后续功能) |
| `SYS_EVT_TIMESYNC_READY` | (预留,暂无生产者置位) | 可选:对时精度敏感的业务 |
+| `SYS_EVT_NET_RECONFIG` | UI 网口设置页保存成功(经 `net_cfg` 应用回调置位) | `netTask` 主循环:消费后 `net_stack_bring_up()` 重建网络(新网口参数立即生效),并递增网络代次号 |
### 3.3 事件组创建时机
`sys_evtHandle` 必须在**任何任务 `wait` 之前**存在。`main.c` 调用顺序为:
diff --git a/docs/项目架构总览.md b/docs/项目架构总览.md
new file mode 100644
index 00000000..cb3b6c09
--- /dev/null
+++ b/docs/项目架构总览.md
@@ -0,0 +1,307 @@
+# STM32F4-Base 项目架构总览
+
+> 版本:v1.0 日期:2026-09-10
+> 平台:STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2)+ FreeRTOS V10.3.1(CMSIS-RTOS2)
+> 用途:一页看清项目的分层、任务、数据流与目录组织。细节以各模块专项文档为准(见 §9)。
+
+---
+
+## 1. 全景分层架构
+
+
+
+> 上图由 `tools/arch_diagram/gen_arch_svg.py` 生成(**手绘 SVG**,布局确定、零连线交叉);改架构后编辑脚本顶部 `ROWS` 数据重跑即可,源数据与 §1.1 表一致。
+> 需要 PNG 预览(PPT/聊天分享):用 Edge 无头渲染 `docs/项目架构总览.svg` 即可(PNG 不入库)。
+
+> 分层原则:**上层只依赖相邻下层(单向)**;**① 业务层为顶层**(仅 `net_cfg`,被 UI 与基础服务共用),**② UI 层与 ③ 基础服务层同级并列**。跨层的具体调用关系见 §1.2 表。
+
+### 1.1 各层职责与关键模块
+
+> ① 业务层(顶层,仅 `net_cfg`);② UI 层与 ③ 基础服务层**同级并列**(均使用 `net_cfg`);④ 中间件层 → ⑤ 驱动层 → ⑥ 内核/HAL → ⑦ 硬件 依次向下。
+
+| 层 | 目录 | 职责 | 关键模块 |
+|---|---|---|---|
+| ① 业务层 | `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)` |
+| ⑤ 驱动层 | `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 |
+| ⑥ 硬件 | — | STM32F407ZGTx @168MHz | SPI1 / SPI2 / I2C1 / UART5 / USART1 / DMA / GPIO / TIM7 |
+
+### 1.2 关键跨层依赖(对应上图未画出的连线)
+
+| 依赖 | 说明 |
+|---|---|
+| `ui_page_net_cfg` → `net_cfg` | 网口设置页显示/编辑 RAM 副本(不直接触 CH395F SPI) |
+| `netTask` → `net_cfg` → `net_socket` | 上电 `net_cfg_load` 后 `net_init(ip,mask,gw,mac)` |
+| `lftpd` / `net_cfg` / 测试 → `FatFs` → `nand_ftl` → `gd5f2gq5ue` | 文件与配置落 NAND |
+| `ui_net_sink` / `time_sync` → `net_socket` | 远程显示 :7003 / 对时 :8888 共用同一 TCP 栈 |
+| `ui_menu` → `ui_content_page` → `ui_page_*` | 框架 + 页面插件(描述符解耦) |
+| `time_sync` / `sys_clock` → `sd2506` | 墙钟基准与写回 RTC |
+| `ui_port` / `ui_page_*` → `LVGL` | UI 全部绘制走 LVGL |
+
+> `*` `adc_task.c` / `rs485_task.c` 任务体已实现,但**当前未在 `freertos.c` 创建**(预留/待接入)。
+
+---
+
+## 2. 运行时任务与就绪屏障
+
+### 2.1 任务一览(`Src/freertos.c` 创建)
+
+| 任务 | 栈 (字节) | 优先级 | 职责 | 入口 |
+|---|---|---|---|---|
+| `defaultTask` | 2048 | Normal | FatFS 挂载(无卷 `f_mkfs`)→ 置 `STORAGE_READY` → LED 闪烁 | `StartDefaultTask` |
+| `netTask` | 4096 | Normal | 等存储 → `net_stack_bring_up()` → 置 `NET_READY` → `net_poll` 轮询 | `StartNetTask` |
+| `ftpTask` | 4096 | Normal | 等网络+存储 → `lftpd_start("/", 21)` | `StartFtpTask` |
+| `testTask` | 4096 | BelowNormal | 测试套件入口(`test/test_config.h` 选择;产品构建空转) | `start_test_task` |
+| `uiTask` | 16384 | BelowNormal | 等网络 → `ui_port_init`/`ui_menu_init`/`ui_net_sink` → 10ms 刷新 | `ui_task_func` |
+| `timeSyncTask` | 2048 | Normal | 等网络 → TCP :8888 收 PC 推送时间 → 写 RTC | `time_sync_task` |
+
+> `idle`(128 words)/ `timer`(256 words)由 FreeRTOS 内核创建。所有任务栈从 48KB heap_4 堆分配。
+
+### 2.2 就绪屏障(Event Flags,取代魔法延时)
+
+
+
+> 三栏式:**左 = 生产者(set)· 中 = 事件组 `sys_evtHandle` · 右 = 消费者(wait)**。
+> 任务卡与事件位对齐同一行,箭头 = `set` / `wait` 关系;`bit1 → ftpTask` 的额外依赖用 **All 汇合点**表达(`osFlagsWaitAll`)。
+> 源码 `tools/arch_diagram/gen_barrier_svg.py`(改数据重跑即可);PNG 预览本地渲染、不入库。
+
+**设计原则**:生产者与消费者互不知晓,只经事件组解耦;新增子系统只需定义新 bit + 各自 wait/set,不改已有任务。
+(详见 `docs/项目启动顺序管理.md`)
+
+### 2.3 线程间通信
+
+| 机制 | 对象 | 用途 |
+|---|---|---|
+| Event Flags | `sys_evtHandle` | 启动就绪屏障(§2.2) |
+| Message Queue | `netMsgQueue`(深度 8,`net_msg_t`) | 所有 CH395F 操作经队列**串行化**到 netTask 执行,保证 SPI 单线程访问 |
+
+---
+
+## 3. 启动时序
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant M as main
+ participant A as app_main_init
+ participant K as osKernelStart
+ participant D as defaultTask
+ participant N as netTask
+ participant F as ftpTask
+ participant U as uiTask
+ participant T as timeSyncTask
+
+ M->>M: HAL_Init + CubeMX 外设初始化
(SPI1/SPI2/I2C1/UART5/USART1/DMA/GPIO/TIM7)
+ M->>A: app_main_init()
+ Note over A: 时间子系统优先,使后续日志带时间戳
+ A->>A: sd2506_init() → sys_clock_init()(墙钟基准)
+ A->>A: rs485_init() → gd5f2gq5ue_init()(BBT 扫描)
+ A->>A: tpafe5160_init() → SD2506 诊断 → start_conv_irq()
+ A-->>M: "App init OK"
+ M->>K: osKernelStart()
+ Note over K: MX_FREERTOS_Init 已创建 6 任务 + 事件组 + 队列
+
+ K->>D: 调度
+ D->>D: f_mount(无卷则 f_mkfs FAT32)→ LED 循环
+ D-->>N: set SYS_EVT_STORAGE_READY
+
+ N->>N: wait STORAGE_READY(≤10s,超时回退工厂默认)
+ N->>N: net_cfg_load() → net_init(ip,mask,gw,mac)
→ PHY 强制 100M 全双工 → 等链路 ≤3s → MAC 回写影子
+ N-->>F: set SYS_EVT_NET_READY
+ N->>N: 循环 net_poll() + net_process_messages() + osDelay(1)
+
+ F->>F: wait NET_READY|STORAGE_READY(All)→ lftpd_start("/", 21)
+ U->>U: wait NET_READY → ui_port_init → ui_menu_init → ui_net_sink_start(7003)
+ T->>T: wait NET_READY → TCP listen :8888
+```
+
+---
+
+## 4. 存储栈
+
+```mermaid
+flowchart LR
+ FS["FatFs (Lib/FatFs)
f_mount / f_open / f_mkfs"]
+ DIO["diskio.c
BSP 适配层"]
+ FTL["nand_ftl.c
dhara FTL(磨损均衡 + 逻辑块映射)"]
+ NAND["gd5f2gq5ue.c
页读写 / ECC / BBT 坏块表"]
+ CHIP[("GD5F2GQ5UE
NAND 2Gbit")]
+
+ subgraph USERS["使用方"]
+ NC["net_cfg
/cfg/netcfg.bin"]
+ LF["lftpd
FTP 文件读写"]
+ ST["测试套件
STORAGE/GD5F"]
+ end
+
+ NC --> FS
+ LF --> FS
+ ST --> FS
+ FS --> DIO --> FTL --> NAND --> CHIP
+
+ subgraph RTC["独立时间源"]
+ SD["SD2506 RTC (I2C1)"] --> SC["sys_clock
墙钟 epoch"]
+ end
+```
+
+- 掉电持久化:`net_cfg` 配置(magic `'NCFG'` + ver + CRC-16/MODBUS),**保存后下次上电生效**。
+- NAND 坏块表(BBT)持久化在保留块池,不交给 dhara 管理。
+
+---
+
+## 5. 网络栈与 TCP 消费者
+
+```mermaid
+flowchart TB
+ CHIP[("CH395F
MAC+PHY · SPI2 + DMA")] --> DRV["ch395f.c
寄存器/命令 + 中断集中读取"]
+ DRV --> NS["net_socket.c
BSD Socket API(单连接模式)"]
+ DRV --> NSEL["net_select.c
select/poll 多路复用"]
+ NS --> Q["netMsgQueue
(串行化到 netTask)"]
+ Q --> NT["netTask
net_poll() + net_process_messages()"]
+ NS --> C1["lftpd
FTP :21"]
+ NS --> C2["time_sync
对时 :8888"]
+ NS --> C3["ui_net_sink
RemoDispBus :7003"]
+ C3 -.->|"回送 1bpp 帧 + 接收远端按键"| CLIENT["DTU-RemoteLCD 客户端"]
+
+ NC["net_cfg
IP/掩码/网关/MAC"] --> NS
+ style NT fill:#efe,stroke:#484
+```
+
+**单连接约束**:CH395F 配置为多 Socket + TCP Server 单连接(`set_fun_para(0x09)`),Socket 0 复用监听与数据通道;FTP / 对时 / 远程显示三个 TCP 服务**串行**占用连接,连接断开后自动回到 LISTEN。
+
+---
+
+## 6. UI 层架构(框架 + 页面插件)
+
+```mermaid
+flowchart TB
+ subgraph LOGIC["逻辑层"]
+ UM["ui_menu.c
级联框状态机 + 按键分发"]
+ MT["ui_menu_tab.c
菜单树生成物(ui_menu_tree.txt)"]
+ end
+ subgraph VIEW["显示层"]
+ MV["ui_menu_view.c
框体/行/装饰自绘"]
+ DECO["ui_deco.c
浅 V 装饰"]
+ end
+ subgraph FRAME["内容页框架"]
+ CP["ui_content_page.c
屏生命周期 + 描述符驱动绘制/按键"]
+ end
+ subgraph PLUGIN["页面插件(每页一文件)"]
+ P1["ui_page_device_info"]
+ P2["ui_page_ac"]
+ P3["ui_page_dc"]
+ P4["ui_page_password"]
+ P5["ui_page_net_cfg"]
+ end
+ subgraph PORT["显示端口"]
+ UP["ui_port.c
160x160 1bpp 缓冲"]
+ SINK["ui_net_sink.c
远程推流"]
+ end
+
+ UM --> MT
+ UM --> MV
+ UM --> CP
+ MV --> DECO
+ CP --> P1 & P2 & P3 & P4 & P5
+ MV --> UP
+ SINK --> UP
+ P5 -.->|"net_cfg RAM 副本"| CFG["net_cfg"]
+ P4 -.->|"ui_pwd_def.h 编译期密码"| PWD["查看 000 / 修改 123"]
+```
+
+- 内容页为**独立全屏屏**,由框架一次性自绘(**零 LVGL 子对象**),ESC 返回菜单屏。
+- 菜单受保护叶子(`ui_menu_tree.txt` 行尾 ` #P`):先弹密码页,授权后 `menu_password_ok` **先关页再续进入动作**。
+- 字体 `ui_font_cn_12.c` 由 `tools/font_gen/` 生成(宋体 12px 1bpp 子集,111 字/208 字形)。
+
+---
+
+## 7. 目录组织
+
+```
+STM32F4-Base/
+├── Src/ Inc/ CubeMX 生成:main.c / freertos.c / HAL 外设 / 中断 / 时基
+│ ├── freertos.c 6 任务 + netMsgQueue + sys_evt 创建(USER CODE 区自定义)
+│ └── 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
+├── Drivers/BSP/ 纯手工驱动/服务(不受 CubeMX 保护)
+│ ├── CH395F/ 以太网 MAC+PHY
+│ ├── GD5F2GQ5UE/ NAND + dhara FTL
+│ ├── TPAFE5160/ ADC
+│ ├── SD2506/ RTC
+│ ├── RS485/ 半双工串口
+│ ├── NET/ net_socket / net_select / lftpd
+│ ├── NETCFG/ 网口配置持久化(MCU + 模拟器双后端)
+│ ├── UI/ LVGL UI(与模拟器共用)
+│ └── dbg_log.h/dbg_cfg.h 分级日志
+├── Lib/ 第三方:LVGL v9.2、FatFs、FreeRTOS、CMSIS
+├── Middlewares/ ST 官方中间件
+├── MDK-ARM/ Keil 工程(uvprojx + scatter + build/download 脚本)
+├── simulator/ PC 模拟器(CMake + SDL2,共用 UI 源码 + 无头回归测试)
+├── test/ 测试套件(test_config.h 互斥选择,产品构建空转)
+├── tools/ font_gen(字体生成)/ menu_gen(菜单表生成)
+├── scripts/ 代码规范检查、RAM 报告等
+└── docs/ 设计/测试/问题记录文档
+```
+
+---
+
+## 8. 构建与双端
+
+```mermaid
+flowchart LR
+ SRC["共享源码
Drivers/BSP/UI/*
Drivers/BSP/NETCFG/net_cfg.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:串口采集 + 烧录 + 日志校验"]
+ SIM --> REG["UI 回归:
check_menu_rows.py
check_content_pages.py"]
+```
+
+| 目标 | 工具链 | 验证 |
+|---|---|---|
+| MCU 固件 | Keil MDK-ARM v5(`MDK-ARM/build.bat`) | `@build` 0 错 0 警 → `@test` 串口闭环 |
+| PC 模拟器 | CMake + SDL2 + MSVC(`simulator/build/`) | `check_menu_rows.py` + `check_content_pages.py`(文本断言 + 像素黄金参考) |
+
+**功能编译开关**:`App/app_cfg.h`(网络/ADC/RS485);`test/test_config.h` 测试套件互斥选择(产品构建全注释)。
+
+---
+
+## 9. 相关专项文档
+
+| 主题 | 文档 |
+|---|---|
+| 启动顺序 / 就绪屏障 | `docs/项目启动顺序管理.md` |
+| 存储架构(NAND/FTL/FatFs) | `docs/STM32F4-Base存储架构说明.md` |
+| 墙钟时间维护 | `docs/系统墙钟时间维护说明.md` |
+| RAM 预算与优化 | `docs/内存管理/RAM使用分析与优化方案.md` |
+| UI 设计架构 / 内容页 / 菜单 | `docs/UI 设计/UI_设计架构.md` · `内容页架构.md` · `菜单页面设计.md` |
+| UI 自动化测试 | `docs/UI 设计/UI_自动化测试流程.md` |
+| 驱动已知陷阱 | `docs/问题记录/{CH395F,GD5F2GQ5UE,SD2506}_Trap_Records.md` |
+| 各模块测试指南 | `docs/测试说明/` |
+| 代码规范 | `docs/代码规范/` |
+
+---
+
+## 10. 变更记录
+
+| 日期 | 变更 |
+|---|---|
+| 2026-09-10 | 初版:六层全景架构 + 任务/就绪屏障 + 启动时序 + 存储栈 + 网络栈 + UI 分层 + 目录组织 |
+| 2026-09-10 | §1 全景分层架构重绘:由"36 节点 + 跨层虚线"改为**分层色带(每层一框)+ 职责表 + 跨层依赖表**,消除连线交叉、提升可读性 |
+| 2026-09-10 | §1 再改为**手绘 SVG**(`tools/arch_diagram/gen_arch_svg.py` → `项目架构总览.svg`:分层色带 + 模块芯片 + 单向下箭头,布局确定);PNG 预览本地渲染、不入库 |
+| 2026-09-10 | `net_cfg` 分层定位修正:按项目规划归入 **① 应用层**(原列在 ③ 服务/中间件);SVG 与 §1.1 表同步(模块物理路径仍在 `Drivers/BSP/NETCFG/`) |
+| 2026-09-10 | §1 布局修正:**应用层与 UI 层同级并列**(同一行并排,均向下依赖);生成器数据由"每层一行"改为 `ROWS`(行内可放多个并列层),顶层两框各引一条向下箭头 |
+| 2026-09-10 | §1 层级重定(按项目规划):**① 业务层**(改名,原"应用层")为**顶层**,含 `net_cfg`(被 UI 与基础服务共用)+ 业务编排;**② UI 层与 ③ 基础服务层同级并列**其下;`time_sync` 归基础服务层 |
+| 2026-09-10 | §1 定稿(严格版):**① 业务层顶层仅 `net_cfg` 一块**;`app_main`/`sys_clock`/`sys_events`/`app_cfg`/`task/*`/`util/*` 并入 **③ 基础服务层**(任务编排 + 网络/存储/图形);② UI 层与 ③ 同级并列 |
+| 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 预览本地渲染、不入库 |
diff --git a/docs/项目架构总览.svg b/docs/项目架构总览.svg
new file mode 100644
index 00000000..9fa61714
--- /dev/null
+++ b/docs/项目架构总览.svg
@@ -0,0 +1,111 @@
+
diff --git a/simulator/tests/check_content_pages.py b/simulator/tests/check_content_pages.py
index d5c84abc..7b97fbcf 100644
--- a/simulator/tests/check_content_pages.py
+++ b/simulator/tests/check_content_pages.py
@@ -71,7 +71,7 @@ TEXTS = {
("r0名", "IP地址", (24, 38), True), ("r0值", "192.168.001.100", (24, 38), True),
("r1名", "子网掩码", (38, 52), False), ("r1值", "255.255.255.000", (38, 52), False),
("r2名", "默认网关", (52, 66), False), ("r2值", "192.168.001.001", (52, 66), False),
- ("r3名", "MAC地址", (66, 80), False), ("r3值", "000000000000", (66, 80), False),
+ ("r3名", "MAC地址", (66, 80), False), ("r3值", "00:00:00:00:00:00", (66, 80), False),
("r4名", "保存", (80, 95), False), ("r4值", "ENTER", (80, 95), False),
("页脚", "第 1 页,共 1 页", (140, 160), False),
],
@@ -80,7 +80,7 @@ TEXTS = {
("r0名", "IP地址", (24, 38), True), ("r0值", "192.168.001.100", (24, 38), True),
("r1名", "子网掩码", (38, 52), False), ("r1值", "255.255.255.000", (38, 52), False),
("r2名", "默认网关", (52, 66), False), ("r2值", "192.168.001.001", (52, 66), False),
- ("r3名", "MAC地址", (66, 80), False), ("r3值", "000000000000", (66, 80), False),
+ ("r3名", "MAC地址", (66, 80), False), ("r3值", "00:00:00:00:00:00", (66, 80), False),
("r4名", "保存", (80, 95), False), ("r4值", "ENTER", (80, 95), False),
("页脚", "第 1 页,共 1 页", (140, 160), False),
],
diff --git a/tools/arch_diagram/gen_arch_svg.py b/tools/arch_diagram/gen_arch_svg.py
new file mode 100644
index 00000000..83ad76cb
--- /dev/null
+++ b/tools/arch_diagram/gen_arch_svg.py
@@ -0,0 +1,221 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""生成 docs/项目架构总览.svg —— 分层架构图(纯标准库,无需第三方依赖)。
+
+为什么不用 Mermaid:自动布局不可控,多节点/跨层依赖会画成交叉的"意大利面"。
+本脚本手工排布:每层一个圆角框(标题在框内顶部 + 模块芯片自动换行),
+同一"行"可放多个并列层(如 应用层 与 UI 层 同级并排),行间单向下箭头。
+
+用法(仓库根目录,任意 python3):
+ python tools/arch_diagram/gen_arch_svg.py
+输出:
+ docs/项目架构总览.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", "项目架构总览.svg")
+
+# ----------------------------- 画布与尺寸参数 -----------------------------
+W = 1560
+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_BIZ = dict(
+ title="① 业务层", sub="顶层共享配置 · 被 UI 与基础服务共用", color="#2563eb", bg="#eff6ff",
+ chips=["net_cfg(网口配置:IP/掩码/网关/MAC + Flash 持久化)"],
+)
+L_UI = dict(
+ title="② UI 层", sub="Drivers/BSP/UI · 与 PC 模拟器共用", color="#7c3aed", bg="#f5f3ff",
+ chips=["ui_port(显示端口)", "ui_menu / ui_menu_view(级联菜单)", "ui_menu_tab(菜单表)",
+ "ui_content_page(内容页框架)", "ui_page_*(5 页插件)",
+ "ui_net_sink(远程显示 :7003)", "ui_font_cn_12(中文字体)", "ui_deco(浅 V 装饰)"],
+)
+L_SVC = dict(
+ title="③ 基础服务层", sub="应用侧服务 / 编排", color="#0d9488", bg="#f0fdfa",
+ chips=["app_main(启动编排)", "sys_clock(墙钟基准)", "sys_events(就绪屏障)",
+ "app_cfg(编译开关)", "task/(net · ui · adc* · rs485*)",
+ "lftpd(FTP :21)", "time_sync(TCP 对时 :8888)"],
+)
+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)"],
+)
+L_DRV = dict(
+ title="⑤ 驱动层", sub="Drivers/BSP · 芯片时序/中断", color="#d97706", bg="#fffbeb",
+ chips=["ch395f(SPI2 + DMA)", "gd5f2gq5ue(SPI1)", "tpafe5160(并行 16 位)",
+ "sd2506(I2C1)", "rs485(UART5 + DE)"],
+)
+L_KERN = dict(
+ title="⑥ 内核 / HAL", sub="调度 · 外设抽象", color="#475569", bg="#f1f5f9",
+ chips=["FreeRTOS V10.3.1(CMSIS-RTOS2 · heap_4)", "STM32 HAL(时基 TIM7)",
+ "CMSIS / 启动文件"],
+)
+L_HW = dict(
+ title="⑦ 硬件", sub="STM32F407ZGTx @168MHz", color="#e11d48", bg="#fff1f2",
+ chips=["CH395F 以太网(SPI2)", "GD5F2GQ5UE NAND(SPI1)", "TPAFE5160 ADC(并行)",
+ "SD2506 RTC(I2C1)", "RS-485(UART5)", "USART1 调试(115200)", "6x LED"],
+)
+
+# 每一行可放多个"同级并列"的层(上层只依赖下方各层)
+ROWS = [
+ [L_BIZ], # 顶层:业务层(仅共享配置 net_cfg)
+ [L_UI, L_SVC], # UI 层与基础服务层同级并排,均使用 net_cfg
+ [L_MW], # 中间件层(通用库 / 协议栈)
+ [L_DRV],
+ [L_KERN],
+ [L_HW],
+]
+
+FOOT = "上层只依赖相邻下层(单向);UI 层与基础服务层同级并列,均使用顶层 net_cfg。跨层调用细节见 docs/项目架构总览.md §1.2 * 预留未接入调度"
+
+
+# ----------------------------- 工具函数 -----------------------------
+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("STM32F4-Base 项目分层架构")))
+ add('%s'
+ % (PAD, 62, SUB_FONT,
+ esc("STM32F407ZGTx @168MHz · FreeRTOS V10.3.1 (CMSIS-RTOS2) · 上层只依赖相邻下层")))
+
+ 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'
+ % (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()
diff --git a/tools/arch_diagram/gen_barrier_svg.py b/tools/arch_diagram/gen_barrier_svg.py
new file mode 100644
index 00000000..cb6569a8
--- /dev/null
+++ b/tools/arch_diagram/gen_barrier_svg.py
@@ -0,0 +1,246 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""生成 docs/就绪屏障.svg —— 启动就绪屏障(Event Flags)依赖图(纯标准库)。
+
+为什么不用 Mermaid:原图 9 条边全挤在单一中心节点上,画成交叉的"意大利面"。
+本脚本手工排布为三栏:
+ 左 = 生产者(set) 中 = 事件组 sys_evtHandle 右 = 消费者(wait)
+任务卡与事件位各自对齐到同一水平行,箭头基本水平/正交;bit1 → ftpTask 的
+"与"依赖用汇合点(All)表达,避免跨行连线。
+
+用法(仓库根目录,任意 python3):
+ python tools/arch_diagram/gen_barrier_svg.py
+输出:
+ docs/就绪屏障.svg
+改依赖时:编辑本文件顶部的 BITS / PRODUCERS / CONSUMERS 数据后重跑。
+"""
+import html
+import math
+import os
+
+ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+OUT = os.path.join(ROOT, "docs", "就绪屏障.svg")
+
+# ----------------------------- 画布与尺寸 -----------------------------
+W = 1400
+PAD = 40
+TITLE_FONT = 20
+SUB_FONT = 13
+CARD_TITLE_FONT = 15
+CARD_NOTE_FONT = 12
+BIT_TITLE_FONT = 14
+BIT_NOTE_FONT = 12
+LABEL_FONT = 11.5
+FOOT_FONT = 12
+
+H_CARD = 84 # 任务卡 / 事件位行高
+GAP = 26 # 行间距
+MAIN_Y = 200 # 首行 y
+CARD_W_L = 290 # 生产者卡宽
+CARD_W_R = 450 # 消费者卡宽
+PANEL_X, PANEL_W = 400, 420
+PROD_X = 40
+CONS_X = 910
+TAP_X = 832 # bit1 → All 汇合点的下引线 x
+BUS_X = 862 # bit0 分发总线 x
+ARROW_W = 2.2
+
+FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif"
+
+C_PROD, BG_PROD = "#2563eb", "#eff6ff"
+C_CONS, BG_CONS = "#7c3aed", "#f5f3ff"
+
+# ----------------------------- 数据(改这里) -----------------------------
+# 事件位(中间栏),自上而下 = 时序先后
+BITS = [
+ dict(bit="bit1", flag="STORAGE_READY", color="#16a34a", bg="#f0fdf4",
+ note="defaultTask 置位 · netTask / ftpTask 等待"),
+ dict(bit="bit0", flag="NET_READY", color="#0d9488", bg="#f0fdfa",
+ note="netTask 置位 · 4 个任务等待"),
+ dict(bit="bit2", flag="FTP_READY", color="#d97706", bg="#fffbeb",
+ note="ftpTask 置位 · 预留消费者"),
+ dict(bit="bit3", flag="TIMESYNC_READY", color="#94a3b8", bg="#f8fafc",
+ note="预留(尚未接入调度)"),
+]
+
+# 生产者(左栏):row = 其置位的 bit 行
+PRODUCERS = [
+ dict(name="defaultTask", note="FatFS 挂载(f_mount / f_mkfs)", row=0, label="set bit1"),
+ dict(name="netTask", note="net_stack_bring_up()", row=1, label="set bit0"),
+ dict(name="ftpTask", note='lftpd_start("/", 21)', row=2, label="set bit2"),
+]
+
+# 消费者(右栏):row = 卡片排布行(前两行与 bit1 / bit0 对齐)
+CONSUMERS = [
+ dict(name="netTask", note="wait bit1 · ≤10s", row=0),
+ dict(name="ftpTask", note="wait bit0 + bit1 · All", row=1),
+ dict(name="uiTask", note="wait bit0 · Forever", row=2),
+ dict(name="timeSyncTask", note="wait bit0 · Forever", row=3),
+ dict(name="testTask", note="wait bit0 · NET 模式", row=4),
+]
+
+FOOT = ("生产者与消费者互不知晓,只经事件组解耦;新增子系统 = 新 bit + 各自 wait/set,"
+ "不改已有任务。详见 docs/项目启动顺序管理.md")
+
+
+# ----------------------------- 工具函数 -----------------------------
+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) >= 0x2E80 else 0.58)
+ return w
+
+
+# ----------------------------- 生成 -----------------------------
+def build():
+ out = []
+ add = out.append
+
+ def row_y(r):
+ return MAIN_Y + r * (H_CARD + GAP)
+
+ def row_cy(r):
+ return row_y(r) + H_CARD / 2.0
+
+ def poly(points, color, dash=False, head=True, width=ARROW_W):
+ pts = " ".join("%.1f,%.1f" % p for p in points)
+ d = ' stroke-dasharray="7 5"' if dash else ''
+ add('' % (pts, color, width, d))
+ if head:
+ (x1, y1), (x2, y2) = points[-2], points[-1]
+ ang = math.atan2(y2 - y1, x2 - x1)
+ hl, hw = 11.0, 5.5
+ bx, by = x2 - hl * math.cos(ang), y2 - hl * math.sin(ang)
+ px, py = -math.sin(ang) * hw, math.cos(ang) * hw
+ add(''
+ % (x2, y2, bx + px, by + py, bx - px, by - py, color))
+
+ def card(x, y, w, title, note, color, bg):
+ add('' % (x, y, w, H_CARD, bg, color))
+ add('%s'
+ % (x + 16, y + 34, CARD_TITLE_FONT, esc(title)))
+ add('%s'
+ % (x + 16, y + 58, CARD_NOTE_FONT, esc(note)))
+
+ # 行/画布尺寸
+ n_rows = max(len(CONSUMERS), len(BITS))
+ main_bottom = row_y(n_rows - 1) + H_CARD
+ total_h = main_bottom + 78
+
+ add('' % (W, total_h))
+
+ # ---- 标题 ----
+ add('%s'
+ % (PAD, TITLE_FONT, esc("就绪屏障(Readiness Barrier)· 启动依赖链")))
+ add('%s'
+ % (PAD, SUB_FONT,
+ esc("CMSIS-RTOS2 Event Flags · 生产者置位声明「我好了」,消费者阻塞等待")))
+
+ # ---- 图例 ----
+ lx, ly = PAD, 108
+ for kind, color, label in [
+ ("rect", C_PROD, "生产者(set)"),
+ ("rect", C_CONS, "消费者(wait)"),
+ ("rect", "#16a34a", "事件位(Event Flag)"),
+ ("dot", "#0d9488", "All 汇合(等全部位)"),
+ ("dash", "#94a3b8", "预留 / 未接入"),
+ ]:
+ if kind == "rect":
+ add('' % (lx, ly - 12, color))
+ elif kind == "dot":
+ add('' % (lx + 8, ly - 4, color))
+ else:
+ add('' % (lx, ly - 4, lx + 16, ly - 4, color))
+ lx += 24
+ add('%s' % (lx, ly, esc(label)))
+ lx += text_w(label, 12) + 34
+
+ # ---- 事件组面板 ----
+ py0 = MAIN_Y - 34
+ py1 = row_y(len(BITS) - 1) + H_CARD + 14
+ add('' % (PANEL_X, py0, PANEL_W, py1 - py0))
+ head = "sys_evtHandle(Event Flags 事件组)"
+ add('%s'
+ % (PANEL_X + 16, py0 + 26, esc(head)))
+ for i, b in enumerate(BITS):
+ by = row_y(i)
+ add('' % (PANEL_X + 14, by, PANEL_W - 28, H_CARD, b["bg"], b["color"]))
+ add(''
+ % (PANEL_X + 28, by + (H_CARD - 30) / 2, b["color"]))
+ add('%s'
+ % (PANEL_X + 43, by + H_CARD / 2 + 5, esc(b["bit"][3])))
+ tx = PANEL_X + 70
+ add('%s'
+ % (tx, by + 36, BIT_TITLE_FONT, b["color"], esc(b["bit"] + " · " + b["flag"])))
+ add('%s'
+ % (tx, by + 58, BIT_NOTE_FONT, esc(b["note"])))
+
+ # ---- 生产者卡 + set 箭头 ----
+ for p in PRODUCERS:
+ y, cy = row_y(p["row"]), row_cy(p["row"])
+ card(PROD_X, y, CARD_W_L, p["name"], p["note"], C_PROD, BG_PROD)
+ poly([(PROD_X + CARD_W_L, cy), (PANEL_X, cy)], BITS[p["row"]]["color"])
+ add('%s'
+ % ((PROD_X + CARD_W_L + PANEL_X) / 2.0, cy - 8, LABEL_FONT,
+ BITS[p["row"]]["color"], esc(p["label"])))
+
+ # ---- 消费者卡 ----
+ for c in CONSUMERS:
+ card(CONS_X, row_y(c["row"]), CARD_W_R, c["name"], c["note"], C_CONS, BG_CONS)
+
+ # ---- wait 连线:bit1 → netTask(水平) ----
+ c0, c1 = row_cy(0), row_cy(1)
+ col1, col0 = BITS[0]["color"], BITS[1]["color"]
+ poly([(PANEL_X + PANEL_W, c0), (CONS_X, c0)], col1)
+ add('%s'
+ % ((PANEL_X + PANEL_W + CONS_X) / 2.0, c0 - 8, LABEL_FONT, col1, esc("wait")))
+
+ # ---- wait 连线:bit1 → All 汇合点(下引,与 bit0 相与) ----
+ poly([(TAP_X, c0), (TAP_X, c1)], col1, head=False)
+ add('' % (TAP_X, c1, col0))
+ add('All'
+ % (TAP_X + 9, c1 - 8, LABEL_FONT, col0))
+
+ # ---- wait 连线:bit0 → 总线 → 4 个消费者 ----
+ c_last = row_cy(len(CONSUMERS) - 1)
+ poly([(PANEL_X + PANEL_W, c1), (BUS_X, c1)], col0)
+ poly([(BUS_X, c1), (BUS_X, c_last)], col0, head=False)
+ for c in CONSUMERS[1:]:
+ poly([(BUS_X, row_cy(c["row"])), (CONS_X, row_cy(c["row"]))], col0)
+ mid_y = (c1 + c_last) / 2.0
+ add('%s'
+ % (BUS_X - 12, mid_y, BUS_X - 12, mid_y, LABEL_FONT, col0, esc("wait bit0")))
+
+ add('%s'
+ % (PAD, total_h - 18, FOOT_FONT, esc(FOOT)))
+
+ svg = ('\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()