4、防回滚计数器实现:三种主流方案深度解析

防回滚,说白了就是让固件版本只能往前走,不能往后退。这个机制的核心,就是那个计数器。计数器一旦写进去,就再也回不来了。今天咱们聊聊三种最常见的实现方案:eFuse、OTP 和 TPM。

我个人习惯把防回滚计数器比作「单向门」。你推开门走进去,门就锁死了。想退回去?没门。这三种方案,本质上都是造这种单向门,只是材料和工艺不同。

核心原则:防回滚计数器的写入操作必须是不可逆的。一旦增加,就不能减少。这是所有方案的底线。

4.1 基于 eFuse 的计数器方案

eFuse,电子熔丝。你可以把它想象成芯片内部的一根根小保险丝。出厂时都是通的(0),一旦你烧断它(1),就再也接不回去了。

我在项目中第一次用 eFuse 做计数器时,心里还挺没底的。烧断了还能再烧吗?后来发现,每个 eFuse 位只能烧一次。所以,你要用多个 eFuse 位来拼成一个计数器。

实现方式

  • 二进制加权法:用 N 个 eFuse 位表示 0 到 2^N-1 的计数值。每次升级,烧断对应的位组合。
  • 独热码法:每个版本对应一个独立的 eFuse 位。升级到版本 N,就烧断第 N 个 eFuse。
  • 混合法:结合上面两种,用少量位表示大版本,用独热位表示小版本。
方案 位效率 安全性 适用场景
二进制加权法 高(N 位表示 2^N 个值) 中(可能被中间值攻击) 版本数量多,但安全要求一般
独热码法 低(N 位只能表示 N 个值) 高(每个版本独立,无法篡改) 版本数量少,安全要求极高
混合法 中 高 大多数实际项目

我曾经踩过一个坑:用二进制加权法时,没有考虑烧断过程的原子性。如果烧断过程中断电,eFuse 可能处于中间状态,导致计数器值既不是旧版本也不是新版本。后来我加了校验位和冗余存储,才解决这个问题。

代码示例:eFuse 写入与校验

// eFuse 写入函数(伪代码)
// 假设使用 8 个 eFuse 位,独热码方式
#define EFUSE_BASE_ADDR 0x40020000
#define MAX_VERSION 8

int efuse_write_version(uint8_t version) {
    if (version >= MAX_VERSION) return -1;
    
    uint32_t *efuse_reg = (uint32_t *)EFUSE_BASE_ADDR;
    uint32_t pattern = 1 << version;
    
    // 检查该位是否已经烧断
    if (*efuse_reg & pattern) {
        return 0; // 已经烧过,无需重复操作
    }
    
    // 写入 eFuse(烧断操作)
    *efuse_reg = pattern;
    
    // 回读校验
    if ((*efuse_reg & pattern) != pattern) {
        return -2; // 写入失败
    }
    
    return 0;
}

// 读取当前版本
uint8_t efuse_read_version(void) {
    uint32_t *efuse_reg = (uint32_t *)EFUSE_BASE_ADDR;
    uint32_t value = *efuse_reg;
    
    // 找到最高位的 1(假设版本号从 0 开始递增)
    for (uint8_t i = 0; i < MAX_VERSION; i++) {
        if (value & (1 << i)) {
            return i;
        }
    }
    return 0; // 没有烧断任何位,版本为 0
}

4.2 基于 OTP 存储的计数器方案

OTP,One-Time Programmable,一次性可编程存储器。它和 eFuse 原理类似,但实现方式不同。OTP 通常使用浮栅晶体管,通过注入电荷来改变存储状态。写入后,电荷被「困」在浮栅里,无法移除。

你想想看,OTP 和 eFuse 最大的区别是什么?OTP 通常以字节为单位操作,而 eFuse 是位级别的。这意味着 OTP 的写入粒度更大,但管理起来也更方便。

实现方式

  • 字节递增法:每个字节代表一个版本号。写入时,将对应字节从 0xFF 改为 0x00(或相反)。
  • 区域划分法:将 OTP 区域分成多个槽位,每个槽位存储一个版本号。升级时写入新的槽位。
  • 磨损均衡法:循环使用 OTP 区域,避免频繁写入同一位置导致物理损坏。

我个人建议:如果 OTP 空间充足,优先使用区域划分法。每个版本独立占一个槽位,读取时扫描所有槽位取最大值。这样即使写入过程中断,也不会破坏已有数据。

代码示例:OTP 区域划分法

// OTP 区域划分法实现
#define OTP_BASE_ADDR     0x40030000
#define SLOT_SIZE         4       // 每个槽位 4 字节
#define MAX_SLOTS         32      // 最多 32 个槽位
#define SLOT_MAGIC        0xA5A5A5A5  // 槽位有效标志

typedef struct {
    uint32_t magic;     // 有效标志
    uint32_t version;   // 版本号
    uint32_t checksum;  // 校验和
    uint32_t reserved;  // 保留
} otp_slot_t;

int otp_write_version(uint32_t version) {
    otp_slot_t *slot;
    
    // 找到第一个空的槽位
    for (int i = 0; i < MAX_SLOTS; i++) {
        slot = (otp_slot_t *)(OTP_BASE_ADDR + i * SLOT_SIZE);
        if (slot->magic != SLOT_MAGIC) {
            // 写入新版本
            slot->magic = SLOT_MAGIC;
            slot->version = version;
            slot->checksum = version ^ 0xFFFFFFFF;
            return 0;
        }
    }
    return -1; // 槽位已满
}

uint32_t otp_read_max_version(void) {
    uint32_t max_ver = 0;
    otp_slot_t *slot;
    
    for (int i = 0; i < MAX_SLOTS; i++) {
        slot = (otp_slot_t *)(OTP_BASE_ADDR + i * SLOT_SIZE);
        if (slot->magic == SLOT_MAGIC) {
            // 校验 checksum
            if ((slot->version ^ 0xFFFFFFFF) == slot->checksum) {
                if (slot->version > max_ver) {
                    max_ver = slot->version;
                }
            }
        }
    }
    return max_ver;
}

4.3 基于 TPM 的计数器方案

TPM,Trusted Platform Module,可信平台模块。这东西是个独立的硬件安全芯片,自带防篡改能力。TPM 内部有专门的单调计数器(Monotonic Counter),硬件保证只能增加不能减少。

我记得第一次用 TPM 做防回滚时,感觉就像开了外挂。不用自己操心物理层的烧断问题,TPM 全包了。你只需要调用它的 API 就行。

TPM 计数器的优势

  • 硬件隔离:计数器值存储在 TPM 内部,主 CPU 无法直接访问,只能通过命令接口操作。
  • 原子操作:TPM 保证每次增加操作要么成功,要么失败,不会出现中间状态。
  • 持久化存储:TPM 内部有 NVRAM,断电不丢失。
  • 认证机制:可以通过 TPM 的签名功能验证计数器的真实性。

关键点:TPM 的单调计数器分为两种:全局计数器(整个 TPM 共享)和 特定计数器(每个应用独立)。做固件防回滚时,建议使用特定计数器,避免与其他应用冲突。

TPM 命令示例(使用 TPM 2.0)

// TPM 2.0 单调计数器操作(伪代码)
// 使用 TPM2_Increment 命令增加计数器

// 1. 创建计数器空间
TPM2_NV_DefineSpace(
    .authHandle = TPM_RH_PLATFORM,
    .auth = "platform_auth",
    .nvIndex = 0x01C00001,  // 自定义 NV 索引
    .nvAttributes = TPMA_NV_COUNTER | TPMA_NV_AUTHWRITE,
    .dataSize = 8
);

// 2. 增加计数器
TPM2_NV_Increment(
    .authHandle = 0x01C00001,
    .nvIndex = 0x01C00001,
    .auth = "nv_auth"
);

// 3. 读取计数器值
TPM2_NV_Read(
    .authHandle = 0x01C00001,
    .nvIndex = 0x01C00001,
    .auth = "nv_auth",
    .size = 8,
    .offset = 0
);

4.4 三种方案对比

特性 eFuse OTP TPM
写入次数 每位一次 每位一次 理论无限(有磨损)
写入粒度 位 字节/页 计数器值
安全性 高(物理不可逆) 高(物理不可逆) 极高(硬件隔离+认证)
成本 低(芯片内集成) 低(芯片内集成) 高(独立芯片)
灵活性 低(烧断后无法修改) 中(可规划多个槽位) 高(可重置计数器)
适用场景 低成本 MCU 中端 SoC 高端安全设备

4.5 我的选择建议

嗯,这里要注意。没有银弹。选哪种方案,取决于你的项目预算和安全等级。

  • 如果你在做消费级 IoT 设备,成本敏感,用 eFuse 就够了。8 个 eFuse 位用独热码,支持 8 次升级,对大多数产品来说绰绰有余。
  • 如果你在做工业控制器,生命周期长,升级次数多,用 OTP 区域划分法。32 个槽位,每个槽位 4 字节,总共才 128 字节,几乎不占什么成本。
  • 如果你在做金融终端或汽车 ECU,别犹豫,上 TPM。虽然贵点,但安全等级完全不是一个量级。而且 TPM 有标准 API,代码移植性也好。

我曾经见过一个项目:用 eFuse 做计数器,但没考虑生产测试阶段的版本号消耗。结果产品还没出货,eFuse 位就用完了。后来他们不得不在产线上用专用工具预烧一个初始版本号,才解决这个问题。所以,设计时一定要预留足够的余量。

防回滚计数器方案对比 eFuse 计数器 OTP 计数器 TPM 计数器 特性 特性 特性 位级操作,每位一次 物理不可逆,安全性高 成本低,集成在芯片内 字节/页级操作 可规划多个槽位 灵活性中等 硬件隔离,原子操作 认证机制,防篡改 成本高,适合高端设备 适用场景 选择建议 消费级 IoT → eFuse | 工业控制器 → OTP | 金融/汽车 → TPM 没有银弹,根据项目预算和安全等级选择