nRF Connect SDK(NCS)/Zephyr固件升级详解 – 重点讲述MCUboot和蓝牙空中升级

############ 本文更新于2025年7月29日,对齐nRF Connect SDK v3.0.0 ############

如何在nRF Connect SDK(NCS)中实现蓝牙空中升级?MCUboot和B0两个Bootloader有什么区别?MCUboot升级使用的image格式是怎么样的?什么是SMP协议?CBOR编码如何解读?NCS可不可以进行单bank升级?可不可以把一个nRF5 SDK应用升级到NCS应用?MCUboot拷贝操作中的swap和overwrite有什么区别?为什么说MCUboot升级永远都不可能变砖?本文将对以上问题进行阐述。 

1.概述

先讲一下DFU和OTA的概念。DFU(Device Firmware Update),就是设备固件升级的意思,而OTA(Over The Air)是实现DFU的一种方式而已,准确说,OTA的全称应该是OTA DFU,即通过空中无线方式实现设备固件升级。只不过大家为了方便起见,直接用OTA来指代固件空中升级(有时候大家也将OTA称为FOTA,即Firmware OTA,这种称呼意思更明了一些)。只要是通过无线通信方式实现DFU的,都可以叫OTA,比如4G/WiFi/蓝牙/NFC/Zigbee/NB-IoT,他们都支持OTA。DFU除了可以通过无线方式(OTA)进行升级,也可以通过有线方式进行升级,比如通过UART,USB或者SPI通信接口来升级设备固件。

不管采用OTA方式还是有线通信方式,DFU包括后台式(background)和非后台式两种模式。后台式DFU,又称静默式DFU(Silent DFU),在升级的时候,新固件在后台悄悄下载,即新固件下载属于应用程序功能的一部分,在新固件下载过程中,应用可以正常使用,也就是说整个下载过程对用户来说是无感的,下载完成后,系统再跳到BootLoader程序,由BootLoader完成新老固件拷贝操作,至此整个升级过程结束。比如智能手机升级Android或者iOS系统都是采用后台式DFU方式,新系统下载过程中,手机可以正常使用哦。非后台式DFU,在升级的时候,系统需要先从应用程序跳到BootLoader程序,由BootLoader进行新固件下载工作,下载完成后BootLoader继续完成新老固件拷贝操作,至此升级结束。早先的功能机就是采用非后台式 DFU来升级操作系统的,即用户需要先长按某些按键进入bootloader模式,然后再进行升级,整个升级过程中手机正常功能都无法使用。

下面再讲双区(2 Slot)DFU和单区(1 Slot)DFU,双区或者单区DFU是新固件覆盖老固件的两种方式。后台式DFU必须采用双区模式进行升级,即老系统(老固件)和新系统(新固件)各占一块Slot(存储区),假设老固件放在Slot0中,新固件放在Slot1中,升级的时候,应用程序先把新固件下载到Slot1中,只有当新固件下载完成并校验成功后,系统才会跳入BootLoader程序,然后擦除老固件所在的Slot0区,并把新固件拷贝到Slot0中,或者把Slot0和Slot1两者的image进行交换。非后台式DFU可以采用双区也可以采用单区模式,与后台式DFU相似,双区模式下新老固件各占一块Slot(老固件为Slot0,新固件为Slot1),升级时,系统先跳入BootLoader程序,然后BootLoader程序把新固件下载到Slot1中,只有新固件下载完成并校验成功后,才会去擦除老固件所在的Slot0区,并把新固件拷贝到Slot0区。单区模式的非后台式DFU只有一个Slot0,老固件和新固件分享这一个Slot0,升级的时候,进入bootloader程序DFU模式后立马擦除老固件,然后直接把新固件下载到同一个Slot中,下载完成后校验新固件的有效性,新固件有效升级完成,否则要求重来。跟非后台式DFU双区模式相比,单区模式节省了一个Slot的Flash空间,在系统资源比较紧张的时候,单区模式是一个不错的选择。不管是双区模式还是单区模式,升级过程出现问题后,都可以进行二次升级,都不会出现“变砖”情况。不过双区模式有一个好处,如果升级过程中出现问题或者新固件有问题,它还可以选择之前的老固件老系统继续执行而不受其影响。而单区模式碰到这种情况就只能一直待在bootloader中,然后等待二次或者多次升级尝试,此时设备的正常功能已无法使用,从用户使用这个角度来说,你的确可以说此时设备已经“变砖”了。所以说,虽然双区模式牺牲了很多存储空间,但是换来了更好的升级体验。

可参考下面三个图来理解上述过程。

  

1.1 DFU五要素

欲实现DFU功能,下面5大要素缺一不可:

  1. Bootloader。Bootloader用来启动image,并在固件升级过程中完成新老image交换功能。
  2. image bin文件。不同的DFU方式对应不同的image bin文件,image bin文件直接决定了image的格式。
  3. image位置。主image在哪执行?新image放在存储器什么位置?
  4. DFU传输协议。如何把新image稳定可靠地传输到secondary slot,这个就是DFU传输协议要做的事情。
  5. 物理传输层。DFU传输协议是上层协议,它是由物理传输层支撑的,比较常见的物理传输层包括蓝牙,Wi-Fi,蜂窝IoT,USB,UART,SPI等。

这里面Bootloader是DFU非常关键部分,Bootloader还将决定image bin文件格式,以及新老image存储位置。

如果你是第一次接触nRF Connect SDK(NCS),那么建议你先看一下这篇文章:开发你的第一个nRF Connect SDK(NCS)/Zephyr应用程序,以建立NCS的一些基本概念,然后再往下看以下章节。

下面我们会先给大家演示nRF Connect SDK蓝牙OTA升级实例,然后再给大家详细阐述nRF Connect SDK设备固件升级原理,以及如何实现自己的蓝牙OTA升级。

2. nRF Connect SDK蓝牙OTA升级实例演示

在nRF Connect SDK中,有一个现成的DFU例子,它所在的目录为:zephyr\samples\subsys\mgmt\mcumgr\smp_svr,这个例子支持多种传输层:蓝牙,串口,USB CDC,UDP,Shell,FS等,如果选择蓝牙作为传输层,即可实现蓝牙OTA升级功能,下面我们以nRF54L15 DK为例具体阐述其操作步骤:

  1. 编译项目工程。如果使用VS code编译,编译界面如下所示:

     编译成功后的界面如下所示:你也可以使用命令行编译,编译命令为:

    cd zephyr\samples\subsys\mgmt\mcumgr\smp_svr
    west build -b nrf54l15dk/nrf54l15/cpuapp -- -DEXTRA_CONF_FILE=overlay-bt.conf
  2. 将nRF54L15 DK通过一根USB线缆插入电脑,然后通过“Flash”将编译好的代码烧入nRF54L15 DK。
  3. 代码跑起来之后,你可以看到如下串口日志:

    打开nRF Connect手机版,你还会看到如下广播:

     连接此设备,成功后将会看到如下SMP服务,说明此设备已具备蓝牙OTA升级功能:

     

  4. 生成新image bin文件。我们修改一下工程,比如修改main.c如下代码行:

     重新编译工程,得到新image bin文件zephyr.signed.bin:

     这个zephyr.signed.bin文件也可以在dfu_application.zip找到:

     升级的时候,你既可以使用dfu_application.zip也可以使用zephyr.signed.bin,如果没有特殊要求,我们一般使用dfu_application.zip来作为升级文件,因为这个压缩包还可以包括多个升级文件的情况。

  5. 将dfu_application.zip或者zephyr.signed.bin通过微信或者其他方式拷贝到手机。
  6. 再次打开手机app:nRF Connect,点击右上角的DFU:

     

  7. 选择刚才拷贝过来的升级文件:dfu_application.zip或者zephyr.signed.bin:

     

  8. 选择“Test and Confirm”:

     

  9. 点击OK后,升级正式开始,可以看到如下升级界面:

     

  10. 升级成功后,我们将会看到如下串口日志:

     可以看出,新image已经生效,升级成功。演示完蓝牙OTA升级实例后,下面我们给大家详细分析里面的工作原理。

3. Bootloader

如果你的应用不需要DFU功能,那么Bootloader就可以不要;反之,如果你的应用需要DFU功能,Bootloader就一定需要。Bootloader在其中起到的作用包括:一判断正常启动还是DFU升级流程,二启动并校验应用image,三升级的时候完成新image和老image的交换或者拷贝工作。进一步说,

  1. Bootloader首先需要判断是进入正常应用程序启动流程还是DFU流程。
  2. 要启动应用image,Bootloader必须知道启动image的启动向量表在哪里。
  3. 要校验一个image,Bootloader必须知道这个image正确的校验值存在哪里。
  4. 要完成升级,Bootloader必须知道新image所在位置和老image所在位置,并执行一定的拷贝算法。

启动向量表可以放在image的最开始处,也可以放在其他地方,这就涉及到image的格式。Image正确的校验值可以跟image合在一块存放,也可以单独放在一个flash page里面。如果image的校验值是跟image本身合在一块存放的,这里再次涉及到image的格式。关于新image和老image存放位置,这就涉及到存储器分区问题。Bootloader的实现将直接决定image的格式,以及存储器的结构划分。

nRF Connect SDK默认支持MCUboot和NSIB(B0)两种Bootloader(当然我们也可以把nRF5 SDK里面的Bootloader移植到NCS中),两个Bootloader选其一即可,一般推荐大家使用MCUboot。

3.1 MCUboot

MCUboot位于如下目录:bootloader/mcuboot/boot/zephyr,在NCS中做DFU的时候,一般都推荐使用MCUboot。MCUboot功能强大,兼容的芯片平台多,而且是一个久经考验的第三方开源Bootloader。MCUboot把存储区划分为Primary slot和Secondary slot,而且primary slot跟secondary slot两者大小是一样的,程序默认在Primary slot中执行。有一点需要大家注意,NCS对MCUboot进行了定制,在NCS中,程序只能在Primary slot中执行,Secondary slot只是用来存储新image,而且Secondary slot可以放在内部Flash,也可以放在外部Flash,这样在NCS中,存储器分区有如下两种典型情况:

 

Secondary slot在内部Flash

 

Secondary slot在外部Flash

注:MCUboot放在0x00地址。

MCUboot是NCS/Zephyr默认的Bootloader,一旦一个工程使能了MCUboot,编译系统将自动生成MCUboot对应的升级文件(新image bin文件)zephyr.signed.bin,这个文件包括三部分:image header,image和image trailer。后面我们会教大家如何使能MCUboot并生成zephyr.signed.bin,下面我们假设已经生成了zephyr.signed.bin,我们结合这个bin文件来理解一下MCUboot的实现。如前所述,Bootloader有四大功能:启动image,校验image,拷贝image以及DFU模式判断,MCUboot又是如何完成这4项功能的。

  1. 启动image。MCUboot通过读image header,得到启动向量,然后跳到启动向量,完成启动。Image header信息定义在bootloader\mcuboot\boot\bootutil\include\bootutil\image.h文件中,如下:(感兴趣的读者,仔细看一下各个结构体字段定义,并对应zephyr.signed.bin进行解读) 从上可以看出,image的最开始是image header,而不是image启动向量。Image header里面有一个字段ih_hdr_size,启动向量就位于ih_hdr_size偏移处,本例ih_hdr_size为0x800,这就意味着启动向量与image header之间的偏移地址为0x800,而且我们确实在0x800处找到了启动向量,如下:

     

2. 校验image。MCUboot通过读image trailer,得到image的SHA256和签名,从而完成校验。Image trailer采用TLV结构,紧跟在image后面,其定义也在bootloader\mcuboot\boot\bootutil\include\bootutil\image.h文件中,常见TLV如下所示:(感兴趣的读者,仔细看一下各个TLV定义,并对应zephyr.signed.bin进行解读)

从上可以看出,image trailer以IMAGE_TLV_INFO_MAGIC开始,后面跟着多个TLV,本例跟着IMAGE_TLV_SHA512,IMAGE_TLV_KEYHASH和IMAGE_TLV_ED25519三个TLV,分别对应image的hash值,密钥以及image的签名。

应用image的hash值和签名放在image bin文件的最后,这样每次重新编译应用image,新的hash值和签名会自动跟着一起更新,你只需直接下载新的image bin文件而无需去更改Bootloader存储区任何部分,大大方便了开发和调试。

3. Image拷贝。MCUboot支持多种image拷贝动作,确切说是image swap(交换)操作,即把secondary slot里面的image交换到Primary slot,如何swap呢?总体上分swap(交换)和overwrite(覆盖)两种。Overwrite先擦除primary slot里面的老image,然后把secondary slot里面的新image拷贝到primary slot,完成整个升级过程。Swap就是把primary slot和secondary slot里面的image进行交换,即primary slot里面的image搬移到secondary slot,secondary slot里面的image搬移到primary slot。欲交换A和B,我们需引入一个媒介:C,算法是C=A;A=B;B=C,这样就实现了A和B的交换。从上可知,实现swap的关键是媒介C的引入,MCUboot支持两种swap算法:swap_move和swap_scratch,默认采用swap_move。swap_scratch的做法是:在存储区中专门划分一块scratch区作为swap媒介,swap的时候,primary slot里面的image先放在scratch区,然后把secondary slot里面的image拷贝到primary slot,最后把scratch区里面的内容拷贝到secondary slot,从而完成一次交换操作,Scratch区应该比primary或者secondary slot小很多,因此要完成整个image交换,需要循环执行多次上述操作直至整个image交换完成。这种算法有两个弊端:一浪费了scratch区,二由于一次image交换,scratch区需要执行多次擦写操作,scratch区的Flash寿命有可能会不够,为解决上述两个问题,引入了第二套算法:swap_move,具体做法是:先把primary slot里面整个image向上搬移一个扇区,即先擦掉image size + 1的扇区,然后把image size所在的扇区内容拷贝到image size + 1扇区,然后擦掉image size扇区,并把image size -1所在的扇区内容拷贝到image size扇区,以此循环往复,直至把整个image向上挪动一个扇区,这样就为下面的primary slot和secondary slot image交换做好准备。Primary slot和secondary slot image交换的时候,先擦掉primary slot第一个扇区,然后把secondary slot第一个扇区的内容拷贝到primary slot第一个扇区并擦掉secondary slot第一个扇区,然后把primary slot第二个扇区内容拷贝到secondary slot第一个扇区并擦掉primary slot第二个扇区,然后把secondary slot第二个扇区内容拷贝到primary slot第二个扇区并擦掉secondary slot第二个扇区,然后把primary slot第三个扇区内容拷贝到secondary slot第二个扇区并擦掉primary slot第三个扇区,以此往复,直至primary slot或者secondary slot两者中最大的那个image size拷贝完成,整个image swap流程宣告完成。从上面算法描述大家可以感觉出,swap操作是比较耗时的,但是它安全,支持回滚操作。如果大家不需要这个回滚操作的话(就像nRF5 SDK那样),那么大家可以选择overwrite模式(也称Update Only模式)以加快MCUboot拷贝速度。Overwrite模式算法比较简单,先把Primary slot里面的image擦掉,然后把Secondary slot里面的image拷贝到Primary slot,所以它速度快,但是不支持回滚。

4. 是否进入DFU模式。MCUboot是通过primary slot和secondary slot的状态组合来决定是否进入DFU模式。在MCUboot中,有一个变量:swap_type,它的取值将决定是否进入DFU模式,而swap_type的值又依赖如下真值表:上述真值表也可以表达为:

                                                           swap_type取值

上述的magic,image_ok和copy_done三个字段位于secondary slot和primary slot最后一个扇区,即slot的最高扇区(这意味着image bin文件大小必须小于(slot大小-4kB)),他们在扇区中的排布如下所示(magic字段在扇区的最高地址):

 

从上可知,根据magic,image_ok和copy_done三个变量的不同取值情况,可以得到不同的结果,即swap_type。在第2章我们演示了蓝牙OTA升级实例,当我们点击“test and confirm”,然后启动了升级过程,新image bin传输完成后,系统将自动复位并进入MCUboot,我们把此时Secondary slot最后一个sector(扇区)的内容导出来,如下:

        

可以看出,magic字段取值为BOOT_IMG_MAGIC,即真值表中的Good;image_ok取值为0xFF,即真值表中的Unset;copy_done取值为0xFF,0xFF也是真值表中ANY的一种。对比一下上面的真值表,可以看出此时正好对应State2表格:

 

由State2表格可以得出此时的swap_type的结果为BOOT_SWAP_TYPE_TEST,这个可以通过代码运行结果直接得到验证:  

可以看出,当secondary slot最后一个扇区的magic字段为Good,即设置成正确的值,而且image_ok字段不等于1,即为unset状态,则不管其他变量取什么值(正常情况下,此时其他变量的值都是0xFF),此时swap_type的结果为:BOOT_SWAP_TYPE_TEST,大家以此类推,就知道其他state表格的swap_type结果是怎么来的。这里有一点需要大家注意的,magic字段在Flash中只有两种正常取值:全FF和BOOT_IMG_MAGIC,而image_ok和copy_done在Flash中也只有两种正常取值:全FF和0x01,而表格中所谓的“Good”,“Any”,“Unset”,“0x01”,是对上述两种取值的泛化,比如magic字段等于BOOT_IMG_MAGIC,就叫“Good”;image_ok等于0xFF,就叫“Unset”或者“Any”(当然“Any”意味着0x55等其他非法值也可以兼容)。swap_type总共有6种结果,每种结果的意义如下所示:

    1. BOOT_SWAP_TYPE_ NONE。正常启动模式,MCUboot将直接跳到app,而不是进入DFU模式。
    2. BOOT_SWAP_TYPE_TEST。MCUboot将进入DFU模式,而且为test目的的DFU。跟下面的BOOT_SWAP_TYPE_ PERM模式相比,BOOT_SWAP_TYPE_TEST的DFU过程与之一模一样,也就是说BOOT_SWAP_TYPE_TEST就是进行正常的真正DFU,只不过DFU完成后,MCUboot跳到新app,这个时候新app必须把primary slot里面的image_ok字段写为1,即调用boot_set_confirmed_multi(0)这个API来完成,否则再次复位进入MCUboot的时候,MCUboot会认为新image有问题(没有确认),从而执行回滚操作,重新把老image换到primary slot,然后继续跑老image(此时升级应该算失败)。
    3. BOOT_SWAP_TYPE_ PERM。如前所述,BOOT_SWAP_TYPE_ PERM跟BOOT_SWAP_TYPE_TEST DFU过程一模一样,唯一区别的是,一旦设为PERM(永久)模式,哪怕新image没有显示地去写image_ok字段,再次复位进入MCUboot,MCUboot也不会去执行回滚操作,而强制认为升级已成功。
    4. BOOT_SWAP_TYPE_ REVERT,回滚操作。前述的回滚操作,swap_type就是BOOT_SWAP_TYPE_ REVERT。一旦检测到BOOT_SWAP_TYPE_ REVERT,MCUboot将进行回滚操作。
    5. BOOT_SWAP_TYPE_ FAIL。当MCUboot校验primary slot里面的image失败时,就会报BOOT_SWAP_TYPE_ FAIL,此时程序将死在MCUboot里面。
    6. BOOT_SWAP_TYPE_ PANIC。当MCUboot执行swap操作出现了致命错误,就会报BOOT_SWAP_TYPE_ PANIC,此时程序将死在MCUboot里面。

从上我们可以总结出,为了让MCUboot进入DFU模式,swap_type结果必须为BOOT_SWAP_TYPE_TEST或者BOOT_SWAP_TYPE_ PERM,而让swap_type取值为BOOT_SWAP_TYPE_TEST或者BOOT_SWAP_TYPE_ PERM的关键是让secondary slot最后一个扇区的magic字段为BOOT_IMG_MAGIC,这是通过调用boot_request_upgrade_multi()来实现的,当调用boot_request_upgrade_multi(1, BOOT_UPGRADE_TEST)将进入BOOT_SWAP_TYPE_TEST模式,当调用boot_request_upgrade_multi(1, BOOT_UPGRADE_PERMANENT)将进入BOOT_SWAP_TYPE_ PERM模式。

State1,State2,State3,State4和State5五个表格是有优先级顺序的,越往前优先级越高,也就是说,如果State1表格匹配成功就不再匹配后面的表格,否则匹配State2表格,成功则不再匹配后面表格,以此往复,直至State5表格。

下面是MCUboot正常启动的一个示例,可以看出,因为magic,image_ok和copy_done三个变量的取值成功匹配了State5表格,所以swap_type的最终结果是BOOT_SWAP_TYPE_ NONE,即正常启动app。注:0x3就代表“Unset”(实际取值为0xFF),“Unset”可以看成“Any”一种。

很多人会好奇为什么MCUboot使用这么复杂的DFU模式判断算法?究其根本,还是因为Flash的限制导致的:Flash每次只能擦一个page(擦除时间还比较长),而且寿命又有限,在尽可能少擦Flash的情况下,又要实现上述那么多swap操作,然后有人就想出了上面的算法。

3.2 B0,亦称nRF Secure Immutable Bootloader(NSIB)

在NCS/Zephyr中,我们比较少用到B0,如果你没用到B0,请跳过本节,直接看之后的内容。

NSIB(nRF Secure Immutable Bootloader),亦称B0,位于nrf/samples/bootloader,这个是Nordic自己开发的一个不可升级的Bootloader。b0把存储区划分成slot0和slot1,并且slot0大小等于slot1大小,s0_image跑在slot0,s1_image跑在slot1,B0根据s0_image和s1_image的版本号来决定跑哪一个image,如果s0_image的版本号高于或等于s1_image的版本号,那么B0启动的时候就会跳到s0_image;反之,如果s1_image的版本号高于s0_image的版本号,那么B0启动的时候就会跳到s1_image。由于s0_image和s1_image都有可能被执行,所以s0_image和s1_image必须都放置在内部Flash,也就是说slot0和slot1必须都在nRF设备内部Flash中。B0将存储区划分成如下模样(B0在0x00地址):

 

如前所述,Bootloader有四大功能:启动image,校验image,拷贝image以及DFU模式判断,那么B0是如何完成这4项功能的:

1. 启动image。B0通过读provision区域信息,得到s0_image和s1_image信息,provision属于B0的一部分,下面为provision的定义及一个示例:(感兴趣的读者,仔细看一下结构体各个字段定义,并对应image hex进行解读) 

从上面示例可以看出,s0_address为0x9000,0x9000即为s0_image的起始地址,s1_image起始地址可以用同样道理获得。得到S0_image或者S1_image的起始地址后,就可以得到两个image的fw_info,fw_info定义及示例如下所示:

 

通过fw_info就可以找到boot_address,从而跳转到相应app。

2. 校验image。B0也支持SHA256或者签名验签,SHA256或者签名放在image的最后,称为fw_validation_info,其定义及示例如下所示:

 

B0通过magic字段找到hash和signature,然后进行校验。

3. 拷贝image。B0没有拷贝image的操作,所谓升级,就是执行高版本image,具体来说,如果s1_image版本比s0_image版本高,则执行s1_image;否则执行s0_image。

4. DFU模式进入。B0不存在DFU模式,也就不存在所谓进入DFU模式判断。每次复位B0都去读s0_image和s1_image的版本,那个image版本高就执行那个image。

基于B0的DFU,有一点需要特别注意,由于S0_image和S1_image两者的偏移或者启动向量不一样,因此即使S0_image和S1_image两者功能一模一样,他们的image内容也不一样,这也意味着slot0和slot1对应的升级image是不一样的。一般来说,手机app或者其他主机并不知道设备当前正在运行哪个slot里面的image,因此DFU的时候,手机app或其他主机需要先跟设备沟通,获知设备当前正在执行哪个image。如果S0_image在运行,就给它传S1_image(signed_by_b0_s1_image.bin)并放置在slot1中;如果S1_image在运行,就给它传S0_image(signed_by_b0_s0_image.bin)并放置在slot0中。升级image接收完毕,系统复位,B0自动选择高版本image执行,至此整个升级完成。从上可知,DFU的升级文件必须同时包含signed_by_b0_s0_image.bin 和signed_by_b0_s1_image.bin,实际中我们一般使用如下zip文件:

 

这里我们做了一个基于B0的DFU例子:https://github.com/aiminhua/ncs_samples/tree/master/nrf_dfu/ble_intFlash_b0,大家感兴趣的话,可以自己去看一下(按照里面的readme来操作)。下面是B0正常启动的一个示例,可以看出B0选择了slot0里面的s0_image进行装载,校验和跳转。 

 

4. DFU传输协议

4.1 概述

前面说过,为了实现固件升级,需要把新image放在secondary slot(以MCUboot为例),如何把新image传输到secondary slot?这就是DFU协议要做的事情,一般来说,DFU协议需要把image bin文件分块一块一块传给设备端,然后设备端按照要求将image块写入secondary slot,并回复写入结果给主机。期间有可能还需要校验传输的image对不对,或者告知每次image块写入的偏移地址。最后DFU协议还有可能涉及一些管理操作,比如image块写入的准备工作,读取设备状态,复位设备等。

这里需要特别强调一下,DFU协议是脱离于物理传输层的,也就是说,同样的DFU协议可以跑在不同的物理传输层,比如蓝牙,Wi-Fi,UDP,USB CDC,UART等,千万不要把DFU协议跟特定的物理传输层混为一谈。

nRF Connect SDK包含多种DFU协议,最著名的就是SMP(Simple Management Protocol)协议,除此之外,还有其他DFU协议,比如http_update,hid_configurator,USB DFU class,PCD DFU,以及从nRF5 SDK移植过来的nrf_dfu协议。不同的应用场景有不同的DFU协议需求,大家需要根据自己的情况选择合适的DFU协议。当然,大家也可以不用nRF Connect SDK里面的DFU协议,而使用自己定义的DFU协议,如前所述,只要把新image bin文件安全可靠的传输到secondary slot,然后调用boot_request_upgrade_multi()触发DFU升级模式并复位系统,后面MCUboot就可以自动完成后续的升级流程。

由于SMP协议用得比较多,下面我们着重介绍SMP协议,大家可以借此领会DFU协议具体要做哪些工作,它的底层逻辑是什么。

4.2 SMP协议

SMP 全称Simple Management Protocol(简单管理协议),它是设备管理协议的一种,在NCS中,mcumgr模块实现了协议,或者说,SMP协议按照mcumgr的要求对相应的传输数据进行编码,这样mcumgr就可以直接解析SMP的header传输数据,并将其映射为相应的命令函数(command handler),同时将SMP的payload数据交给命令函数进行处理,从而完成image传输工作。mcumgr实现的功能比较多,SMP DFU只是其中一种,除此之外,它还有很多其他功能,比如shell管理,日志管理,文件管理等。这里我们只对DFU相关命令进行介绍,其他命令就不在这里讲了。

4.2.1 SMP帧结构

SMP协议本质就是规定SMP帧结构,并把这个帧从client端(一般是手机或者PC)传到server端(一般就是设备本身)。SMP帧包含两部分:帧头(header)和有效载荷(data),具体结构如下所示:

每个字段的解释如下所示:

SMP帧头对应的C语言结构体定义在zephyr\subsys\mgmt\mcumgr\transport\include\mgmt\mcumgr\transport\smp_internal.h:

帧头每一个字节正好对应结构体的每一个成员变量,即第一个字节代表nh_op(操作类型),第二个字节代表nh_flags,第三和四个字节代表nh_len,第五和六个字节代表nh_group(命令组编号),第7个字节代表nh_seq,第8个字节代表nh_id(命令组中的具体命令)。

SMP帧头有两个关键字段:OP(对应nh_op)和Command ID(包含Group ID,对应nh_group和nh_id),这两个字段直接决定此帧数据将交给哪个handler来处理。对于SMP来说,它只支持read和write两种操作方式,不管是read还是write操作,它又包含两步:request和response,这样我们就有4种组合:read(request),read response,write(request),write response,这4种组合的定义可以在zephyr\include\zephyr\mgmt\mcumgr\mgmt\mgmt_defines.h找到:

client(一般是手机或者PC)发送read(request)命令给server(一般就是设备本身),server回应read response命令;client发送write(request)命令给server,server回应write response命令。

mcumgr包含了多个命令,每个命令都有自己的handler,而每个命令又是由Group ID和command ID指定。mcumgr先把命令分组,它支持很多命令组,跟DFU有关的命令组是:OS(复位命令所在的组),IMAGE(传输image命令所在的组),STAT(设备信息命令所在的组)和SETTINGS(擦除settings区命令所在的组),如下:

然后每个命令组里面又包含多个具体的命令,比如OS命令组有如下具体命令:

而IMAGE命令组包含如下具体命令:

有了Group ID和command ID,SMP解析代码就可以找到对应的handler,SMP的关键解析函数为smp_handle_single_payload,位于zephyr\subsys\mgmt\mcumgr\smp\src\smp.c,它的关键代码如下所示: 

group = mgmt_find_group(req_hdr->nh_group);
handler = mgmt_get_handler(group, req_hdr->nh_id);
    switch (req_hdr->nh_op) {
    case MGMT_OP_READ:
        handler_fn = handler->mh_read;
        break;

    case MGMT_OP_WRITE:
        handler_fn = handler->mh_write;
        break;
ok = zcbor_map_start_encode(cbuf->writer->zs,
                            CONFIG_MCUMGR_SMP_CBOR_MAX_MAIN_MAP_ENTRIES);
rc = handler_fn(cbuf);

mgmt_get_handler的关键实现代码如下所示:

return &group->mg_handlers[command_id];

而我们每个Group按照结构体struct mgmt_group去定义,比如OS group,它的定义如下:

static struct mgmt_group os_mgmt_group = {
    .mg_handlers = os_mgmt_group_handlers,
    .mg_handlers_count = OS_MGMT_GROUP_SZ,
    .mg_group_id = MGMT_GROUP_ID_OS,
#ifdef CONFIG_MCUMGR_SMP_SUPPORT_ORIGINAL_PROTOCOL
    .mg_translate_error = os_mgmt_translate_error_code,
#endif
#ifdef CONFIG_MCUMGR_GRP_ENUM_DETAILS_NAME
    .mg_group_name = "os mgmt",
#endif
};

而os_mgmt_group_handlers定义如下:

static const struct mgmt_handler os_mgmt_group_handlers[] = {
#ifdef CONFIG_MCUMGR_GRP_OS_ECHO
    [OS_MGMT_ID_ECHO] = {
        os_mgmt_echo, os_mgmt_echo
    },
#endif
#ifdef CONFIG_MCUMGR_GRP_OS_TASKSTAT
    [OS_MGMT_ID_TASKSTAT] = {
        os_mgmt_taskstat_read, NULL
    },
#endif

#ifdef CONFIG_MCUMGR_GRP_OS_DATETIME
    [OS_MGMT_ID_DATETIME_STR] = {
        os_mgmt_datetime_read, os_mgmt_datetime_write
    },
#endif

#ifdef CONFIG_REBOOT
    [OS_MGMT_ID_RESET] = {
        NULL, os_mgmt_reset
    },
#endif
#ifdef CONFIG_MCUMGR_GRP_OS_MCUMGR_PARAMS
    [OS_MGMT_ID_MCUMGR_PARAMS] = {
        os_mgmt_mcumgr_params, NULL
    },
#endif
#ifdef CONFIG_MCUMGR_GRP_OS_INFO
    [OS_MGMT_ID_INFO] = {
        os_mgmt_info, NULL
    },
#endif
#ifdef CONFIG_MCUMGR_GRP_OS_BOOTLOADER_INFO
    [OS_MGMT_ID_BOOTLOADER_INFO] = {
        os_mgmt_bootloader_info, NULL
    },
#endif
};

这样,当command_id为OS_MGMT_ID_RESET时,os_mgmt_reset(client发过来的write request命令)将被触发,os_mgmt_reset关键实现代码如下所示:

static int os_mgmt_reset(struct smp_streamer *ctxt)
{
    /* Reboot the system from the system workqueue thread. */
    k_work_schedule(&os_mgmt_reset_work, K_MSEC(CONFIG_MCUMGR_GRP_OS_RESET_MS));

    return 0;
}

每次新image传输完毕,手机会主动发这个命令给设备,以让其复位,从而进入MCUboot。

4.2.2 SMP数据payload和CBOR编码

如前所述,SMP帧最后跟着SMP Data,即SMP数据payload,SMP data不是没有结构的bin数据,而是会使用CBOR编码对其编码。CBOR编码是一种结构化的编码方式,它会将数据分成一个一个data item,每个data item遵守TLV结构,并且data item可以相互嵌套,其基本的编码格式如下所示: 

从上可知,每个data item第一个字节包含2部分:数据类型和数据长度。数据类型包含3 bit,用来规定接下来数据的类型,CBOR定义了如下数据类型:

  • 0,正数
  • 1,负数
  • 2,字节串(byte string)
  • 3,UTF-8字符串(text string)
  • 4,数组
  • 5,map(又称字典)
  • 6,tag(这个用得少)
  • 7,浮点数或者特殊类型,其中特殊类型将short count取值20–23定义为 false, true, null和undefined

数据类型就是TLV的T,TLV的L就是后面的count,count有可能是5bit,有可能是1个字节,有可能是2个字节,有可能是4个字节,具体规则见下面描述:

  • 如果count为0–23,则直接用short count的5 bits来表示,从第2个字节开始表示data payload
  • 如果short count为24(0x18),则表示第2个字节代表长度,从第3个字节开始表示data payload
  • 如果short count为25(0x19),则表示第2和第3个字节合起来表示长度,从第4个字节开始表示data payload
  • 如果short count为26(0x1A),则表示第2,第3,第4和第5个字节合起来表示长度,从第6个字节开始表示data payload
  • 如果short count为27(0x1B),则表示第2至第9个字节合起来表示长度,从第10个字节开始表示data payload
  • 如果short count为31(0x1F),则表示长度为未定义,从第2个字节开始表示data payload,直到遇到停止符:0xFF

count字段后面紧跟着data payload了,count有多大,data payload就有多长,比如count为0x0032,则表示后面0x32个字节都属于data payload,至此一个data item结束,同时意味着另一个data item的开始,以此往复,周而复始。我们看一个完整的CBOR数据示例:64 64 61 74 61,这个数据表示什么意思呢?首先我们把第一个字节0x64写成二进制形式:0b011 00100,可以看出它的数据类型为0b011,即3,表示UTF-8字符串;short count为0b00100,即4,表示后面的payload长度为4字节,即后面紧跟的64 61 74 61就是我们要找的payload,由于这个数据是string,不难发现64 61 74 61这4个数据应该就是ASCII码,他们对应的字符是“data”,这样我们就成功解析出这个CBOR数据了。

4.2.3 SMP数据包详细解析示例

如前所述,SMP协议的核心就是通过SMP包头或者帧头找到要处理该数据包的handler(命令函数),并把payload打包成一个特定参数传给该handler,然后执行该handler

现在我们以一个实际的SMP数据包为例,结合上面的知识,来看看mcumgr是如何解析这个SMP数据包,并找到对应的handler,并将data payload打包作为参数传给该handler,然后执行该handler,并返回response给对端。下面这个数据包发生在新image刚开始传输时,我们一起来看看这个数据包是什么意思:

                                     0a 00 09 a1 00 01 03 01  a4 64 64 61 74 61 59 09 |........ .ddataY.
                                     64 3d b8 f3 96 00 00 00  00 00 08 00 00 d0 f5 03 |d=...... ........
                                     00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00 |........ ........
                                     00 ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff    |........ ....... 
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff    |........ ....... 
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff    |........ .......
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff    |........ .......
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff |........ ........
                                     ff ff ff ff ff 80 58 01  20 81 22 02 00 b1 af 03 |......X.  .".....
                                     00 6d 22 02 00 6d 22 02  00 6d 22 02 00 6d 22 02 |.m"..m". .m"..m".
                                     00 6d 22 02 00 00 00 00  00 00 00 00 00 00 00 00 |.m"..... ........
                                     00 09 24 02 00 6d 22 02  00 00 00 00 00 89 23 02 |..$..m". ......#.
                                     00 6d 22 02 00 c1 25 02  00 c1 25 02 00 c1 25 02 |.m"...%. ..%...%.
                                     00 c1 25 02 00 c1 25 02  00 c1 25 02 00 c1 25 02 |..%...%. ..%...%.
                                     00 c1 25 02 00 c1 25 02  00 c1 25 02 00 c1 25 02 |..%...%. ..%...%.
                                     00 c1 25 02 00 c1 25 02  00 c1 25 02 00 c1 25 02 |..%...%. ..%...%.
                                     00 c1 25 02 00 c1 25 02  00 c1 25 02 00 c1 25 02 |..%...%. ..%...%.
                                     00 c1 25 02 00 c1 25 02  00 c1 25 02 00 c1 25 02 |..%...%. ..%...%.
                                     00 c1 25 02 00 c1 25 02  00 c1 25 02 00 c1 25 02 |..%...%. ..%...%.
                                     00 c1 25 02 00 c1 25 02  00 c1 25 02 00 c1 25 02 |..%...%. ..%...%.
                                     00 c1 25 02 00 c1 25 02  00 c1 25 02 00 c1 25 02 |..%...%. ..%...%.
                                     00 c1 25 02 00 c1 25 02  00 c1 25 02 00 c1 25 02 |..%...%. ..%...%.
                                     00 c1 25 02 00 c1 25 02  00 c1 25 02 00 c1 25 02 |..%...%. ..%...%.
                                     00 c1 25 02 00 c1 25 02  00 c1 25 02 00 c1 25 02 |..%...%. ..%...%.
                                     00 c1 25 02 00 c1 25 02  00 c1 25 02 00 c1 25 02 |..%...%. ..%...%.
                                     00 c1 25 02 00 c1 25 02  00 c1 25 02 00 c1 25 02 |..%...%. ..%...%.
                                     00 c1 25 02 00 c1 25 02  00 c1 25 02 00 c1 25 02 |..%...%. ..%...%.
                                     00 c1 25 02 00 c1 25 02  00 c1 25 02 00 c1 25 02 |..%...%. ..%...%.
                                     00 c1 25 02 00 c1 25 02  00 c1 25 02 00 c1 25 02 |..%...%. ..%...%.
                                     00 c1 25 02 00 c1 25 02  00 c1 25 02 00 c1 25 02 |..%...%. ..%...%.
                                     00 c1 25 02 00 c1 25 02  00 63 6c 65 6e 1a 00 03 |..%...%. .clen...
                                     fe 68 63 73 68 61 58 20  a5 33 36 36 b6 e5 e7 c8 |.hcshaX  .366....
                                     52 2c 8a 25 ed 20 18 9d  a5 ac e0 81 de c7 ae 1b |R,.%. .. ........
                                     2d 3b cd c2 44 76 83 eb  63 6f 66 66 00          |-;..Dv.. coff.

可以看出这个数据包的包头是:

  • 0a 00 09 a1 00 01 03 01

对照前面的SMP帧结构:

我们可以得到:Ver=1,OP=2,Data length = 0x9a1,Group ID = 1,Sequence Num = 3,Command ID = 1,结合mcumgr的宏定义:

根据上述解析结果,上面SMP数据包将关联到handler(命令函数):img_mgmt_upload,它的原型为:

static int img_mgmt_upload(struct smp_streamer *ctxt)

包头解析完了,我们再看上面SMP数据包后面的内容,即:

  • a4 64 64 61 74 61 59 09 64 3d b8 f3 96 00 00 00  00 00 08 00 00 d0 f5 03 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00 00 ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff ......

现在我们使用上面提到的CBOR编码对payload进行解析:(为方便大家解析上面的SMP数据包,我把每个data item的第一个字节都用红色字体标出)

  • 0xa4,即0b101 00100,数据类型为5,即字典数据(key-value对形式),short count为4,表示后面跟着4个字典数据,也就是有4个key-value对。
  • 0x64,即0b011 00100,数据类型为3,即text string,short count为4,表示后面字符串为4个字节,即64 61 74 61,他们正好是“data”的四个ASCII码。
  • 0x59,即0b010 11001,数据类型为2,即byte string,short count为0x19,表示后面2个字节用来表示长度:0x0964,也就是说后面会跟着0x964个字节的数据。
  • 到这里我们提炼出第一个字典数据,它的key是data,它的value是0x964字节的byte string。data就是我们的image bin文件。数完0x964个字节后,我们来得了这里:
  • 63 6c 65 6e 1a 00 03 fe 68 63 73 68 61 58 20  a5 33 36 36 b6 e5 e7 c8 52 2c 8a 25 ed 20 18 9d  a5 ac e0 81 de c7 ae 1b 2d 3b cd c2 44 76 83 eb  63 6f 66 66 00
  • 0x63,即0b011 00011,数据类型为3,即text string,short count为3,表示后面字符串为3个字节,即6c 65 6e,他们正好是“len”的ASCII码。
  • 0x1a,即0b000 11010,数据类型为0,即正数,short count为0x1a,表示后面4个字节用来表示长度:0x0003fe68
  • 到这里我们提炼出第二个字典数据,它的key是len,它的value是0x0003fe68。len是我们的image bin文件总大小。
  • 0x63,即0b011 00011,数据类型为3,即text string,short count为3,表示后面字符串为3个字节,即73 68 61,他们正好是“sha”的ASCII码。
  • 0x58,即0b010 11000,数据类型为2,即byte string,short count为0x18,表示后面1个字节用来表示长度:0x20,也就是说后面会跟着0x20个字节的数据。
  • 到这里我们提炼出第三个字典数据,它的key是sha,它的value是0x20字节的byte string。sha是我们的image bin文件的哈希值。
  • 0x63,即0b011 00011,数据类型为3,即text string,short count为3,表示后面字符串为3个字节,即6f 66 66,他们正好是“off”的ASCII码。
  • 0x00,即0b000 00000,数据类型为0,即正数,short count为0,表示长度为0
  • 到这里我们提炼出第四个字典数据,它的key是off,它的value是0。off是我们的image bin文件写入Flash的偏移量。
  • 至此,整个SMP数据包解析完成。

成功解析后,我们可以得知上面SMP数据包包含data,len,sha和off四个字典(map)数据,具体的解析过程是由下面关键代码实现的:

zcbor_state_t *zsd = ctxt->reader->zs;
struct img_mgmt_upload_req req = {
        .off = SIZE_MAX,
        .size = SIZE_MAX,
        .img_data = { 0 },
        .data_sha = { 0 },
        .upgrade = false,
        .image = 0,
    };
struct zcbor_map_decode_key_val image_upload_decode[] = {
        ZCBOR_MAP_DECODE_KEY_DECODER("image", zcbor_uint32_decode, &req.image),
        ZCBOR_MAP_DECODE_KEY_DECODER("data", zcbor_bstr_decode, &req.img_data),
        ZCBOR_MAP_DECODE_KEY_DECODER("len", zcbor_size_decode, &req.size),
        ZCBOR_MAP_DECODE_KEY_DECODER("off", zcbor_size_decode, &req.off),
        ZCBOR_MAP_DECODE_KEY_DECODER("sha", zcbor_bstr_decode, &req.data_sha),
        ZCBOR_MAP_DECODE_KEY_DECODER("upgrade", zcbor_bool_decode, &req.upgrade)
    };

ok = zcbor_map_decode_bulk(zsd, image_upload_decode,
        ARRAY_SIZE(image_upload_decode), &decoded) == 0;

img_mgmt_upload得到image bin数据后,就会调用相关的API将bin数据写入内部Flash或者外部Flash或者其他类型的NVM,关键代码如下所示:

img_mgmt_write_image_data(req.off, req.img_data.value, action.write_bytes, last);

通过上述演示过程,相信各位已经对mcumgr的实现机制有了初步认识。正如前文所述,其核心原理可归纳为:系统首先通过解析SMP帧头定位对应的处理程序(handler),随后将SMP协议中的有效载荷(data payload)传递给该处理程序执行具体操作。

4.2.4 SMP DFU流程

我们在第2章演示了蓝牙OTA升级过程,其实不管是蓝牙传输层,还是UART等其他传输层,基于SMP协议的DFU流程大概包含如下几步:

  1. 签名升级image。注:zephyr.signed.bin已经是签过名的image
  2. 上传image,即把zephyr.signed.bin传送到目标设备
  3. 列出image以获得image的hash值
  4. 测试image,即写magic字段,以让MCUboot进入DFU模式
  5. 复位设备,以重新进入MCUboot,从而MCUboot进入DFU模式,并执行相应的swap操作,并完成两个slot image之间的交换或者拷贝动作
  6. Confirm image,即新image启动成功后,对其image_ok字段进行置1操作

关于固件确认(confirm image)步骤,您有两种可选方案: 一是等待新固件启动成功后,重新连接主机,由主机发送"confirm image"指令触发boot_set_confirmed_multi()调用来完成确认; 二是在新固件启动成功后,无需主机介入,直接主动调用boot_set_confirmed_multi()完成确认。 需要注意的是,只有当固件完成确认(image confirm)后,整个升级流程才被视为最终成功。

5. 移植蓝牙OTA到peripheral_uart

得益于nRF Connect SDK丰富的组件库和灵活的配置机制,我们可以便捷地实现蓝牙OTA(空中升级)功能。本文将以nrf\samples\bluetooth\peripheral_uart示例工程为基础,演示如何为其添加蓝牙OTA功能。该示例作为经典的蓝牙数据透传应用,被广泛用作蓝牙应用开发的参考模板,但其默认实现并不支持OTA功能。我们将针对不同应用场景,分三种情况详细说明OTA功能的移植方法。

首先需要说明的是,MCUboot采用双槽(Dual-slot)设计,将存储空间划分为Primary slot(主槽)和Secondary slot(副槽)。根据副槽存储位置的不同,我们将分别演示以下两种实现方案:

  • 副槽位于内部Flash的方案
  • 副槽位于外部Flash的方案

此外,针对部分资源受限的应用场景,当内部Flash的副槽空间不足以容纳完整镜像时,我们还将介绍第三种解决方案——压缩DFU。该方案通过对新固件镜像进行压缩,使其能够适配有限的内部Flash存储空间。

MCUboot及对应的DFU采用了image签名验签机制,这就要求开发者必须替换默认的签名密钥为自己专用签名密钥以保证设备固件安全。本章也将对密钥配置和安全注意事项进行详细说明。

5.1 升级镜像存放在内部Flash

peripheral_uart默认是不带bootloader的,为了实现OTA功能,第一步就要使能bootloader,为此我们在工程根目录下新建一个sysbuild.conf(其实就是一个conf后缀的文本文件)文件,并在这个文件里面使能mcuboot,如下: 

SB_CONFIG_BOOTLOADER_MCUBOOT=y

然后修改prj.conf文件,加入如下配置:

CONFIG_NCS_SAMPLE_MCUMGR_BT_OTA_DFU=y
CONFIG_NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP=y
CONFIG_NCS_SAMPLE_MCUMGR_BT_OTA_DFU_VALIDATION=y

如果为了彻底禁止手机app操控用户数据存储区,可以加入如下配置:

CONFIG_MCUMGR_GRP_ZBASIC_STORAGE_ERASE=n

上面这个选项不是必须的,如果没有这个选项,手机app就可以擦除用户数据存储区,这样当系统出问题了,可以帮忙恢复系统。

至此,peripheral_uart就具备了蓝牙OTA升级功能。是不是有点意外,感觉太不可思议了,这就是nRF Connect SDK的魅力!

下面我们来测试一下这个蓝牙OTA升级功能。

  1.  编译并下载修改后的peripheral_uart工程
  2. 将nRF54L15 DK插入电脑,并使用串口助手打开其中一个串口,可以看到如下日志:

  3. 打开手机app:nRF Connect,此时可以看到如下广播:

     

  4. 连接这个蓝牙设备,成功后,我们可以看到SMP服务,说明这个设备已经具备蓝牙OTA功能。

     

  5. 生成新image。我们将peripheral_uart工程的广播名改为:new_dfu,为了加快编译速度,我们直接在main.c里面修改,如下:

  6. 编译修改后的工程(不要点击下载哦),此时我们得到升级文件:dfu_application.zip,如下:

     有的人不喜欢zip升级包,想直接用bin升级包,这个也是可以的。如果需要bin格式的升级文件,请使用zephyr.signed.bin,它在如下目录:

  7. 将dfu_application.zip或者zephyr.signed.bin通过微信或者其他方式拷贝到手机
  8. 再次打开手机app:nRF Connect,点击右上角的DFU

     

  9. 选择刚才拷贝过来的升级文件:dfu_application.zip或者zephyr.signed.bin
  10. 选择“Test and Confirm”

     

  11. 点击OK后,升级正式开始,可以看到如下升级界面

     同时在串口助手,我们可以看到如下日志:

  12. 升级完成后,手机nRF Connect会自动重连设备,我们主动断开蓝牙连接,此时可以看到设备的广播名已经改成:new_dfu,如下:

     

  13. 至此,整个蓝牙OTA升级成功完成

上面的例子是把升级image放在内部Flash中,也就是说,原始image和升级image共享内部Flash区域,其中原始image放在Primary slot,升级image放在Secondary slot,而且Primary slot大小等于Secondary slot大小,这个我们可以从编译输出的分区文件nrf\samples\bluetooth\peripheral_uart\build\partitions.yml得到进一步验证:

将升级镜像(image)存储在内部Flash的方案可以节省外部Flash的成本。然而,由于系统需要同时维护Primary slot、Secondary slot和MCUboot分区,且Primary slot和Secondary slot容量必须相等,这意味着应用程序镜像的大小不能超过总Flash容量的50%。以nRF54L15为例,其1.5MB的Flash容量限制单个镜像不得超过750kB。

在实际应用中,特别是对于nRF52系列芯片,当应用程序规模较大时,内部Flash可能无法同时容纳原始镜像和升级镜像。此时,可以采用将升级镜像存储在外部Flash的方案:内部Flash全部用于Primary slot存放原始镜像,而Secondary slot则置于外部Flash中。这种架构突破了内部存储容量的限制,能够支持更复杂、更大规模的应用程序。

在深入探讨外部Flash的Secondary slot方案之前,我们需要先理解nRF Connect SDK(ncs)中的存储器分区管理机制。掌握这一基础概念后,外部Flash的DFU(Device Firmware Update)方案实现原理将更加清晰易懂。

5.2 存储器分区管理

5.2.1 Partition/Area/Map

在嵌入式系统中,存储器分区(Partition) 是指对物理存储介质(如内部 Flash、外部 Flash 或 RAM)进行逻辑划分的管理方式。开发者可以灵活定义不同区域的用途,比如MCUboot这个镜像,它必须占存储器一块地方,这块地方就是分区(Partition);比如主应用这个镜像,也必须占存储器一块地方,也就是说主应用也需要占一块分区。所谓分区管理,就是把MCUboot,主应用,用户数据存储区等这些分区放在芯片内部Flash或者外部Flash相应地方。比如对于MCUboot镜像来说,我们可以将其放在0x0000 至 0xC000 的内部Flash空间,0x0000 至 0xC000 的内部Flash空间就是一个分区。

分区(Partition)对应物理存储器空间的一段,它由起始地址和结束地址来规定它的范围。在NCS中,我们可以通过Devicetree或者pm.yml文件来划分存储器分区,当只有一个image的时候,即sysbuild没有起作用的时候,存储器分区由Devicetree决定;当系统包括2个或者更多image的时候,即sysbuild发挥作用的时候,存储器分区由pm.yml决定。不管是Devicetree定义的分区还是pm.yml定义的分区,在NCS代码中,都被称作Flash Area,给存储器分区,也被叫做Flash map。也就是说,每一个物理存储器,都包含多个Flash area,这多个Flash area共同组成了存储器的Flash map(或者叫Flash layout)。在NCS中,每个分区都会被系统分配一个ID(通过Python脚本分配的),flash_area_open就是通过这个ID来获得该分区的句柄,然后再通过句柄去操作该分区,比如写数据到该分区,我们可以看一下zephyr\include\zephyr\storage\flash_map.h里面的具体定义:


struct flash_area {
    /** ID number */
    uint8_t fa_id;
    uint16_t pad16;
    /** Start offset from the beginning of the flash device */
    off_t fa_off;
    /** Total size */
    size_t fa_size;
    /** Backing flash device */
    const struct device *fa_dev;
#if CONFIG_FLASH_MAP_LABELS
    /** Partition label if defined in DTS. Otherwise nullptr; */
    const char *fa_label;
#endif
};
int flash_area_open(uint8_t id, const struct flash_area **fa);
int flash_area_write(const struct flash_area *fa, off_t off, const void *src, size_t len);

可以看出Flash area的结构体定义除了包含前面讲的ID,起始地址和结束地址(fa_off和fa_size),它还包括fa_dev成员,fa_dev用来指定物理存储器的类型,比如芯片内部Flash还是外部Flash。

再次强调一下,Devicetree和pm.yml是两套独立并互斥的系统,当系统使用了pm.yml里面的分区,就不再管devicetree里面的分区。不管是devicetree还是pm.yml,他们都会定义一系列分区(partition)或者area,而且给这些分区分配一个唯一的ID。不管是Devicetree还是pm.yml,他们生成的分区最终都会赋给变量flash_map,flash_map就是const struct flash_area类型的数组,数组索引就代表每个Flash area的ID,数组成员就是一个Flash area。

大家可以参考如下代码去理解上面的概念:

 

const int flash_map_entries = ARRAY_SIZE(default_flash_map);
const struct flash_area *flash_map = default_flash_map;

static inline struct flash_area const *get_flash_area_from_id(int idx)
{
  for (int i = 0; i < flash_map_entries; i++) {
    if (flash_map[i].fa_id == idx) {
      return &flash_map[i];
    }
  }

  return NULL;
}

int flash_area_open(uint8_t id, const struct flash_area **fap)
{
  const struct flash_area *area;

  area = get_flash_area_from_id(id);

  *fap = area;

}

int flash_area_write(const struct flash_area *fa, off_t off, const void *src, size_t len)
{
  if (!is_in_flash_area_bounds(fa, off, len)) {
    return -EINVAL;

  }

  return flash_write(fa->fa_dev, fa->fa_off + off, (void *)src, len);
}

5.2.2 设备树分区

相信大家之前已经了解了设备树的概念,在zephyr.dts文件里面,每一种NVM,不管是Flash还是RRAM等,都会划分成不同的分区,下面是一个设备树分区的例子:

上面,设备树定义了boot,slot0,slot1和storage四个分区,其中storage_partition,即用户数据存储区是我们经常看到并会用到的,其他分区很少被NCS代码引用。

如前所述,设备树定义的Flash分区结构将赋给变量flash_map,具体赋值操作是由zephyr\subsys\storage\flash_map\flash_map_default.c完成的,请注意,只有设备树分区生效时,flash_map_default.c才会生效,否则将采用pm.yml对应的文件。flash_map_default.c关键代码行定义如下所示:

const int flash_map_entries = ARRAY_SIZE(default_flash_map);
const struct flash_area *flash_map = default_flash_map;

const struct flash_area default_flash_map[] = {
  DT_INST_FOREACH_STATUS_OKAY(FOREACH_PARTITION)
};

#define FOREACH_PARTITION(n) DT_FOREACH_CHILD(DT_DRV_INST(n), FLASH_AREA_FOOO)

#define FLASH_AREA_FOO(part)              \
  {.fa_id = DT_FIXED_PARTITION_ID(part),          \
   .fa_off = DT_REG_ADDR(part),           \
   .fa_dev = DEVICE_DT_GET(DT_MTD_FROM_FIXED_PARTITION(part)),          \
   .fa_size = DT_REG_SIZE(part), },

5.2.3 pm.yml分区

pm是Partition Manager的简称,yml是一种文件格式,pm.yml分区就是用yml文件格式来定义各个分区,它实现的功能跟上面的Devicetree分区大同小异,只是定义分区的格式不一样。pm.yml分区后的结果将呈现在build目录下的partitions.yml文件中,这个文件跟autoconf.h一样,是一个编译系统自动生成的文件,它体现了存储器最终的分区结果,用户不能直接修改,跟kconfig一样,用户只能通过修改它的输入文件来间接修改这个文件,后面我们会细讲如何修改partitions.yml。现在我们先看一个partitions.yml实际例子:nrf/samples/bluetooth/peripheral_uart/build/partitions.yml,这个就是我们上面例子生成的分区文件。

EMPTY_0:
  address: 0xd800
  end_address: 0xe000
  placement:
    after:
    - mcuboot
  region: flash_primary
  size: 0x800
EMPTY_1:
  address: 0x162000
  end_address: 0x163000
  placement:
    after:
    - mcuboot_secondary
  region: flash_primary
  size: 0x1000
app:
  address: 0xe800
  end_address: 0xb8000
  region: flash_primary
  size: 0xa9800
bootconf:
  address: 0xffd080
  end_address: 0xffd084
  region: bootconf
  size: 0x4
mcuboot:
  address: 0x0
  end_address: 0xd800
  placement:
    align:
      end: 0x1000
    before:
    - mcuboot_primary
  region: flash_primary
  size: 0xd800
mcuboot_pad:
  address: 0xe000
  end_address: 0xe800
  placement:
    before:
    - mcuboot_primary_app
  region: flash_primary
  size: 0x800
mcuboot_primary:
  address: 0xe000
  end_address: 0xb8000
  orig_span: &id001
  - mcuboot_pad
  - app
  region: flash_primary
  sharers: 0x1
  size: 0xaa000
  span: *id001
mcuboot_primary_app:
  address: 0xe800
  end_address: 0xb8000
  orig_span: &id002
  - app
  region: flash_primary
  size: 0xa9800
  span: *id002
mcuboot_secondary:
  address: 0xb8000
  end_address: 0x162000
  placement:
    after:
    - mcuboot_primary
    align:
      start: 0x1000
  region: flash_primary
  share_size:
  - mcuboot_primary
  size: 0xaa000
otp:
  address: 0xffd500
  end_address: 0xffd9fc
  region: otp
  size: 0x4fc
settings_storage:
  address: 0x163000
  end_address: 0x165000
  placement:
    after:
    - app
    align:
      start: 0x1000
    before:
    - end
  region: flash_primary
  size: 0x2000
sram_primary:
  address: 0x20000000
  end_address: 0x2002f000
  region: sram_primary
  size: 0x2f000

从上面可以看出,这个partitions.yml定义了很多分区,比如app,mcuboot,mcuboot_pad,mcuboot_primary等(冒号前面的就是分区名),而且每一个分区规定了它的起始地址,结束地址,大小,相对位置以及放在什么物理存储器上,比如app这个分区:

 

关于分区名,只有“app”这个名字是系统预留的并且app分区也是系统自动生成的,其他名字及对应的分区都是人为指定的,他们的分区名直接采用NCS默认的名字即可,但是他们的起始地址和结束地址必须人为指定。如前所述,flash_area API是通过ID来找到各个分区的句柄,pm.yml生成的分区对应的ID可以在build目录下的pm.config文件找到,比如上面的例子它所在的路径为:nrf\samples\bluetooth\peripheral_uart\build\pm.config,如下:

上面把mcuboot_secondary分区的ID用红框圈出来了,mcuboot_secondary是实现DFU非常关键的一个分区,新image就存放在这个分区里面,mcuboot_secondary操作的关键代码如下所示:

#define FLASH_AREA_IMAGE_0_SLOTS    PM_MCUBOOT_PRIMARY_ID, PM_MCUBOOT_SECONDARY_ID,
#define ALL_AVAILABLE_SLOTS FLASH_AREA_IMAGE_0_SLOTS
static inline uint32_t __flash_area_ids_for_slot(int img, int slot)
{
    static const int all_slots[] = {
  ALL_AVAILABLE_SLOTS
  MCUBOOT_S0_S1_SLOTS
    };
    return all_slots[img * 2 + slot];
};

#define FLASH_AREA_IMAGE_SECONDARY(x) __flash_area_ids_for_slot(x, 1)

rc = flash_area_open(FLASH_AREA_IMAGE_SECONDARY(image_index),
            &fap_secondary_slot);

rc = boot_copy_region(state, fap_secondary_slot, fap_primary_slot, 0, 0, size);

mcuboot_secondary分区既可以放在芯片内部Flash,又可以放在外部Flash。如下为mcuboot_secondary在内部Flash的情况:

mcuboot_secondary:
  address: 0xb8000
  end_address: 0x162000
  placement:
    after:
    - mcuboot_primary
    align:
      start: 0x1000
  region: flash_primary
  share_size:
  - mcuboot_primary
  size: 0xaa000

下面为mcuboot_secondary在外部Flash的情况:

mcuboot_secondary:
  address: 0x0
  end_address: 0x166000
  orig_span: &id003
  - mcuboot_secondary_pad
  - mcuboot_secondary_app
  region: external_flash
  size: 0x166000
  span: *id003

可以看出当region为external_flash时,mcuboot_secondary将放在外部Flash;当region为flash_primary时,mcuboot_secondary将放在内部Flash。NCS支持哪些物理存储器,即partitions.yml里面region字段可以取哪些值,这个可以在nrf\cmake\partition_manager.cmake找到,大家也可以直接在build目录下的regions.yml文件中查看当前工程支持哪些物理存储器,比如上面例子:

需要特别指出的是,分区名和物理存储器名是两套独立的系统,他们可以取一样的名字,比如:

这里,第一个“external_flash”是分区名,第二个“external_flash”是物理存储器名。当你把升级image放在外部Flash,即mcuboot_secondary放在外部Flash,外部Flash没有用完的区域会被编译系统自动命名为external_flash分区。作为分区名的external_flash,可以用来存放文件系统,比如littlefs,早期的zephyr的确是把littlefs对应的分区取名为external_flash,最新的zephyr为了避免混淆,已经把littlefs对应的分区改名为:littlefs_storage,即将上面的分区重新定义为:

 

仔细观察nrf/samples/bluetooth/peripheral_uart/build/partitions.yml,我们会发现这个文件还包含placement,span和share size等关键字,placement又包含after,before和align等关键字,这些关键字都是用来指定各个分区的相对位置,比如mcuboot_secondary share_size mcuboot_primary,表示mcuboot_secondary分区跟mcuboot_primary分区一样大;mcuboot_primary span app mcuboot_pad,表示mcuboot_primary分区包含app和mcuboot_pad两个分区;mcuboot_pad placement before mcuboot_primary_app,表示mcuboot_pad分区放在mcuboot_primary_app分区前面,等等。很多人会疑惑:既然指定了分区的起始地址和结束地址,那还有必要去指定各个分区的相对位置吗?这种情况下的确没必要再指定相对位置了,其实这里弄反了一件事情:partitions.yml里面的起始地址和结束地址是根据pm.yml源文件规定的相对位置以及kconfig的值自动生成的,也就是说,先有相对位置,后有绝对地址。一般来说,各个分区的相对位置是不变的,变的只是每个分区的大小。相对位置的引入,为编译系统动态确定各个分区的位置提供了便利。

前面也说过partitions.yml这个文件是系统自动生成,用户不能直接修改,那如果我们想调整系统默认分区,怎么办?首先明确一点,系统有哪些分区,这个是系统定义的,我们直接采用默认值即可;其次,这些系统定义的分区,他们之间的相对位置也是固定的,我们也无法更改;其实,我们能改的就是每个分区的大小,或者增加一个自己定义的分区。当我们更改其中某一个分区的大小的时候,其他分区大小也是跟着一起变的,这里面两个分区是关键,一个是mcuboot分区的大小,一个是用户数据存储区的大小,一旦这两个分区大小确定了,其他分区跟着一起确定。为此,NCS中定义了如下两个kconfig用来控制mcuboot和settings存储区的大小:

CONFIG_PM_PARTITION_SIZE_MCUBOOT //mcuboot分区大小,需放在mcuboot的prj.conf中
CONFIG_PM_PARTITION_SIZE_SETTINGS_STORAGE //settings分区大小,需放在应用的prj.conf中

我们还是以nrf/samples/bluetooth/peripheral_uart为例,把mcuboot分区调整为0x10000,settings分区调整为0x4000。

如前所述,CONFIG_PM_PARTITION_SIZE_MCUBOOT需放在mcuboot的prj.conf中,大家可以参考“sysbuild和多image管理”来完成这个工作:我们先在sysbuild目录新建一个mcuboot.conf文件(其实就是一个以.conf为后缀的文本文件),然后加入如下语句:

CONFIG_PM_PARTITION_SIZE_MCUBOOT=0x10000

然后在根目录下的prj.conf加入:

CONFIG_PM_PARTITION_SIZE_SETTINGS_STORAGE=0x4000

重新编译peripheral_uart,编译成功后,我们将得到一个新的partitions.yml:里面的分区将根据上面两个kconfig的值自动调整,如下:

 

除了kconfig这种更改方式,我们还可以通过pm_static.yml方式更改,pm_static.yml可以看成是partitions.yml的overlay(覆盖)文件,也就是说,他们俩的语法一模一样,一旦一个分区在pm_static.yml里面定义了,它将自动覆盖系统默认定义,最终生成的partitions.yml分区定义以pm_static.yml为准;反之,pm_static.yml里面没有定义的分区,将自动采用系统默认定义。如果你需要定义一个系统没有的分区用来存放特定的用户数据,比如Matter的生产数据,那么我们就可以使用pm_static.yml来定义,当然pm_static.yml也可以用来改变系统定义的分区大小,比如前面的mcuboot分区和settings分区。pm_static.yml是标准Zephyr命名,你只能使用跟它一模一样的名字,否则系统不会自动识别。有时一个工程会同时支持多个板子,而每个板子的分区又不一样,此时就不能使用单一的pm_static.yml文件,而是每个板子都使用自己的pm_static.yml文件,这个可以通过pm_static_<board>.yml实现,也就是说,把每个板子编号放在pm_static后面,这个分区文件就是该板子独有的,别的板子就不适用。比如板子nrf54l15dk_nrf54l15_cpuapp,它对应的分区文件为:pm_static_nrf54l15dk_nrf54l15_cpuapp.yml,pm_static_nrf54l15dk_nrf54l15_cpuapp.yml会被系统自动识别而且自动应用在nrf54l15dk_nrf54l15_cpuapp开发板上,而其他板子将忽略这个文件。

下面我们使用pm_static.yml来创建一个新分区:factory_data,同时更改mcuboot分区大小为0xd800,并将settings分区大小调整为0x3000。在peripheral_uart根目录下,新建一个文件:pm_static.yml(其实就是yml为后缀的文本文件),然后加入如下定义:


mcuboot:
  address: 0x0
  size: 0xd800
settings_storage:
  address: 0x161000
  size: 0x3000
factory_data:
  address: 0x164000
  size: 0x1000

 

重新编译peripheral_uart,编译成功后,我们将得到一个新的partitions.yml:里面的分区将根据上面pm_static.yml自动调整,如下:

细心的读者可能已经发现了,我在编译上面例子的时候,并没有将之前的CONFIG_PM_PARTITION_SIZE_MCUBOOT和CONFIG_PM_PARTITION_SIZE_SETTINGS_STORAGE修改撤销,但是最终的partitions.yml还是以pm_static.yml里面的值为准,说明pm_static.yml的优先级高于kconfig

有时为了兼容不同NCS版本默认生成的partitions.yml,我们可以把我们工程用到的所有分区都定义在pm_static.yml里面,这样不管NCS如何升级,其最终生成的partitions.yml一定是相同的,从而保证我们可以在不同NCS版本之间自由升级。这种情况下,一般建议先生成系统默认的partitions.yml,然后把它拷到工程根目录,并改名为:pm_static.yml,然后在此基础上进行修改。此时,我们每一个region(物理存储器)的所有地址空间都要定义在pm_static.yml里面,也就是说,不能出现某一个物理地址空间在pm_static.yml里面没有对应的分区。另外,分区与分区之间的地址范围是可以重叠的,只要你的代码不会去操作这些重叠的分区。

总结一下,kconfig配置适用于此工程支持的所有板子,pm_static.yml配置也适用于此工程支持的所有板子,pm_static_<board>.yml只适用于某单一板子。一般来说,kconfig配置起来还是简单些,除了前面提到两个kconfig,我们还会经常用到下面几个kconfig:

SB_CONFIG_PM_EXTERNAL_FLASH_MCUBOOT_SECONDARY=y
SB_CONFIG_PM_OVERRIDE_EXTERNAL_DRIVER_CHECK=y
CONFIG_PM_PARTITION_SIZE_SETTINGS_STORAGE=0x4000   //change the value accordingly
CONFIG_PM_PARTITION_SIZE_LITTLEFS=0x6000           //change the value accordingly
CONFIG_PM_PARTITION_SIZE_MCUBOOT=0x10000           //change the value accordingly

5.3 升级镜像存放在外部Flash

有时应用image比较大,基本上把芯片内部Flash占满了,内部Flash无法腾出一个空间来存放升级image,此时我们可以把升级image存放在外部Flash,不管升级image存放在内部Flash,还是存放在外部Flash,它都是放在Secondary slot中,也就是说,此时Secondary slot是定义在外部Flash中。 如何将Secondary slot定义在外部Flash中?首先,我们将5.2节的修改撤销,将上面的peripheral_uart工程恢复到5.1节状态。然后我们在peripheral_uart/sysbuild.conf加入:

SB_CONFIG_PM_EXTERNAL_FLASH_MCUBOOT_SECONDARY=y

 

这样Secondary slot就会自动定义在外部Flash。

然后,我们需要选择外部Flash驱动:QSPI NOR Flash驱动还是SPI NOR Flash驱动,以及外部Flash的型号,这里之所以说是选择,是因为Nordic所有开发板都自带一块外部Flash,并且外部Flash驱动都已写好并成为Zephyr的一个库,我们只需选择相应的Flash型号和驱动即可。

一般我们可以在工程根目录下app.overlay文件,加入如下修改以选择外部Flash:

nordic,pm-ext-flash = &mx25r64;

但对于peripheral_uart这个工程,由于工程根目录下已经有boards目录,boards目录下已经有nrf54l15dk_nrf54l15_cpuapp.overlay,此时app.overlay是不生效的,因此我们需要把上述修改加到nrf54l15dk_nrf54l15_cpuapp.overlay,如下:

上面修改告诉应用,外部Flash使用mx25r64驱动。除了应用需要访问外部Flash,我们的mcuboot需要swap image,它也需要访问外部Flash,因此我们还需要在mcuboot工程对应的overlay里面作上述同样修改。大家可以参考“sysbuild和多image管理”来完成这个工作:我们首先在peripheral_uart/sysbuild目录下,新建一个mcuboot.overlay文件,然后做如下修改:

 

对于nRF52840DK和nRF5340DK等,因为这些DK都是使用QSPI连接外部Flash,这个是NCS默认的连接方式,我们的修改基本上就结束了(mcuboot的配置有可能还需要改CONFIG_PM_PARTITION_SIZE_MCUBOOT和CONFIG_BOOT_MAX_IMG_SECTORS这两个配置),此时工程已经实现了我们的目标:支持升级image放在外部Flash的蓝牙OTA升级功能;对于nRF54L15DK,nRF7002DK和nRF52832DK等,因为他们是通过SPI连接外部Flash, 我们必须在prj.conf或者boards/<board>.conf里面显式地指定外部Flash的连接方式,我们可以加入如下配置:

CONFIG_NORDIC_QSPI_NOR=n
CONFIG_SPI=y
CONFIG_SPI_NOR=y
CONFIG_SPI_NOR_SFDP_DEVICETREE=y
CONFIG_SPI_NOR_FLASH_LAYOUT_PAGE_SIZE=4096

 

跟overlay文件一样,上面的kconfig不仅要加在应用的prj.conf,还需要加在mcuboot的prj.conf,由于MCUboot默认是单线程的而SPI NOR Flash必须在多线程下跑,为此还需要额外加上:CONFIG_MULTITHREADING=y。

由于我们将secondary slot放到了外部Flash,这样我们的主应用image可以占据更多的空间,假设MCUboot大小为0x10000,即CONFIG_PM_PARTITION_SIZE_MCUBOOT=0x10000,settings分区大小为0x2000,对于nRF54L15,设备树默认分配0x165000(1428kB)空间给主应用,这样主应用image可以占据的空间为:0x165000 - 0x10000 - 0x2000 = 0x153000,0x153000就是我们的mcuboot_primary和mcuboot_secondary空间的大小,我们的PM系统会自动帮你计算各个分区的大小,所以我们不用显式地指定各个分区的大小(当然,你显式地指定也是没问题的)。但是,我们MCUboot代码会去检验我们的image,同时会将每个sector(4KB大小)的swap状态保存到Flash,MCUboot是通过CONFIG_BOOT_MAX_IMG_SECTORS得知主应用大小的最大值,并在swap status对应的Flash区域预留CONFIG_BOOT_MAX_IMG_SECTOR个word来保留每个sector的swap状态,这就要求CONFIG_BOOT_MAX_IMG_SECTORS * 4kB必须大于等于mcuboot_primary分区大小。CONFIG_BOOT_MAX_IMG_SECTORS默认值是256,对应1MB的主应用大小,对于nRF54L15来说,显然是不够的,为此我们将其改为512,即对应2MB应用大小。综上,mcuboot.conf最终的配置如下:

然后,我们选择nrf54l15dk/nrf54l15/cpuapp再次编译这个工程,编译成功后,我们将得到一个新的partitions.yml:里面的分区将根据上面要求自动调整,如下:

后面,大家就可以按照5.1节的步骤去测试支持升级image放在外部Flash的蓝牙OTA升级功能。

可以看出,同一个工程,并且在用户层面展示的代码也是一样的,通过更改Devicetree和Kconfig配置,就可以实现不同的蓝牙OTA功能,这个例子再次说明了Zephyr的灵活和强大。 

5.4 压缩DFU

当应用固件体积超过nRF芯片内部Flash容量的50%,但尚未达到必须外挂Flash的程度时,可采用压缩DFU方案,即把升级image压缩,这样mcuboo_secondary分区就可以比mcuboot_primary分区小,这样主应用就可以超过内部Flash容量的一半。NCS支持压缩DFU,压缩算法为LZMA2,根据升级image数据的不同,LZMA2压缩比率是不一样,为了安全起见,我们一般将mcuboot_secondary设为mcuboot_primary的70%。NCS自带一个压缩DFU例子:nrf\samples\nrf_compress,它其实就是文章开头的zephyr\samples\subsys\mgmt\mcumgr\smp_svr的延伸版,加上了一些配置,使其支持压缩DFU。下面我们给大家演示如何将压缩DFU功能移植到peripheral_uart。

首先计算mcuboo_secondary和mcuboot_primary的大小。由于mcuboo_secondary和mcuboot_primary大小不一样,我们就不能依靠系统自动生成这两个分区,而必须通过前面所述的pm_static.yml方式来人为指定各个分区的大小,下面我们以nRF54L15为例来计算这两个分区(其他芯片计算方法与此类似),首先假设mcuboot分区为0x10000,settings区域为0x2000,nRF54L15设备树默认分配0x165000(1428kB)空间给用户,这样mcuboot_primary和mcuboo_secondary两者之和可以占据的空间为:0x165000 - 0x10000 - 0x2000 = 0x153000,假定mcuboot_secondary为mcuboot_primary的70%,这样可以得到mcuboot_primary分区为0xC7696左右,由于mcuboo_secondary和mcuboot_primary分区必须0x1000对齐,mcuboot_primary最终圆整为:0xC7000,mcuboo_secondary圆整为:0x8C000。

总可用空间 = 设备树分配空间(0x165000) 
            - mcuboot分区(0x10000) 
            - settings区域(0x2000)
          = 0x153000

计算过程:
mcuboot_primary ≈ 总空间 / (1 + 0.7) ≈ 0xC7696
对齐后:
mcuboot_primary = 0xC7000 (按0x1000对齐)
mcuboot_secondary = 0x8C000

然后我们将上面的peripheral_uart工程恢复到5.1节状态,然后在peripheral_uart工程根目录下新建一个文件:pm_static_nrf54l15dk_nrf54l15_cpuapp.yml(其实就是以yml为后缀的文本文件),然后在其中定义如下分区:

app:
  address: 0x10800
  end_address: 0xd7000
  region: flash_primary
  size: 0xc6800
mcuboot:
  address: 0x0
  end_address: 0x10000
  region: flash_primary
  size: 0x10000
mcuboot_pad:
  address: 0x10000
  end_address: 0x10800
  region: flash_primary
  size: 0x800
mcuboot_primary:
  address: 0x10000
  end_address: 0xd7000
  orig_span: &id001
  - mcuboot_pad
  - app
  region: flash_primary
  size: 0xc7000
  span: *id001
mcuboot_primary_app:
  address: 0x10800
  end_address: 0xd7000
  orig_span: &id002
  - app
  region: flash_primary
  size: 0xc6800
  span: *id002
mcuboot_secondary:
  address: 0xd7000
  end_address: 0x163000
  region: flash_primary
  size: 0x8c000
settings_storage:
  address: 0x163000
  end_address: 0x165000
  region: flash_primary
  size: 0x2000

image 

然后在sysbuild.conf中使能如下kconfig:

SB_CONFIG_MCUBOOT_COMPRESSED_IMAGE_SUPPORT=y
SB_CONFIG_BOOT_SIGNATURE_TYPE_ECDSA_P256=y
SB_CONFIG_MCUBOOT_MODE_OVERWRITE_ONLY=y
SB_CONFIG_MCUBOOT_UPDATEABLE_IMAGES=1

SB_CONFIG_MCUBOOT_COMPRESSED_IMAGE_SUPPORT是我们的核心Kconfig,表示升级image会被压缩,MCUboot配置为压缩模式。

至此,将压缩DFU功能移植到peripheral_uart工程已算完成。

不管是压缩DFU还是非压缩DFU(前面的章节描述的DFU),他们的升级文件都是zephyr.signed.bin(或者dfu_application.zip),但是zephyr.signed.bin的大小是不一样的,非压缩DFU zephyr.signed.bin跟原始image文件zephyr.bin差不多大,比如我们5.1节例子生成的两个文件:

但压缩DFU  zephyr.signed.bin会比原始image文件zephyr.bin小很多,下面是上面移植后的工程编译成功后得到的两个文件的大小: 

image

zephyr.signed.bin为153kB,zephyr.bin为263kB,压缩比为153/263=58.2%

我们可以按照第2章的测试步骤来测试一下压缩DFU,这里面唯一需要注意的是,手机版nRF Connect还不支持压缩DFU,目前只能使用手机版Device Manager来测试压缩DFU。把上面代码下载到nRF54L15 DK,然后修改DEVICE_NAME为“new_dfu”,再次编译,然后把dfu_application.zip或者zephyr.signed.bin拷贝到手机,打开手机app:Device manager,然后依次执行下面操作:

image

image

image

image 

image 

升级成功后我们可以看到如下串口日志:

ff74401377e95fbd37c295a792b12826

5.5 使用自己的签名验签密钥

5.5.1 概述

前面所有例子编译的时候,我们都可以看到如下警告:

image

重要安全提示:MCUboot默认使用公开的签名密钥进行固件签名,该密钥由于公开暴露会导致重大安全隐患——任何第三方均可利用此密钥伪造合法签名,从而实施固件篡改等恶意行为。因此必须特别强调:在量产固件时,务必使用自行生成的私有密钥替换默认密钥

首先你需要选择签名密钥的类型,NCS目前主要支持三种:SB_CONFIG_BOOT_SIGNATURE_TYPE_ECDSA_P256,SB_CONFIG_BOOT_SIGNATURE_TYPE_ED25519和SB_CONFIG_BOOT_SIGNATURE_TYPE_RSA,RSA由于计算速度慢,占用的资源又多,现在一般比较少采用。ECDSA_P256使用椭圆曲线数字签名算法,曲线为P-256,它计算速度次之,占用资源最少。ED25519使用Edwards曲线数字签名算法,它计算速度最快,占用资源次之。前面的非压缩DFU选用的是ED25519签名密钥,压缩DFU选择的是ECDSA_P256签名密钥。

密钥选好后,就要选择它的实现方式,每种签名类型可能有多种实现方式,它们在性能、安全性和资源占用上有所不同。签名验签实现方式主要可分为软件实现和硬件加速两大类。在软件实现方面,主要包含TinyCrypt和mbedTLS两种方案。TinyCrypt作为轻量级加密库,具有代码体积小的特点,非常适合资源受限的嵌入式设备使用,但其功能相对有限。而mbedTLS则是功能更为全面的加密库,支持更多算法和功能,但相应地会占用更多系统资源。在硬件加速方面,主要解决方案包括CC310和PSA Crypto。CC310是Nordic芯片专用的硬件加密加速器,能够显著提升加密运算性能并降低CPU负载,但需要特定硬件支持。PSA Crypto则是基于平台安全架构的加密API,提供标准化的加密接口,可以透明地使用底层硬件加速能力,但同样需要硬件平台提供PSA支持。此外,MCUboot还提供了完善的密钥管理功能,包括:使用硬件密钥管理单元(KMU)安全存储密钥,避免密钥明文存储在固件中,支持1-3个密钥槽和自动密钥撤销机制;灵活的密钥导入方式,既支持传统的PEM文件编译方式,也支持运行时从硬件安全模块动态获取;以及可选的硬件密钥验证功能。

非KMU实现方式如下所示:

deepseek_mermaid_20250726_279292

KMU实现方式如下所示:

deepseek_mermaid_20250726_7b8f0d

大家可以参考如下表格选择自己的密钥实现方式: 

image

对于大多数人都会选择TinyCrypt方式,因为它占用资源最少,适配的芯片平台最多,实际上,NCS默认就是采用TinyCrypt方式。我们前面的例子,非压缩DFU选择ED25519密钥,压缩DFU选择ECDSA_P256密钥,但他们的后端实现方式都是TinyCrypt。对于MCUboot来说,这是一个比较明智的选择。对于应用来说,如果它也用到了签名和验签,或者使能了TFM,那么它就不会选择TinyCrypt,而会选择更高安全级别的组合:PSA+硬件加速,或者PSA+KMU。这是两个独立的密钥实现方案选择机制:应用层的密钥实现方式与MCUboot的密钥实现方式是完全解耦的,二者可以分别独立配置,互不影响。

5.5.2 替换默认公私钥

下面我们给大家演示如何将默认的ED25519密钥替换成自己的ECDSA_P256密钥(大家也可以将默认的ED25519密钥替换成自己的ED25519密钥,方法会更简单,就不在这演示)。

首先,我们需要生成自己的P256私钥,这个方法很多,由于NCS工具链自带imgtool工具,这里使用imgtool来生成P256私钥。执行imgtool必须在NCS工具链环境中,为此,我们需打开nRF Connect终端,如下:

image

进入到nrf\samples\bluetooth\peripheral_uart根目录,然后输入如下命令:

imgtool keygen -k my-ec-p256.pem -t ecdsa-p256

image

上述命令成功执行后,将会生成如下私钥文件,这个就是我们自己的P256私钥,请妥善保管,不要泄露给第三方:

image

然后我们将peripheral_uart工程P256私钥设置为这个新生成的私钥。请先确保peripheral_uart例子恢复到5.1节状态,然后在sysbuild.conf添加如下配置:
SB_CONFIG_BOOTLOADER_MCUBOOT=y
SB_CONFIG_BOOT_SIGNATURE_TYPE_ECDSA_P256=y
SB_CONFIG_BOOT_SIGNATURE_KEY_FILE="C:/ncs/v3.0.0/nrf/samples/bluetooth/peripheral_uart/my-ec-p256.pem"

image

如前所述,我们还需选择P256后端实现方式,这个是MCUboot固件的配置,为此,我们在sysbuild目录下新建一个mcuboot.conf文件(其实就是一个以.conf为后缀的文本文件),然后加入如下语句:

CONFIG_BOOT_ECDSA_TINYCRYPT=y

image

到这里,我们的密钥替换工作就结束了,我们重新编译peripheral_uart工程,成功后,可以看到MCUboot镜像从之前的50kB变成了33kB,如下: 

image

image

此时,如果我们使用之前的升级镜像去升级我们的设备,应用会拒绝升级;你只有使用这个新密钥签名的升级镜像才能升级我们的固件。 

6 手机端DFU参考代码

Nordic不仅提供设备端的DFU参考代码,同时提供手机端的参考代码。Nordic分别开发了Android版和iOS版的DFU库,大家可以直接拿过来使用,集成到自己的移动端app中。前面蓝牙OTA升级的演示都是基于手机app:nRF Connect来做的,nRF Connect功能非常强大,对很多用户来说也不好掌控,因此它的源代码并没有开放。Nordic还有一个移动端app:nRF Toolbox,nRF Toolbox是代码开源的,也可以用来完成上述蓝牙OTA升级功能,大家可以参考nRF Toolbox来开发自己的移动端app,其源码放在Github上:

nRF Toolbox软件界面如下所示: 

 

不管是nRF Connect手机app,还是nRF Toolbox手机app,他们的SMP DFU都使用了Device manager库,大家也可以直接参考Device manager手机app来完成SMP DFU手机端app开发:

Device manager手机app启动界面如下所示:

919e2d638e5f8eec60af3dcdc5811bcd

 

posted on 2022-04-01 11:08  iini  阅读(21522)  评论(7)    收藏  举报

导航