背景
原 STM32 工程把配置镜像和诊断快照直接写裸 Flash 地址,靠手动 unlock/erase/program/lock 维护。迁到 Zephyr 后这层交给 NVS 子系统:擦写均衡、掉电保护、回读校验都内置,业务代码只看到 nvs_read/nvs_write 两个调用。鉴权部分同步换掉 STM32 自实现 SHA-256,改用 PSA Crypto API,JSON 解析改用 cJSON。
NVS 子系统与分区
Zephyr NVS(Non-Volatile Storage,<zephyr/kvss/nvs.h>)是基于 ID 的键值存储,固定长度的扇区循环写入:每次 nvs_write 在当前 sector 末尾追加新副本,旧副本标记删除,sector 写满后做一次 GC 把有效项搬到下一个 sector。这种 append-only 写法天然支持掉电恢复,不用业务层维护擦写计数。
storage_partition 是设备树里预留的 4KB 分区,位于片上 Flash 末尾:
storage_partition: partition@7f000 {
label = "storage";
reg = <0x0007f000 0x00001000>; /* 4KB */
};
STM32F1 单页 2KB,所以 4KB 正好是 2 个 sector,满足 NVS 至少 2 sector 的最小要求(一个写、另一个做 GC 切换)。
dr154_nvs_init 挂载流程
挂载只用三步:拿到 flash device、填偏移和扇区参数、调 nvs_mount。
#define NVS_PARTITION storage_partition
#define NVS_PARTITION_ID FIXED_PARTITION_ID(NVS_PARTITION)
static int dr154_nvs_init(void)
{
g_nvs_fs.flash_device = FIXED_PARTITION_DEVICE(NVS_PARTITION);
g_nvs_fs.offset = FIXED_PARTITION_OFFSET(NVS_PARTITION);
g_nvs_fs.sector_count = 2u; /* 2 个 sector = 4KB */
g_nvs_fs.sector_size = 2048u; /* STM32F1 page size */
return nvs_mount(&g_nvs_fs);
}
FIXED_PARTITION_DEVICE/FIXED_PARTITION_OFFSET 由 devicetree 的 fixed-partitions 模型解析出 device_get_binding("stm32-flash-controller") 和 0x0007f000,避免硬编码。nvs_mount 内部读出 sector 头、重建 ID→最新副本偏移的映射表,挂载失败时业务仍能跑默认配置,仅打告警。
NVS ID 管理
NVS 用 16 位整数 ID 而不是字符串 key,省下哈希和字符串表空间。Mill 工程只占两个槽位:
#define DR154_NVS_CONFIG_ID 1u /* 配置镜像 */
#define DR154_NVS_DIAG_ID 2u /* 诊断快照 */
MCUboot 自己的镜像确认状态走 CONFIG_SETTINGS 子系统(也是 NVS 后端),由 settings_save_one 写到独立 ID 空间,不与本工程冲突。
配置镜像:结构 + CRC16 + NVS 读写
配置镜像把 DR154RemoteConfig 整块封进一个带 magic/version/CRC 的容器:
typedef struct {
uint32_t magic; /* 0x47584346 = "GXCF" */
uint16_t version; /* DR154_CFG_VERSION = 0x0003 */
uint16_t payload_size; /* sizeof(DR154RemoteConfig) */
DR154RemoteConfig config; /* 实际配置 */
uint16_t crc16; /* CRC16 over [magic..payload_size] */
} DR154ConfigImage;
保存时填好头部,对 offsetof(image, crc16) 之前的字节算 CRC16,再整块写:
uint8_t DR154_SaveConfigToFlash(const DR154RemoteConfig *config)
{
DR154ConfigImage image = {0};
image.magic = DR154_CFG_MAGIC;
image.version = DR154_CFG_VERSION;
image.payload_size = sizeof(DR154RemoteConfig);
image.config = *config;
image.crc16 = DR154_CRC16((const uint8_t *)&image,
offsetof(DR154ConfigImage, crc16));
ssize_t rc = nvs_write(&g_nvs_fs, DR154_NVS_CONFIG_ID,
&image, sizeof(image));
return (rc < 0) ? DR154_ERR_FLASH_PROGRAM : DR154_ERR_NONE;
}
nvs_write 内部完成擦写、回读校验、必要时 GC。业务层不再操心 FLASH_PAGE_SIZE、stm32_flash_unlock 这些细节。
加载时按 nvs_read 返回的字节数判断版本:
rc = nvs_read(&g_nvs_fs, DR154_NVS_CONFIG_ID, &image, sizeof(image));
if (rc == -ENOENT) return DR154_ERR_FLASH_EMPTY; /* 首次启动 */
if (rc == sizeof(image)) { /* V2 路径:完整校验 magic+version+crc */ }
if (rc == sizeof(DR154ConfigImageV1)) { /* V1 路径:仅基础字段 */ }
-ENOENT 是 NVS 明确的”该 ID 没写过”语义,比直接判 -ESPIPE 干净。
sequence 字段实现乐观并发控制
DR154RemoteConfig.sequence 是单调递增的 32 位版本号。每次 DR154_LoadConfigFromRegisters 构造候选配置时自动 +1:
config->sequence = g_active_config.sequence + 1UL;
云端 config_set 命令携带 cfg_sequence 字段,设备在 DR154_HandleConfigSetFrame 里比对:如果云端给的序号 ≤ 当前已生效序号,判定为陈旧命令直接拒绝。这相当于把”读-改-写”的窗口压到协议层,云端不用先锁设备就能安全下发,冲突由序号兜底。
DR154_UpdateMaintenanceRegisters 同步把 sequence 拆 H/L 两个 16 位寄存器暴露给 Modbus 轮询,上位机随时能读到当前生效版本号。
诊断快照 V1/V2 兼容加载
诊断页存上电次数、最近一次保存配置的 uptime 和序号、最近一次升级来源/状态/错误码。V1 时代只有上电次数:
typedef struct {
uint32_t magic;
uint16_t version;
uint16_t payload_size;
uint32_t power_on_count;
uint16_t crc16;
} DR154DiagImageV1; /* version=0x0001 */
V2 扩展为含升级快照:
typedef struct {
/* ... 同 V1 头 ... */
uint32_t power_on_count;
uint32_t last_save_uptime_s;
uint32_t last_save_sequence;
uint16_t last_upgrade_request_source;
uint16_t last_upgrade_state;
uint16_t last_upgrade_error;
uint16_t reserved;
uint16_t crc16;
} DR154DiagImage; /* version=0x0002 */
加载策略按返回字节数和 version 字段双判:
if (image.version == DR154_DIAG_VERSION && /* V2 payload_size 校验 */) {
/* 全字段加载 */
}
if (image_v1->version == 0x0001u && image_v1->payload_size == sizeof(uint32_t)) {
/* 只加载 power_on_count,其它字段走运行时默认 */
}
NVS 的 nvs_read 返回的是该 ID 历史写入中最大长度的那次副本——旧固件写 V1 后升级到新固件写 V2,NVS 会用更新版本覆盖,但首次升级时仍可能读出 V1 长度,所以双判路径必须保留。V1 路径补齐默认值后再交给 DR154_ValidateConfig 做范围校验,避免历史数据带病升级。
上电计数刷新
每次启动 DR154_InitPowerOnCounter 都会 Load → +1 → Save:
uint8_t err = DR154_LoadDiagFromFlash();
if (err == DR154_ERR_NONE) {
g_power_on_count++;
} else {
DR154_ResetDiagRuntime();
g_power_on_count = 1UL;
}
DR154_SaveDiagToFlash();
首次上电(-ENOENT)走重置分支把计数置 1。每次保存配置或升级事件都顺带调一次 DR154_SaveDiagToFlash,把”最近一次保存时间/序号”和”最近一次升级状态”作为现场可观测的取证字段持久化。
HMAC-SHA256 鉴权流程
所有需要副作用(relay_set / ota_prepare / set_upload_period)的 JSON 命令都强制 HMAC 鉴权。鉴权流程由 DR154_VerifyJsonAuth 串起来:
JSON 文本
├─ cJSON_Parse → 取 dev / auth_alg / auth_sig 三个字段
├─ dev 必须等于 DR154_JSON_DEVICE_ID ("FM002")
├─ auth_alg 转小写后必须等于 "hmac-sha256-v1"
├─ auth_sig 是 64 字符十六进制,解析成 32 字节摘要
└─ 用 canonical 串重算 HMAC-SHA256,常量时间比较
cJSON 取代了原 STM32 手写的 strstr/strchr 解析,键名错位、嵌套对象、转义字符都能正确处理。字段取完立即 cJSON_Delete,鉴权全程不保留 JSON 树。
canonical 串拼接
canonical 不是 JSON,是 HMAC 的明文输入。用 snprintf 按 v1|<dev>|<cmd>|<cmd_seq>[|<args>] 拼接:
char canonical[DR154_JSON_AUTH_CANONICAL_MAX];
snprintf(canonical, sizeof(canonical),
"v1|%s|relay_set|%lu|%lu",
DR154_JSON_DEVICE_ID,
(unsigned long)cmd_seq,
(unsigned long)(relay_mask & 0xFFFFu));
字段顺序、分隔符、版本前缀 v1| 都是协议固定约定,云端必须按同一规则拼才能算出相同摘要。cmd_seq 同时承担防重放:DR154_AcceptJsonCommandSeq 严格大于上次已接受值,否则判 replay。
PSA Crypto 实现 HMAC
DR154_HmacSha256 不用 PSA key import API(不同 mbedtls 版本对 key store 行为有差异),而是直接用 PSA hash API 手搓 RFC 2104:
/* 密钥填到 64 字节 block,构造 ipad/opad */
for (i = 0u; i < DR154_JSON_AUTH_BLOCK_SIZE; i++) {
uint8_t kb = (i < DR154_JSON_AUTH_KEY_SIZE) ? key[i] : 0u;
ipad[i] = kb ^ 0x36u;
opad[i] = kb ^ 0x5Cu;
}
/* 内层: SHA256(ipad || message) */
psa_hash_setup(&op, PSA_ALG_SHA_256);
psa_hash_update(&op, ipad, DR154_JSON_AUTH_BLOCK_SIZE);
psa_hash_update(&op, (const uint8_t *)message, strlen(message));
psa_hash_finish(&op, inner, sizeof(inner), &out_len);
/* 外层: SHA256(opad || inner) */
psa_hash_setup(&op, PSA_ALG_SHA_256);
psa_hash_update(&op, opad, DR154_JSON_AUTH_BLOCK_SIZE);
psa_hash_update(&op, inner, sizeof(inner));
psa_hash_finish(&op, digest, DR154_JSON_AUTH_DIGEST_SIZE, &out_len);
输出与 Node.js crypto.createHmac("sha256", key).update(msg).digest() 字节对齐,便于云端验证。摘要比较用 DR154_ConstTimeDigestEqual 累积异或再判零,避免短路退出被时序侧信道利用。
设备密钥注入:WSL 私有配置
每设备 HMAC 密钥不写进 C 头文件,也不随 Git 同步。密钥放在 WSL 用户私有目录:
/home/mydei/.config/mill/device-auth.conf
文件内容就一行 Kconfig 片段:
CONFIG_DR154_DEVICE_AUTH_KEY_HEX="<64 个十六进制字符>"
构建时通过 -DZephyr_EXTRA_CONF_FILE= 注入到 sysbuild:
west build -b stm32f103ze -d /home/mydei/zephyr/build-mill \
/home/mydei/repos/Mill/Zephyr --sysbuild --pristine -- \
-DBOARD_ROOT=/home/mydei/repos/Mill/Zephyr \
-DZephyr_EXTRA_CONF_FILE=/home/mydei/.config/mill/device-auth.conf
这套和 MCUboot 签名私钥 /home/mydei/.config/mill/keys/root-ecdsa-p256.pem 走同一套 WSL 私有配置目录模型,密钥不入仓、不进构建产物。
fail-closed:密钥缺失就拒绝
DR154_IsDeviceAuthConfigured 检查密钥字符串长度是否为 64、ParseHexBytes 是否成功、字节是否非全零。任一不满足就返回 0:
if (DR154_IsDeviceAuthConfigured() == 0u) {
DR154_ReportJsonAuthReject("key_not_configured");
return 0u;
}
DR154_VerifyJsonAuth 第一道关卡就是它——密钥缺失直接拒绝,不走后续任何 HMAC 计算。这样开发期忘配密钥的固件虽然能正常启动(仅 printf 一条 PSA crypto init 告警),但所有需要鉴权的远程命令一律打回,不会留下”鉴权已通过但密钥为空”的灰色状态。
g_json_auth_reject_count 累计拒绝次数,前 3 次和每 20 次打一条日志,方便现场排查但又不会把串口刷爆。
Kconfig 配置项
prj.conf 里 NVS 和加密相关只开 6 项:
# NVS 存储(settings 子系统用于保存镜像确认状态 + 业务配置/诊断数据)
CONFIG_NVS=y
CONFIG_SETTINGS=y
# 加密:mbedtls HMAC-SHA256(替代 STM32 自实现 SHA-256 + HMAC)
# mbedtls v4.x:PSA Crypto 提供底层 hash,MD_C 提供 HMAC 接口
CONFIG_MBEDTLS=y
CONFIG_PSA_CRYPTO=y
CONFIG_MBEDTLS_PSA_CRYPTO_C=y
CONFIG_MBEDTLS_MD_C=y
CONFIG_SETTINGS 不是必须(业务层直接调 nvs_*),但 MCUboot image trailer 确认状态依赖它,所以一并打开。CONFIG_MBEDTLS_MD_C 在本工程里其实没直接用上(HMAC 走 PSA hash),保留是为了让 mbedtls_md_hmac_starts 这类高层 API 在其它子模块里可用。
小结
NVS 把 Flash 擦写、掉电恢复、ID 管理都封装掉了,业务代码只剩”构造镜像 → nvs_write”和”nvs_read → 校验镜像”两条路径,比原 STM32 工程的手写页管理干净得多。V1/V2 双路径兼容让固件升级时配置/诊断不丢字段,sequence 字段把并发控制压到协议层,HMAC 鉴权用 PSA Crypto + cJSON 替换掉 BSP 私有实现,密钥从 WSL 私有配置注入保证 fail-closed。这套组合在 STM32F103ZE 这种 64KB Flash 的小芯片上够用,对上层只暴露两个 nvs_read/nvs_write 调用。