MCU 代码框架设计:借鉴 Linux Device Tree 的驱动架构与 Loader/App 双分区升级

2022/12/01

某公司在自研 MCU 平台(基于 RT-Thread)时,驱动架构参考了 Linux device tree 的设计思路,固件升级采用 Loader + App 双分区方案。本文整理这套框架的设计要点。

一、框架设计思路

三个原则:

  1. 简洁易懂
  2. 兼容有 RTOS 和无 RTOS 两种系统
  3. 模块化,参考 PX4 的模块思路

二、运行框架(FreeRTOS)

任务调度、初始化、任务退出采用统一模式:

void task(void *pvParameters)
{
    init();
    while (1)
    {
    }
    vTaskDelete(NULL);
}

任务同步与信息广播:

三、中断与缓存管理

四、设备管理:仿 Linux 设备树

借鉴 device tree 的思路描述和注册设备:

配合 OSAL(操作系统抽象层)屏蔽 RTOS 差异。

驱动的三类划分

OS 中的驱动分成三类:

五、Loader 与固件升级

为什么不用 U-Boot

U-Boot 是为嵌入式 Linux 设计的 bootloader(Cortex-A/MIPS/PowerPC),需要几 MB 的 RAM 和 Flash、完整的 C 运行时环境、DDR 初始化、网卡驱动、文件系统。Cortex-M 单片机有自己的一套方案。

三种可选 Loader 方案

方案 A:MCUboot — 最成熟的开源方案

核心能力:

需求MCUboot 能力
加密固件AES-128/256 加密镜像,密钥通过 RSA-OAEP / ECIES / AES-KW 加密分发
串口升级boot_serial 模块通过任意串口提供 SMP 协议服务,用 mcumgr CLI 工具上传镜像
签名验证RSA2048/3072、ECDSA、Ed25519
升级策略swap / overwrite / direct-xip / ram-load

分区管理(双槽位模型):

Flash 布局:
┌──────────────┐
│  Bootloader  │  ← MCUboot 本身(20-30KB)
├──────────────┤
│  Slot 0      │  ← Primary(当前运行固件)
│  (App A)     │
├──────────────┤
│  Slot 1      │  ← Secondary(新固件暂存区)
│  (App B)     │
├──────────────┤
│  Scratch     │  ← 可选,swap 算法用
└──────────────┘

三种升级策略:

  1. Swap(交换):新固件放 slot 1,bootloader 把 slot 0/1 互换,失败可回滚
  2. Overwrite(覆盖):直接把 slot 1 覆盖到 slot 0,简单但无回滚
  3. Direct-XIP:两个槽位平等,bootloader 直接选版本号最高的启动

方案 B:ESP-IDF Bootloader — ESP32 生态首选

方案 C:自研轻量 Loader

自研平台上实际采用的就是方案 C:Loader + App 双分区,固件传输使用 Ymodem 协议。

独立 Loader vs A/B 分区

核心矛盾:

Flash 约束下的算术(1MB Flash):

A/B 分区:  Loader 8KB + App A 504KB + App B 504KB  → App 最大 504KB
独立 Loader: Loader 16KB + App 1008KB              → App 最大 1008KB

Flash 越小,独立 Loader 优势越明显。256KB Flash:A/B 的 App 只有 120KB,独立 Loader 有 240KB。

结论:Flash 紧张时选独立 Loader。 Loader 协议可以极简化(三个命令),外设负担 30 行 C,远小于省出来的半个 Flash。

极简 Loader 串口协议设计

帧格式:

┌──────────┬──────────┬──────────┬──────────────┬──────────┐
│ 魔数      │ 命令     │ 地址      │ 数据          │ CRC16    │
│ 0xA55A   │ 1B       │ 4B LE    │ N bytes       │ 2B       │
│ 2B       │          │          │               │          │
└──────────┴──────────┴──────────┴──────────────┴──────────┘

三个命令:

1. ERASE <addr> <len>     → ACK
2. WRITE <addr> <data>    → ACK
3. CHECK <crc32>          → ACK / NAK

回复帧:
[0xA55A] [status 1B] [CRC16 2B]

ESP32 实现只需 30 行 C。

Loader 体积估算:

组件体积
向量表 + 启动 + 时钟~500B
UART 轮询驱动~200B
Flash 擦写~300B
帧接收状态机~150B
CRC16~80B
App 有效性检查~100B
小计(不含加密验签)~1.3KB
ECDSA 验签(micro-ecc,可选)~3KB
含验签~4.5KB

App 触发升级机制

1. App 收到 OTA 请求
2. 写标志区:{MAGIC=0xA55A, CMD=UPGRADE, ...}
3. NVIC_SystemReset() → 软复位
4. Loader 启动 → 读标志区 → 发现升级命令
5. 进入串口接收模式
6. 升级完成 → 清标志区 → 跳转 App

断电保护:升级中断电 → 重启 → Loader 读标志区发现 CMD=UPGRADE,但 App CRC 不对 → 继续等上位机重发。Loader 永远不会丢,App 写坏了标志区还留着。

App 有效性检查

bool is_app_valid(void) {
    // 1. 栈指针检查
    uint32_t sp = *(uint32_t *)APP_ADDR;
    if (sp < 0x20000000 || sp > 0x20080000) return false;
    // 2. Reset Handler 检查
    uint32_t reset = *(uint32_t *)(APP_ADDR + 4);
    if (reset < 0x08000000 || reset > 0x083C0000) return false;
    // 3. CRC 校验(可选)
    return true;
}

硬件加速:GD32H7 的 RTDEC

GD32H7 FMC 内置硬件 AES 解密引擎(Real-Time Decryption):

这意味着固件加密升级,Loader 端零代码:上位机加密后直接写入 Flash → 硬件自动解密 → CPU 执行明文。签名验证(micro-ecc ECDSA)是可选的第二层安全。

另有 BOOT_ADDR 选项字节(FMC_BTADDR 寄存器),BOOT 管脚高低分别对应 BOOT_ADDR0/BOOT_ADDR1 两个启动地址,出厂默认指向片内 Bootloader;修改选项字节改变启动地址,可实现软件 A/B 切换。

推荐方案组合:

Flash 布局:
┌──────────┬──────────┬──────────────────────────┐
│ Loader   │ 标志区    │ App                      │
│ 8KB      │ 4KB      │ 3828KB                   │
│ 0x0800   │ 0x0802   │ 0x0803                   │
│ 0000     │ 0000     │ 0000                     │
└──────────┴──────────┴──────────────────────────┘

安全链:
  上位机:app.bin → AES加密(用设备密钥) → app.enc.bin
  串口传输 → Loader 写入 Flash(密文)
  CPU 执行 → RTDEC 自动解密 → 运行明文代码

六、MCUmgr 与 SMP 协议(开源生态参考)

MCUmgr 是 MCUboot 生态里的设备管理协议和工具:

SMP 串口帧格式(源码验证)

线缆上的格式:
[0x06 0x09] base64(frame) \n     ← 新帧开始
[0x04 0x14] base64(continuation) \n  ← 后续分片

base64 解码后的 frame:
┌──────────┬──────────────────────────────┬──────────┐
│ totlen   │ nmgr_hdr + CBOR data         │ CRC16    │
│ 2B BE    │ 8B + N bytes                 │ 2B BE    │
└──────────┴──────────────────────────────┴──────────┘
               └─── CRC16 覆盖这段 ──────────┘

nmgr_hdr 结构(8 字节):

struct nmgr_hdr {
    uint8_t  nh_op:3;       // 0=READ, 2=WRITE
    uint8_t  nh_version:2;
    uint8_t  _res1:3;
    uint8_t  nh_flags;
    uint16_t nh_len;        // CBOR payload 长度 (大端)
    uint16_t nh_group;      // 0=OS, 1=IMG, 64=PERUSER
    uint8_t  nh_seq;
    uint8_t  nh_id;         // 组内命令ID
} __packed;

关键特性:

SMP 支持的命令:echo(OS,测通断)、reset(OS,复位)、image list(IMG,查看槽位固件)、image upload(IMG,上传新固件)。

二进制序列化格式选型

格式编码大小解析器体积自描述适用场景
CBOR小~500B是通用嵌入式
MessagePack小~1KB是嵌入式、Redis
Protobuf (nanopb)极小~2KB否(需 .proto)带宽极度受限
FlatBuffers零拷贝~3KB否零解析开销

设备管理/固件升级协议生态

协议标准化编码传输代码体积适用场景
SMPMynewt 项目规范CBOR任意串口~2KB本地串口升级
LwM2MOMA 标准CBOR/TLV/JSONCoAP over UDP~20KB+云端管理百万设备
SUIT (RFC 9019)IETF RFCCBOR不限定~30KB+需过认证的产品

经典串口传输协议:XMODEM(128B,停等)、YMODEM(1024B,批量)、ZMODEM(可变包,流式、断点续传)。

七、帧格式设计:字节冲突与 COBS

二进制帧协议的核心难题:帧头魔术字节(如 0xA55A)可能出现在数据中,导致误判。

方案开销帧同步安全性代码量
base6433%天然安全(数据变 ASCII)~80行
COBS<0.4%0x00 边界保证~40行
SLIP<0.1%(典型)0xC0 边界保证~50行
裸二进制 + 长度定界0%CRC 兜底(概率性)~30行

COBS(Consistent Overhead Byte Stuffing)把数据中的 0x00 替换为偏移量,编码后数据内部不存在 0x00,用 0x00 做帧边界标记绝不误判;实际固件开销 <1%(全零数据是理论最坏情况 100%,但固件不会全零)。被 PX4 Autopilot、Zephyr shell 等实际使用。

推荐:

八、签名工具链:imgtool

MCUboot 的 PC 端工具,即使不用 MCUboot 运行时也可单独使用:

# 生成签名密钥对
imgtool keygen -t ecdsa-p256 -k priv.pem

# 签名固件
imgtool sign --key priv.pem --align 4 --version 1.2.0 \
  --header-size 0x200 --slot-size 0x3C0000 \
  app.bin app.signed.bin

# 加密固件(如果不用 RTDEC)
imgtool encrypt --key enc.pem app.signed.bin app.encrypted.bin

生成带 TLV trailer 的标准格式签名镜像,自己写的 Loader 解析 TLV 做验签即可。

九、补充:设备树与 U-Boot/Linux

compatible = "rockchip,rk3588-uart", "rockchip,rk3399-uart", "snps,dw-apb-uart";
//            ↑ 最具体的芯片         ↑ 兼容的上一代           ↑ 通用 IP 核

两边的驱动匹配表各自维护,碰巧用同一个 compatible 字符串查自己的驱动。

十、题外:Protobuf + Zenoh 的适用边界