NCS下基于MCUboot的双通道DFU实现:BLE与UART固件升级实战
1. 项目背景与核心需求:为什么要在NCS下做双通道DFU?
如果你正在用Nordic的nRF52或nRF53系列芯片做产品,并且跑的是基于Zephyr RTOS的NCS(nRF Connect SDK),那么“固件升级”这个功能迟早会摆在你面前。这不是一个“有就更好”的锦上添花,而是一个“没有就麻烦”的刚需。想象一下,产品已经部署到成百上千个终端,你发现了一个必须修复的Bug,或者需要增加一个吸引用户的新功能。难道要派人一个个去现场拆机、用J-Link烧录吗?显然不现实。这时候,无线、远程的固件升级(DFU, Device Firmware Update)能力就成了产品生命线的保障。
Nordic的生态提供了强大的DFU支持,但在NCS这个相对较新的框架下,很多朋友会感到迷茫。官方文档虽然全面,但信息分散;社区方案五花八门,但未必适合你的具体场景。特别是当你的设备同时具备蓝牙和串口(比如通过USB转TTL或RS-232)时,你自然会想:能不能让用户自己选择用手机App(BLE)还是电脑上位机(UART)来升级?这就是“BLE加UART”双通道DFU的核心价值——提供灵活、可靠的升级路径,提升用户体验和产品可维护性。
我最近在一个工业传感器项目上就实现了这个需求。设备安装在难以触及的角落,通过BLE连接手机进行常规配置和数据查看很方便,但当固件包较大(超过1MB)时,BLE的传输速率和稳定性就成了瓶颈。同时,设备也预留了一个调试UART接口。于是,我们设计了一套方案:平时小版本更新用BLE,快速便捷;大版本更新或BLE连接不稳定时,则可以用电脑通过UART进行,速度更快、更可靠。这套基于NCS-Zephyr的方案跑下来非常稳定,今天我就把其中的核心设计、实操步骤以及我踩过的那些坑,毫无保留地分享出来。
2. NCS DFU基础架构深度解析:MCUboot与镜像管理
在动手写代码之前,我们必须彻底理解NCS下DFU的基石——MCUboot。它不是Nordic独有的,而是一个来自Zephyr社区的、经过工业级验证的通用引导加载程序(Bootloader)。你可以把它想象成电脑的BIOS,但它更智能,专门负责验证和启动你的应用程序固件,并管理固件升级。
2.1 MCUboot的核心工作流程
MCUboot遵循“A/B双镜像”或“直接XIP(就地执行)”的升级策略。在资源相对紧张的嵌入式设备上,我们通常使用交换模式(Swap Mode)。我们以nRF52840(1MB Flash)为例,看看Flash是如何被划分的:
| Flash区域 | 起始地址(示例) | 大小 | 用途说明 |
|---|---|---|---|
| MCUboot | 0x0000 0000 | 48KB | 引导程序本身。负责初始化、镜像验证、升级逻辑。 |
| Image-0 (插槽0, Primary) | 0x0000 C000 | 448KB | 存放当前正在运行或下次将要运行的固件镜像。 |
| Image-1 (插槽1, Secondary) | 0x0007 C000 | 448KB | 存放通过DFU接收到的新固件镜像。升级时,MCUboot会将此镜像与插槽0的镜像交换。 |
| 暂存区 (Scratch) | 0x000F 8000 | 4KB | 一个很小的区域,用于在交换镜像时临时存储元数据,确保掉电安全。 |
整个升级过程是这样的:
- 初始状态:设备运行在插槽0的固件(App)。
- 下载新固件:你的App通过BLE或UART,将新的
.bin或.hex文件写入到插槽1。注意:MCUboot和App都“知道”插槽1的地址,下载过程由App控制。 - 触发升级:下载完成后,App需要向MCUboot“打报告”。它通过一个特定的API(如
boot_request_upgrade)或直接设置Flash中的升级标志位,告诉MCUboot:“插槽1里有个新家伙,下次启动你处理一下。” - 重启与交换:设备重启。MCUboot在启动时检查到升级标志,开始执行交换操作:将插槽0的旧镜像与插槽1的新镜像进行交换(实际是交换镜像的“位置”信息,并非物理搬运全部数据,效率很高)。如果交换成功,它清除标志,然后从新的插槽0(即原来的新固件)启动。
- 确认与回滚:新固件启动后,应该尽快调用
boot_write_img_confirmed()来“确认”本次升级成功。如果新固件启动失败(比如卡死在某个地方,无法发出确认),MCUboot会在下一次重启时,自动执行“回滚”,换回之前稳定运行的旧版本。这是MCUboot提供的最重要的安全特性之一。
2.2 在NCS中配置MCUboot
理解了原理,配置就有的放矢了。NCS通过Kconfig和设备树(DTS)来管理这些配置,这比直接修改代码要清晰和安全得多。
首先,在你的项目目录(如app)下,你需要确保prj.conf文件启用了MCUboot和必要的功能:
# 启用MCUboot引导程序 CONFIG_BOOTLOADER_MCUBOOT=y # 启用对固件镜像的签名验证(强烈建议生产环境开启) CONFIG_MCUBOOT_SIGNATURE_KEY_FILE=\"root-rsa-2048.pem\" # CONFIG_MCUBOOT_ENCRYPTION_KEY_FILE=\"enc-rsa-2048.pem\" # 如果需要加密可启用 # 启用串口控制台,方便MCUboot打印调试信息 CONFIG_SERIAL=y CONFIG_UART_CONSOLE=y CONFIG_CONSOLE=y # 启用Flash操作相关的驱动 CONFIG_FLASH=y CONFIG_FLASH_PAGE_LAYOUT=y CONFIG_STREAM_FLASH=y CONFIG_IMG_MANAGER=y注意:
MCUBOOT_SIGNATURE_KEY_FILE指向你的RSA私钥文件。你需要使用imgtool(NCS自带)生成一对密钥,私钥用于在编译时对固件签名,公钥会被编译进MCUboot。MCUboot在启动时会用公钥验证镜像签名,只有签名正确的镜像才会被启动,这从根本上防止了恶意固件的刷入。
其次,Flash的分区布局是在设备树中定义的。你通常不需要从头写,NCS为Nordic芯片提供了预定义的分区。但你必须理解并检查它。查看<ncs_root>/zephyr/boards/arm/<your_board>/<your_board>.dts文件,找到类似下面的部分:
&flash0 { partitions { compatible = "fixed-partitions"; #address-cells = <1>; #size-cells = <1>; boot_partition: partition@0 { label = "mcuboot"; reg = <0x00000000 0x0000C000>; // 48KB }; slot0_partition: partition@c000 { label = "image-0"; reg = <0x0000C000 0x00070000>; // 448KB }; slot1_partition: partition@7c000 { label = "image-1"; reg = <0x0007C000 0x00070000>; // 448KB }; scratch_partition: partition@ec000 { label = "image-scratch"; reg = <0x000EC000 0x00004000>; // 16KB, 用作暂存区 }; // ... 可能还有文件系统等其他分区 }; };你的App代码需要通过标签(如slot1_partition)来获取次级插槽的起始地址和大小,以便向正确的位置写入数据。这是连接MCUboot和你的DFU传输逻辑的桥梁。
3. 构建双通道传输层:BLE DFU与UART DFU的实现
MCUboot准备好了,它只关心“有没有一个有效的镜像在插槽1里”。至于这个镜像是怎么来的,它不管。这就是我们传输层要做的:通过BLE或UART,把固件文件安全、可靠地搬运到插槽1。
3.1 BLE DFU实现:基于Nordic的DFU服务
最省心的方式是使用Nordic提供的Secure DFU Service。这是一个标准的GATT服务,定义了用于控制DFU过程(如选择、创建、接收数据)的特性和用于传输固件包数据的特性。手机端可以使用Nordic的nRF Connect App进行测试,或者集成Nordic提供的移动端SDK(nRF-DFU)到你的App中。
在NCS应用中启用它非常简单:
# 在prj.conf中 CONFIG_BOOTLOADER_MCUBOOT=y CONFIG_MCUBOOT_BOOTLOADER_MODE_DIRECT_XIP=n # 确保使用交换模式 CONFIG_IMG_MANAGER=y CONFIG_IMG_ERASE_PROGRESSIVELY=y # 渐进式擦除,避免长时间阻塞 CONFIG_MCUBOOT_IMG_MANAGER=y # 启用BLE和必要的GATT服务 CONFIG_BT=y CONFIG_BT_PERIPHERAL=y CONFIG_BT_DEVICE_NAME="MyDFUDevice" CONFIG_BT_DFU_SMP=y # 这是关键!启用通过BLE的DFU服务 CONFIG_BT_DFU_SMP_SECURITY_ENABLED=y # 启用安全连接,推荐 CONFIG_BT_GATT_DYNAMIC_DB=y编译并烧录后,你的设备就会广播并包含DFU服务。用nRF Connect App连接后,你会发现一个名为“Secure DFU Service”的服务,里面包含“DFU Control Point”、“DFU Packet”等特性。你可以通过App直接选择.bin或.hex文件进行升级。
但是,这里有一个巨大的“坑”:Nordic的BT_DFU_SMP服务,其底层默认使用的是MCUmgr协议。MCUmgr是一个设备管理协议,它传输的并不是原始的二进制镜像,而是一个经过CBOR编码、包含哈希、签名等信息的SMP数据包。这意味着,你不能简单地把编译输出的zephyr.bin文件通过这个服务发送。你需要先用imgtool生成一个适合MCUmgr传输的.bin文件,或者使用mcumgr命令行工具。
# 在NCS环境下的操作 # 1. 首先,像往常一样编译你的应用,这会生成zephyr.signed.bin(已签名) west build -b nrf52840dk_nrf52840 # 2. 使用imgtool生成适合MCUmgr的镜像文件 # 假设你的编译输出在build/zephyr目录 cd build/zephyr imgtool create --align 4 --version 1.2.3 --header-size 32 --slot-size 0x70000 --pad-header zephyr.signed.bin dfu_image.bin生成的dfu_image.bin才是可以通过BLE DFU服务正确传输的文件。手机App(如果使用nRF SDK)内部也会做类似的处理。如果你尝试直接发送zephyr.signed.bin,升级过程很可能会在最后验证失败。
3.2 UART DFU实现:自定义简单可靠的协议
当BLE因为环境、速率或功耗限制不合适时,UART就派上用场了。MCUboot本身支持通过串口进行升级(MCUboot的串口恢复模式),但那通常需要让设备进入一种特殊的引导模式。我们这里要实现的是:在应用程序正常运行期间,通过一个额外的UART接口接收固件数据。这给了我们最大的灵活性。
我们需要自己设计一个简单的、基于UART的DFU协议。它不需要像MCUmgr那么复杂,核心目标是:可靠地将原始二进制数据写入插槽1的Flash。下面是一个我经过实践验证的简单协议框架:
协议帧格式:为了应对UART的流式特性和可能的数据错乱,我们必须设计帧结构。
[帧头 0xAA 0x55] [命令字] [数据长度L] [数据...] [校验和]- 帧头:固定的两个字节,用于帧同步。
- 命令字:定义操作,如
CMD_START_DFU(0x01),CMD_DATA(0x02),CMD_FINISH(0x03),CMD_RESET(0x04)。 - 数据长度:后续数据段的长度。
- 数据:有效载荷,对于
CMD_DATA,就是固件数据的片段。 - 校验和:简单的字节和校验或CRC8,用于验证帧完整性。
应用层逻辑:
- 上电后,UART DFU功能处于监听状态。
- 上位机首先发送
CMD_START_DFU帧,其中数据段可以包含固件总大小、版本号等。设备端收到后,需要擦除整个插槽1分区。这是一个关键且耗时的操作,一定要在开始传输数据前完成。 - 上位机将固件文件(
zephyr.signed.bin)分片,例如每片256字节,依次发送CMD_DATA帧。设备端收到后,校验帧,然后将数据写入插槽1的当前偏移地址,并更新偏移量。 - 传输完成后,上位机发送
CMD_FINISH帧。设备端计算已写入数据的哈希值(如SHA-256),并与帧中携带的哈希值对比。如果一致,则向MCUboot设置升级标志(调用boot_request_upgrade函数)。 - 最后,上位机发送
CMD_RESET,设备重启,MCUboot执行镜像交换。
Zephyr中的关键代码片段:
#include <drivers/flash.h> #include <storage/flash_map.h> #include <dfu/mcuboot.h> #include <sys/crc.h> // 获取slot1分区的信息 const struct flash_area *fa; int err = flash_area_open(FIXED_PARTITION_ID(slot1_partition), &fa); if (err) { /* 处理错误 */ } size_t offset = 0; // 当前写入偏移 // 在收到CMD_START_DFU后,擦除整个分区 err = flash_area_erase(fa, 0, fa->fa_size); // 这是一个阻塞操作,时间较长! if (err) { /* 发送错误响应给上位机 */ } // 在收到CMD_DATA帧后,写入数据 err = flash_area_write(fa, offset, data_buf, data_len); if (err) { /* 发送错误响应 */ } offset += data_len; // 在收到CMD_FINISH后,验证并设置升级标志 if (hash_matches) { // 注意:boot_request_upgrade需要MCUboot的模式支持 int rc = boot_request_upgrade(BOOT_UPGRADE_TEST); // 或BOOT_UPGRADE_PERMANENT if (rc == 0) { // 发送成功响应 } } // 不要忘记最后关闭flash_area flash_area_close(fa);
重要提示:
flash_area_erase和flash_area_write是阻塞操作,在写入Flash期间,CPU无法处理其他任务(包括响应UART中断!)。这会导致数据丢失。解决方案:
- 使用双缓冲:开辟两个缓冲区(A和B)。当UART DMA或中断填满缓冲区A时,启动一个线程(如
k_work)将A的数据写入Flash,同时UART继续向缓冲区B填充数据。- 控制帧速率:上位机在发送下一帧数据前,必须等待设备端的“ACK”响应。设备端在完成Flash写入后,才发送ACK。
- 启用流控:如果硬件支持(RTS/CTS),使用硬件流控是最可靠的方式。
4. 双通道协同与状态机设计
现在我们有BLE和UART两个升级通道,它们不能同时工作,否则会争抢Flash资源,导致数据错乱。我们需要一个全局的DFU状态机来管理。
一个清晰的状态机设计如下:
- IDLE:正常应用运行状态,两个通道都监听但未激活。
- BLE_DFU_ACTIVE:BLE连接进入DFU模式,开始接收SMP包。此时应锁定资源,UART DFU收到任何启动命令都应返回“忙”错误。
- UART_DFU_ACTIVE:UART收到有效的
CMD_START_DFU帧,进入该状态。应暂停或拒绝新的BLE DFU连接请求。 - PROCESSING:固件数据接收完成,正在计算哈希、设置升级标志等。此状态任何新的传输请求都应被拒绝。
- PENDING_RESET:升级标志已设置,等待设备重启。可以通知用户“升级成功,设备即将重启”。
这个状态机可以用一个全局变量dfu_state来实现,所有相关的函数在操作前都必须检查状态。例如,在UART的解析函数中:
if (dfu_state != IDLE && dfu_state != UART_DFU_ACTIVE) { send_uart_response(STATUS_BUSY); return; } switch(cmd) { case CMD_START_DFU: if (dfu_state == IDLE) { dfu_state = UART_DFU_ACTIVE; // ... 初始化Flash操作 } break; case CMD_DATA: if (dfu_state == UART_DFU_ACTIVE) { // ... 处理数据 } break; // ... }同时,在BLE DFU服务启动的回调函数中,也要将状态设置为BLE_DFU_ACTIVE。这样,两个通道就实现了互斥访问,保证了升级过程的安全。
5. 实战中的“坑”与优化策略
理论走通只是第一步,实际调试中会遇到各种问题。下面是我总结的几个关键点和优化建议:
5.1 Flash操作阻塞系统与看门狗复位
这是最常遇到的问题。无论是BLE还是UART DFU,向Flash写入几KB的数据都可能需要几十毫秒。在这期间,如果看门狗(WDT)没有被喂食,系统就会复位。
解决方案:
- 分片与小块写入:不要一次性写入整个帧的数据。将每帧数据再分成更小的块(如64字节)进行写入,在每小块写入间隙喂狗。
- 使用线程和信号量:将Flash写入操作放在一个低优先级的后台线程中。主线程或中断服务程序收到数据后,将数据放入队列,并释放一个信号量。后台线程等待信号量,然后从队列取出数据写入Flash。在后台线程的循环中定期喂狗。
- 调整看门狗超时时间:在
prj.conf中适当增加看门狗的超时时间,但这不是根本解决办法。CONFIG_WDT_NRFX=y CONFIG_WDT_NRF_TIMEOUT=8000 # 超时时间设为8秒
5.2 电源稳定性与掉电保护
升级过程中掉电,可能导致Flash中的数据处于不一致状态,最坏情况是设备“变砖”。
MCUboot的交换机制和暂存区设计已经提供了很好的掉电保护。但为了更安全:
- 在UART协议中增加断点续传:
CMD_START_DFU帧可以携带一个“起始偏移量”参数。如果设备在升级中意外复位,重新连接后,它可以向上位机报告当前已写入的偏移量。上位机可以从该偏移量处继续发送剩余数据,而无需重新擦除和传输整个文件。这需要设备在写入Flash时,定期将当前偏移量保存到非易失性存储(如Flash的另一个小分区或FRAM)中。 - 使用
CONFIG_IMG_ERASE_PROGRESSIVELY:这个配置项会让MCUboot在交换时按需擦除页,而不是一开始就擦除整个目标区域,可以缩短交换过程中的“危险窗口期”。
5.3 镜像验证失败问题排查
升级后重启,MCUboot日志(如果使能了串口输出)报错“Image not valid”或“Signature failed”。
排查步骤:
- 检查签名密钥:确保你编译MCUboot时使用的公钥(
root-rsa-2048.pem.pub)和编译应用时使用的私钥是配对的。一个常见的错误是:修改了密钥文件,但忘记清理build目录重新编译MCUboot,导致MCUboot里的公钥还是旧的。 - 检查Flash写入完整性:在UART DFU中,确保每一帧数据都正确写入了Flash。可以在
CMD_FINISH时,从Flash中重新读取整个插槽1的数据,计算哈希并与上位机发送的哈希对比。也可以在代码中开启调试,将每个写入块的地址和数据和校验打印出来。 - 检查镜像头:使用
imgtool的info命令检查你生成的dfu_image.bin或zephyr.signed.bin文件。
检查镜像大小是否超过插槽1的分区大小,检查版本号等信息是否正确。imgtool info zephyr.signed.bin - 检查分区地址:这是最隐蔽的坑!确保你的App在写入Flash时,使用的
slot1_partition的地址,与MCUboot认为的slot1_partition地址完全一致。它们都来自设备树文件。务必检查你的应用项目是否使用了正确的设备树覆盖(overlay)文件,没有错误地覆盖分区表。
5.4 性能优化:提升UART DFU速度
UART的波特率(比如921600)是理论上限,实际速度受限于Flash写入速度、协议开销和流控。
- 增大数据帧长度:在保证可靠性的前提下,将每帧的数据段长度从256字节提高到512甚至1024字节,可以减少协议头尾的开销比例。
- 启用DMA:如果MCU的UART支持DMA,务必在Zephyr中启用它。这可以极大减少CPU中断负载,让CPU有更多时间处理Flash写入和喂狗。
CONFIG_UART_ASYNC_API=y CONFIG_UART_0_ASYNC=y CONFIG_UART_0_NRF_HW_ASYNC=y CONFIG_UART_0_NRF_HW_ASYNC_TIMER=2 # 指定使用的Timer实例 - 优化Flash写入:
flash_area_write函数内部会处理跨页写入。但如果你能保证每次写入的数据块都是页大小(如4KB)的整数倍,并且地址对齐,效率会最高。不过这通常需要在上位机端做分片对齐。
6. 完整开发、测试与生产流程
最后,我们把所有环节串起来,形成一个可重复的流程。
环境搭建与密钥生成:
# 安装NCS和工具链(略) # 生成签名密钥对 cd <your_project> imgtool keygen -k root-rsa-2048.pem -t rsa-2048 # 这会生成私钥root-rsa-2048.pem和公钥root-rsa-2048.pem.pub编译MCUboot:
# 进入MCUboot目录(NCS中通常位于bootloader/mcuboot) west build -b nrf52840dk_nrf52840 bootloader/mcuboot/boot/zephyr west flash # 将MCUboot烧录到设备编译并生成应用镜像:
# 进入你的应用目录 west build -b nrf52840dk_nrf52840 # 编译后,在build/zephyr下会生成zephyr.signed.bin(已签名) # 如果需要用于UART DFU,这个文件可以直接用。 # 如果需要用于BLE DFU (MCUmgr),需要转换: cd build/zephyr imgtool create --align 4 --version 1.2.3 --header-size 32 --slot-size 0x70000 --pad-header zephyr.signed.bin dfu_image.bin测试BLE DFU:
- 使用nRF Connect App连接设备。
- 进入DFU服务,选择
dfu_image.bin文件进行升级。 - 观察App日志和设备串口日志(如果MCUboot开启了
CONFIG_MCUBOOT_SERIAL)。
测试UART DFU:
- 编写一个简单的Python上位机脚本,使用
pyserial库,按照你定义的协议发送zephyr.signed.bin文件。 - 脚本应包含帧封装、校验和计算、流控等待(等待设备ACK)和断点续传逻辑。
- 通过串口助手观察设备打印的调试信息。
- 编写一个简单的Python上位机脚本,使用
生产部署:
- 产线首先烧录MCUboot。
- 然后通过UART(高速、可靠)烧录第一个版本的应用程序(包含完整的双通道DFU功能)。
- 此后,设备在客户端可以通过BLE或UART进行任意次数的升级。
- 关键:务必保管好你的私钥(
root-rsa-2048.pem),泄露意味着任何人都可以为你的设备签名固件。公钥则被编译进MCUboot,无需保密。
实现NCS下的双通道DFU,就像为你的设备安装了一个永不停机的“软件加油站”。BLE提供了随时随地、无接触的升级便利性,而UART则作为高速、稳定的后备通道,确保了在最恶劣通信环境下升级的可行性。整个方案的核心在于理解MCUboot的分区管理和升级逻辑,然后围绕它构建可靠的数据传输层。过程中最耗费时间的往往是调试Flash写入、协议同步和状态管理这些细节。希望我分享的这些具体步骤和踩坑经验,能帮你更快地打通这条关键路径。当你第一次通过手机App成功让设备在几十秒内完成功能更新时,那种成就感会让你觉得所有的调试都是值得的。
