如何在nRF Connect SDK(NCS)中实现蓝牙空中升级?MCUboot和B0两个Bootloader有什么区别?MCUboot升级使用的image格式是怎么样的?什么是SMP协议?CBOR编码如何解读?NCS可不可以进行单bank升级?可不可以把一个nRF5 SDK应用升级到NCS应用?MCUboot拷贝操作中的swap和overwrite有什么区别?为什么说MCUboot升级永远都不可能变砖?本文将对以上问题进行阐述。
目录
先讲一下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中,然后等待二次或者多次升级尝试,此时设备的正常功能已无法使用,从用户使用这个角度来说,你的确可以说此时设备已经“变砖”了。所以说,虽然双区模式牺牲了很多存储空间,但是换来了更好的升级体验。
可参考下面三个图来理解上述过程。
如果你是第一次接触nRF Connect SDK(NCS),那么建议你先看一下这篇文章:开发你的第一个NCS/Zephyr应用程序,以建立NCS的一些基本知识,然后再往下看以下章节。
如果你的应用不需要DFU功能,那么Bootloader就可以不要;反之,如果你的应用需要DFU功能,Bootloader就一定需要。Bootloader在其中起到的作用包括:一判断正常启动还是DFU升级流程,二启动并校验应用image,三升级的时候完成新image和老image的交换或者拷贝工作。进一步说,
启动向量表可以放在image的最开始处,也可以放在其他地方,这就涉及到image的格式。Image正确的校验值可以跟image合在一块存放,也可以单独放在一个flash page里面。如果image的校验值是跟image本身合在一块存放的,这里再次涉及到image的格式。关于新image和老image存放位置,这就涉及到存储器分区问题。Bootloader的实现将直接决定image的格式,以及存储器的结构划分。
NCS支持MCUboot,B0和nRF5 Bootloader三种Bootloader,三个Bootloader选其一即可,一般推荐大家使用MCUboot。由于很多读者对Nordic老的SDK,即nRF5 SDK比较熟悉,我们先以这个nRF5 Bootloader为例来讲解他们的Flash分区以及image格式,然后再讲MCUboot和B0,看看他们又是如何分区和定义image格式的。注意:如果你只对其中某一个具体的Bootloader感兴趣,可以跳过其他章节,直接阅读相关章节,比如如果你只对MCUboot感兴趣,可以只看2.2节。
nRF5 Bootloader是指nRF5_SDK_17.1.0_ddde560\examples\dfu\secure_bootloader这里面定义的Bootloader,如果你的DFU想使用这个Bootloader,那么nRF5 SDK的存储区划分(双bank)是下面这样的:
在nRF Connect SDK(NCS)中,如果也使用nRF5 Bootloader,此时存储器的分区跟上面大同小异,我们用NCS中的语言重新组织如下:
当前固件(老固件)在Bank0里面执行,新固件接收后直接存放在Bank1,而且程序永远只执行Bank0里面的代码,Bank1的起始地址是动态的,其计算公式为:Bank0起始地址 + Bank0 image大小。由于nRF5 Bootloader跳到Bank0的时候,直接跳到一个固定地址(0x1000),因此它不需要专门去找新image的启动向量,换句话说,如果使用nRF5 Bootloader的话,新image就是应用代码编译后的样子,不需要添加任何的头或者尾信息。如果这样的话,image的SHA256或者签名校验怎么做?在nRF5 Bootloader中,把正确的SHA256或者签名放在settings page里面,这样image就真得不需要任何头或者尾信息,当需要校验image的时候,从settings page中取出标准值,然后进行校验。那这些标准的SHA256或者签名怎么从远程传过来呢?答案是init包,所以nRF5 Bootloader升级的时候,需要把一个zip包传给目标设备,如下所示:
这个zip包除了新image本身,还包含一个dat文件,这个dat文件包含新image的大小,SHA256,签名等信息。
至于升级拷贝,nRF5 Bootloader做法也很简单,先擦掉Bank0里面的内容,然后把Bank1里面的内容拷贝到Bank0,然后重新从Bank0启动,完成整个升级。在拷贝之前,Bootloader会校验Bank1里面的image完整性,只有校验通过才会做下一步的拷贝工作,否则退出升级模式。从上可以看出,虽然nRF5 Bootloader会校验image的完整性,但是如果出现发版错误(打个比方,Win11和Win7都是微软验签,因此完整性校验都可以通过,但是如果微软把Win11发到一台只能跑Win7的设备上,那么这台设备将无法运行),由于它没有新image确认操作,也不支持回滚操作,那么升级后系统有可能挂死在一个错误的版本里面。
说完了启动,校验和升级拷贝,最后说一下如何进入DFU模式。在nRF5 Bootloader里面,通过判断某些Flag(标志位)来决定要不要进入DFU模式,这些标志位有一个为真,进入DFU模式,否则正常启动app:
可以看出,整个判断逻辑还是比较简单,大家很容易读懂相关的源代码。
nRF5 Bootloader既可以运行在nRF5 SDK中,也可以运行在NCS中。nRF5 Bootloader既支持非后台式DFU,也支持后台式DFU,我们做了一个跑在NCS中的后台式DFU例子:https://github.com/aiminhua/ncs_samples/tree/master/nrf_dfu/ble_intFlash_nrf5_bl。跟nRF5 SDK DFU相比,这个例子有两个要注意的地方:
从这个例子大家可以体会到,分区和新image格式只跟Bootloader有关,跟SDK或者DFU协议无关。
下面是nRF5 Bootloader启动的一个示例,供大家参考:
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放在0x000000地址。
如前所述,Bootloader有四大功能:启动image,校验image,拷贝image以及DFU模式判断,那么MCUboot是如何完成这4项功能的:
从上可以看出,image的最开始是image header,而不是image启动向量。Image header里面有一个字段image header size,启动向量就位于image header size的偏移处,image header一般为0x200大小,一般来说,app的基地址是0xC000,这样image的启动向量就在0xC000+0x200=0xC200,MCUboot启动app的时候就跳转到0xC200这个地址。
2. 校验image。MCUboot通过读image的尾信息(tail或者tlv),得到image的SHA256和签名,从而完成校验。Image tlv紧跟在image后面,其内容示例如下所示:(感兴趣的读者,仔细看一下各个结构体字段定义,并对应image hex进行解读)
上述示例解读结果为:没有IMAGE_TLV_PROT_INFO_MAGIC,只有普通的IMAGE_TLV_INFO_MAGIC,IMAGE_TLV_INFO_MAGIC总共有3个tag:IMAGE_TLV_SHA256 (0x10), IMAGE_TLV_KEYHASH(0x01),以及IMAGE_TLV_ECDSA256(0x22)。
nRF5 Bootloader把app image的SHA256和签名放在settings page里,这样每次重新编译一次app image,还需要重新生成一个settings page,然后把两者一起合并烧到芯片里,这样Bootloader才能通过image完整性校验而跳到app;如果只把新编译的app image烧到芯片里,此时image完整性校验将失败而导致程序一直死在Bootloader里,可以看出这种方案是不太方便开发和调试的。而MCUboot把app image的SHA256和签名放在image后面,这样每次重新编译一次app image,新的sha256和签名会自动跟着一起更新,你只需直接下载app而无需去更改Bootloader任何部分,大大方便了开发和调试。
3. Image拷贝。MCUboot支持多种image拷贝动作,确切说是image swap(交换)操作,即把secondary slot里面的image交换到Primary slot,如何swap呢?总体上分swap和overwrite两种。Overwrite跟上面的nRF5 Bootloader一样,即先擦除primary slot里面的老image,然后把secondary slot里面的新image拷贝到primary slot,完成整个升级过程。Swap就是把primary slot和secondary slot里面的image进行交换,即primary slot里面的image搬移到secondary slot,secondary slot里面的image搬移到primary slot。欲swap 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(以两个slot中最大的为准)交换完成。这种算法有两个弊端:一浪费了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模式(打开#define MCUBOOT_OVERWRITE_ONLY)以加快MCUboot拷贝速度。
4. 是否进入DFU模式。nRF5 Bootloader通过判断某些标志位以此决定是否进入DFU模式,与此简单判断不同,MCUboot是通过primary slot和secondary slot的状态组合来决定是否进入DFU模式。在MCUboot中,有一个变量:swap_type,它的取值将决定是否进入DFU模式,而swap_type的值又依赖如下真值表:
swap_type取值
上述的magic,image_ok和copy_done三个字段位于slot最后一个扇区,即slot的最高扇区,他们在扇区中的排布如下所示(magic字段在扇区的最高地址):
从上可知,根据magic,image_ok和copy_done三个变量的不同取值情况,可以得到不同的结果,即swap_type。我们以State1 表格为例来解读其中的结果,State1表格如下:
可以看出,当secondary slot最后一个扇区的magic字段为Good,即设置成正确的值,而且image_ok字段不等于1,即为unset状态,则不管其他变量为什么值(正常情况下,此时其他变量的值都是0xFF),此时swap_type的结果为:BOOT_SWAP_TYPE_TEST,大家以此类推,就知道State2,State3和State4表格的swap_type结果是怎么来的。这里有一点需要大家注意的,magic字段在Flash中只有两种正常取值:全FF和0x96f3b83d,而image_ok和copy_done在Flash中也只有两种正常取值:全FF和0x01,而表格中所谓的“Good”,“Any”,“Unset”,“0x01”,是对上述两种取值的泛化,比如magic字段等于0x96f3b83d,就叫“Good”;image_ok等于0xFF,就叫“Unset”或者“Any”(当然“Any”意味着0x55等其他非法值也可以兼容)。swap_type总共有6种结果,每种结果的意义如下所示:
从上我们可以总结出,为了让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字段为0x96f3b83d,这是通过调用boot_request_upgrade()来实现的,当调用boot_request_upgrade(false)进入BOOT_SWAP_TYPE_TEST模式,当调用boot_request_upgrade(true)进入BOOT_SWAP_TYPE_ PERM模式。
State1,State2,State3和State4四个表格是有优先级顺序的,越往前优先级越高,也就是说,如果State1表格匹配成功就不再匹配后面的表格,此时swap_type就是BOOT_SWAP_TYPE_TEST。下面是MCUboot正常启动的一个示例,可以看出,因为magic,image_ok和copy_done三个变量的取值没有匹配成功真值表State1,State2和State3,但匹配成功State4表格,所以swap_type的最终结果是BOOT_SWAP_TYPE_ NONE,即正常启动app。注:0x3就代表“Unset”(实际取值为0xFF),“Unset”可以看成“Any”一种,因此下述启动日志表明此时swap_type不匹配State1,State2和State3表格,而匹配State4表格。
很多人会好奇为什么MCUboot使用这么复杂的DFU模式判断算法?究其根本,还是因为Flash的限制导致的。Flash每次只能擦一个page(擦除时间还比较长),而且寿命又有限,在尽可能少擦Flash的情况下,又要实现上述那么多swap操作,然后有人就想出了上面的算法。
一般来说,一旦你使能MCUboot(CONFIG_BOOTLOADER_MCUBOOT=y),编译系统会自动帮你生成升级需要的升级文件:app_update.bin或者app_signed.hex(两者内容一模一样)。当然如果你选择双核MCU,那么除了上述应用核的升级文件,编译系统还会自动生成网络核的升级文件:net_core_app_update.bin或者net_core_app_signed.hex(两者内容一模一样)。升级文件示例如下所示:
升级的时候,把相应的升级文件传给设备端,设备端把接收到的升级文件放在secondary slot,待整个image接收完毕,复位进入MCUboot,MCUboot将完成后续工作直至升级成功。
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将存储区划分成如下模样:
如前所述,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进行装载,校验和跳转。
前面说过,为了实现固件升级,需要把新image放在secondary slot(以MCUboot为例),如何把新image传输到secondary slot?这就是DFU协议要做的事情,一般来说,DFU协议需要把image文件分块一块一块传给设备端,然后设备端按照要求将image块写入secondary slot,并回复写入结果给主机。期间有可能还需要校验传输的image对不对,或者告知每次image块写入的偏移地址。最后DFU协议还有可能涉及一些管理操作,比如image块写入的准备工作,读取设备状态,复位设备等。
这里需要特别强调一下,DFU协议是脱离于传输层的,也就是说,同样的DFU协议可以跑到不同的传输层,比如蓝牙,WiFi,UDP,USB CDC,UART等,千万不要把DFU协议跟特定的传输层混为一谈。
nRF Connect SDK包含多种DFU协议,最著名的就是SMP DFU协议,除此之外,还有其他DFU协议,比如http_update,hid_configurator,USB DFU class,PCD DFU,以及从nRF5 SDK移植过来的nrf_dfu协议。不同的应用场景有不同的DFU协议需求,大家需要根据自己的情况选择合适的DFU协议,就像前述的Bootloader一样,这些DFU协议选择一个适合自己的就可以,不需要全部都要会用。下面着重讲一下smp dfu和nrf_dfu两个dfu协议。
smp 全称simple management protocol(简单管理协议),它是设备管理协议的一种,在NCS中,mcumgr模块实现了smp协议,或者说,smp协议按照mcumgr的要求对相应的传输数据进行编码,这样mcumgr里面注册的命令组(command group)可以直接对传输数据进行解析。mcumgr实现的功能比较多,smp DFU只是其中一种,除此之外,它还有很多其他功能,比如shell管理,日志管理等。这里我们只对DFU相关命令组进行介绍,其他命令组就不在这里讲了。
mcumgr里面有两个命令组跟DFU有关:
smp协议把数据包(packet)分成两部分:包头(header)和有效载荷(payload),包头每一个字节正好对应如下结构体的每一个字段,即第一个字节代表nh_op(操作类型),第二个字节代表nh_flags,第三和四个字节代表nh_len,第五和六个字节代表nh_group(命令组编号),第7个字节代表nh_seq,第8个字节代表nh_id(命令在该命令组中的编号)。
这样我们就可以通过SMP的包头找到相应的handler,比如包头00 00 00 02 00 01 00 00,即对应命令组1的0号命令集的00操作(读命令),最终找到img_mgmt_state_read这个handler。我们会在3.2.3节对此示例的解析做详细说明。
SMP payload采用CBOR编码,CBOR将一连串二进制数据分成多个data item,如下所示:
从上可知,每个data item第一个字节包含2部分:数据类型和数据长度,数据类型定义如下:
关于数据长度(count)字段,这个有点特殊,它的定义如下:
count字段后面就紧跟着data payload了,count有多大,data payload就有多长,比如count为0x0032,则表示后面0x32个字节都属于data payload,至此一个data item结束,同时意味着另一个data item的开始,以此往复,周而复始。需要大家注意的是,CBOR中的data item可以嵌套另一个data item,也就是说,data item之间是可以有结构的。
比如数据payload:64 64 61 74 61,0x64(0b011 00100)表示此data item的数据类型为utf-8字符串,长度为4字节,即后面紧跟的64 61 74 61,这4个ASCII码对应的字符就是:”data”,这样我们就成功解析出这个payload了。
smp协议的核心就是通过包头找到要处理该数据包的handler(命令),并把payload打包成一个特定参数传给该handler,然后执行该handler。
我们现在结合上面的定义,再看一个实际的smp数据包(包含包头和payload),看看我们最终解析的结果是什么。
可以看出,nh_op为00,而nh_op定义如下,所以此时为read操作。
nh_group的值为0x0001,目前mcumgr支持的group ID见下图,所以该数据包将触发img_mgmt命令组。
nh_id为00,由于nh_group指向 image management group,而img_mgmt命令组定义了如下命令,可以看出00为IMG_MGMT_ID_STATE。
再次结合下面这个命令或者handler定义列表:
我们现在可以解读出最终的结果:00 00 00 02 00 01 00 00 bf ff这个数据包将触发img_mgmt组里面的IMG_MGMT_ID_STATE集里面的mh_read函数,即img_mgmt_state_read,这个函数的定义是:
int img_mgmt_state_read(struct mgmt_ctxt *ctxt)
而数据包的payload,即bf ff,将作为实参赋给上面的ctxt。我们用CBOR编码来解析一下bf ff,看看它表示什么意思?bf,即0b101 11111,可以看出,data type为5(表示map类型),count为0x1F(表示未定义长度,通过0xFF划分data item);ff,根据前面的描述,此处应该是分隔符,至此一个data item结束。可以看出,bf ff本身并没有实际的意义,实际上img_mgmt_state_read也没有使用输入参数:ctxt,两者是可以对起来的。
讲完smp DFU工作原理,我们再讲smp DFU整个工作流程,具体来说,包括如下几步:
上述有几个步骤,可以通过发命令远程去完成,也可以通过调用本地API自己去完成,两种选择都可以。比如confirm image这一步,你可以等待新image启动成功,然后重连主机,主机再发“confirm image”命令,这个时候升级才算真正完成;也可以在新image启动成功后,在不连主机的情况下,通过调用前述API:boot_write_img_confirmed()来完成这个确认过程。不管采用那种方法,本质上都是调用boot_write_img_confirmed()来实现,不同的是触发方式或者时机,发命令的方式由主机远程触发(SMP DFU就是选择这种主机远程发命令方式),而本地API方式则是设备自己选择时机来触发(nrf dfu就是选择这种本地API调用方式)。
DFU命令说明
当采用UART或者USB传输层的时候,上述DFU流程对应的命令如下:
上面每一个命令就是一个request(请求),每一个request就有一个response(响应),通过这种request/response方式,SMP DFU可以安全可靠地完成DFU数据传输。
蓝牙DFU流程解读
当采用BLE作为传输层的时候,上面命令都被手机app打包成二进制数据包直接下发给设备端,但解析出来之后,你会发现蓝牙DFU流程跟上面说明的流程基本上一模一样。比如前面的00 00 00 02 00 01 00 00 bf ff,就是手机发给设备的第一条DFU命令或者说请求(request)。我们再举一个例子:上传image命令(request),它的第一个数据包示例如下所示:
从包头02 00 00 eb 00 01 00 01可以看出,这个数据包将触发handler:img_mgmt_upload,我们再来看数据包payload的前面8个字节:bf 64 64 61 74 61 58 cc,bf表示后面是map数据,即key/value数据对,0x64,表示后面是text string数据,长度为4,从而得到64这个data item对应的payload为:64 61 74 61,即key=”data”;从0x58开始,就表示value这个data item了,0x58表示这个item为字节串并且长度为下一个字节:0xcc,也就是说”data”这个key对应的value包含了0xcc个数据的字节流,这样第一个key/value对解析完毕。然后再解析63 6c 65 6e 1a 00 02 05 a8,0x63,表示此item为text string数据,长度为3,从而得到payload为6c 65 6e,即key = ”len”;0x1a表示此item为正数,count为后面4个字节,也就是说”len”这个key对应的value为0x000205a8,至此第二个key/value对解析完毕。以此类推,我们后面又可以解析出”sha”和”off”两个key以及他们各自的value,最后碰到停止符:0xFF,整个map item结束。前面说过,整个数据包的payload会通过参数传给img_mgmt_upload作为实参,img_mgmt_upload的函数声明为:
img_mgmt_upload(struct mgmt_ctxt *ctxt)
而struct mgmt_ctxt定义如下:
struct mgmt_ctxt {
struct CborEncoder encoder;
struct CborParser parser;
struct CborValue it;
};
实际上,SMP数据包payload所在的buffer地址将赋给成员变量it后面的指针(这个指针本身不属于结构体的一部分,但它紧挨着结构体最后一个元素),这样我们通过ctxt就可以间接操作SMP数据包的payload,请看如下代码:
rc = cbor_read_object(&ctxt->it, off_attr);
这样我们就把一个image chunk拷贝到变量:req.img_data,再通过如下代码调用Flash访问API。
img_mgmt_impl_write_image_data(req.off, req.img_data, action.write_bytes, last);
如前所述,每一个request命令都会有一个response,比如上面request命令的response为:
这样,一个image chunk数据就成功写入到Flash中,不断循环这个request和response过程,直至整个image传送完毕,最后主机还会发送如下两条命令以正式结束整个DFU传输过程:
nrf dfu协议就是nRF5 SDK使用的DFU协议,相信很多读者都很熟悉它。nrf dfu协议定义了两个角色:controller和target,controller发request,target回response,一来一往,完成DFU传输过程。nrf dfu定义了如下request命令以及他们的response。
Request命令的格式是:Opcode + parameters,Response的格式是:60 + Opcode + parameters,比如编码:01 02 00 10 00 00,通过上面解析可以知道它是一个创建数据对象命令NRF_DFU_OP_OBJECT_CREATE,而这条命令的响应是:60 01 01,可以看出也符合上面的定义。
nrf dfu用到了对象概念,什么叫对象(object)?对象分两种:command object和data object,其中init包是command对象,而image chunk(image块)是data对象。
我们可以进一步提炼一下,nrf dfu协议主要涉及的命令是如下几个:
我们可以把nrf dfu流程大致归纳为如下几步:
这里就不再对nrf dfu协议进行详细解读了,有兴趣的读者可以自己查阅Nordic infocenter的相关章节介绍,具体链接为:https://infocenter.nordicsemi.com/index.jsp?topic=%2Fsdk_nrf5_v17.1.0%2Flib_dfu_transport.html。
在nRF connect SDK中,有一个现成的smp DFU例子,它所在的目录为:zephyr\samples\subsys\mgmt\mcumgr\smp_svr,这个例子支持多种传输层:蓝牙,串口,USB CDC,UDP,Shell,FS等,如果使用蓝牙作为传输层,其升级操作步骤如下所示:
4.修改原始工程,比如广播名字(CONFIG_BT_DEVICE_NAME="NEW_DFU"放在overlay-bt.conf中),再重新编译,然后拷贝“build_nrf52840dk_nrf52840/zephyr/app_update.bin”到手机版nRF Connect
5.用手机nRF Connect连接设备,成功后,点击右上角的“DFU”图标,选择前面的“app_update.bin”文件,然后选择“Test and Confirm”,DFU开始
6.升级文件传输完毕,系统将重启
7.MCUboot完成swap操作,并跳到新app,广播将变成“NEW_DFU”
8.手机nRF Connect连接新app,并发送confirm命令
9.至此整个升级结束
除了上述的smp_svr例子,我们还做了其他smp例子,这些例子都放在GitHub这里:https://github.com/aiminhua/ncs_samples/tree/master/smp_dfu。请大家仔细阅读例子里面的readme,并按照readme去操作。
这篇文章:详解蓝牙空中升级(BLE OTA)原理与步骤,详细阐述了nrf dfu升级步骤说明,虽然文章是以nRF5 SDK为例来叙述的,但其步骤也适用NCS nrf dfu过程。我们在NCS中做了很多nrf dfu例子,他们都放在这里:https://github.com/aiminhua/ncs_samples/tree/master/nrf_dfu,我们以nrf_dfu/ble_intFlash为例来简要阐述nrf dfu升级步骤,以帮助大家理解整个DFU过程:
1) 准备。
a. 安装PC版nrfutil。nrfutil安装有两种方式,一种是直接下载exe文件,一种是以Python的方式进行安装。nrfutil.exe直接下载链接为:https://github.com/NordicSemiconductor/pc-nrfutil/releases,记得把nrfutil.exe所在目录放在Windows环境变量中。Python方式安装nrfutil步骤如下所示:
安装完成后,在Windows命令行工具输入:nrfutil version,如果可以正确显示版本信息,说明安装已经成功
对于Windows用户,nrfutil运行需要几个特殊的DLL库,而这几个库有些Windows机器是没有的,如此,可往:https://www.microsoft.com/en-us/download/details.aspx?id=40784下载
b. 进入nrf_dfu/ble_intFlash/sdk_change目录,选择你的SDK版本,比如ncs_v1.8.0,把nrf_dfu/ble_intFlash/sdk_change/ncs_v1.8.x下面内容直接覆盖nrf仓库目录
c. 建议大家对照例子里面的readme看一下还有没有其他准备工作
2) 进入项目目录:cd nrf_dfu/ble_intFlash
3) 编译:west build -b nrf52840dk_nrf52840 -d build_nrf52840dk_nrf52840 -p (根据你自己手上的板子情况,把nrf52840dk_nrf52840换成其他DK,比如nrf5340dk_nrf5340_cpuapp)
4) 烧写:west flash -d build_nrf52840dk_nrf52840,此时设备将广播“Nordic_DFU”
5) 修改原始工程,比如广播名字(CONFIG_BT_DEVICE_NAME="NEW_DFU"),再重新编译,然后拷贝“build_nrf52840dk_nrf52840/zephyr/ app_signed.hex”到update目录
6) 双击update目录中的zip_generate.bat,将生成ble_intFlash.zip,将ble_intFlash.zip拷贝到手机nRF Connect中
7) 用手机nRF Connect连接设备,成功后,点击右上角的“DFU”图标,选择前面的“ble_intFlash.zip”文件
8) 升级文件传输完毕,系统将重启
9) MCUboot完成swap操作,并跳到新app,新app自动完成image confirm操作
10) 此时广播已经变成“NEW_DFU”,至此整个升级结束
https://github.com/aiminhua/ncs_samples/tree/master/nrf_dfu这个目录下面还有很多其他nrf dfu例子,建议大家可以好好看一下,按照里面的readme文件实际操作一下,相信对MCUboot和nrf dfu理解就会更深入了。
不管是smp dfu还是nrf dfu,都存在secondary slot在内部flash还是在外部flash情况,即ble_extFlash和ble_intFlash这两个例子,两个例子功能基本上一模一样,唯一区别就是secondary slot所在位置,ble_intFlash这个例子secondary slot在内部flash,ble_extFlash这个例子secondary slot在外部flash,这两个例子的main.c文件一模一样,唯一不同的是conf文件,以及分区文件partitions.yml。conf文件大家比较容易理解,但是分区文件大家经常困惑,这里再给大家介绍一下,具体可以参考:开发你的第一个NCS(Zephyr)应用程序。
所谓分区(Partition),就是对Flash(包括内部Flash和外部flash)或者RAM物理区域进行一个逻辑划分,人为划定哪块区域干什么工作,比如把MCUboot这个image放在0x0000到0xC000这块区域,这种分区是人为的,所以你可以随意调整,比如你把MCUboot放在0x0000到0x10000,当然也是可以的。我们对Flash或者RAM进行分区,目的就是为了把空间利用好,给各个分区一个ID以便后续引用,如果代码里不引用这个分区,那么此分区只是一个占位符而已,比如app和mcuboot这两个分区。
我们先看一下smp_dfu/ble_intFlash这个例子生成的partitions.yml:
从上面可以看出,这个partitions.yml定义了很多分区,比如app,mcuboot,mcuboot_pad,mcuboot_primary等(冒号前面的就是分区名),而且每一个分区规定了它的起始地址,结束地址,大小,相对位置以及放在什么物理存储器上,比如app这个分区:
关于分区名,只有“app”这个名字是必须有,而且是固定的,代表着主应用程序image;其他分区名,比如mcuboot,settings_storage,external_flash等,都是随意定义的,可以修改。比如0x0~0xc000这块内部Flash区,上面取名叫mcuboot,你也可以改成“my_boot”之类的名字,这个也没关系的,取名字主要考虑两点:一是能醒目标识这块区域的功能,二是跟代码里面的引用对起来,比如如下分区定义,经常有人困惑:
第一个“external_flash”是分区名,第二个“external_flash”是物理存储器名。作为分区名的“external_flash”,其实我们可以改成其他名字,以消除某些困惑,之所以使用这个名字,是因为老的littlefs例子里面对外部文件系统所在区域就称为“external_flash”,代码如下所示:
FS_LITTLEFS_DECLARE_DEFAULT_CONFIG(external_flash);
static struct fs_mount_t fs_mnt = {
.type = FS_LITTLEFS,
.fs_data = &external_flash,
.storage_dev = (void *)FLASH_AREA_ID(external_flash),
.mnt_point = "/lfs",
};
实际上最新的littlefs例子已经把这块区域重新命名为:littlefs_storage或者storage,所以大家可以把这块分区名改为littlefs_storage,如下:
partitions.yml里面使用的region其实是在这个文件:nrf\cmake\partition_manager.cmake定义的,大家可以通过build目录下的regions.yml文件得知目前定义了几个物理存储器:
至于partitions.yml里面使用的placement/span等,这个是用来指定各个分区的相对位置的,很多人会疑问,既然指定了分区的起始地址和结束地址,那还有必要去指定各个分区的相对位置吗?这种情况下的确没必要再指定相对位置了,其实这里弄反了一件事情:partitions.yml里面的地址是placement相对位置定下来之后的结果。使用placement相对位置,为编译系统动态确定各个分区的位置提供了便利。如果是我们自己来划分存储器的分区,我们就可以直接使用绝对地址的方式静态指定各个分区的位置(当然使用placement也是可以的)。
如何人为静态指定?答案就是把刚才动态生成的partitions.yml文件拷贝到项目根目录下,然后改名为:pm_static.yml,然后再按照自己的需求去修改,比如smp_dfu/ble_extFlash这个例子,如果由系统动态生成partitions.yml文件,此时mcuboot_secondary分区所在地址为0x0~0xf0000,而文件系统external_flash或者littlefs_storage分区所在地址为0xf0000~0x800000,实际上很多客户喜欢把文件系统放在外部Flash 0x00地址,而把secondary slot放在外部flash最后,据此可以做如下修改:
这个pm_static.yml文件没有定义的分区,还是由系统动态分配。有时为了后续升级方便,我们会在pm_static.yml文件里面把所有的分区都按照自己的规划重新定义一遍,这样就不担心某个image突然变大而导致新的partitions.yml跟老的文件不兼容,从而无法升级。在定义pm_static.yml文件时,有如下规则必须遵守:
对于flash_primary这个region,由于系统默认认为必须要有一个“app”分区,所以它可以存在而且只能存在一个空隙(gap),这样系统默认这个gap就是“app”分区。当然你也可以把flash_primary所有区域都分好区,包括“app”分区。
现在我们从零开始,一步一步教大家如何把smp服务添加到peripheral_uart例子中。
peripheral_uart例子所在目录为:nrf\samples\bluetooth\peripheral_uart,这个例子跟nRF5 SDK里面的nRF5_SDK_17.1.0_ddde560\examples\ble_peripheral\ble_app_uart功能一模一样,都实现了著名的NUS服务,即蓝牙透传服务。如前所述zephyr\samples\subsys\mgmt\mcumgr\smp_svr这个例子则实现了SMP DFU服务,我们现在把smp蓝牙服务移植到peripheral_uart上。
我们仔细查看zephyr\samples\subsys\mgmt\mcumgr\smp_svr这个例子,为了实现SMP DFU,主要修改两个地方:一是修改prj.conf以包含相应模块,二是修改main.c的初始化函数以初始化SMP相关模块,prj.conf主要修改点如下:
CONFIG_BOOTLOADER_MCUBOOT=y
CONFIG_MCUMGR=y
CONFIG_MCUMGR_CMD_IMG_MGMT=y
CONFIG_MCUMGR_CMD_OS_MGMT=y
CONFIG_BT_L2CAP_TX_MTU=252
CONFIG_BT_BUF_ACL_RX_SIZE=256
CONFIG_MCUMGR_SMP_BT=y
CONFIG_MCUMGR_SMP_BT_AUTHEN=n
CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=2304
CONFIG_MAIN_STACK_SIZE=2048
我们把上述config加在nrf\samples\bluetooth\peripheral_uart\prj.conf文件最后,这样prj.conf就改完了。
main.c的修改就更简单,在启动广播之前,我们加入如下初始化函数:
smp\_bt\_register();
os\_mgmt\_register\_group();
img\_mgmt\_register\_group();
就这样两步工作,轻轻松松就把SMP DFU服务移植到peripheral_uart上,整个代码已经上传到https://github.com/aiminhua/ncs_samples/tree/master/smp_dfu/peripheral_uart,大家可以下载下来参考或者测试一下。
从上述例子我们可以看出,在NCS中移植一个例子非常方便,它不需要去添加c文件和头文件,也不需要去修改编译选项,还不需要去修改传统的头文件进行配置,仅仅修改conf文件和初始化函数,就轻轻松松完成了整个移植,这也是NCS非常大的一个好处。
其实https://github.com/aiminhua/ncs_samples/tree/master/smp_dfu下面包含的例子都同时具备smp和nus两个服务,并且区分各种不同情形下的DFU情况,比如secondary slot在外部Flash,通过串口传输image等,同时其对peripheral_uart例子进行了小小改动,以更符合某些实际应用场景,建议大家好好看一下,相信对大家理解MCUboot和SMP会帮助不少。
Nordic不仅提供设备端的DFU参考代码,同时提供手机端的参考代码。Nordic分别开发了Android版和iOS版的DFU库,大家可以直接拿过来使用,集成到自己的移动端app中,这两个库都放在github上,其中smp dfu对应的DFU库链接如下所示:
而nrf dfu对应的DFU库链接如下所示:
Nordic还提供了一个移动端app:nRF Toolbox,nRF Toolbox是代码开源的,里面也集成了上面提到的两种DFU库(iOS版同时支持SMP DFU和nrf dfu,而Android版仅支持nrf dfu),大家可以参考nRF Toolbox来开发自己的移动端app。nRF Toolbox源码也可以在github上找到:
nRF Toolbox软件界面如下所示:
手机扫一扫
移动阅读更方便
你可能感兴趣的文章