返回 00-项目导航 · 前置阅读 05-联网数据与人工智能
对象是
weixue完整应用,不能与moqian驱动演示混为一谈。本篇只整理代码证据和学习练习,没有修改原工程、导出配置、构建烧录或硬件联调。源码行号以 2026-10-25 核对结果为准。
一、先认识两条控制通道
蓝牙 GATT 与设备内置 HTTP 服务是两个入口。蓝牙侧通过写特征接收 JSON,通过通知特征返回状态;HTTP 侧通过 URI 接收手机或电脑的请求。二者最后都操作设备的课程、备忘录、页面等业务状态。
ble_manager.c:187 注册一个服务和两个特征;写特征允许 WRITE、WRITE_NO_RSP,通知特征允许 NOTIFY。手机连接成功还不够,必须订阅通知,ble_is_connected() 在 :80 同时检查连接和订阅状态。设备不会因为看见名字就自动获得可靠的双向应用协议。
内置网页由 web_server_start()(web_server.c:569)在 80 端口启动。main.c:999 不以外网连接成功为前提启动它;接入点与 DNS 门户为本地访问提供入口,但这不等于所有手机都会自动弹出页面,也不等于设备已经连上互联网。
二、蓝牙回调为什么只负责入队
NimBLE 有自己的协议栈执行上下文,项目在 GATT 写回调中只做短操作,业务逻辑转交主循环:
- 手机写特征
- 写回调复制数据
- 四槽接收队列
- 主循环取消息
- 解析并按命令类型分发
- 修改状态与设置刷新标志
- 主循环刷新屏幕
查看原始 Mermaid 源码
flowchart LR
A[手机写特征] --> B[写回调复制数据]
B --> C[四槽接收队列]
C --> D[主循环取消息]
D --> E[解析并按命令类型分发]
E --> F[修改状态与设置刷新标志]
F --> G[主循环刷新屏幕]
证据依次是 chr_rx_access_cb()(ble_manager.c:156)、ble_process_pending()(:497)、main.c:1099、on_ble_rx()(main.c:829)、ble_cmd_handle()(ble_cmd.c:573)。这避免在蓝牙回调里直接等待墨水屏刷新或执行 HTTP。ESP-IDF 的 nimble_port_freertos_init() 默认启动协议栈线程,主循环与协议栈并非同一执行流。ESP-IDF 5.1.2 NimBLE 文档
接收队列只有四项,每项数组 512 字节(ble_manager.c:70)。当前最多复制 511 字节并补零,满队列时直接丢弃;回调仍返回零。因此,“蓝牙写操作成功”不能证明命令已经执行,更不能证明已写入 Flash。协议应该依靠业务应答确认最终状态。
三、分包有三层,不能互相替代
第一层是 BLE 协议栈传输,第二层是应用命令边界,第三层才是图片文件的分块。当前固件把一次写回调当成一个完整 JSON,没有跨写操作累积字符串的重组器。如果手机把一个 JSON 随意拆成两次写入,设备会尝试分别解析,通常都失败。
此外,写回调直接读取 ctxt->om->om_len 和第一段 om_data,没有展开整个 mbuf 链;不能把队列容量 512 当成任何情况下都可完整收到的单包能力。sdkconfig:556 的首选 ATT MTU 为 256,但首选值不等于最终协商结果。发包长度应根据协商 MTU和协议开销决定。
发送侧 ble_notify()(ble_manager.c:466)把整段 JSON 交给 ble_gatts_notify_custom(),没有应用层切片、序号或重发;长新闻列表不能据此保证一次通知完整送达。GATT 通知 API 和缓冲区所有权应按 Apache NimBLE 官方参考核对,不能用“底层会分片”替代应用协议设计。
图片协议已经存在 img_start → img_chunk → img_end(ble_cmd.c:438 起)。每块是带 Base64 文本的独立 JSON,解码后 fwrite() 到 SPIFFS,这属于文件层分块。当前 idx 只检查是否为数字,没有验证顺序或去重;声明总长度没有在结束时核对,也未检查实际写入长度、校验和或上传超时,不能称为可靠断点续传。
教学命令可以从最短消息开始,下面三条应分别作为完整消息发送,且不含任何真实配置:
{"type":"ping"}
{"type":"get_status"}
{"type":"refresh_news"}
其中刷新命令在 ble_cmd.c:256 只设置标志,主循环才停止蓝牙并执行联网更新。连接中断是这个路径的预期代码行为,完成后仍需要客户端重新建立连接与订阅。
四、HTTP 请求也必须循环接收
web_server.c:586 起注册首页、新闻、备忘录、无线网络、课程、状态、切页等接口。以 POST /api/memos 为例,:296 限制正文不超过 4096 字节,申请长度加一的缓冲区,循环 httpd_req_recv(),补结束符后解析 JSON。TCP 不保证一次读取就是完整正文。
下面只展示该流程的教学骨架,省略错误响应与释放分支;实际代码必须处理它们:
size_t received = 0;
while (received < req->content_len) {
int n = httpd_req_recv(req, buf + received,
req->content_len - received);
if (n <= 0) return ESP_FAIL; /* 实际实现应先释放 buf。 */
received += (size_t)n;
}
buf[received] = '\0';
当前实现遇到非正返回值就失败,没有专门回复超时状态;ESP-IDF 提供 HTTPD_SOCK_ERR_TIMEOUT 供应用区分。HTTP 服务有独立任务,URI 处理器也不是主循环的一部分。ESP-IDF 5.1.2 HTTP 服务端文档
五、从“收到”到“掉电不丢”有几个状态
网页备忘录是一个很适合练习读代码的反例。api_memos_post() 在 web_server.c:334 直接更新全局数组,:353 设置 g_memo_dirty=true,随后返回成功。然而当前 main 源码中没有找到消费这个标志的逻辑。注释写着“主循环保存并刷新”,但这条链尚未接完:只能确认网页请求更新了 RAM,不能确认已经写 NVS 或刷新屏幕。
蓝牙 h_memo_update() 则会调用 storage_save_memos(),课程网页接口也直接调用保存函数,不能把不同入口的能力合并描述。更进一步,storage_save_memos() 和 storage_save_courses() 拒绝数量为零;清空列表时,即使 RAM 变空,也可能保留旧的持久数据。重启后出现旧数据,是应设计验收用例检查的边界。
并发也没有因为用了 volatile 就自动解决:HTTP 任务可能正在改数组,主循环同时读取并绘制。当前没有看到保护这些共享业务数组的统一互斥或快照机制。可改进为“HTTP/BLE 入队,主循环统一应用、保存并应答”;这是建议方向,本轮没有实施。现有蓝牙队列只隔离了自己的接收路径。
六、四种存放位置各管什么
| 位置 | 数据与入口 | 需要理解的区别 |
|---|---|---|
| RAM | 全局新闻、天气、课程、备忘录与显示列表 | 速度快,复位后不能依靠它恢复 |
| NVS | 设置、课程、备忘录、原始新闻缓存 | 键值与 blob;必须提交并检查错误 |
| SPIFFS | /spiffs,蓝牙上传图片 |
板载 Flash 文件区;不依赖插卡 |
| SD 卡 FAT 文件系统 | /sdcard,字体、书籍、日志、图片及新闻 JSON |
外部存储;挂载成功后才可访问 |
NVS 的典型过程是 nvs_open → nvs_set_blob → nvs_commit → nvs_close。storage_manager.c:29、:62、:139 都采用类似流程,但保存函数没有把提交失败完整向上传递;“调用过保存”还不能当作“确认落盘”。把 C 结构体直接存为 blob,还意味着以后改变结构体布局时需要考虑版本兼容。ESP-IDF 5.1.2 NVS 文档
storage_manager_init()(:22)仅打印就绪,实际 nvs_flash_init() 在 main.c 启动处完成。读函数名不能代替检查初始化内容。SPIFFS 在 main.c:874 挂载到 /spiffs,配置为挂载失败可格式化;SD 在 sd_card.c:24 设置挂载失败不格式化,两者的恢复代价不同。SPIFFS 写入也不是固定耗时操作。ESP-IDF 5.1.2 SPIFFS 文档
sd_card_init() 使用 SPI2 与独立片选,调用 esp_vfs_fat_sdspi_mount() 后,VFS 才能把 fopen("/sdcard/...") 转交给 FAT 文件系统。当前主循环会重试挂载;这不能证明任意热插拔、电气连接和文件完整性均已验证。ESP-IDF 5.1.2 FAT 文档
七、缓存优先级还藏着一个实际问题
storage_save_news_sd()(storage_manager.c:172)优先存 SD JSON,打不开文件才退回 NVS;storage_load_news_sd()(:211)优先读 SD,再回退 NVS。但编排每轮直接更新 NVS,而 main.c:1070 的 SD 保存只位于启动流程。周期刷新以后,SD 旧缓存可能优先遮住更新的 NVS 缓存。
读取函数还存在边界:只要打开了 SD 文件,即使内容超长、分配失败或 JSON 解析失败,当前路径仍可能返回 ESP_OK;数组字段的声明数量也没有完全按实际解析数量规范化。因此不能宣称已经具备可靠的“损坏文件自动回退”。这是静态代码发现,需要后续用副本和设备复现,不能冒充已发生的现场故障。
八、能验证理解的练习
- 只读连通检查:在已有设备条件下发送
ping、订阅通知并读取状态;分别记录连接、订阅、入队和业务应答,不能只记“手机显示连接成功”。本轮未实际执行。 - 拆包推演:把一个 JSON 分成两次写操作,再与两个完整
img_chunk比较;验收标准是能解释命令重组与文件分块的区别。 - 掉电恢复用例设计:分别用网页和蓝牙修改一条虚构备忘录,再设计清空、重启检查;验收标准是提前指出两个入口的保存差异和零条数据边界,不用真实个人数据试错。
- 缓存故障用例设计:使用备份卡的副本准备合法、空、截断三种新闻 JSON;验收标准是每种结果都区分 RAM、SD 和 NVS 来源,记录返回值与有效位。不要在原卡或真实缓存上直接做破坏性实验。
学完后应能把一条命令追踪为“传输接收、解析通过、状态更新、写入提交、屏幕刷新”五个独立事实,并说明哪些环节当前已接通、哪些仍只是注释中的意图。
来源与关联索引
- 来源
- 嵌入式比赛
- 原笔记
- 项目/墨嵌墨水屏/知识讲解/06-蓝牙网页与本地存储.md
- 资料性质
- 实践指南。原文的版本、来源与验证边界继续保留。