第 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 寿命问题);
- 不同类型有不同 API(
nvs_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;
}
两步获取字符串:
- 传 nullptr 让 NVS 告诉你需要多大 buffer;
- 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 直接读,bool 用 uint8_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);
}
}
}
EraseKey 对 ESP_ERR_NVS_NOT_FOUND 容错——擦不存在的 key 不算错误。
7.2.6 项目中已知的 NVS namespace
通过 rg 'Settings\("' 可以列出:
| namespace | 内容 | 谁写 |
|---|---|---|
wifi | ota_url(OTA 服务器 URL)、wifi SSID/密码 | OTA 阶段 / wifi_board |
mqtt | endpoint / client_id / username / password / keepalive / publish_topic | OTA Check 写 |
websocket | url / token / version | OTA Check 写 |
assets | download_url(自定义资源 URL) | MCP 工具 self.assets.set_download_url |
audio | volume(用户音量持久化) | AudioCodec::SetOutputVolume |
display | brightness / theme | LvglDisplay |
board | uuid(板子软件 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 行代码,是本章最复杂的文件。功能分四块:
- 检查版本:调云端接口、对比版本号、缓存 mqtt/websocket 配置;
- 设备激活:用 efuse 里烧的序列号 + HMAC challenge/response 完成首次激活;
- 下载安装固件:流式写入 OTA 分区;
- 回滚保护:启动后标记当前 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 = 256bit;- 第一个字节为 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_0 和 ota_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_WRITESflag:声明会顺序写入,让底层做优化(无需擦掉整个分区,分段擦分段写);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 安全设计要点小结
- eFuse 序列号 + HMAC:硬件级身份证明,软件层无法伪造。
- OTA A/B 双分区:升级失败可回滚,永不变砖。
- PENDING_VERIFY 状态:新固件没跑起来的话自动回滚。
- 固件签名:
esp_ota_end内部做整体 SHA-256 校验。 - NVS 隔离 namespace:不同模块的配置互不污染。
- assets checksum:防误读,下载完立即校验。
- assets magic ZZ:每个资源文件加 magic 防错位。
- 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 |
settimeofday | HTTP 同步时间替代 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/ 显示与指示子系统。