“软件永远不会’完成’——它只是被发布了。”
固件烧录到设备里,发给用户,然后呢?如果发现了一个 bug,或者想添加新功能,总不能让用户拆开外壳、接上数据线、用命令行工具重新烧写吧?大多数用户根本没有编程线,更别说会用 PlatformIO 了。 这就是为什么 CrossPoint Reader 需要 OTA(Over-The-Air) 升级——让用户通过 WiFi 无线更新固件,就像手机系统更新一样简单。但无线升级带来一个新问题:如果新固件有严重 bug 导致设备启动就崩溃怎么办?设备连 WiFi 都进不去,就没法再次更新,彻底变砖。 本章我们来拆解 CrossPoint Reader 如何实现安全的 OTA 升级,以及崩溃后如何自救。

14.1 双 OTA 分区机制

OTA 安全升级的关键是双分区——Flash 上同时保留两份固件,一份正在运行,一份用来写入新版本。

Flash 分区布局

Flash 分区布局(16MB 总容量):
┌──────────────┐
│  NVS (24KB)  │ ← 键值存储(设置、WiFi 密码等)
├──────────────┤
│ otadata (8KB)│ ← 记录当前从哪个分区启动
├──────────────┤
│              │
│ app0 (6.25MB)│ ← OTA 分区 A(当前正在运行的固件)
│              │
├──────────────┤
│              │
│ app1 (6.25MB)│ ← OTA 分区 B(写入新固件的位置)
│              │
├──────────────┤
│coredump(64KB)│ ← 崩溃转储数据
└──────────────┘
每个分区都有明确的职责:
  • NVS:存储设备设置(阅读偏好、WiFi 配置等),升级后不丢失。
  • otadata:只有 8KB,但至关重要——它记录着”下次启动应该从 app0 还是 app1 启动”。
  • app0 / app1:两个大小相同的固件分区。任何时刻,一个是”当前运行”的,另一个是”备用”的。
  • coredump:崩溃时保存现场信息,后面会详细讲。

更新流程

正常更新流程:

1. 当前从 app0 启动运行
   otadata 标记:app0 = 活跃

2. 下载新固件,写入 app1(备用分区)
   app0: v1.2.0 [运行中]    app1: v1.3.0 [刚写入]

3. 更新 otadata,标记 app1 为下次启动分区

4. 重启 → 从 app1 启动
   app0: v1.2.0 [备用]      app1: v1.3.0 [运行中]
关键在于:新固件写入的是备用分区,而不是覆盖当前运行的分区。 这意味着即使写入过程中断电、WiFi 断开、或者下载到一半失败了,当前正在运行的固件完全不受影响——设备重启后还是从原来的分区正常启动。

自动回滚

如果新固件有 bug,启动后立刻崩溃怎么办?ESP-IDF 提供了一个安全网:
回滚流程:

1. 从 app1 启动新固件 v1.3.0
2. 新固件崩溃!还没来得及确认"我启动成功了"
3. 设备重启,bootloader 检测到 app1 启动失败
4. 自动切回 app0(旧固件 v1.2.0)
5. 设备恢复正常工作
新固件启动后需要主动调用一个”确认”函数,告诉系统”我启动成功了,这个版本可以保留”。如果新固件在确认之前就崩溃了,bootloader 下次启动时会发现上一次启动没有成功确认,自动回滚到上一个已知可用的分区。
类比:这就像在悬崖边系了一根安全绳。你可以放心往前探——如果脚下踩空(新固件崩溃),安全绳会把你拉回安全的地方(旧固件)。

14.2 检查 GitHub Release

CrossPoint Reader 的固件发布在 GitHub Releases 上。设备通过 GitHub API 检查是否有新版本:
// src/network/OtaUpdater.cpp
constexpr char latestReleaseUrl[] =
    "https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/latest";

Error checkForUpdate() {
  // JSON 过滤器:只解析需要的字段
  filter["tag_name"] = true;
  filter["assets"][0]["name"] = true;
  filter["assets"][0]["browser_download_url"] = true;

  // 请求 GitHub API
  // ... HTTP GET ...

  // 遍历 release 的附件,找到 firmware.bin
  for (auto asset : doc["assets"]) {
    if (asset["name"] == "firmware.bin") {
      otaUrl = asset["browser_download_url"];
    }
  }
}

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
// src/network/OtaUpdater.cpp
bool isUpdateNewer() const {
  int latestMajor, latestMinor, latestPatch;
  int currentMajor, currentMinor, currentPatch;

  sscanf(latestVersion, "%d.%d.%d", &latestMajor, &latestMinor, &latestPatch);
  sscanf(currentVersion, "%d.%d.%d", &currentMajor, &currentMinor, &currentPatch);

  // major.minor.patch 逐级比较
  if (latestMajor != currentMajor) return latestMajor > currentMajor;
  if (latestMinor != currentMinor) return latestMinor > currentMinor;
  return latestPatch > currentPatch;
  // RC(Release Candidate)版本视为比同版本号的正式版旧
}
比较规则很直觉:先比 major,不同就出结果;major 相同比 minor;minor 也相同才比 patch。比如 2.0.0 > 1.9.91.3.0 > 1.2.5 还有一个细节:RC 版本(Release Candidate,发布候选版)的优先级低于同版本号的正式版。比如 1.3.0-RC1 被视为比 1.3.0 旧——因为 RC 是”还没正式发布”的意思,正式版才是最终形态。

14.4 固件下载与写入

确认有新版本后,就可以开始下载和安装了:
// src/network/OtaUpdater.cpp
Error installUpdate() {
  esp_wifi_set_ps(WIFI_PS_NONE);  // 关闭 WiFi 省电模式

  esp_https_ota_begin(&ota_config, &ota_handle);
  do {
    esp_err = esp_https_ota_perform(ota_handle);
    processedSize = esp_https_ota_get_image_len_read();
    render = true;   // 通知渲染任务刷新进度条
    delay(100);
  } while (esp_err == ESP_ERR_HTTPS_OTA_IN_PROGRESS);

  esp_https_ota_finish(ota_handle);
  // 重启,从新分区启动
}

关键细节解读

关闭 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 的策略是:崩溃时自动保存现场,下次启动时展示崩溃报告。

崩溃信息的保存

// lib/hal/HalSystem.cpp
RTC_NOINIT_ATTR char panicMessage[256];
RTC_NOINIT_ATTR HalSystem::StackFrame panicStack[32];

// 链接器劫持:崩溃时自动调用
void IRAM_ATTR __wrap_panic_abort(const char* message) {
  // 手动逐字节复制崩溃信息
  int i = 0;
  for (; i < 255 && message[i]; i++) {
    panicMessage[i] = message[i];
  }
  panicMessage[i] = '\0';

  // 保存调用栈帧(省略细节)
  // ...

  __real_panic_abort(message);  // 继续原来的崩溃流程
}
这段代码涉及几个嵌入式开发中的高级技巧:

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
这样我们就能在系统崩溃时”插队”执行自己的代码(保存崩溃信息),然后再调用原始的崩溃处理流程。这比修改 ESP-IDF 的源码优雅得多——不需要改动任何框架代码,只需要在链接阶段”劫持”一个函数。

为什么要手动逐字节复制

你可能注意到代码没有用 strcpymemcpy,而是写了一个手动的 for 循环来逐字节复制字符串。这不是偷懒,而是有意为之。 在崩溃发生的那一刻,系统的状态是不确定的。堆内存可能已经被破坏,标准库函数(strcpymemcpy)可能依赖的内部数据结构可能已经不一致。调用这些函数可能会触发二次崩溃,导致连崩溃信息都保存不了。 手动的字节拷贝不依赖任何库函数,是最安全的方式。同样,IRAM_ATTR 确保这个函数本身就在 RAM 中——因为崩溃时 Flash 读取可能也不可靠了。

14.6 崩溃报告界面(CrashActivity)

保存了崩溃信息后,下次启动时要展示给用户看:
// lib/hal/HalSystem.cpp
void HalSystem::checkPanic() {
  if (isRebootFromPanic()) {
    // 从 RTC 内存中提取崩溃信息
    auto info = getPanicInfo(true);
    // 写入 SD 卡的崩溃报告文件
    auto file = Storage.open("/crash_report.txt", O_WRITE | O_CREAT | O_TRUNC);
    file.write(info.c_str(), info.size());
  }
}
setup() 的初始化阶段,系统检查”上一次是不是崩溃了”。如果是,就把崩溃信息从 RTC 内存取出来,写入 SD 卡上的 crash_report.txt 文件,然后显示一个专门的 CrashActivity 界面:
┌───────────────────────────────────┐
│         Crash Report              │
│                                   │
│  Firmware: v1.3.0                 │
│  Message:  LoadProhibited         │
│            at 0x42015A8C          │
│                                   │
│  Stack Trace:                     │
│    #0  0x42015A8C  app_main+0x1C  │
│    #1  0x42008F40  reader_loop    │
│    #2  0x4200A310  parse_epub     │
│    ...                            │
│                                   │
│  Report saved to SD card.         │
│  Press any key to continue.       │
└───────────────────────────────────┘
崩溃报告包含三类关键信息:
  1. 固件版本号:让开发者知道是哪个版本出了问题。
  2. 崩溃消息:比如 LoadProhibited(访问了非法内存地址)、StoreProhibited(写入了只读内存)等,直指崩溃类型。
  3. 堆栈帧:调用栈的回溯,展示崩溃发生时的函数调用链。开发者可以通过地址(如 0x42015A8C)在编译输出中查找对应的源码行号,定位 bug。
用户虽然看不懂这些信息,但他们可以把 SD 卡中的 crash_report.txt 文件发给开发者,或者直接拍一张崩溃界面的照片反馈——这比”我的设备崩溃了”有用得多。

14.7 把安全网拼在一起

让我们用一个完整的场景串联本章的所有机制:
场景:用户升级到有 bug 的新版本

1. 用户看到"有新版本 v1.3.0 可用",点击升级
2. 设备连接 WiFi,从 GitHub 下载 firmware.bin
3. 边下载边写入 app1 分区,进度条实时更新
4. 下载完成,更新 otadata,重启

5. 从 app1 启动 v1.3.0
6. 新版本在解析某本 EPUB 时触发了一个空指针访问
7. __wrap_panic_abort 被调用:
   - 崩溃消息 "LoadProhibited" 写入 RTC 内存
   - 调用栈帧保存到 RTC 内存
   - 系统重启

8. Bootloader 检测到 app1 上次启动失败
9. 自动回滚到 app0(v1.2.0)

10. v1.2.0 启动成功
11. checkPanic() 检测到上次崩溃
12. 崩溃报告写入 SD 卡
13. CrashActivity 显示崩溃信息
14. 用户按任意键继续使用——设备一切正常

15. 开发者收到崩溃报告,修复 bug
16. 发布 v1.3.1,用户再次 OTA 升级
在整个过程中,设备从未变砖。即使新固件有严重 bug,双分区回滚保证了用户总能回到一个可用的旧版本。崩溃捕获则确保了 bug 的诊断信息不会丢失。

本章要点

  1. OTA(Over-The-Air)升级 允许用户通过 WiFi 无线更新固件,无需拆机连线。CrossPoint Reader 从 GitHub Releases 获取新版本。
  2. 双 OTA 分区(app0/app1)是安全升级的基础:新固件写入备用分区,不影响正在运行的分区。写入失败或中途断电,旧固件完好无损。
  3. 自动回滚机制:新固件启动失败(崩溃)时,bootloader 自动切回上一个可用分区,设备不会变砖。
  4. 流式下载写入:固件边下载边写 Flash,不需要先完整下载到 RAM——因为 RAM 装不下几 MB 的固件文件。
  5. 崩溃捕获利用 RTC 内存(重启不清零)保存崩溃消息和调用栈。__wrap 链接器技巧劫持崩溃处理函数,手动逐字节复制避免库函数在崩溃状态下的二次崩溃风险。
  6. CrashActivity 在启动时展示上次的崩溃报告,并将报告保存到 SD 卡,方便用户反馈给开发者。
  7. 整套机制的核心理念:固件升级不能让设备变砖,崩溃不能让诊断信息丢失。

下一章预告:第 15 章我们将回顾整个项目的架构全貌——从硬件层到应用层,从按键输入到文字渲染,看看 CrossPoint Reader 是如何用不到 400KB 内存撑起一个功能完整的阅读器的。