1. 项目概述:为什么FOTA是嵌入式开发的“必修课”?
在嵌入式项目里,最让人头疼的场景之一莫过于设备已经部署到现场,却发现了一个必须修复的Bug,或者需要增加一个新功能。传统的做法是派人去现场,用JTAG或者串口线一个一个地烧录,成本高、效率低,对于部署在偏远地区或数量庞大的设备群来说,这几乎是个不可能完成的任务。固件空中升级(FOTA)技术就是为了解决这个痛点而生的。它让设备能够通过网络或通信接口,远程、安全地更新自身固件,是实现产品“可运营、可维护”能力的基石。
这次我们要拆解的,是基于德州仪器F29H85x这款高性能微控制器的UART FOTA实现方案。你可能会有疑问:现在都202X年了,为什么还用UART这种“古老”的接口做FOTA?原因很实际:可靠、简单、通用。在复杂的工业电磁环境或对成本极其敏感的消费类产品中,UART的硬件简单性和通信可靠性往往是首选。而F29H85x作为TI C2000系列的新成员,集成了硬件安全模块(HSM)和独特的A/B Bank闪存架构,让基于UART的FOTA在安全性和可靠性上达到了新的高度。
简单来说,这个方案的核心是:设备里永远存着两套完整的固件(A区和B区),一套正在运行,另一套用于接收和验证新固件。升级时,新固件通过UART传到空闲区,验证通过后,一次重启就能完成“分区切换”,让新固件接管控制权。整个过程,就像给飞行中的飞机更换引擎,要求绝对平稳、安全、不间断。接下来,我会结合自己实际调试这个例程的经验,带你从设计思路到代码细节,彻底搞懂它。
2. 核心机制深度解析:A/B分区与HSM如何保驾护航
2.1 A/B分区交换机制:实现“无感”升级的基石
F29H85x的FOTA功能,其硬件基础是它的A/B Bank闪存交换机制。你可以把CPU的闪存地址空间想象成两个“视图”:活动视图和更新视图。物理上,有两组闪存Bank(例如FLC1.B0/B1和FLC1.B2/B3),它们可以被动态地映射到这两个视图上。
- 活动区:当前CPU正在执行的代码所在地址空间。
- 更新区(或称非活动区):专门用于接收和存储新固件的地址空间。
关键在于,这个映射关系不是固定的,而是由一个叫做BANKMGMT(Bank Management)的特殊闪存区域来管理的。这个区域存放着决定哪个物理Bank是“活动”状态的关键信息。
这里有个至关重要的细节:Bank Mode。F29H85x支持多种Bank Mode,但只有Bank Mode 1(仅CPU1支持交换)和Bank Mode 3(CPU1和CPU3均支持交换)才启用A/B交换功能。如果你的应用只跑在CPU1上,用Mode 1就够了;如果是双核应用(CPU1+CPU3),则必须配置为Mode 3。这个模式在芯片出厂时或第一次通过CCS编程时就需要设定好,之后FOTA流程会基于此模式工作。
BANKMGMT区域里最重要的三个字段是:
- BANK_STATUS:标识该Bank对(例如B0/B1作为一个对)的内容是否有效。有效值是一个固定的魔数
0x55555555_55555555。如果两个Bank都无效,设备就无法从闪存启动。 - BANK_UPDATE_CTR:一个64位的递减计数器,用于标识固件版本的新旧。数值越小,版本越新。当两个Bank都有效时,BootROM会比较它们的计数器,选择数值更小的(即版本更新的)作为活动Bank。
- BANKMODE:仅存在于CPU1的
BANKMGMT区域,定义了设备当前的Bank Mode,在启动时被加载到SSU_GEN_REGS.BANKMODE寄存器。
触发交换的逻辑:当我们在“更新区”成功写入新固件后,需要让系统在下次重启时切换到新固件。操作就是:去修改“更新区”对应的BANKMGMT区域。具体是将其BANK_UPDATE_CTR设置为一个比当前“活动区”BANKMGMT中的计数器更小的值(通常是减1)。同时,确保其BANK_STATUS有效,且BANKMODE字段正确。设备复位后,BootROM会重新比较计数器,发现“更新区”的版本更“新”,于是执行交换,将原先的“更新区”映射为“活动区”,系统也就自然启动了新固件。
实操心得:Bank Mode配置的坑我曾经在调试双核FOTA时,死活无法触发CPU3的交换,最后发现是Bank Mode设成了Mode 1。Mode 1下,CPU3根本没有分配闪存空间,自然无法进行FOTA。所以,在项目初期,务必根据你的应用核数,通过CCS的Flash插件或调用
Fapi_issueProgBankMode()API,正确配置Bank Mode。这个操作需要擦写闪存,配置完成后必须重启芯片才能生效。
2.2 HSM安全模块:为固件加上“数字指纹”
如果说A/B分区解决了升级的“连续性”问题,那么HSM解决的就是“可信性”问题。在物联网时代,固件被篡改的风险极大。F29H85x的HSM是一个独立的协处理器,专门负责密码学运算和安全启动。
在HS-SE(高安全-安全使能)设备状态下,HSM会深度参与FOTA流程:
- 认证:HSM会使用预置的客户密钥,验证即将写入闪存的新固件镜像的签名(基于X.509证书)。只有签名验证通过的固件才会被放行。
- 编程:在非安全状态(HS-FS)下,由CPU1直接调用Flash API编程。在HS-SE状态下,编程动作本身也由HSM来执行。CPU1只是把接收到的固件数据块传给HSM,HSM在验证后亲自写入闪存。这实现了“隔离执行”,即使CPU1的代码被攻破,攻击者也无法直接写入闪存。
- 完整性检查:全部固件写入后,HSM还会对整个更新区的固件进行一次完整性校验,确保在传输和编程过程中没有发生位翻转等错误。
安全状态迁移:新的F29H85x芯片默认是HS-FS状态,此时HSM使用TI默认密钥,安全启动未强制开启,JTAG调试口也是开放的,方便初期开发。当你准备量产时,需要通过KeyWriter工具将自己的密钥注入HSM,并将设备转换为HS-SE状态。一旦转为HS-SE,JTAG口默认关闭,安全启动强制开启,任何未经签名的固件都将无法运行。这个转换是不可逆的,务必在充分测试后进行。
注意事项:开发与量产的安全流程强烈建议在HS-FS状态下完成所有的功能开发、调试和FOTA逻辑测试。因为此时JTAG可用,调试方便。等所有逻辑都验证无误后,再在最后的量产固件上启用签名,并将设备转为HS-SE状态。TI的TIFS SDK提供了完整的密钥管理和签名工具链(
mcu_rom_image_gen.py等),需要集成到你的构建流程中。
3. 工程实现详解:从双工程结构到无缝跳转
TI提供的示例采用了“引导程序+应用工程”的双工程结构。这种结构非常清晰,将底层的、通用的FOTA引擎与上层的、业务特定的应用代码分离。
3.1 工程结构剖析:SBL与App的分工
Flash-Based UART SBL工程:这是FOTA的核心引擎。它常驻在CPU1闪存起始的一段空间(例如0x10001000开始的前83KB)。它的职责包括:
- 设备初始化(时钟、GPIO、外设)。
- 监听UART命令,解析主机发送的升级指令。
- 执行固件接收、校验(如果使能安全)和编程到“更新区”的完整流程。
- 管理
BANKMGMT区域,在升级完成后为下一次重启配置好交换条件。 - 提供明确的入口点,供应用程序跳转回来触发升级。
FOTA_Example_Application工程:这是一个示例应用,比如一个简单的LED闪烁程序。它的关键在于与SBL的协同:
- 它被链接到SBL之后的高地址空间(例如0x10020000),与SBL共存于闪存中。
- 它在自己的主循环中,依然监听UART。当收到特定的FOTA命令(如
DFU_CPU1)时,它不是自己处理,而是直接跳转到SEL工程中预留的特定函数入口地址。 - 跳转后,CPU的控制权交还给SBL,由SBL接管后续的所有升级操作。而此时,应用的中断服务程序(如那个定时器触发的LED闪烁)并不会被禁用,SBL会在处理UART数据包的间隙,继续响应这些中断。这就实现了“后台静默升级”,用户可能只会看到LED闪烁稍微慢了一点,但功能并未中断。
3.2 链接与后构建步骤:让两个工程合二为一
让两个独立的工程编译后能合并成一个完整的、可一次烧录的镜像,是工程配置的关键。这里主要依靠链接器命令文件和后构建步骤。
链接器配置: 在SBL工程的.cmd文件里,你需要仔细规划内存布局。必须为SBL代码、App代码以及SBL内部用于跳转的“桩函数”分配好互不重叠的地址空间。例如:
MEMORY { ... CPU1_FLASH_RP0 : origin = 0x10001000, length = 0x014000 /* 83KB for SBL */ CPU1_APP : origin = 0x10020000, length = 0x060000 /* 留给应用程序的空间 */ ... } SECTIONS { ... /* SBL的代码段放在CPU1_FLASH_RP0 */ .text : > CPU1_FLASH_RP0 /* 一个特殊的输出段,比如叫`.fota_app`,它本身是空的,但占据了CPU1_APP的空间 */ .fota_app : > CPU1_APP, type = DSECT /* DSECT类型表示该段初始为空 */ ... }同时,在SBL的C源文件中,你需要使用#pragma或__attribute__来定义一个函数,并将其定位到这个空的.fota_app段,这个函数就是应用跳转回来的入口。
后构建步骤: 这是自动化合并的魔法所在。流程如下:
- 编译App:首先编译
FOTA_Example_Application,生成一个.out文件。 - 转换为二进制:使用
armofd和armhex工具(或TI的objcopy)将.out文件中的代码/数据段提取出来,生成一个纯净的二进制文件firmware.bin。 - 编译SBL:编译SBL工程,生成SBL的
.out文件。注意,此时SBL的.fota_app段是空的。 - 合并二进制:使用
objcopy工具,将上一步生成的firmware.bin文件,作为数据更新到SBL的.out文件的.fota_app段中。命令类似于:ti-cgt-arm.objcopy --update-section .fota_app=firmware.bin sbl_with_empty_app.out sbl_with_app.out - 安全签名(HS-SE必需):如果是安全启动,需要使用TI的
mcu_rom_image_gen.py脚本,对合并后的sbl_with_app.out(或对应的.bin)进行签名,生成X.509证书,并将证书再更新到镜像的特定证书段。 - 生成最终镜像:最后,将处理后的
.out文件再次转换为可供烧录的.bin或.hex文件。
经过这些步骤,你得到的就是一个包含了SBL引导程序和用户应用程序的单一镜像文件,可以直接烧录到设备的“活动区”。
踩坑记录:地址对齐与跳转指令在实现从App跳转回SBL时,我最初直接使用函数指针调用,结果发生了硬件错误。原因是C函数调用会使用
BL等指令,这些指令有相对跳转的范围限制。更可靠的做法是,将SBL中的入口点函数地址定义为绝对地址常量,在App中使用汇编指令BX或直接设置PC寄存器来跳转。TI的示例中使用的CPU_jumpToAddr((uint32_t)ENTRY_POINT_ADDR)函数,其内部很可能就是这样的汇编跳转。务必确保你跳转的地址,正是SBL中那个特殊输出段的起始地址。
4. 完整FOTA操作流程与主机端工具
4.1 设备端工作流程(以HS-FS状态为例)
让我们跟随一次完整的非安全FOTA升级,看看代码是如何流动的:
- 上电/复位:设备从Flash启动,BootROM根据
BANKMGMT信息找到有效的SBL,并开始执行。 - SBL初始化:SBL初始化系统时钟、GPIO、UART等外设。它会读取Data Flash中存储的“上一次成功升级的Bank Mode”,并与当前硬件Bank Mode比较。
- 如果相同:启动一个5秒的“升级等待窗口”,通过UART打印提示信息,等待主机发送升级命令。
- 如果不同:说明Bank Mode被更改过(例如从Mode 1换到了Mode 3),则进入无限等待升级状态,不尝试启动旧应用。这是一种安全策略,强制要求在新模式下进行首次升级。
- 决策点:
- 如果在超时前收到升级命令:SBL进入固件接收与编程循环。
- 如果超时未收到命令:SBL通过函数指针跳转到应用程序的入口地址(即
0x10020000),将控制权交给用户App。
- 应用程序运行:用户App(如LED闪烁)开始运行,并保持UART中断使能。主循环可能也在监听串口。
- 运行时触发升级:设备在运行中收到主机发来的
DFU_CPU1命令。App的中断服务程序收到此命令,设置一个标志位。App的主循环检测到这个标志位,立刻跳转回SEL中指定的FOTA处理函数入口点。 - 后台升级:SBL重新获得控制权,开始通过UART接收新的固件数据包,同时仍然响应App配置的定时器中断(LED继续闪烁)。它调用Flash API,将接收到的数据编程到“非活动”的Flash Bank中。
- 更新Bank管理信息:固件编程完毕后,SBL擦写“非活动区”对应的
BANKMGMT区域,将其BANK_UPDATE_CTR设置为比当前活动区更小的值。 - 返回与复位:SBL将当前Bank Mode记录到Data Flash(用于下次启动比较),然后可以返回App,或者直接触发一个软件复位。更常见的做法是通知主机“升级成功,请重启设备”,由用户或主机命令触发硬件复位。
- 重启与切换:设备复位。BootROM再次读取
BANKMGMT,发现原先的“非活动区”计数器值更小,于是执行Bank Swap。新的固件被映射到活动地址空间,系统启动后,运行的就是刚刚升级的新版本了。
4.2 主机端通信协议与工具
TI的示例配套了一个Python脚本作为主机端编程工具。理解其通信协议对于调试和二次开发至关重要。协议通常是简单的基于字节包的请求-响应模型。
一个典型的命令包可能包含:
- 同步头:如
0x08 0x19,用于标识帧开始。 - 命令字:如
0xA0代表PING,0xA1代表DFU_CPU1(开始CPU1固件升级)。 - 数据长度:后续数据段的长度。
- 数据载荷:具体的参数或固件数据。
- 校验和:CRC8或累加和,用于验证数据完整性。
主机端升级脚本的核心步骤:
- 连接与握手:打开串口,发送
PING命令,等待设备回复特定的响应(如PONG),确认通信链路及设备状态正常。 - 发送升级命令:发送
DFU_CPU1或DFU_CPU3命令,告诉设备:“准备好,我要开始发送CPU1/CPU3的固件了”。 - 发送固件数据:将待升级的
.bin文件分拆成多个固定大小的数据包(如512字节),依次发送。每发送一个包,等待设备回复“ACK”确认,再发送下一个。如果收到“NACK”,则重发该包。 - 验证与结束:发送完所有数据包后,发送一个“结束传输”命令。设备会进行内部校验(在HS-SE状态下会要求HSM做完整性检查),并回复最终成功或失败的状态。
- 触发重启:主机发送设备复位命令,或提示用户手动重启设备,以完成Bank Swap。
调试技巧:自己写个简单的主机测试工具在开发初期,我强烈建议你基于Python的
pyserial库,自己编写一个精简版的主机工具。不一定需要像TI示例那样功能完整,但至少要能完成:连接、握手、发送一个简单的固件包(比如一个只有几条指令的小程序)。这能帮助你快速隔离问题:是设备端UART驱动有问题?是命令解析出错?还是Flash编程逻辑有bug?自己写的工具,添加调试日志、单步控制都会方便得多。
5. 开发与调试中的常见问题与解决方案
在实际动手实现这个FOTA框架时,你几乎一定会遇到下面这些问题。这里我把踩过的坑和解决方法总结出来。
5.1 链接地址冲突与内存布局错误
问题现象:程序编译正常,但烧录后运行,要么直接跑飞,要么在跳转到App或跳回SBL时发生硬件错误。
排查思路:
- 检查
.map文件:这是最重要的调试文件。分别打开SBL工程和App工程编译生成的.map文件。- 确认SBL的代码、数据段是否完全位于你规划的区域(如
0x10001000到0x1001AFFF)。 - 确认App的代码、数据段是否完全位于你规划的区域(如
0x10020000开始),并且与SBL的区域没有任何重叠。 - 特别检查那个用于跳转的“桩函数”(如
CPU1_FOTA_ENTRY)的绝对地址,是否与App代码中跳转时使用的地址完全一致。
- 确认SBL的代码、数据段是否完全位于你规划的区域(如
- 检查链接器命令文件:确保
MEMORY定义的长度足够,且SECTIONS分配没有越界。注意DSECT类型段不占用实际的二进制空间,但它声明了这块地址已被占用,链接器不会将其他内容放进去。 - 检查后构建脚本:确认合并二进制文件的命令是否正确。使用
fromelf或objdump工具查看最终生成的合并后的.out或.bin文件,用十六进制编辑器查看关键地址(如App起始地址0x10020000)处是否有正确的代码(通常是第一条指令的机器码)。
解决方案:仔细核对并统一SBL和App工程中的内存地址定义。最好使用一个公共的头文件来定义这些绝对地址,如:
// fota_memory_map.h #define SBL_BASE_ADDR 0x10001000 #define APP_BASE_ADDR 0x10020000 #define JUMP_TO_FOTA_ENTRY (SBL_BASE_ADDR + 0x5000) // 假设入口点在SBL内偏移0x5000处确保SBL的.cmd文件、App的.cmd文件以及双方源代码中跳转用的地址,都引用自同一个头文件。
5.2 Flash编程失败或校验错误
问题现象:主机显示发送成功,但设备端报告编程失败,或重启后新固件未运行。
排查思路:
- Flash驱动与算法:确保你使用的Flash API(
Fapi_xxx系列函数)与你的芯片型号和Flash Bank完全兼容。F29H85x的Flash控制器可能与老型号C2000不同。 - 擦除操作:在编程前,是否对目标Flash Sector执行了擦除?Flash只能将bit从1写成0,擦除是将整个Sector置1。编程前必须擦除。
- 地址对齐:Flash编程通常有最小写入单位(如128-bit)。确保你发送的数据包大小和起始地址是对齐的。
- 数据缓冲区:Flash编程函数通常需要一个RAM中的源数据缓冲区。确保这个缓冲区地址是正确且可访问的。如果是在中断服务程序中调用,注意栈空间是否足够。
- 干扰中断:在擦除/编程Flash期间(这是一个相对耗时的操作),必须禁止全局中断吗?查阅芯片手册。有些芯片要求,有些则允许。F29H85x的示例中,在编程期间似乎没有禁用所有中断,但为了绝对安全,在调用
Fapi_issueAsyncProgrammingWithStatus()等函数期间,可以考虑临时提升中断优先级或屏蔽相关中断。 - 电源稳定性:Flash编程对电源电压非常敏感。在调试阶段,尤其是使用调试器供电时,确保电源纹波在合理范围内。不稳定的电源会导致编程数据错误。
解决方案:在Flash编程的关键函数周围添加详细的日志输出(通过UART),记录每一步的返回状态(Fapi_StatusType)。TI的Flash API通常会返回非常具体的错误码,如Fapi_Error_FlashRegionsNotAligned等,根据错误码能快速定位问题。
5.3 双核(CPU3)FOTA的特殊问题
问题现象:CPU1的FOTA正常,但CPU3的固件升级后不运行。
排查思路:
- Bank Mode确认:这是最常见的原因。必须将芯片设置为Bank Mode 3,CPU3才有自己独立的、可交换的Flash Bank。
- CPU3启动流程:在Bank Mode 3下,CPU3的代码存放在哪里?它的入口点是什么?在SBL中,你需要先将CPU3从复位状态释放(通过配置
CPUSYS相关寄存器),然后才能将固件编程到CPU3的Flash区域。TI示例中take_cpu3_out_of_reset()函数就是干这个的。 - 独立的BANKMGMT:CPU3有自己的
BANKMGMT区域(在FRI-2中)。在触发CPU3的Bank Swap时,你需要修改的是CPU3对应的BANKMGMT区域,而不是CPU1的。命令包中的目标地址需要正确指向CPU3的更新区域。 - 内存隔离:确保CPU1和CPU3的代码、数据地址空间没有冲突。仔细规划两者的链接文件。
5.4 从HS-FS切换到HS-SE状态后的异常
问题现象:在HS-FS下调试正常的FOTA流程,切换到HS-SE状态后,升级失败,甚至设备“变砖”(无法启动)。
排查思路与预防:
- 签名证书:HS-SE状态下,所有在Flash中执行的代码(包括SBL和App)都必须经过正确的签名。确保你的后构建步骤正确调用了
mcu_rom_image_gen.py,并使用了与注入HSM的密钥对应的签名密钥。 - 证书注入:生成的X.509证书必须被正确编程到Flash镜像的特定证书区域。这个区域在链接文件中定义(通常是
.certs段)。后构建脚本需要将生成的证书二进制文件更新到这个段。 - 调试接口:转为HS-SE后,JTAG可能被禁用。这意味着你无法再通过CCS连接芯片进行调试。在转换前,务必确认你的SBL和App在HS-FS下已完全稳定,并且UART日志输出功能是可靠的,这是你后续在HS-SE状态下唯一的调试窗口。
- 回退方案:在HS-SE状态下,如果新固件签名错误或启动失败,设备会卡在BootROM。设计一个“安全恢复模式”至关重要。例如,可以通过一个特定的GPIO引脚电平,让BootROM跳过安全启动验证,从UART接收一个急救程序。这需要在项目初期就和硬件同事一起规划。
实现一个稳定可靠的FOTA功能,是嵌入式产品迈向成熟的关键一步。F29H85x的方案提供了从硬件安全模块到灵活内存架构的强力支持。整个过程中,最耗费时间的往往不是核心逻辑的编写,而是对内存布局、链接脚本、构建后处理以及安全流程这些“周边工程”的细致把握。多花时间理解.map文件,用简单的测试用例验证每一个环节(比如先实现一个只能接收和回显数据的UART SBL),再逐步增加Flash编程、Bank管理、安全签名等复杂功能,稳扎稳打,最终你得到的将不仅仅是一个升级功能,而是对整个嵌入式系统启动链、内存管理和安全机制的深刻理解。