跳到主要内容

第 7 章 系统服务层:OTA / Assets / Settings / SystemInfo

这一章覆盖 4 个独立但相互配合的"系统级"服务文件:

  • settings.{h,cc} —— NVS(Non-Volatile Storage)配置存取(最底层,所有其它模块都用)
  • system_info.{h,cc} —— 设备信息查询(MAC、芯片型号、内存、任务列表)
  • ota.{h,cc} —— 检查/下载/激活/安装固件(最复杂,含 HMAC 设备激活)
  • assets.{h,cc} —— 资源分区管理(语音模型、字体、表情、皮肤),含下载和 mmap

7.1 整体关系图

┌────────────────────────────────────────────────────────────┐
│ Application::ActivationTask 调用顺序 │
│ ① CheckAssetsVersion() ──── 用 Assets 检查资源版本 │
│ ② CheckNewVersion() ──── 用 Ota 检查固件版本+激活 │
│ ③ InitializeProtocol() ──── 根据 Ota 结果选择协议 │
└────────────────────────────────────────────────────────────┘
┃ ┃ ┃
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌──────────────────────────┐
│ Assets │ │ Ota │ │ Application::Initialize │
│ │ │ │ │ (注册回调时也访问 OTA) │
│ download() │ │ Check..() │ │ │
│ apply() │ │ Upgrade() │ └──────────────────────────┘
│ getData() │ │ Activate() │
└─────┬──────┘ └─────┬──────┘
│ │
▼ ▼
┌────────────────────────┐
│ Settings │ ← NVS namespace 维度(wifi/mqtt/websocket/assets...)
│ (NVS namespace) │
│ GetString/SetString │
│ GetInt/SetInt │
│ GetBool/SetBool │
└───────────┬───────────┘

┌────────────────────────┐
│ SystemInfo (静态工具) │
│ GetMacAddress │ ← 设备唯一标识,HTTP 请求头 Device-Id
│ GetUserAgent │ ← BOARD_NAME/版本号
│ GetMinimumFreeHeap │ ← 监控用
│ PrintTaskCpuUsage │ ← 调试用
└────────────────────────┘

7.2 settings.{h,cc} —— NVS 配置封装

109 行代码,最简洁但被全项目使用最多。

7.2.1 NVS 是什么

NVS(Non-Volatile Storage)是 ESP-IDF 提供的键值对持久化机制,落盘在专门的 NVS 分区(partitions/v1/16mb.csv 里能看到)。特点:

  • 键值结构(key 最长 15 字节);
  • 支持 namespace 隔离(一个项目可用多个 namespace);
  • 掉电安全(写入后调用 commit 持久化);
  • 磨损均衡(NVS 自己处理 flash 寿命问题);
  • 不同类型有不同 APInvs_get_str / nvs_get_i32 / nvs_get_u8 等)。

7.2.2 RAII 风格的 Settings

Settings::Settings(const std::string& ns, bool read_write)
: ns_(ns), read_write_(read_write) {
nvs_open(ns.c_str(), read_write_ ? NVS_READWRITE : NVS_READONLY, &nvs_handle_);
}

Settings::~Settings() {
if (nvs_handle_ != 0) {
if (read_write_ && dirty_) {
ESP_ERROR_CHECK(nvs_commit(nvs_handle_));
}
nvs_close(nvs_handle_);
}
}

经典 RAII

  • 构造 = 打开句柄;
  • 析构 = (如有改动)提交 + 关闭句柄;
  • 用法:在栈上创建 → 离开作用域自动提交关闭。

dirty_ 标志的作用:只有真改过才提交,避免每次"只读检查"也触发 flash 写入(NVS commit 有 flash 操作开销)。

典型使用:

{
Settings settings("mqtt", true); // 打开 mqtt namespace, 读写
settings.SetString("endpoint", "mqtts://server.com:8883");
settings.SetInt("keepalive", 240);
// 析构 → 自动 commit
}

7.2.3 三种类型的存取

std::string Settings::GetString(const std::string& key, const std::string& default_value) {
if (nvs_handle_ == 0) return default_value;
size_t length = 0;
if (nvs_get_str(nvs_handle_, key.c_str(), nullptr, &length) != ESP_OK) {
return default_value;
}
std::string value;
value.resize(length);
ESP_ERROR_CHECK(nvs_get_str(nvs_handle_, key.c_str(), value.data(), &length));
while (!value.empty() && value.back() == '\0') {
value.pop_back();
}
return value;
}

两步获取字符串

  1. 传 nullptr 让 NVS 告诉你需要多大 buffer;
  2. resize string 后再读到 buffer。

最后 while (back == '\0') pop_back() 去掉 NVS 存储末尾的 null 终止符——nvs_get_str 会把 C 字符串的 \0 也读进来。

int32_t Settings::GetInt(const std::string& key, int32_t default_value) {
if (nvs_handle_ == 0) return default_value;
int32_t value;
if (nvs_get_i32(nvs_handle_, key.c_str(), &value) != ESP_OK) return default_value;
return value;
}

bool Settings::GetBool(const std::string& key, bool default_value) {
if (nvs_handle_ == 0) return default_value;
uint8_t value;
if (nvs_get_u8(nvs_handle_, key.c_str(), &value) != ESP_OK) return default_value;
return value != 0;
}

int32 直接读,booluint8_t 当容器(NVS 没有原生 bool 类型)。

7.2.4 Set* + dirty_ 标志

void Settings::SetString(const std::string& key, const std::string& value) {
if (read_write_) {
ESP_ERROR_CHECK(nvs_set_str(nvs_handle_, key.c_str(), value.c_str()));
dirty_ = true;
} else {
ESP_LOGW(TAG, "Namespace %s is not open for writing", ns_.c_str());
}
}

写操作都先检查 read_write_,再调对应 nvs_set_*,最后置 dirty_

注意 ESP_ERROR_CHECK会 panic 的宏——NVS 出错(如分区满、key 太长)会直接 abort 整个程序。对配置存储而言,写不进去本来就是致命问题,宁可崩了重启。

7.2.5 EraseKey / EraseAll

void Settings::EraseKey(const std::string& key) {
if (read_write_) {
auto ret = nvs_erase_key(nvs_handle_, key.c_str());
if (ret != ESP_ERR_NVS_NOT_FOUND) {
ESP_ERROR_CHECK(ret);
}
}
}

EraseKeyESP_ERR_NVS_NOT_FOUND 容错——擦不存在的 key 不算错误。

7.2.6 项目中已知的 NVS namespace

通过 rg 'Settings\("' 可以列出:

namespace内容谁写
wifiota_url(OTA 服务器 URL)、wifi SSID/密码OTA 阶段 / wifi_board
mqttendpoint / client_id / username / password / keepalive / publish_topicOTA Check 写
websocketurl / token / versionOTA Check 写
assetsdownload_url(自定义资源 URL)MCP 工具 self.assets.set_download_url
audiovolume(用户音量持久化)AudioCodec::SetOutputVolume
displaybrightness / themeLvglDisplay
boarduuid(板子软件 UUID,首次启动随机生成)Board::GetUuid

7.3 system_info.{h,cc} —— 设备信息查询

152 行代码,全是 static 静态方法。无状态、随处可用。

7.3.1 GetMacAddress() —— 设备唯一标识

std::string SystemInfo::GetMacAddress() {
uint8_t mac[6];
#if CONFIG_IDF_TARGET_ESP32P4
esp_wifi_get_mac(WIFI_IF_STA, mac);
#else
esp_read_mac(mac, ESP_MAC_WIFI_STA);
#endif
char mac_str[18];
snprintf(mac_str, sizeof(mac_str), "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
return std::string(mac_str);
}
  • ESP32-P4 特殊化:P4 自身没有 Wi-Fi,要走 esp_wifi_remote(通过 SPI 接外部 ESP32-C6 Wi-Fi 模块)拿 MAC;
  • 其它芯片用 esp_read_mac(ESP_MAC_WIFI_STA) —— Wi-Fi station 模式的烧入 MAC(全球唯一);
  • 格式化为 xx:xx:xx:xx:xx:xx 字符串(HTTP header 用)。

7.3.2 GetUserAgent()

std::string SystemInfo::GetUserAgent() {
auto app_desc = esp_app_get_description();
auto user_agent = std::string(BOARD_NAME "/") + app_desc->version;
return user_agent;
}

BOARD_NAME 是 Kconfig 注入的板子名,app_desc->version 是 ESP-IDF 自动从 git tag 注入的版本号。形如 xiaozhi-s3-cardputer/1.5.0

HTTP 请求都带这个 User-Agent,服务器侧可以做版本分流。

7.3.3 GetFlashSize / GetFreeHeapSize / GetMinimumFreeHeapSize

size_t SystemInfo::GetFreeHeapSize() {
return esp_get_free_heap_size();
}

size_t SystemInfo::GetMinimumFreeHeapSize() {
return esp_get_minimum_free_heap_size();
}

最小可用堆是个历史水位标记——任何时候堆使用峰值最高时的剩余值。监控这个能发现"运行了 1 小时之后内存就吃紧了"这种泄漏问题。

7.3.4 PrintTaskCpuUsage() —— 任务 CPU 占用统计

esp_err_t SystemInfo::PrintTaskCpuUsage(TickType_t xTicksToWait) {
TaskStatus_t *start_array = NULL, *end_array = NULL;
UBaseType_t start_array_size, end_array_size;
configRUN_TIME_COUNTER_TYPE start_run_time, end_run_time;

// 1. 第一次采样
start_array_size = uxTaskGetNumberOfTasks() + ARRAY_SIZE_OFFSET;
start_array = (TaskStatus_t*)malloc(sizeof(TaskStatus_t) * start_array_size);
start_array_size = uxTaskGetSystemState(start_array, start_array_size, &start_run_time);

vTaskDelay(xTicksToWait); // 2. 等一段时间

// 3. 第二次采样
end_array_size = uxTaskGetNumberOfTasks() + ARRAY_SIZE_OFFSET;
end_array = (TaskStatus_t*)malloc(sizeof(TaskStatus_t) * end_array_size);
end_array_size = uxTaskGetSystemState(end_array, end_array_size, &end_run_time);

// 4. 计算差值并打印
total_elapsed_time = (end_run_time - start_run_time);
for (int i = 0; i < start_array_size; i++) {
// 在 end_array 里找同一个 handle 匹配
for (int j = 0; j < end_array_size; j++) {
if (start_array[i].xHandle == end_array[j].xHandle) { k = j; break; }
}
if (k >= 0) {
uint32_t task_elapsed_time = end_array[k].ulRunTimeCounter - start_array[i].ulRunTimeCounter;
uint32_t percentage_time = (task_elapsed_time * 100UL) / (total_elapsed_time * CONFIG_FREERTOS_NUMBER_OF_CORES);
printf("| %-16s | %8lu | %4lu%%\n", start_array[i].pcTaskName, task_elapsed_time, percentage_time);
}
}
}

思路

  • 两次采样所有任务的 ulRunTimeCounter(FreeRTOS 内部维护的累计运行 tick);
  • 求差 = 这段时间该任务占的 CPU 时间;
  • 除以总耗时 = CPU 占比;
  • 多核(双核)要把分母乘 CONFIG_FREERTOS_NUMBER_OF_CORES

输出例子:

| Task             | Run Time | Percentage
| main | 12345 | 5%
| audio_input | 65432 | 32%
| audio_output | 38291 | 19%
| afe_processor | 28194 | 14%
| ...

调试 CPU 瓶颈用——本项目主要在 wake_word_test.cc 和某些板子的 debug 路径下用。

7.3.5 PrintHeapStats() —— 堆统计

void SystemInfo::PrintHeapStats() {
int free_sram = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
int min_free_sram = heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL);
ESP_LOGI(TAG, "free sram: %u minimal sram: %u", free_sram, min_free_sram);
}

MALLOC_CAP_INTERNAL 专门统计内置 SRAM(不含 PSRAM)。SRAM 满 = 系统快崩,PSRAM 满 = 性能严重下降但不会立即崩。


7.4 ota.{h,cc} —— 固件升级与设备激活

473 行代码,是本章最复杂的文件。功能分四块:

  1. 检查版本:调云端接口、对比版本号、缓存 mqtt/websocket 配置;
  2. 设备激活:用 efuse 里烧的序列号 + HMAC challenge/response 完成首次激活;
  3. 下载安装固件:流式写入 OTA 分区;
  4. 回滚保护:启动后标记当前 firmware 为 valid。

7.4.1 构造 —— 读取序列号

Ota::Ota() {
#ifdef ESP_EFUSE_BLOCK_USR_DATA
uint8_t serial_number[33] = {0};
if (esp_efuse_read_field_blob(ESP_EFUSE_USER_DATA, serial_number, 32 * 8) == ESP_OK) {
if (serial_number[0] == 0) {
has_serial_number_ = false;
} else {
serial_number_ = std::string(reinterpret_cast<char*>(serial_number), 32);
has_serial_number_ = true;
}
}
#endif
}

eFuse 是 ESP32 内部的一次性可编程位,烧入后不可修改ESP_EFUSE_USER_DATA 是 256 位(32 字节)的用户区——出厂时厂商烧 32 字节序列号进去。

读法:

  • esp_efuse_read_field_blob 第三参是 bit 数,32 * 8 = 256 bit;
  • 第一个字节为 0 → 未烧序列号(属于"未量产"的开发板);
  • 有序列号 → 启用激活模式 v2(HMAC challenge-response)。

7.4.2 SetupHttp() —— 共用的 HTTP 头部

std::unique_ptr<Http> Ota::SetupHttp() {
auto& board = Board::GetInstance();
auto network = board.GetNetwork();
auto http = network->CreateHttp(0);
auto user_agent = SystemInfo::GetUserAgent();
http->SetHeader("Activation-Version", has_serial_number_ ? "2" : "1");
http->SetHeader("Device-Id", SystemInfo::GetMacAddress().c_str());
http->SetHeader("Client-Id", board.GetUuid());
if (has_serial_number_) {
http->SetHeader("Serial-Number", serial_number_.c_str());
}
http->SetHeader("User-Agent", user_agent);
http->SetHeader("Accept-Language", Lang::CODE);
http->SetHeader("Content-Type", "application/json");
return http;
}

OTA 请求的标准头:

  • Activation-Version: 1 或 2,告诉服务端用哪套激活协议;
  • Device-Id: MAC(硬件唯一,伪造代价高);
  • Client-Id: 软件 UUID(首次启动 NVS 随机生成);
  • Serial-Number: efuse 烧的序列号(仅 Activation-Version 2);
  • User-Agent: 板名/版本号;
  • Accept-Language: 语言代码(zh-CN / en-US / 日语等)。

7.4.3 CheckVersion() —— 一次拿全部配置

esp_err_t Ota::CheckVersion() {
auto& board = Board::GetInstance();
auto app_desc = esp_app_get_description();
current_version_ = app_desc->version;

std::string url = GetCheckVersionUrl();
if (url.length() < 10) return ESP_ERR_INVALID_ARG;

auto http = SetupHttp();
std::string data = board.GetSystemInfoJson();
std::string method = data.length() > 0 ? "POST" : "GET";
http->SetContent(std::move(data));

if (!http->Open(method, url)) return http->GetLastError();
auto status_code = http->GetStatusCode();
if (status_code != 200) return status_code;
data = http->ReadAll();
http->Close();

ota_url(默认从 Kconfig 的 CONFIG_OTA_URL 来)发 POST 请求,请求体是设备的 GetSystemInfoJson()(含芯片、版本、剩余内存、电量等),服务端响应一个聚合 JSON。

为什么用 POST 而不是 GET?因为要把设备信息送上去——服务端可能根据芯片型号下发不同 mqtt 配置,根据版本号决定是否给新固件 URL。

服务端响应示例:

{
"activation": {
"message": "请打开 https://xz.com/activate 输入激活码:",
"code": "ABC123",
"challenge": "0x1234abcd...",
"timeout_ms": 60000
},
"mqtt": {
"endpoint": "mqtt.example.com:8883",
"client_id": "device_xxx",
"username": "...",
"password": "...",
"publish_topic": "device/in"
},
"websocket": {
"url": "wss://ws.example.com/",
"token": "Bearer xxx",
"version": 3
},
"server_time": {
"timestamp": 1715500000000,
"timezone_offset": 480
},
"firmware": {
"version": "1.6.0",
"url": "https://firmware.example.com/v1.6.0.bin",
"force": 0
}
}

下面逐段解析。

7.4.3.1 解析 activation 段

has_activation_code_ = false;
has_activation_challenge_ = false;
cJSON *activation = cJSON_GetObjectItem(root, "activation");
if (cJSON_IsObject(activation)) {
cJSON* message = cJSON_GetObjectItem(activation, "message");
if (cJSON_IsString(message)) activation_message_ = message->valuestring;
cJSON* code = cJSON_GetObjectItem(activation, "code");
if (cJSON_IsString(code)) {
activation_code_ = code->valuestring;
has_activation_code_ = true;
}
cJSON* challenge = cJSON_GetObjectItem(activation, "challenge");
if (cJSON_IsString(challenge)) {
activation_challenge_ = challenge->valuestring;
has_activation_challenge_ = true;
}
cJSON* timeout_ms = cJSON_GetObjectItem(activation, "timeout_ms");
if (cJSON_IsNumber(timeout_ms)) {
activation_timeout_ms_ = timeout_ms->valueint;
}
}

两种激活路径

  • code 模式(Activation-Version 1,无 efuse 序列号):服务端给出一串数字 code,设备语音播报 + 屏幕显示,让用户去网页输入这段码绑定设备账号。Application 在 ShowActivationCode 里处理。
  • challenge 模式(Activation-Version 2,有 efuse 序列号):服务端给一个随机 challenge 字符串,设备用 efuse 里的 HMAC key 算 HMAC-SHA256,回传给服务端验证。这是真·硬件级身份认证,不可伪造。详见 7.4.6。

7.4.3.2 解析 mqtt / websocket 段并写 NVS

has_mqtt_config_ = false;
cJSON *mqtt = cJSON_GetObjectItem(root, "mqtt");
if (cJSON_IsObject(mqtt)) {
Settings settings("mqtt", true);
cJSON *item = NULL;
cJSON_ArrayForEach(item, mqtt) {
if (cJSON_IsString(item)) {
if (settings.GetString(item->string) != item->valuestring) {
settings.SetString(item->string, item->valuestring);
}
} else if (cJSON_IsNumber(item)) {
if (settings.GetInt(item->string) != item->valueint) {
settings.SetInt(item->string, item->valueint);
}
}
}
has_mqtt_config_ = true;
}

要点:

  • cJSON_ArrayForEach 遍历 mqtt object 的所有 key(cJSON 实现里 object 内部也用链表,能用 ArrayForEach);
  • 遍历前先比对再写:避免对未变化的字段无谓的 flash 写入(NVS 写入会增加 flash 寿命磨损);
  • 由 Settings 析构时统一 commit

websocket 段处理完全一样的逻辑。

7.4.3.3 解析 server_time 段 —— NTP 替代

has_server_time_ = false;
cJSON *server_time = cJSON_GetObjectItem(root, "server_time");
if (cJSON_IsObject(server_time)) {
cJSON *timestamp = cJSON_GetObjectItem(server_time, "timestamp");
cJSON *timezone_offset = cJSON_GetObjectItem(server_time, "timezone_offset");
if (cJSON_IsNumber(timestamp)) {
struct timeval tv;
double ts = timestamp->valuedouble;
if (cJSON_IsNumber(timezone_offset)) {
ts += (timezone_offset->valueint * 60 * 1000); // 分钟转毫秒
}
tv.tv_sec = (time_t)(ts / 1000);
tv.tv_usec = (suseconds_t)((long long)ts % 1000) * 1000;
settimeofday(&tv, NULL);
has_server_time_ = true;
}
}

走自家服务器同步时间替代 NTP 协议

  • NTP 在某些受限网络(公司防火墙、运营商封 UDP)不可用;
  • HTTP 已经能通 → 顺便把时间塞进响应;
  • timezone_offset 是分钟为单位的时区偏移(如东八区 = 480);
  • settimeofday 直接设置系统时间——后续 time(NULL) / RTC / 日志时间戳全都对。

注意 valuedouble 而非 valueint——int 最多 ~21 亿 = 2.1×10⁹ 毫秒约 24 天,存不下 1715500000000 这种 13 位毫秒时间戳,必须用 double。

7.4.3.4 解析 firmware 段

has_new_version_ = false;
cJSON *firmware = cJSON_GetObjectItem(root, "firmware");
if (cJSON_IsObject(firmware)) {
cJSON *version = cJSON_GetObjectItem(firmware, "version");
if (cJSON_IsString(version)) firmware_version_ = version->valuestring;
cJSON *url = cJSON_GetObjectItem(firmware, "url");
if (cJSON_IsString(url)) firmware_url_ = url->valuestring;

if (cJSON_IsString(version) && cJSON_IsString(url)) {
has_new_version_ = IsNewVersionAvailable(current_version_, firmware_version_);
cJSON *force = cJSON_GetObjectItem(firmware, "force");
if (cJSON_IsNumber(force) && force->valueint == 1) {
has_new_version_ = true;
}
}
}

force: 1 强制覆盖比较结果——即使版本号相同或更老也升级。用于回滚下发或紧急修复版。

7.4.4 ParseVersion / IsNewVersionAvailable —— 语义化版本比较

std::vector<int> Ota::ParseVersion(const std::string& version) {
std::vector<int> versionNumbers;
std::stringstream ss(version);
std::string segment;
while (std::getline(ss, segment, '.')) {
versionNumbers.push_back(std::stoi(segment));
}
return versionNumbers;
}

bool Ota::IsNewVersionAvailable(const std::string& currentVersion, const std::string& newVersion) {
std::vector<int> current = ParseVersion(currentVersion);
std::vector<int> newer = ParseVersion(newVersion);
for (size_t i = 0; i < std::min(current.size(), newer.size()); ++i) {
if (newer[i] > current[i]) return true;
else if (newer[i] < current[i]) return false;
}
return newer.size() > current.size();
}

逐段比较 1.2.3[1, 2, 3]

  • 高位优先(1.2.0 < 2.0.0);
  • 相等继续比下一位;
  • 长度不等以更长为新(1.2 < 1.2.1)。

注意没有处理 1.2.3-rc1 这种预发布标签——本项目版本号约定纯数字。

7.4.5 Upgrade() —— 流式下载 + 写入 OTA 分区

最复杂的函数(104 行),完整复述:

bool Ota::Upgrade(const std::string& firmware_url, std::function<void(int, size_t)> callback) {
esp_ota_handle_t update_handle = 0;
auto update_partition = esp_ota_get_next_update_partition(NULL);
if (update_partition == NULL) return false;

关键概念:ESP32 用 A/B 双分区 OTA——ota_0ota_1 交替使用。esp_ota_get_next_update_partition(NULL) 返回当前没在运行的那一个——升级时写它,不影响正在运行的固件。

    bool image_header_checked = false;
std::string image_header;
auto http = network->CreateHttp(0);
if (!http->Open("GET", firmware_url)) return false;
if (http->GetStatusCode() != 200) return false;
size_t content_length = http->GetBodyLength();
if (content_length == 0) return false;

GET 固件 URL,获取 Content-Length 用于进度条。

    char buffer[512];
size_t total_read = 0, recent_read = 0;
auto last_calc_time = esp_timer_get_time();
while (true) {
int ret = http->Read(buffer, sizeof(buffer));
if (ret < 0) return false;

recent_read += ret;
total_read += ret;
if (esp_timer_get_time() - last_calc_time >= 1000000 || ret == 0) {
size_t progress = total_read * 100 / content_length;
if (callback) callback(progress, recent_read);
last_calc_time = esp_timer_get_time();
recent_read = 0;
}
if (ret == 0) break;

512 字节一块流式读,每秒计算一次进度(百分比 + 实时速度 B/s)调 callback。Application::CheckNewVersion 注册的 callback 会更新屏幕上 "下载中 35% (240KB/s)"。

        if (!image_header_checked) {
image_header.append(buffer, ret);
if (image_header.size() >= sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t) + sizeof(esp_app_desc_t)) {
esp_app_desc_t new_app_info;
memcpy(&new_app_info, image_header.data() + sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t), sizeof(esp_app_desc_t));

auto current_version = esp_app_get_description()->version;

if (esp_ota_begin(update_partition, OTA_WITH_SEQUENTIAL_WRITES, &update_handle)) {
esp_ota_abort(update_handle);
return false;
}
image_header_checked = true;
std::string().swap(image_header); // ★ 立即释放临时 buffer
}
}
auto err = esp_ota_write(update_handle, buffer, ret);
if (err != ESP_OK) {
esp_ota_abort(update_handle);
return false;
}
}

首个块的头部校验

  • ESP32 固件的前部依次是 esp_image_header_t + esp_image_segment_header_t + esp_app_desc_t
  • 解析出 esp_app_desc_t 里的版本号打日志(不强制检查,因为可能是同版本 force 升级);
  • 通过 esp_ota_begin 申请写入句柄;
  • OTA_WITH_SEQUENTIAL_WRITES flag:声明会顺序写入,让底层做优化(无需擦掉整个分区,分段擦分段写);
  • std::string().swap(image_header)强制释放 string 内存的 idiom——clear() 只是 size=0 容量还在。

后续每块都通过 esp_ota_write 写到分区。

    http->Close();

esp_err_t err = esp_ota_end(update_handle);
if (err != ESP_OK) {
if (err == ESP_ERR_OTA_VALIDATE_FAILED) {
ESP_LOGE(TAG, "Image validation failed, image is corrupted");
}
return false;
}

err = esp_ota_set_boot_partition(update_partition);
if (err != ESP_OK) return false;
return true;
}

收尾:

  • esp_ota_end 内部做整体 SHA-256 校验(ESP32 固件末尾有 hash),不通过返回 ESP_ERR_OTA_VALIDATE_FAILED
  • esp_ota_set_boot_partition 把启动指针指向新分区,但不立即重启——交给上层(Application::UpgradeFirmware 之后会调 esp_restart())。

7.4.6 Activate() + GetActivationPayload() —— 硬件 HMAC 激活

std::string Ota::GetActivationPayload() {
if (!has_serial_number_) return "{}";

std::string hmac_hex;
#ifdef SOC_HMAC_SUPPORTED
uint8_t hmac_result[32];
esp_err_t ret = esp_hmac_calculate(HMAC_KEY0,
(uint8_t*)activation_challenge_.data(),
activation_challenge_.size(),
hmac_result);
if (ret != ESP_OK) return "{}";

for (size_t i = 0; i < sizeof(hmac_result); i++) {
char buffer[3];
sprintf(buffer, "%02x", hmac_result[i]);
hmac_hex += buffer;
}
#endif

cJSON *payload = cJSON_CreateObject();
cJSON_AddStringToObject(payload, "algorithm", "hmac-sha256");
cJSON_AddStringToObject(payload, "serial_number", serial_number_.c_str());
cJSON_AddStringToObject(payload, "challenge", activation_challenge_.c_str());
cJSON_AddStringToObject(payload, "hmac", hmac_hex.c_str());
// ...
}

ESP32 内置 HMAC 加速外设SOC_HMAC_SUPPORTED,S3/C6 有,C3 没有)的工作机制:

  • 出厂时厂商在 eFuse 烧入一个 HMAC key(key0/key1/key2 三个槽位之一);
  • key 一旦烧入软件无法读出——只能让硬件用它做 HMAC 运算;
  • esp_hmac_calculate(HMAC_KEY0, msg, len, out) 让硬件用 key0 算 HMAC-SHA256;
  • 即使攻击者拷贝了完整固件 + 序列号,没有原厂 eFuse 也算不出正确 HMAC。
esp_err_t Ota::Activate() {
if (!has_activation_challenge_) return ESP_FAIL;

std::string url = GetCheckVersionUrl();
if (url.back() != '/') url += "/activate";
else url += "activate";

auto http = SetupHttp();
std::string data = GetActivationPayload();
http->SetContent(std::move(data));

if (!http->Open("POST", url)) return ESP_FAIL;
auto status_code = http->GetStatusCode();
if (status_code == 202) return ESP_ERR_TIMEOUT;
if (status_code != 200) return ESP_FAIL;
return ESP_OK;
}
  • {algorithm, serial_number, challenge, hmac} POST 到 /activate
  • 202 Accepted = 服务器还没准备好(如用户还没在手机端输激活码绑定),让设备 5 秒后再试(Application::CheckNewVersion 里有重试循环);
  • 200 OK = 激活成功,下一次 CheckVersion 不会再下 activation 段。

7.4.7 MarkCurrentVersionValid() —— 防回滚保护

void Ota::MarkCurrentVersionValid() {
auto partition = esp_ota_get_running_partition();
if (strcmp(partition->label, "factory") == 0) return;

esp_ota_img_states_t state;
if (esp_ota_get_state_partition(partition, &state) != ESP_OK) return;

if (state == ESP_OTA_IMG_PENDING_VERIFY) {
ESP_LOGI(TAG, "Marking firmware as valid");
esp_ota_mark_app_valid_cancel_rollback();
}
}

Anti-Bricking 机制

  • ESP32 OTA 支持"先启动新固件,跑一段时间没崩才标记为 valid";
  • esp_ota_set_boot_partition 后第一次启动新固件,状态是 ESP_OTA_IMG_PENDING_VERIFY
  • 如果不调 mark_app_valid_cancel_rollback下次重启会自动回滚到旧固件——保护因新固件有 bug 卡死的情况;
  • 本函数在 Application::Initialize 早期调用——意思是"只要能跑到这一步说明新固件基本正常"。

注意 factory 分区跳过——出厂固件不参与 A/B 切换。


7.5 assets.{h,cc} —— 资源分区管理

533 行代码,管理一个独立的 assets flash 分区,里面打包了:

  • 语音模型(ESP-SR 唤醒词模型 srmodels.bin
  • 字体(fonts.bin
  • 表情图片(emoji_collection / icon_collection)
  • 主题皮肤(light / dark 的颜色 + 背景图)
  • 布局配置(layout_json

为什么单独一个分区?因为这些资源比固件大、按需更新(换一个语种 → 只换模型不刷固件)。

7.5.1 分区数据格式(手写的二进制协议)

offset 0:  stored_files     (uint32_t)  ← 文件数量
offset 4: stored_chksum (uint32_t) ← 后续数据的校验和
offset 8: stored_len (uint32_t) ← 元数据 + 数据总长

offset 12: 文件索引表 stored_files 个 mmap_assets_table
- asset_name[32]
- asset_size (uint32_t)
- asset_offset (uint32_t) ← 相对于"文件数据区"起始的偏移
- asset_width (uint16_t)
- asset_height (uint16_t)

offset 12 + sizeof(table)*stored_files: 各文件数据连续排列
每个文件以 'Z' 'Z' magic 起始

7.5.2 InitializePartition() —— 内存映射 + 校验

bool Assets::InitializePartition() {
partition_valid_ = false;
checksum_valid_ = false;
assets_.clear();

partition_ = esp_partition_find_first(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, "assets");
if (partition_ == nullptr) return false;

按 label 查找 assets 分区(partitions/v1/16mb.csv 里定义了 label="assets")。

    int free_pages = spi_flash_mmap_get_free_pages(SPI_FLASH_MMAP_DATA);
uint32_t storage_size = free_pages * 64 * 1024;
if (storage_size < partition_->size) return false;

esp_err_t err = esp_partition_mmap(partition_, 0, partition_->size, ESP_PARTITION_MMAP_DATA, (const void**)&mmap_root_, &mmap_handle_);
if (err != ESP_OK) return false;

partition_valid_ = true;

esp_partition_mmap 是 ESP32 的杀手锏 —— 把 flash 分区映射到 CPU 可寻址的虚拟地址,之后用 mmap_root_[offset] 就能直接像内存一样访问,硬件 cache 加速。不用每次 esp_partition_read 把数据拷贝到 RAM。

mmap 占用 64KB-aligned 的虚拟地址槽("pages"),先检查可用 pages 够不够,不够则报错。

    uint32_t stored_files = *(uint32_t*)(mmap_root_ + 0);
uint32_t stored_chksum = *(uint32_t*)(mmap_root_ + 4);
uint32_t stored_len = *(uint32_t*)(mmap_root_ + 8);

if (stored_len > partition_->size - 12) return false;

uint32_t calculated_checksum = CalculateChecksum(mmap_root_ + 12, stored_len);
if (calculated_checksum != stored_chksum) return false;
checksum_valid_ = true;

for (uint32_t i = 0; i < stored_files; i++) {
auto item = (const mmap_assets_table*)(mmap_root_ + 12 + i * sizeof(mmap_assets_table));
auto asset = Asset{
.size = static_cast<size_t>(item->asset_size),
.offset = static_cast<size_t>(12 + sizeof(mmap_assets_table) * stored_files + item->asset_offset)
};
assets_[item->asset_name] = asset;
}
return checksum_valid_;
}

读 3 个 header 字段:文件数、校验和、长度。

CalculateChecksum 是最朴素的累加和 mod 0x10000:

uint32_t Assets::CalculateChecksum(const char* data, uint32_t length) {
uint32_t checksum = 0;
for (uint32_t i = 0; i < length; i++) checksum += data[i];
return checksum & 0xFFFF;
}

这种校验只能防"误写"——抗不住有针对性的篡改,但对 firmware 配套用够了(真要防篡改在固件签名层做)。

最后遍历索引表,把每个文件的 size + 绝对偏移塞进 std::map<std::string, Asset> 加速后续按名字查找。

7.5.3 GetAssetData() —— 按名字找

bool Assets::GetAssetData(const std::string& name, void*& ptr, size_t& size) {
auto asset = assets_.find(name);
if (asset == assets_.end()) return false;
auto data = (const char*)(mmap_root_ + asset->second.offset);
if (data[0] != 'Z' || data[1] != 'Z') {
ESP_LOGE(TAG, "The asset %s is not valid with magic %02x%02x", name.c_str(), data[0], data[1]);
return false;
}
ptr = static_cast<void*>(const_cast<char*>(data + 2));
size = asset->second.size;
return true;
}

返回的 ptr 直接指向 mmap 区域——零拷贝。后续 LVGL 字体/图像组件直接拿 ptr 用,省下数 MB 内存。

每个文件以 ZZ magic 起始作为防误读保险。

7.5.4 Apply() —— 解析 index.json 并加载所有资源

bool Assets::Apply() {
void* ptr = nullptr;
size_t size = 0;
if (!GetAssetData("index.json", ptr, size)) return false;

cJSON* root = cJSON_ParseWithLength(static_cast<char*>(ptr), size);
if (root == nullptr) return false;

cJSON* version = cJSON_GetObjectItem(root, "version");
if (cJSON_IsNumber(version)) {
if (version->valuedouble > 1) {
ESP_LOGE(TAG, "The assets version %d is not supported, please upgrade the firmware", version->valueint);
return false;
}
}

资源包入口是 index.json

{
"version": 1,
"srmodels": "srmodels.bin",
"text_font": "fonts/fonts.bin",
"emoji_collection": [
{ "name": "happy", "file": "emojis/happy.bin" },
{ "name": "sad", "file": "emojis/sad.bin" }
],
"icon_collection": [...],
"skin": {
"light": { "text_color": "#000000", "background_color": "#FFFFFF", "background_image": "bg_light.bin" },
"dark": { ... }
},
"hide_subtitle": false,
"layout": [...]
}

version > 1 → 资源包格式比当前固件支持的版本更新,提示用户升级固件。前向兼容性保护

7.5.4.1 加载 srmodels

cJSON* srmodels = cJSON_GetObjectItem(root, "srmodels");
if (cJSON_IsString(srmodels)) {
std::string srmodels_file = srmodels->valuestring;
if (GetAssetData(srmodels_file, ptr, size)) {
if (models_list_ != nullptr) {
esp_srmodel_deinit(models_list_);
models_list_ = nullptr;
}
models_list_ = srmodel_load(static_cast<uint8_t*>(ptr));
if (models_list_ != nullptr) {
auto& app = Application::GetInstance();
app.GetAudioService().SetModelsList(models_list_);
}
}
}

srmodel_load(ptr) 是 ESP-SR 库的 API——直接拿 mmap 指针解析模型 metadata。模型实际权重数据保留在 flash 上不拷贝,运行时按需读。

SetModelsList 见第 4 章 4.6 —— audio_service 根据模型列表选 AfeWakeWord / EspWakeWord / CustomWakeWord。

7.5.4.2 加载字体(LVGL 路径)

#ifdef HAVE_LVGL
cJSON* font = cJSON_GetObjectItem(root, "text_font");
if (cJSON_IsString(font)) {
std::string fonts_text_file = font->valuestring;
if (GetAssetData(fonts_text_file, ptr, size)) {
auto text_font = std::make_shared<LvglCBinFont>(ptr);
if (text_font->font() == nullptr) return false;
if (light_theme != nullptr) light_theme->set_text_font(text_font);
if (dark_theme != nullptr) dark_theme->set_text_font(text_font);
}
}

LvglCBinFont(ptr) 用 mmap 指针构造 LVGL 字体对象(CBIN 是 LVGL 自定义的紧凑二进制字体格式)。shared_ptr 让 light + dark 主题共享同一份字体。

7.5.4.3 加载 emoji_collection

cJSON* emoji_collection = cJSON_GetObjectItem(root, "emoji_collection");
if (cJSON_IsArray(emoji_collection)) {
auto custom_emoji_collection = std::make_shared<EmojiCollection>();
int emoji_count = cJSON_GetArraySize(emoji_collection);
for (int i = 0; i < emoji_count; i++) {
cJSON* emoji = cJSON_GetArrayItem(emoji_collection, i);
if (cJSON_IsObject(emoji)) {
cJSON* name = cJSON_GetObjectItem(emoji, "name");
cJSON* file = cJSON_GetObjectItem(emoji, "file");
cJSON* eaf = cJSON_GetObjectItem(emoji, "eaf");
if (cJSON_IsString(name) && cJSON_IsString(file) && (NULL == eaf)) {
if (!GetAssetData(file->valuestring, ptr, size)) continue;
custom_emoji_collection->AddEmoji(name->valuestring, new LvglRawImage(ptr, size));
}
}
}
if (light_theme != nullptr) light_theme->set_emoji_collection(custom_emoji_collection);
if (dark_theme != nullptr) dark_theme->set_emoji_collection(custom_emoji_collection);
}

把每个表情图直接构造成 LvglRawImage(ptr, size) 并以 name 注册到 EmojiCollection。

注意 (NULL == eaf) —— EAF 格式(emote-animation-frame)走 elif 分支的另一条路径(emote_display),不是 LVGL 路径。

7.5.4.4 emote 路径(OLED / 像素屏特殊设备)

某些板子用 emote_display 而非 LVGL(例如带极小 OLED 又要播放表情动画的板子):

#elif defined(CONFIG_USE_EMOTE_MESSAGE_STYLE)
auto emote_display = dynamic_cast<emote::EmoteDisplay*>(display);

cJSON* emoji_collection = cJSON_GetObjectItem(root, "emoji_collection");
if (cJSON_IsArray(emoji_collection)) {
// ... 加载带 fps/loop/lack 参数的动画
emote_display->AddEmojiData(name->valuestring, ptr, size,
static_cast<uint8_t>(fps_value),
loop_value, lack_value);
}

cJSON* layout_json = cJSON_GetObjectItem(root, "layout");
// ... emote_display->AddLayoutData(name, align, x, y, width, height);

fps / loop / lack 字段控制 GIF-like 动画的播放参数;layout 数组定义 UI 元素的绝对定位。

7.5.5 Download() —— 下载并写入 assets 分区

bool Assets::Download(std::string url, std::function<void(int, size_t)> progress_callback) {
if (mmap_handle_ != 0) {
esp_partition_munmap(mmap_handle_);
mmap_handle_ = 0;
mmap_root_ = nullptr;
}
checksum_valid_ = false;
assets_.clear();

先 unmap——要往这个分区写入了,不能再让 mmap 句柄持有它。

    auto http = network->CreateHttp(0);
if (!http->Open("GET", url)) return false;
if (http->GetStatusCode() != 200) return false;
size_t content_length = http->GetBodyLength();
if (content_length > partition_->size) return false;

const size_t SECTOR_SIZE = esp_partition_get_main_flash_sector_size();
size_t sectors_to_erase = (content_length + SECTOR_SIZE - 1) / SECTOR_SIZE;

ESP32 flash 最小擦除单位是扇区(4KB),先算需要擦多少扇区。

    char buffer[512];
size_t total_written = 0;
size_t current_sector = 0;
while (true) {
int ret = http->Read(buffer, sizeof(buffer));
if (ret <= 0) break;

// 边下载边擦除:刚好够覆盖当前要写的位置就擦
size_t write_end_offset = total_written + ret;
size_t needed_sectors = (write_end_offset + SECTOR_SIZE - 1) / SECTOR_SIZE;
while (current_sector < needed_sectors) {
size_t sector_start = current_sector * SECTOR_SIZE;
esp_partition_erase_range(partition_, sector_start, SECTOR_SIZE);
current_sector++;
}

esp_partition_write(partition_, total_written, buffer, ret);
total_written += ret;
// ... 进度回调
}

if (!InitializePartition()) return false;
return true;
}

渐进式擦写的好处:

  • 不需要一次擦完整个分区(避免几秒钟阻塞);
  • 擦除和下载交错进行 → 总耗时 = max(擦除时间, 下载时间) 而不是两者之和;
  • 同时不会触发分区"擦完一半但写入失败 → 资源完全丢失"的中间状态(虽然中断仍会损坏,但概率小)。

下载完后立即重新 InitializePartition 校验 checksum,失败说明文件损坏(或下载到一半网络断了)。

7.5.6 析构 —— 释放 mmap

Assets::~Assets() {
if (mmap_handle_ != 0) {
esp_partition_munmap(mmap_handle_);
}
}

单例模式下其实析构很少触发(程序结束才会),但写出来是良好习惯。


7.6 跨模块时序:从启动到正常工作的完整生命周期

启动

├─ app_main() [main.cc]
│ └─ Application::Run()

├─ Application::Initialize() [application.cc]
│ ├─ Ota::MarkCurrentVersionValid() ← OTA 防回滚标记
│ ├─ Assets::InitializePartition() ← mmap + checksum
│ ├─ AudioService::Initialize()
│ ├─ McpServer::AddCommonTools() ← 注册 LLM 可见工具
│ └─ board.StartNetwork() ← Wi-Fi 异步连接

├─ [NETWORK_CONNECTED 事件触发]
│ │
│ └─ ActivationTask
│ │
│ ├─ CheckAssetsVersion() [application.cc]
│ │ └─ HTTP 检查资源版本
│ │ └─ Assets::Download() ← 如有新版下载
│ │ └─ Assets::Apply() ← 加载字体/模型/皮肤
│ │
│ ├─ CheckNewVersion() [application.cc]
│ │ └─ Ota::CheckVersion() ← 拿固件URL/mqtt配置/时间
│ │ └─ 若 has_new_version_: Ota::Upgrade() + esp_restart
│ │ └─ 若 has_activation_*_: Ota::Activate() / ShowActivationCode
│ │ └─ 否则继续
│ │
│ └─ InitializeProtocol() [application.cc]
│ └─ 根据 has_mqtt_config_ / has_websocket_config_
│ 实例化 MqttProtocol 或 WebsocketProtocol

├─ Application::Run() 主循环 ← 至此进入正常工作状态
│ ├─ 处理 MAIN_EVENT_TOGGLE_CHAT
│ ├─ 处理 MAIN_EVENT_SEND_AUDIO
│ ├─ 处理 MAIN_EVENT_STATE_CHANGED
│ └─ ...

7.7 安全设计要点小结

  1. eFuse 序列号 + HMAC:硬件级身份证明,软件层无法伪造。
  2. OTA A/B 双分区:升级失败可回滚,永不变砖。
  3. PENDING_VERIFY 状态:新固件没跑起来的话自动回滚。
  4. 固件签名esp_ota_end 内部做整体 SHA-256 校验。
  5. NVS 隔离 namespace:不同模块的配置互不污染。
  6. assets checksum:防误读,下载完立即校验。
  7. assets magic ZZ:每个资源文件加 magic 防错位。
  8. NVS 写入比对:避免无谓的 flash 写入磨损。

7.8 本章用到的关键技术

技术应用
NVS(Non-Volatile Storage)settings 全部
RAII(构造打开/析构关闭)Settings 类
dirty_ 脏标志只在改过时 commit
eFuse / esp_efuse_read_field_blob读出厂烧的序列号
SOC HMAC 加速器 / esp_hmac_calculate设备激活的身份验证
esp_partition_mmap把 flash 分区映射到 CPU 地址空间,零拷贝读
esp_ota_* API 全家桶begin / write / end / set_boot_partition / mark_app_valid
A/B 分区 OTA旧固件不变,新固件写另一槽
流式 HTTP 下载512 字节块 + 进度回调,不一次性占大 RAM
esp_partition_erase_range + esp_partition_write擦写交错节省时间
std::string().swap() idiom强制释放 string 容量
std::stringstream + getline语义化版本字符串解析
cJSON_ArrayForEach + 对象字段遍历 mqtt/websocket object
settimeofdayHTTP 同步时间替代 NTP
Meyers 单例(Assets / Ota)全局唯一实例
二进制文件 magic 'ZZ'资源文件结构错位检测
简单累加校验和资源分区数据完整性
shared_ptr + LVGL 资源多主题共享字体/图片

7.9 看完本章你应该掌握的

  • NVS 是什么、namespace 怎么用
  • Settings 的 RAII + dirty 设计
  • SystemInfo 全部静态方法 + ESP32-P4 的 Wi-Fi 特殊化
  • OTA 的完整流程:CheckVersion → 拿固件 URL/MQTT 配置/时间 → Activate → Upgrade
  • Activation 两套协议(code 模式 vs HMAC challenge 模式)
  • ESP32 HMAC 硬件加速器的工作原理(eFuse key 不可读 + 硬件签名)
  • A/B 双分区 OTA + PENDING_VERIFY 防变砖机制
  • Assets 二进制分区格式(header + 索引表 + 数据区)
  • esp_partition_mmap 零拷贝访问 flash
  • Assets 渐进式擦写下载
  • index.json 解析 + 模型/字体/表情/皮肤的多路径加载
  • LVGL 路径 vs emote 路径的差异(一个用统一主题、一个用 AddXxxData)

下一章覆盖 display/ + led/ 显示与指示子系统。