“软件永远不会’完成’——它只是被发布了。”固件烧录到设备里,发给用户,然后呢?如果发现了一个 bug,或者想添加新功能,总不能让用户拆开外壳、接上数据线、用命令行工具重新烧写吧?大多数用户根本没有编程线,更别说会用 PlatformIO 了。 这就是为什么 CrossPoint Reader 需要 OTA(Over-The-Air) 升级——让用户通过 WiFi 无线更新固件,就像手机系统更新一样简单。但无线升级带来一个新问题:如果新固件有严重 bug 导致设备启动就崩溃怎么办?设备连 WiFi 都进不去,就没法再次更新,彻底变砖。 本章我们来拆解 CrossPoint Reader 如何实现安全的 OTA 升级,以及崩溃后如何自救。
14.1 双 OTA 分区机制
OTA 安全升级的关键是双分区——Flash 上同时保留两份固件,一份正在运行,一份用来写入新版本。Flash 分区布局
- NVS:存储设备设置(阅读偏好、WiFi 配置等),升级后不丢失。
- otadata:只有 8KB,但至关重要——它记录着”下次启动应该从 app0 还是 app1 启动”。
- app0 / app1:两个大小相同的固件分区。任何时刻,一个是”当前运行”的,另一个是”备用”的。
- coredump:崩溃时保存现场信息,后面会详细讲。
更新流程
自动回滚
如果新固件有 bug,启动后立刻崩溃怎么办?ESP-IDF 提供了一个安全网:类比:这就像在悬崖边系了一根安全绳。你可以放心往前探——如果脚下踩空(新固件崩溃),安全绳会把你拉回安全的地方(旧固件)。
14.2 检查 GitHub Release
CrossPoint Reader 的固件发布在 GitHub Releases 上。设备通过 GitHub API 检查是否有新版本:JSON 过滤器的妙用
GitHub Release API 返回的 JSON 数据可能有好几 KB,包含作者信息、release notes、各种附件详情等。但我们只关心三个字段:版本号(tag_name)、文件名(name)和下载地址(browser_download_url)。
在内存只有 380KB 的设备上,解析完整的 JSON 既浪费内存又浪费时间。ArduinoJson 库提供了 JSON 过滤器功能:你告诉解析器”我只关心这几个字段”,它在解析过程中就会跳过其他所有内容,大幅减少内存占用。
这又是一个”资源受限环境下的取舍”——在服务器端你可能不在乎多解析几个字段,但在嵌入式设备上,每一个字节的内存都很金贵。
14.3 语义化版本号比较
拿到最新版本号后,需要和当前运行的版本号做比较。CrossPoint Reader 使用语义化版本号(Semantic Versioning):major.minor.patch,例如 1.3.2。
2.0.0 > 1.9.9,1.3.0 > 1.2.5。
还有一个细节:RC 版本(Release Candidate,发布候选版)的优先级低于同版本号的正式版。比如 1.3.0-RC1 被视为比 1.3.0 旧——因为 RC 是”还没正式发布”的意思,正式版才是最终形态。
14.4 固件下载与写入
确认有新版本后,就可以开始下载和安装了:关键细节解读
关闭 WiFi 省电。 WiFi 正常运行时会周期性地进入省电模式(关闭射频模块几十毫秒再打开),以节省电量。但在下载固件时,我们需要最大带宽和最低延迟,所以用esp_wifi_set_ps(WIFI_PS_NONE) 关闭省电模式。
边下边写。 esp_https_ota_perform 每次调用会从网络上下载一小块数据(通常几 KB),然后立刻写入 Flash 的备用分区。这种”流式写入”非常重要——固件可能有好几 MB,而设备的空闲 RAM 远没有这么大,不可能先下载完整个文件再写入。
进度反馈。 每次 esp_https_ota_perform 返回后,通过 esp_https_ota_get_image_len_read() 获取已下载的字节数,然后通知渲染任务更新进度条。对于可能持续几十秒的下载过程,进度反馈非常重要——否则用户会以为设备卡死了。
HTTPS。 注意函数名里的 https——下载走的是加密连接。这可以防止中间人攻击(有人拦截下载请求,替换成恶意固件)。ESP-IDF 的 HTTPS OTA 库会验证服务器的 TLS 证书,确保固件确实来自 GitHub。
14.5 崩溃捕获与诊断
固件发布后,如果用户的设备崩溃了怎么办?用户大概率不会连接串口看 log,开发者无法远程调试。CrossPoint Reader 的策略是:崩溃时自动保存现场,下次启动时展示崩溃报告。崩溃信息的保存
RTC_NOINIT_ATTR:重启后不丢失的内存
普通的全局变量存在 RAM 里,重启后全部清零。但 ESP32-C3 有一小块特殊的 RTC 内存,它由 RTC 模块的电源独立供电。RTC_NOINIT_ATTR 告诉编译器”把这个变量放在 RTC 内存里,并且重启时不要初始化它”。
这意味着:崩溃时写入 panicMessage 的数据,在设备重启后仍然可以读取到。这是崩溃信息能够”幸存”的关键。
注意:RTC 内存在深度睡眠中保留,但在彻底断电后会丢失。如果用户拔电池,崩溃信息就没了。
__wrap 链接器技巧
__wrap_panic_abort 是一种链接器包装(Linker Wrapping)技巧。通过在编译参数中添加 --wrap=panic_abort,链接器会:
- 把所有对
panic_abort的调用重定向到__wrap_panic_abort - 同时把原来的
panic_abort重命名为__real_panic_abort
为什么要手动逐字节复制
你可能注意到代码没有用strcpy 或 memcpy,而是写了一个手动的 for 循环来逐字节复制字符串。这不是偷懒,而是有意为之。
在崩溃发生的那一刻,系统的状态是不确定的。堆内存可能已经被破坏,标准库函数(strcpy、memcpy)可能依赖的内部数据结构可能已经不一致。调用这些函数可能会触发二次崩溃,导致连崩溃信息都保存不了。
手动的字节拷贝不依赖任何库函数,是最安全的方式。同样,IRAM_ATTR 确保这个函数本身就在 RAM 中——因为崩溃时 Flash 读取可能也不可靠了。
14.6 崩溃报告界面(CrashActivity)
保存了崩溃信息后,下次启动时要展示给用户看:setup() 的初始化阶段,系统检查”上一次是不是崩溃了”。如果是,就把崩溃信息从 RTC 内存取出来,写入 SD 卡上的 crash_report.txt 文件,然后显示一个专门的 CrashActivity 界面:
- 固件版本号:让开发者知道是哪个版本出了问题。
- 崩溃消息:比如
LoadProhibited(访问了非法内存地址)、StoreProhibited(写入了只读内存)等,直指崩溃类型。 - 堆栈帧:调用栈的回溯,展示崩溃发生时的函数调用链。开发者可以通过地址(如
0x42015A8C)在编译输出中查找对应的源码行号,定位 bug。
crash_report.txt 文件发给开发者,或者直接拍一张崩溃界面的照片反馈——这比”我的设备崩溃了”有用得多。
14.7 把安全网拼在一起
让我们用一个完整的场景串联本章的所有机制:本章要点
- OTA(Over-The-Air)升级 允许用户通过 WiFi 无线更新固件,无需拆机连线。CrossPoint Reader 从 GitHub Releases 获取新版本。
- 双 OTA 分区(app0/app1)是安全升级的基础:新固件写入备用分区,不影响正在运行的分区。写入失败或中途断电,旧固件完好无损。
- 自动回滚机制:新固件启动失败(崩溃)时,bootloader 自动切回上一个可用分区,设备不会变砖。
- 流式下载写入:固件边下载边写 Flash,不需要先完整下载到 RAM——因为 RAM 装不下几 MB 的固件文件。
- 崩溃捕获利用 RTC 内存(重启不清零)保存崩溃消息和调用栈。
__wrap链接器技巧劫持崩溃处理函数,手动逐字节复制避免库函数在崩溃状态下的二次崩溃风险。 - CrashActivity 在启动时展示上次的崩溃报告,并将报告保存到 SD 卡,方便用户反馈给开发者。
- 整套机制的核心理念:固件升级不能让设备变砖,崩溃不能让诊断信息丢失。
下一章预告:第 15 章我们将回顾整个项目的架构全貌——从硬件层到应用层,从按键输入到文字渲染,看看 CrossPoint Reader 是如何用不到 400KB 内存撑起一个功能完整的阅读器的。