{"code":1,"msg":"成功","time":"1793358248","data":{"title":"〖2026嵌入式大赛〗基于 NS800RT7P65 与 ESP32-C3 的双板协同智能终端","content":"# 基于 NS800RT7P65 与 ESP32-C3 的双板协同智能终端\r\n\r\n## —— RT-Thread + LVGL 桌面系统与联网桥的完整实现\r\n\r\n硬件平台：NS800RT7P65（Cortex-M7，显示终端） + ESP32-C3（RISC-V，联网桥）\r\n软件平台：RT-Thread 5.3.0、LVGL 9.1.0、ESP-IDF v6.1\r\n文档日期：2026 年 10 月\r\n\r\n---\r\n\r\n## 一、作品简介\r\n\r\n本作品是一套能够脱离电脑独立运行的多功能桌面终端。NS800RT7P65 作为显示与交互端，运行 RT-Thread 与 LVGL，提供 3×3 网格桌面与 14 个应用；ESP32-C3 作为联网端，负责 WiFi 连接、NTP 对时、HTTP 上报与固件升级。两块板通过 UART 板间链路协同工作，组成一个完整的设备。\r\n\r\n界面支持中文，用户通过旋钮选择、按键确认即可完成全部操作：查看温湿度、计时、调节屏幕背光与色调、看图、玩一个横屏跑酷小游戏、设置闹钟、给联网模块下发 WiFi 配置。配置项与游戏最高分写入外置 Flash 上的文件系统，断电不丢。\r\n\r\n联网能力集中在 ESP32-C3 一侧：它连接路由器，把时间同步给 NS800，并提供一个Material Design 风格的网页管理页，可以查看设备状态、修改 WiFi、上传固件完成本机 OTA。\r\n\r\n**作品聚焦本机交互与显示**，当前版本没有实现云端对接、远程控制与视频播放。\r\n关于视频播放：受板间串口带宽限制（115200 波特率下每秒约 11 KB），整屏原始像素\r\n需要 150 KB，一帧要十几秒，因此当前版本不含该功能，具体分析见第八章。\r\n\r\n---\r\n\r\n## 二、需求与设计目标\r\n\r\n实际使用中需要同时解决「看得到」「能操作」「记得住」「联得上」四类问题：\r\n\r\n| 需求 | 当前实现 |\r\n|---|---|\r\n| 多参数显示 | DHT22 采集温湿度，2 秒刷新，界面同时给出 ADC 原始值与斜率便于排查 |\r\n| 本机交互 | 3×3 桌面 + 14 个应用，EC11 旋钮选择、按键确认，全中文界面 |\r\n| 参数可调 | 屏幕背光、冷暖\/明暗\/饱和三轴色调调节 |\r\n| 数据留存 | 背光、调色参数、游戏最高分、累计运行时长、闹钟，全部写入外置 Flash |\r\n| 定时提醒 | 最多 4 个闹钟，到点响铃，判定逻辑常驻，不依赖闹钟应用是否打开 |\r\n| 联网 | ESP32-C3 负责 WiFi STA\/AP、NTP 对时、HTTP 上报 |\r\n| 远程管理 | 网页查看状态、修改 WiFi 配置、上传固件完成 OTA |\r\n| 可复用框架 | 新增应用只需改动 4 处，框架自带页面切换、焦点管理与内存约束 |\r\n\r\n需要说明的是，DHT22 属于低成本数字温湿度传感器，其精度受器件个体、供电与安装位置\r\n影响较大，界面显示的小数位不代表实际测量精度，不能作为计量依据。\r\n\r\n---\r\n\r\n## 三、系统总体方案\r\n\r\n### 3.1 硬件架构\r\n![screenshot_硬件架构图.png](https:\/\/oss-club.rt-thread.org\/uploads\/20261018\/44153782d12dede3de6daa966545e911.png.webp)\r\n\r\n系统由两大部分组成。NS800RT7P65 一侧挂屏幕、外置 Flash、温湿度传感器、蜂鸣器、\r\nLED、旋钮与按键；ESP32-C3 一侧负责 WiFi、HTTP 服务、配置存储与 OTA。\r\n两者之间只靠一路 UART 相连，除 TX\/RX 交叉相接外必须共地。\r\n\r\n### 3.2 为什么用两块板\r\n\r\n这是能力互补的结果，不是堆料：\r\n\r\n| | NS800RT7P65 | ESP32-C3 |\r\n|---|---|---|\r\n| 强项 | 大屏驱动、图形渲染、实时外设 | WiFi 联网、完整 TCP\/IP 协议栈 |\r\n| 不足 | 无联网能力 | 无屏幕接口，GPIO 资源有限 |\r\n| 承担角色 | 显示端、业务端、被动方 | 数据源、通信端、主动方 |\r\n\r\nNS800 有 Cortex-M7 和一块 240×320 彩屏，但没有 WiFi；C3 有完整联网能力，\r\n却无法直接驱动这类彩屏。把联网与显示拆开，两边固件可以独立开发、独立升级，\r\n任何一侧换型只要协议不变，另一侧不受影响。\r\n\r\n\r\n## 四、硬件设计\r\n\r\n### 4.1 主控与显示\r\n\r\nNS800RT7P65 使用 Arm Cortex-M7 内核并带硬件浮点（fpv5-d16，hard-float ABI），\r\n板级 PLL 配置见 `board\/board.h`。工程基于 RT-Thread 原有 BSP\r\n`bsp\/novosns\/ns800\/ns800rt7p65-nssinepad` 扩展，保留原工程结构，\r\n使用 scons 构建。\r\n\r\n屏幕为 ILI9341，240×320，RGB565，走 SPI1 接口。**SPI 速率上限实测为 50 MHz**，\r\n工程里曾尝试 70 MHz，结果整屏花屏，原因是排线带宽不足导致采样点错位，\r\n理论算出来的 55 FPS 并不等于能跑的值。\r\n\r\n| 功能 | 引脚 | 说明 |\r\n|---|---|---|\r\n| 屏幕 | SPI1 @ 50 MHz | 70 MHz 实测花屏，50 MHz 是上限 |\r\n| 外置 Flash | SPI3 | 16 MB NOR，挂 LittleFS |\r\n| 调试控制台 | UART1（PA12 \/ PA13） | 不要占用 |\r\n| 板间链路 | UART2（PB6 \/ PB7） | ALT1 复用 |\r\n| DHT22 | GPIO_40 | 单总线温湿度 |\r\n| 蜂鸣器 | GPIO_1 | 有源 |\r\n| LED1 \/ LED2 | GPIO_68 \/ GPIO_69（PC4 \/ PC5） | 低电平点亮 |\r\n| EC11 旋钮 | GPIO_6 \/ GPIO_7（PA6 \/ PA7） | 接 eQEP3 正交解码 |\r\n| EC11 按键 | GPIO_31 | 直读引脚加软件消抖 |\r\n| 板载按键 | GPIO_41 | 独立按键 |\r\n\r\n### 4.2 外置存储\r\n\r\n外置 SPI NOR 容量 16 MB，经 SFUD 识别后注册为块设备，再挂载 LittleFS。\r\n选择 LittleFS 而不是 FAT 的原因是它自带掉电保护与磨损均衡，\r\n适合频繁写入小文件（配置、闹钟）的场景。\r\n\r\n| 文件 | 内容 |\r\n|---|---|\r\n| `\/cfg.txt` | 背光、调色参数、游戏最高分、累计运行时长 |\r\n| `\/alarm.txt` | 闹钟列表，每行格式 `A <时> <分> <开关>` |\r\n| `\/wallpaper.bin` | 240×320 壁纸原始像素，150 KB |\r\n\r\n### 4.3 板间链路接线\r\n\r\n| NS800 侧 | 方向 | C3 侧 | 说明 |\r\n|---|---|---|---|\r\n| PB6（UART2_TX） | ──► | GPIO5（UART1_RX） | NS800 发，C3 收 |\r\n| PB7（UART2_RX） | ◄── | GPIO4（UART1_TX） | C3 发，NS800 收 |\r\n| GND | ──── | GND | **必须共地** |\r\n\r\nTX 必须交叉接 RX。C3 侧使用 `UART_NUM_1`，不是 UART0 —— UART0 是它的日志口。\r\n\r\n**接线时的一个坑：NS800 的全局 IO 编号和 port 字母不是简单对齐的。**\r\n本 SDK 的 `GPIO_n` 按物理封装引脚顺序排列，中间夹了 GPIOF 的散脚：\r\n\r\n| 全局编号 | 实际引脚 |\r\n|---|---|\r\n| `GPIO_6` \/ `GPIO_7` | PA6 \/ PA7（不是 PB6\/PB7） |\r\n| `GPIO_38` \/ `GPIO_39` | PB6 \/ PB7（板间链路） |\r\n| `GPIO_12` \/ `GPIO_13` | PA12 \/ PA13（控制台） |\r\n\r\n各段起点为 `GPIOA → 0`、`GPIOB → 33`、`GPIOC → 64`，A 组只到 21 就跳到 F 组，\r\n不能按「每 16 个一组」推算。接线看板子丝印即可，写代码时才需要对照编号。\r\n\r\n### 4.4 ESP32-C3 侧资源\r\n\r\n| 项目 | 规格 |\r\n|---|---|\r\n| Flash | 4 MB，使用自定义分区表，OTA 双槽 |\r\n| 联网 | WiFi STA \/ AP |\r\n| 串口 | UART1 用于板间链路，UART0 是日志口 |\r\n\r\n分区表布局：\r\n\r\n```\r\n0x000000  bootloader      21 KB\r\n0x008000  partition table  3 KB\r\n0x009000  nvs             24 KB    WiFi 配置存储\r\n0x00F000  otadata          8 KB    OTA 槽选择标记\r\n0x011000  phy_init         4 KB    RF 校准\r\n0x020000  ota_0          1.5 MB    主程序槽 A\r\n0x1A0000  ota_1          1.5 MB    主程序槽 B\r\n0x320000  (free)        ~0.94 MB    预留给数据分区\r\n```\r\n\r\n每个 OTA 槽 1.5 MB，当前固件实测 1054 KB，占用约 69%，余量约 31%。\r\n两个槽等大且起始地址 64 KB 对齐，`otadata` 紧邻两槽之前，这是 IDF 的约定。\r\n\r\n---\r\n\r\n## 五、软件实现\r\n\r\n### 5.1 RT-Thread 任务组织\r\n\r\n| 线程 \/ 执行体 | 优先级 | 周期 | 栈 | 主要职责 |\r\n|---|---|---|---|---|\r\n| LVGL 主循环 | 20 | 5 ms | 4096 B | 渲染与输入事件分发 |\r\n| `led_thread` | 低 | 10 ms | 512 B | 双 LED 节拍：慢闪 \/ 快闪 \/ 心跳 |\r\n| c3_link RX | 中 | 事件驱动 | 1024 B | 读字节并送入帧状态机 |\r\n| `c3_report` | — | 5000 ms | — | 业务数据上行（lv_timer） |\r\n| `alarm_watch_cb` | — | 1000 ms | — | 闹钟到点判定（lv_timer，常驻） |\r\n| `uptime_flush_cb` | — | 长周期 | — | 累计运行时长落盘（lv_timer） |\r\n| `app_*_tick` | — | 50~2000 ms | — | 当前应用的界面刷新 |\r\n\r\nRT-Thread 中数值越小优先级越高。界面刷新、数据上报、闹钟判定分别挂在不同的\r\n`lv_timer` 上，周期由 App 注册表指定，避免全屏无差别重绘。\r\n\r\n**一条硬性约束：所有 LVGL 调用只能在 LVGL 线程内执行。**\r\nmsh 命令如果需要驱动界面，必须用 `lv_async_call()` 派发到 LVGL 线程，\r\n否则会出现难以复现的渲染异常。\r\n\r\n### 5.2 软件架构\r\n![screenshot_软件架构图.png](https:\/\/oss-club.rt-thread.org\/uploads\/20261018\/e4abe5ea87807e4c1020b45fa3008b7c.png.webp)\r\n\r\n左侧为 NS800，右侧为 ESP32-C3，中间灰色的协议层是两侧唯一共用的部分。\r\n两层应用之间没有直接依赖，换掉任意一侧只要协议不变，另一侧不受影响。\r\n\r\n各层职责：\r\n\r\n- **应用层（NS800）**：桌面框架、App 注册表、状态栏，以及 14 个应用的实现。\r\n  界面上出现的所有中文都从 `ui_text.h` 取，不在代码里直接写中文。\r\n- **业务层（NS800）**：配置持久化、闹钟模型、壁纸存取、横屏游戏与调色台。\r\n- **通信层（NS800）**：`c3_link.c` 负责链路收发与收帧状态机，\r\n  `c3_report.c` 周期把业务数据打包上行。\r\n- **启动层（C3）**：`c3_bridge_main.c` 编排启动顺序并拉起心跳等常驻任务。\r\n- **服务与功能层（C3）**：WiFi 连接与配置、周期上报、NTP 对时、\r\n  上行 KV 缓存、网页服务与 OTA 升级。\r\n- **协议层**：`ns800_link.h` 在两工程各存一份副本，必须逐字节一致。\r\n\r\n### 5.3 应用框架与新增应用\r\n\r\n桌面为 3×3 网格，14 个应用分 2 页。每个应用在注册表里是一个表项：\r\n\r\n```c\r\ntypedef struct {\r\n    const char *name;               \/* 中文名（zh18） *\/\r\n    const char *sym;                \/* 图标字符 *\/\r\n    uint32_t    color;              \/* 图标配色 *\/\r\n    void      (*build)(lv_obj_t *body);   \/* 进入时构建界面 *\/\r\n    void      (*tick)(void);        \/* 周期刷新，可为 NULL *\/\r\n    uint16_t    period;             \/* tick 周期 ms，0 = 不刷新 *\/\r\n    rt_uint8_t  flat_nav;           \/* 是否不叠加导航栏底色 *\/\r\n    void      (*teardown)(void);    \/* 离开时收尾，可为 NULL *\/\r\n} app_def_t;\r\n\r\nstatic const app_def_t g_apps[APP_COUNT] = {\r\n    { S_A_TEMP,  \"\\xC2\\xB0\" \"C\",  AC_TEMP,  app_temp_build, app_temp_tick, 2000 },\r\n    ...\r\n};\r\n\r\n\/* 编译期校验：表项数必须等于 APP_COUNT *\/\r\n_Static_assert(sizeof(g_apps) \/ sizeof(g_apps[0]) == APP_COUNT,\r\n               \"APP_COUNT 与 g_apps[] 表项数不一致！\");\r\n```\r\n\r\n**新增一个应用需要改动 4 处**，这是框架的约定：\r\n\r\n| 位置 | 改动内容 |\r\n|---|---|\r\n| `ui_text.h` | 加中文文案宏，并重新生成字库 |\r\n| `ui_apps.c` | 写 `build` \/ `tick` 实现 |\r\n| `ui_apps.h` | 声明函数（注意：声明的函数定义不能带 `static`） |\r\n| `ui.c` | `APP_COUNT + 1` 并增加表项；图标配色写在 `ui.h` |\r\n\r\n`APP_COUNT` 必须与表项数严格相等。调试命令 `uitest` \/ `uiapp` 会按\r\n`APP_COUNT` 遍历表，写大了就会越界读出 NULL 的函数指针，调用即 hard fault。\r\n上面那条 `_Static_assert` 就是为此加的兜底。\r\n\r\n### 5.4 中文字库\r\n\r\nLVGL 自带字体不含汉字，全字库又太大，因此采用**按需生成子集**的做法。\r\n\r\n字库生成脚本只扫描 `ui_text.h` 里 `#define` 行尾 `\/* 原文 *\/` 注释中的汉字，\r\n所以**所有文案必须集中在 `ui_text.h`**，代码里直接写中文字面量不会进字库，\r\n运行时会显示成方块。当前子集包含 181 个汉字，产物 232 KB 源码。\r\n\r\n中文用 UTF-8 转义写法（如 `\"\\xE6\\xB8\\xA9\\xE5\\xBA\\xA6\"` 即「温度」），\r\n规避编辑器与工具链之间的编码漂移。这里有两个容易踩的点：\r\n\r\n- **C 的 `\\x` 转义会贪婪吞掉后续十六进制字符**。表示「°C」必须写成\r\n  `\"\\xC2\\xB0\" \"C\"`，写成 `\"\\xC2\\xB0C\"` 会被解析成 `\\xC2` + `\\xB0C`，\r\n  轻则编译报错，重则显示错字。\r\n- **转义字节与注释原文必须严格对应**。手写转义很容易打错一个字节，\r\n  而症状极具误导性：错一个字节屏上就是乱码或另一个字，看起来像字库缺字，\r\n  实际字库完全正常。为此专门写了 `check_text_escapes.py`，\r\n  逐条比对字节序列与注释原文，不一致直接中止字库生成。\r\n\r\n---\r\n\r\n## 六、关键技术实现\r\n\r\n### 6.1 显示移植与双缓冲\r\n\r\nLVGL 采用**双缓冲 + PARTIAL 渲染模式**：渲染进 buf1 时 SPI 同时在刷 buf2，\r\n避免单缓冲下「渲染完才能刷屏」的串行等待。\r\n\r\n```c\r\n\/* 缓冲按最宽的横屏 320 分配，竖屏复用同一块内存 *\/\r\nstatic lv_color_t s_buf1[LAND_BUF_LINES * LAND_W];\r\nstatic lv_color_t s_buf2[LAND_BUF_LINES * LAND_W];\r\n\r\nlv_display_set_buffers(disp, s_buf1, s_buf2, s_buf_bytes,\r\n                       LV_DISPLAY_RENDER_MODE_PARTIAL);\r\n```\r\n\r\n按最宽分辨率分配是关键：运行时切换横竖屏时缓冲不用重新申请，\r\n也避免了切换瞬间的指针失效。\r\n\r\nILI9341 走 SPI 需要大端 RGB565，在 `flush_cb` 里做字节交换后送屏。\r\n\r\n### 6.2 板间自定义协议\r\n\r\n没有使用现成的 Modbus 或自定义文本协议，而是设计了一个紧凑的二进制帧协议，\r\n原因是板间链路只有 115200 波特率，每一字节都要省：\r\n\r\n```\r\n┌──────┬──────┬──────┬──────────────┬──────┐\r\n│ 0xAA │ 0x55 │ LEN  │   PAYLOAD    │ CRC8 │\r\n└──────┴──────┴──────┴──────────────┴──────┘\r\n```\r\n\r\n- 小端序；`payload[0]` 为命令码；CRC 覆盖 `LEN + PAYLOAD`\r\n- 校验算法 CRC-8\/ATM（poly `0x07`，init `0x00`），自检向量 `\"123456789\" → 0xF4`\r\n- 最大载荷 96 字节，最长帧 100 字节\r\n\r\n命令码按方向分段，0x01~0x08 为 C3 下行，0x81~0x88 为 NS800 上行：\r\n\r\n| 码 | 名称 | 方向 | 用途 |\r\n|---|---|---|---|\r\n| `0x01` | HELLO | C3→NS800 | 上线问候，含固件版本 |\r\n| `0x02` | WIFI_STATE | C3→NS800 | WiFi 状态枚举 |\r\n| `0x03` | SENSOR | C3→NS800 | 传感器数据（定长二进制） |\r\n| `0x04` | TEXT | C3→NS800 | 通用文本 |\r\n| `0x05` | SERVER_RESP | C3→NS800 | 服务器回包摘要 |\r\n| `0x06` | WIFI_CFG | C3→NS800 | 回报 WiFi 配置 |\r\n| `0x07` | OTA_PROGRESS | C3→NS800 | OTA 进度百分比 |\r\n| `0x08` | TIME | C3→NS800 | 时间同步（NTP 对时后下发） |\r\n| `0x81` | PING | NS800→C3 | 心跳探测 |\r\n| `0x82` | REQ_WIFI | NS800→C3 | 请求重连 WiFi |\r\n| `0x83` | SET_REPORT | NS800→C3 | 设置上报周期（2 字节小端） |\r\n| `0x84` | SET_WIFI_CFG | NS800→C3 | 下发 WiFi 配置并连接 |\r\n| `0x85` | GET_WIFI_CFG | NS800→C3 | 请求回报当前配置 |\r\n| `0x86` | OTA_START | NS800→C3 | 触发 OTA（载荷为固件 URL） |\r\n| `0x87` | WIFI_FACTORY_RST | NS800→C3 | 恢复出厂 WiFi 配置 |\r\n| `0x88` | HOST_DATA | NS800→C3 | 通用 KV 数据上行 |\r\n\r\n### 6.3 收帧状态机与假帧头处理\r\n\r\n帧同步靠 `0xAA 0x55` 两个字节。原始实现里，状态机在读到非法长度时直接丢弃\r\n当前字节并回到等待 SOF0 —— 这里有个隐患：**被丢弃的那个字节本身可能就是新帧的\r\n帧头**。噪声 `AA AA AA 55` 会被误判成 `SOF0 SOF1` + `LEN=0x55`，\r\n一次性吞掉后面 85 字节，导致连续丢失真帧。\r\n\r\n修正方式是遇到非法长度时按 SOF 语义重新匹配：\r\n\r\n```c\r\ns_state = (b == SOF0) ? ST_SOF1 : ST_SOF0;\r\n```\r\n\r\n这个问题配套写了回归测试脚本 `tools\/test_ns800_link.py`，\r\n覆盖分片收包、噪声注入、CRC 错误与边界长度四类场景。\r\n\r\n### 6.4 通用 KV 数据上行通道\r\n\r\n设备数据的种类是开放集合：今天是背光、调色、游戏分数、温湿度，\r\n明天可能加光照、噪音。如果一种数据一个命令码，协议会迅速膨胀，\r\n而且每加一项都要同时改两侧代码、同步两份头文件。\r\n\r\n因此设计了 `0x88` 通用通道，载荷是纯文本 `\"key=value\"`，长度由帧 LEN 给出：\r\n\r\n```c\r\nrt_err_t c3_link_send_kv(const char *key, const char *value)\r\n{\r\n    rt_uint8_t buf[NS800_LINK_MAX_PAYLOAD];\r\n    rt_size_t kl = rt_strlen(key), vl = value ? rt_strlen(value) : 0;\r\n    rt_size_t total = kl + 1 + vl;\r\n\r\n    if (total > (rt_size_t)(NS800_LINK_MAX_PAYLOAD - 1)) return -RT_EINVAL;\r\n\r\n    rt_memcpy(&buf[0], key, kl);\r\n    buf[kl] = '=';\r\n    if (vl) rt_memcpy(&buf[kl + 1], value, vl);\r\n\r\n    return send_raw(NS800_CMD_HOST_DATA, buf, (rt_uint8_t)total);\r\n}\r\n```\r\n\r\nC3 侧用一张 16 槽的定长 KV 表保存最新值（静态分配，不走 malloc）。\r\n**两侧不需要同步加代码**：C3 收到不认识的 key 也原样存下来供网页展示，\r\nNS800 想上报新指标时只改一侧即可。\r\n\r\n载荷结尾**不带 `'\\0'`**，长度完全由 LEN 决定。接收侧必须用显式长度处理，\r\n不能把 `payload + 1` 直接当 C 字符串用，否则会读到数组残留字节。\r\n\r\n### 6.5 掉电安全的文件写入\r\n\r\n配置与闹钟都采用「写临时文件 + rename」的范式：\r\n\r\n```c\r\nremove(ALARM_TMP);\r\nfd = open(ALARM_TMP, O_WRONLY | O_CREAT | O_TRUNC);\r\n\/* ... 写入全部内容 ... *\/\r\nclose(fd);\r\n\r\nremove(ALARM_PATH);\r\nrename(ALARM_TMP, ALARM_PATH);      \/* 原子替换 *\/\r\n```\r\n\r\n这样任何时刻掉电，要么是旧文件完整、要么是新文件完整，不会出现写了一半的半成品。\r\n解析侧一律容错：遇到坏行跳过继续读，不因单行错误废弃整个文件。\r\n\r\n写 Flash 有数十毫秒的阻塞，会拖住 LVGL 线程造成动画卡顿，\r\n因此累计运行时长这类高频变化的数据采用长周期落盘，而不是每次变化都写。\r\n\r\n### 6.6 运行时横竖屏切换\r\n\r\n跑酷游戏需要横屏，其余界面竖屏，因此要在运行时切换方向。\r\n这需要**三层配对**，缺一层就会出问题：\r\n\r\n| 层 | 接口 |\r\n|---|---|\r\n| 屏幕驱动 | `LCD_direction` |\r\n| LVGL 显示 | `lv_port_disp_set_orientation` |\r\n| UI 层 | `ui_set_fullscreen` |\r\n\r\n进入和退出都是**先切方向再重排**。此外导航栏宽度也要跟随重设，\r\n否则横屏时只有 240 宽、回到竖屏又会溢出 320 宽。\r\n\r\n### 6.7 界面颜色一致性的一个细节\r\n\r\n图片应用和调色台要求「页面里的图像与桌面壁纸颜色完全一致」。\r\n最初这几页看起来总蒙着一层蓝灰，排查后发现是 App 容器\r\n`g_layer_app` 默认带了一层 `0x141820 @ 90%` 的半透明深蓝底。\r\n凡是需要精确比色的页面，都要打开 `flat_nav` 标志，在 `app_open()` 时\r\n把这层底色置为 `TRANSP`，层级才与桌面相同。\r\n\r\n这也解释了为什么图片应用只做几何变换（缩放、平移）而不做颜色处理 ——\r\n任何像素级处理都会引入色彩偏差。\r\n\r\n### 6.8 ESP32-C3 侧：网页与本机 OTA\r\n\r\nC3 运行一个基于 `esp_http_server` 的管理页，Material Design 浅色风格，\r\n页面每秒自动刷新。主要接口：\r\n\r\n| 路由 | 作用 |\r\n|---|---|\r\n| `GET \/` | 管理主页，浅色，1 秒自刷新 |\r\n| `GET \/api\/status` | 当前状态 JSON |\r\n| `GET \/api\/ping` | 物理层活性探针 |\r\n| `GET \/api\/host_data` | NS800 上行数据的展示出口 |\r\n| `GET \/api\/wifi` | 读写 WiFi 配置 |\r\n| `GET \/api\/ota` | 查询 OTA 状态 |\r\n| `POST \/api\/ota\/upload` | **本地上传固件**（流式写入，不占内存） |\r\n| `POST \/api\/reboot` | 重启 |\r\n\r\nOTA 使用 `esp_ota_*` 手工 API 完成「写入 → 校验 → 切槽 → 重启」全流程，\r\n升级包通过 HTTP 分块上传直接流式写入 Flash 分区，不在 RAM 里缓存整个固件。\r\n两个槽轮流写入，配合 `PENDING_VERIFY → confirm_valid` 实现启动失败自动回滚。\r\n\r\n---\r\n\r\n## 七、调试与测试\r\n\r\n### 7.1 启动日志\r\n\r\nNS800 侧的串口输出：\r\n\r\n```\r\n \\ | \/\r\n- RT -     Thread Operating System\r\n \/ | \\     5.3.0 build Oct 11 2026\r\n 2006 - 2024 Copyright by RT-Thread team\r\n[I\/SFUD] Found a flash chip. Size is 16777216 bytes.\r\n[I\/SFUD] spiflash0 flash device initialized successfully.\r\n[lfs] auto-mounted 'norflash0' at '\/'\r\n[lvgl] display ready: ILI9341 240x320 @50MHz\r\n[lvgl] encoder indev ready (eQEP + KEY31)\r\n[alarm] 已载入 2 个闹钟（文件 35 字节）\r\n[I\/c3link] C3 上线: c3-bridge v2.0\r\n```\r\n\r\nC3 侧启动后会周期性打印链路统计与上报情况：\r\n\r\n```\r\nI (330263) report: 收到主机数据: backlight=80\r\nI (330283) report: 收到主机数据: temp_c=25.60\r\nI (330283) report: 收到主机数据: humidity=63.70\r\nI (334163) link: TX SENSOR      15B  (累计 416)\r\nI (334163) report: rssi=-46 free=185KB up=333s (网络 成0 败0)\r\n```\r\n\r\n### 7.2 调试手段\r\n\r\nNS800 侧提供了一组 msh 命令，覆盖链路、界面、存储与外设四类：\r\n\r\n| 类别 | 命令 |\r\n|---|---|\r\n| 链路 | `c3stat` `c3ping` `c3wifi` `c3period` `c3cfg` `c3ota` `c3time` `c3kv` `c3wifirst` `c3dbg` |\r\n| 界面 | `uitest` `uiapp <n>` `uihome` `page` `focusnext` `focusprev` `focusdump` `fontinfo` `lvmem` `fpsinfo` |\r\n| 显示 | `dispdir` `lcdcolor` `tune` |\r\n| 存储 | `lfsmount` `lfsformat` `lfstest` `spi3probe` `sfudprobe` `wallstatus` `wallsave` `cfg` |\r\n| 闹钟 | `alist` `aadd` `adel` `aon` `acheck` |\r\n| 外设 | `ec11` `ec11raw` `dhx22` `keyinfo` `gamestate` `gamejump` |\r\n\r\n其中 `uiapp <n>` 与 `uitest` 支持**远程遥控界面**，不必手转旋钮就能逐页验证，\r\n这在做界面回归时非常省事；`fontinfo` 用来确认字库的描述符与位图格式；\r\n`lvmem` 查看 LVGL 内存池占用。\r\n\r\n### 7.3 调试过程中解决的主要问题\r\n\r\n| 问题 | 原因 | 解决方案 |\r\n|---|---|---|\r\n| 屏幕 70 MHz 花屏 | 排线带宽不足，采样点错位。理论算出的 55 FPS 不等于能跑的值 | 降回 50 MHz，以实测确定实际上限 |\r\n| 所有汉字显示成空心方块 | `lv_font_conv` 默认输出压缩位图，与 `LV_USE_FONT_COMPRESSED = 0` 不匹配，LVGL 取位图直接返回 NULL | 生成时加 `--no-compress`。判据是产物里 `bitmap_format` 必须为 0，或在设备上敲 `fontinfo` |\r\n| 旋钮在横竖屏间高频抖动 | 用 `lv_indev_get_state()` 判按键沿，而该状态每 30 ms 才刷新，主循环被拖住时读到陈旧值，一次按下被判成多个上升沿 | 改为直读引脚电平，自己做「连续 N 次同电平才认账」的消抖 |\r\n| 噪声导致连续丢帧 | 收帧状态机遇非法长度直接丢弃当前字节，而该字节可能就是新帧的帧头。`AA AA AA 55` 会被当成帧头加长度 0x55，一次吞掉 85 字节 | 按 SOF 语义重新匹配：`s_state = (b == SOF0) ? ST_SOF1 : ST_SOF0` |\r\n| 闹钟重启后读不回 | `read(fd, line, n)` 逐行读在 LittleFS 上不工作，而 `cat` 看文件内容完全正常，容易误判成写入失败 | 改为整块读进缓冲，再自己按 `\\n` 切分 |\r\n| 板间链路判为「没信号」，排查方向错误 | 用 C3 的接收字节数是否增长判断物理层，而 NS800 平日静默不发帧，该计数本就不会增长 | 改用两侧各自的方向性计数判断，见下文 |\r\n\r\n最后一条值得单独说明。排查时观察到 C3 侧的 `rx_bytes` 长时间不变，于是判定物理层断开，\r\n去换线、重插、验证共地，折腾了很久，结果接线本来就是对的。\r\n\r\n问题在于 `rx_bytes` 只反映 NS800 有没有在说话。NS800 是纯被动端，只有在心跳或收到命令\r\n时才发帧，平时完全静默。因此正确的判据要分方向看：\r\n\r\n| 方向 | NS800 侧观察 | C3 侧观察 |\r\n|---|---|---|\r\n| C3 → NS800 | `feed` \/ `parsed` 增长，`CRC错 0` | — |\r\n| NS800 → C3 | — | `rx_bytes` \/ `frames` 增长，`badcrc=0` |\r\n\r\n单向接线时，另一个方向的所有计数必然为零，这是物理必然，不是故障。\r\n\r\n### 7.4 测试结论\r\n\r\n- 板间链路双向打通，两侧计数持续增长、CRC 错误为 0\r\n- 14 个应用均可通过旋钮与按键正常进入、操作、退出\r\n- 配置与闹钟经真复位后正确回读（`pyocd reset` 后验证，非软重启）\r\n- 闹钟到点自动响铃，同分钟不重复触发，`alist` 能正确显示响铃状态\r\n- 业务数据 11 个字段 5 秒\/轮上行，C3 侧全部收到\r\n- 网页可查看状态、修改 WiFi、上传固件完成 OTA 并成功切槽\r\n\r\n---\r\n\r\n## 八、资源占用与限制\r\n\r\n### 8.1 内存与 Flash\r\n\r\n**NS800 片内 Flash 已用 93.18%**（488520 B \/ 512 KB，剩余约 35 KB）。\r\n\r\n| 内存区域 | 用量 | 占比 |\r\n|---|---|---|\r\n| `.lvgl_pool` | 64 KB | LVGL 堆，`lv_malloc` 从这里分配 |\r\n| `.wall_buf` | 150 KB | 壁纸缓冲 |\r\n| SRAM12 合计 | 214 KB \/ 256 KB | 83.6% |\r\n| DTCM | 59472 B \/ 71 KB | 81.8% |\r\n| RT-Thread 堆 | 56 KB | 100%，运行时可用约 39.9 KB |\r\n\r\n这张表决定了后续所有功能设计：**任何需要大块连续内存的方案都不可行**。\r\n例如曾经考虑给图片应用加「灰度」视图，思路是 `lv_malloc(153600)` 再开一块缓冲\r\n做像素级处理，实际申请必然失败 —— LVGL 内存池总共只有 64 KB。\r\n\r\n类似「改变图片显示效果」的需求，应当优先使用 LVGL 原生样式\r\n（`image_recolor`、`lv_image_set_scale`），不要自备像素缓冲。\r\n\r\n### 8.2 已知限制\r\n\r\n| 限制 | 说明 |\r\n|---|---|\r\n| 无视频播放能力 | 板间链路 115200 波特率下每秒约 11 KB，整屏 240×320 RGB565 为 150 KB，一帧需要十几秒。若必须实现，需 C3 侧编码 JPEG 并把串口提速到 921600，同时注意 M7 软解 JPEG 的耗时与 Flash 余量（解码库约需 15~20 KB） |\r\n| 无返回键 | 旋钮只有一个按键，因此没有设计二级子页面，否则进入后无法返回。所有可交互元素都是可聚焦按钮 |\r\n| 传感器读取成功率不高 | DHT22 需要重试两次。上报侧曾出现「温度应用有值、上报没有值」的现象，实际只是少了一次重试 |\r\n| Flash 余量紧张 | 新增功能前需要先核算，中文文案会同时占用字库与代码空间 |\r\n| 时间依赖 C3 | NS800 无独立时钟源，C3 离线时时间与闹钟判定不可用 |\r\n| OTA 回滚未验证 | 已实现双槽与校验，但「新固件启动失败自动回滚」还需在实机上做异常注入测试 |\r\n\r\n---\r\n\r\n## 九、后续可做的事\r\n\r\n- 链路上行带宽优化。评估把波特率提到 460800 或 921600，配合帧长上限调整，\r\n  为传输更多业务数据留出空间。\r\n- OTA 回滚验证。注入启动失败场景，确认 `PENDING_VERIFY` 逻辑生效。\r\n- 新增传感器。接入光照或噪音传感器时，需同步扩展 KV 字段、界面入口与\r\n  C3 侧的展示，但不需要改动协议，这正是通用通道的设计目的。\r\n- 界面回归自动化。利用 `uitest` 与 `uiapp` 做逐页截图比对，\r\n  把界面改动的影响范围自动查出来。\r\n- 报警与事件日志。当前闹钟响铃不留记录，可考虑把事件写入文件，便于事后回溯。\r\n\r\n---\r\n\r\n## 十、参考资料\r\n\r\n1. RT-Thread 5.3.0 源码与官方文档：https:\/\/www.rt-thread.org\/\r\n2. RT-Thread Env 工具与 scons 构建说明\r\n3. LVGL 9.1 官方文档：https:\/\/docs.lvgl.io\/\r\n4. `lv_font_conv` 字体转换工具\r\n5. ESP-IDF v6.1 编程指南（含 `esp_http_server`、`app_update`）\r\n6. LittleFS 与 SFUD 组件文档\r\n7. ILI9341、DHT22 数据手册\r\n\r\n**工程内的配套文档**：\r\n\r\n- `C3_NS800_板间链路联调手册.md` — 接线、判据与排障流程\r\n- `LVGL_AI_STANDARD.md` — LVGL 开发规范（脏区重绘、内存预算、新增应用清单）\r\n- `FLASHING_AI_STANDARD.md` — 编译烧录规范\r\n- `tools\/test_ns800_link.py` — 协议回归测试脚本\r\n\r\n---\r\n\r\n## 附：复现指引\r\n\r\n### 环境准备\r\n\r\n- RT-Thread Env 工具（提供 scons 与工具链）\r\n- ARM GCC 工具链\r\n- pyocd（用于烧录 NS800）\r\n- ESP-IDF v6.1（用于构建 C3 固件）\r\n\r\n### 构建与烧录\r\n\r\nNS800 侧：\r\n\r\n```bash\r\ncd bsp\/novosns\/ns800\/ns800rt7p65-nssinepad\r\nsource ..\/env.sh\r\nscons -j22\r\npyocd flash -t ns800rt7p65 rtthread.elf\r\n```\r\n\r\n- `pyocd` 的 target 依靠当前目录下的 `pyocd_user.py` 自动加载，\r\n  不在 BSP 根目录执行会报 `Target not recognized`。\r\n- 烧录成功的判据是输出 `identical 0 bytes`。若显示非 0，\r\n  说明内容没有变化，等同于没有烧录。\r\n- 修改过 `ui_text.h` 的文案后，必须重新执行字库生成脚本：\r\n  `sh applications\/lvgl\/ui\/gen_font_zh.sh`\r\n\r\nESP32-C3 侧：\r\n\r\n```bash\r\ncd c3_bridge\r\nsource ~\/esp-env.sh; idf.py build; idf.py -p <PORT> flash monitor\r\n```\r\n\r\n`source ~\/esp-env.sh` 定义的是 shell 函数，必须与 `idf` 在同一条 shell 中执行。\r\n用 `&&` 会开启子 shell 导致函数丢失，因此这里用 `;` 而不是 `&&`。\r\n\r\n> 工程源码地址：<https:\/\/github.com\/gcgjh\/ns800pd>\r\n> 演示视频：<https:\/\/v.douyin.com\/Ikw8nUcdRZs\/>","updatetime":"2026-10-21 10:08:50","author":"UMQW_7009"}}