背景

原 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_SIZEstm32_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 的明文输入。用 snprintfv1|<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 调用。