本文介绍FDS的技术细节。

1. 基础

nRF52系列芯片都是Cortex-M4内核,芯片的Flash操作由NVMC(Non-volatile memory controller)管理,读写擦的机制相同:

  • 写:以Word(4字节)为单位进行Flash写操作。
    写入地址要Word对齐,往未对齐的地址执行写操作会导致Hard Fault。
    写入过程中,CPU处于挂起(Halt)状态。
    芯片写入次数有寿命限制
  • 擦:以Page(4kB)为单位进行Flash擦操作。
    擦除过程中,CPU处于挂起(Halt)状态。
  • 读:自由操作,无寿命限制

nRF52832与nRF52840的Flash擦写参数不完全一致,nRF52840更先进。本文以nRF52832为例。

擦Flash是将比特置1,写是置0。写0后不能再写回1,只能通过擦除置1。一个Word区域可以有条件的写两次,比如先写入0x1000 0000,再写入0xEFFF FFFF,对于第一个比特,第一次其实并未写它,第二次才对其写0。

写Flash需要三步:

  • 打开写操作:NVMC.Config = WEN
  • 赋值:*(uint32_t *) addr = value
  • 关闭写操作:NVMC.Config = REN

在(nrf_nvmc.c)中可以看到Flash写操作的基础代码:

void nrf_nvmc_write_word(uint32_t address, uint32_t value)
{
    // Enable write.
    NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Wen;
    __ISB();
    __DSB();

    *(uint32_t*)address = value;
    while (NRF_NVMC->READY == NVMC_READY_READY_Busy);

    NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren;
    __ISB();
    __DSB();
}

类似的,擦除操作也是三步:打开擦操作,擦除某Page,关闭擦操作。

对应的代码如下:

void nrf_nvmc_page_erase(uint32_t address)
{
    // Enable erase.
    NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Een;
    __ISB();
    __DSB();

    // Erase the page
    NRF_NVMC->ERASEPAGE = address;
    while (NRF_NVMC->READY == NVMC_READY_READY_Busy);

    NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren;
    __ISB();
    __DSB();
}

底层驱动并未提供读操作的代码,因为读Flash数据与读RAM数据一样,从指定的地址获取数据内容即可,比如:memcpy(p_dest, p_flash_src, len)

写Flash和擦Flash都是耗时操作,并且不能被打断,所以会出现资源竞争问题。SDK提供了一个驱动库(nrf_fstorage_nvmc.c)来避免竞争。该库设置了一个busy标志位(m_flash_operation_ongoing),操作Flash之前先检测busy标志位,避免多个操作冲突。

该库包含以下API:

  • init
  • uinit
  • read
  • write
  • erase
  • is_busy

这些API放在结构体(nrf_fstorage_api_t)中作为对外接口。

注意,这些函数都是阻塞的,Flash操作执行完毕后才返回函数,不会产生异步事件。

蓝牙协议栈Softdevice也操作Flash,并提供了一个驱动库(nrf_fstorage_sd.c)。nrf_fstorage_sd.c略复杂,这里暂不深究。

现在有两个驱动库:nrf_fstorage_nvmc.c和nrf_fstorage_sd.c,于是SDK提供了一个抽象层(nrf_fstorage.c) 来统一接口 。

nrf_fstorage.c提供了以下API:

  • nrf_fstorage_init
  • nrf_fstorage_uninit
  • nrf_fstorage_read
  • nrf_fstorage_write
  • nrf_fstorage_erase
  • nrf_fstorage_is_busy

在初始化阶段,nrf_fstorage_init函数的传入参数决定了底层使用nrf_fstorage_nvmc还是nrf_fstorage_sd,上层应用只需调用nrf_fstorage即可,从而隔离底层差异。

在sdk_config.h中,配置FDS_BACKEND来选择底层驱动库:

// <i> NRF_FSTORAGE_SD uses the nrf_fstorage_sd backend implementation using the SoftDevice API. Use this if you have a SoftDevice present.
// <i> NRF_FSTORAGE_NVMC uses the nrf_fstorage_nvmc implementation. Use this setting if you don't use the SoftDevice.
// <1=> NRF_FSTORAGE_NVMC 
// <2=> NRF_FSTORAGE_SD 

#ifndef FDS_BACKEND
#define FDS_BACKEND 1
#endif

观察nrf_fstorage中的写Flash函数,其代码实现如下:

ret_code_t nrf_fstorage_write(nrf_fstorage_t const * p_fs,
                              uint32_t               dest,
                              void           const * p_src,
                              uint32_t               len,
                              void                 * p_context)
{
    return (p_fs->p_api)->write(p_fs, dest, p_src, len, p_context);
}

因为nrf_fstorage_nvmc是阻塞的,所以这个写Flash函数也是阻塞的。

该函数的参数中包含了目标地址信息,当应用程序需要往指定地址写入数据时候,可以调用它来实现。Bootloader 在保存Settings信息时就是直接调用该函数。

与之类似,擦Flash函数也是阻塞的。

nrf_fstorage具备Flash操作的基本功能,但还有优化空间。比如没有多操作调度,没有断电保护,过于依赖绝对地址。

FDS在nrf_fstorage基础上实现了一个小型文件系统,解决了上述问题并提供了诸多好处。强大的功能背后是复杂的设计,需要仔细拆解方能理解。

2. FDS area

FDS从Flash中划定一块空间,专门操作用户数据,所有的Flash操作都限定在该空间内。该空间取名为FDS area。

如果芯片中没有Bootloader,该空间位于Flash最顶端:

对于nRF52832(512 kB Flash),Flash的顶端地址是0x0008 0000。

如果FDS area有N个Page,那么FDS area起始地址为:0x0008 0000 – N * 0x1000。N由sdk_config.h中的FDS_VIRTUAL_PAGES参数决定。

如果芯片中有Bootloader,该空间位于Bootloader下方:

Infocenter中可知,Bootloader的起始地址为:0x00078000,FDS area的地址可以据此计算获得。

SDK15.3 的FDS库引入了一个新参数:FDS_VIRTUAL_PAGES_RESERVED。设置该参数可以在FDS area的顶部设置一段保留空间,FDS area不计算该空间,所有的FDS操作均不影响该空间,用户可以在这里利用fstorage库来读写数据。

FDS area有两种Page:Data Page和Swap Page。

Data Page用于存放有效数据,Swap Page在垃圾回收时转存Data Page中的数据。Swap Page大小恒为1 Page,Data Page大小为(FDS_VIRTUAL_PAGES – 1) Page。Swap Page的地址并不固定,随着FDS的使用会产生变动。

FDS area 每个Page的前两个Word(8字节)表示Page Tag,两种Page Tag分别为:

  • Swap Page Tag:0xDEAD C0DE 0xF11E 01FF
  • Data Page Tag:0xDEAD C0DE 0xF11E 01FE

3. 数据格式

FDS中的数据被封装成Record,其格式如下:

Header部分包含了元信息,Content部分包含了有效数据。

  • Record Key和File ID用于索引一个Record,它们不唯一,可以通过二者定位到Record
  • Data Length表示Content长度
  • Record ID代表Record的唯一ID
  • CRC Value表示其他字段的CRC16值

FDS作为文件系统,对外隐藏了地址信息,也不允许直接访问Record ID,而是对外提供了Record Key和File ID作为Record的访问接口,使用时候应避免使用绝对地址来访问Record。实际上,Record的绝对地址在运行中是变化的。

下图方便理解Record Key和File ID的关系:

多个Record数据可以使用相同的File ID和Record Key(如第一列),相同的File ID但不同的Record Key(如第三列),或相同的Record Key但不同的File ID(如第二行)。所以, File ID与Record Key 本质上是从两个维度描述Record,它们不是简单的包含关系。

通过Record Key与File ID进行查找,可以找到匹配数据。在上图中,如果以Rec_Key_1作为关键字,将会找到五个数据:abc, def, ghi, mmm, nnn。如果以Rec_Key_1和File_ID_1作为组合关键字,则能找到abc, def, ghi。

Record Key是16-bit数据,由用户自己设定,可用范围为: 0x0001 ~ 0xBFFF。0x0000表示该数据为脏数据(Dirty Data),0xBFFF之后的数据为Peer Manager保留使用。

File ID也是16-bit数据,由用户自己设定,可用范围为:0x0000 ~ 0xBFFFF。0xBFFF之后的数据为Peer Manager保留使用。

4. 工作机制

FDS的操作类型有:

  • write:写入一个新record
  • find:查找一个record
  • update:更新一个record
  • delete:删除一个record
  • gc:垃圾回收,释放空间
  • init:初始化FDS系统

在代码中,Record结构体(fds_record_t)如下:

typedef struct
{
    uint16_t file_id;
    uint16_t key;
    struct
    {
        void     const * p_data;
        uint32_t         length_words;
    } data;
} fds_record_t;

结构体提供了Record Key 和File ID,以及数据内容和长度。数据长度以Word为单位,它与字节长度换算方法为:len_word = (len_byte + 3) / 4。

如果数据的长度不能整除4,则要小心处理。比如上层代码写入uint8_t arr[2] = 0x1010,底层实际写入的数据是:0x1010 xxxx。后面的xxxx取决于arr在内存中后面的两个字节内容,这实际上是越界访问!在编码阶段,可以准备一个较大Buffer,确保待写入数据的len_word不超过Buffer的总长度。

读数据时候,FDS总是读出Word倍数的数据,如果实际数据不等于Word倍数,需要手动记录数据长度。

写入一个Record,先写入TL字段(Record Key + Data Length),再写入Record ID,再写入Record Content,最后写入IC字段(Fild ID + CRC)。如果IC字段未正常写入,将保持原始值0xFFFF。

假如写Flash中途断电导致IC字段没有完整写入,查询时候就能够获知此信息,这个机制解决了中途断电的问题。(如果写入IC字段的过程中突然断电会发生什么?我也不知道🙉)

FDS的所有操作都在Page内进行的,Record不能跨Page增删改。一个Page 长度等于1024 Words,考虑Data Page Tag和Record Header,单个Record最大的数据长度等于 1024 Words – 2 Words – 3 Words = 1019 Words。

如果当前Page已经快写满,剩余空间无法装下新数据,则该数据将被写入到下一个Page。

这种情况将在Page末尾形成空间碎片,为了利用这些空间碎片,FDS写数据时会遍历各Page查找空间碎片,如果某个空间碎片足够大,则直接写入其中,否则继续往后查找,如果所有空间碎片都太小,则写入新的Page。

这个机制导致Flash中的数据排列顺序不等同于写入顺序,后写入的小块数据可能放在前面Page中,此时递归读取所有Record,会发现这个小数据先被读出来。

一个Record的唯一定位标志是Record ID,可以通过Record Key和File ID定位到Record ID。Record ID属于私有变量,外部程序访问和操作它需要使用Record Descriptor结构体:

typedef struct
{
    uint32_t         record_id;
    uint32_t const * p_record;
    uint16_t         gc_run_count;
    bool             record_is_open;
} fds_record_desc_t;

一次查找只能返回一个Record Descriptor,如果有多个匹配的Record,需要迭代查找以返回所有的Record Descriptor。从代码实现上考虑,迭代操作需要一个“游标”来记录迭代进度,在FDS中,这个“游标”是token:

typedef struct
{
    uint32_t const * p_addr;
    uint16_t         page;
} fds_find_token_t;

Token记录了当前Record在Page中的地址,迭代查找将遍历整个FDS area,逐Page查找所有匹配项,并依次返回匹配项的Record Descriptor。有了Record Descriptor,即可进行后续的update或delete操作。

更新一个Record时并非直接修改数据内容,而将其标记为脏数据,然后把新数据写入到空间碎片或新Page中。所以更新操作实际上包含两次写操作。

标记脏数据的原理是,往Record Header的TL字段写入0x0000 FFFF,使Record Key等于0x0000。FDS查询Record时,遇到Record Key等于0的情况自动忽略。

删除一个Record与更新操作类似,不过它将目标Record标记为脏数据后,不再写入新数据。

这种标记机制能确保新数据写入之前旧数据总是有效,但脏数据会产生空间浪费。极端情况下,对一个数据反复更新产生的脏数据可能消耗全部的FDS area。此时需要执行垃圾回收(GC)来释放脏数据空间。

假设Page-1中有脏数据,执行GC的时候,读取Page-1中的有效数据并复制到Swap Page中。当全部有效数据都转存到Swap Page后,擦除Page-1,同时更新两个Page的Page Tag,将Page-1变成新的Swap Page,原来的Swap Page变成新的Data Page。该过程示意图如下:

可见,Swap Page不是某个固定的Page,而是随着GC过程不停的与其他Data Page交换身份。

为了实现Swap机制,FDS初始化时要准备好Swap Page和Data Page,写入相应的Page Tag。所以FDS初始化过程也是个耗时操作,需要根据异步事件来判断初始化完成。

5. API介绍

5.1 fds_register(evt_handler)

注册回调函数。FDS支持多用户,所以可以在多个模块中注册各自的事件回调函数。

5.2 fds_init()

初始化FDS。异步函数,初始化完毕后产生相应的事件。

在进行其他FDS操作之前,务必等待初始化完毕事件。

5.3 fds_record_write(&desc, &rec)

写入Record,并返回描述符desc。异步函数,写入完毕后产生相应的事件。

rec->data.p_data需要Word对齐。对齐方法是:__ALIGN(4) your_type data;

观察SDK的示例工程flash_fds,发现它的测试数据m_dummy_cfg并未手动对齐。原因是编译器对变量会自动执行对齐,对齐规范可简单参考ARM文档(该文档不是最佳文档)。对于小于4字节的数据类型(uint8_t,uint16_t,char等),默认对齐到1或2,对于大于等于4字节的数据类型(uint32_t,double,pointer等)默认对齐到4,结构体则需要单独分析。m_dmmy_cfg默认对齐到4字节,所以运行不报错。

如果待写入数据长度小于4,比如uint_8 a[1]或uint8_t a[2],则很有可能出现没有对齐到4的情况。

简单的处理,就是总是加上__ALIGN(4)。

rec.data.p_data应该指向一个全局变量或静态变量,因为FDS会从该地址读取数据,如果指向局部变量,可能遭遇在写操作执行之前变量已经被释放的意外情况。

应确保(rec.data.len_words * 4)不超过p_data的可访问范围,避免指针越界访问。

写入操作应根据对应事件来判断操作完成与否,通过手动增加delay来假设Flash操作完成是不可靠的。

5.4 fds_record_find(file_id, rec_key, &desc, &tok)

查找匹配的Record。普通函数,不会产生异步事件。

它还有两个姐妹函数:

  • fds_record_find_by_key
  • fds_record_find_in_file

desc为输出参数,tok为输入输出参数。tok记录了迭代查找的的进度,所以第一次使用,tok需要清零:memset(&tok, 0, sizeof(tok)) ,FDS从第一个Data Page开始查找。循环调用该函数,FDS将利用tok的当前值往后查找并更新tok值。

5.5 fds_record_update(&desc, &rec)

更新desc所指向的Record。异步函数,更新完毕后产生相应的事件。

5.6 fds_record_open(&desc, &rec),
fds_record_close(&desc)

读Flash之前要先Open,读完毕再Close。普通函数,不会产生异步事件。

读Flash本身不需要额外步骤,FDS设计Open和Close主要为了避免读取Flash数据时后台执行的GC修改了数据内容。

5.7 fds_record_delete(&desc)

删除Record。异步函数,删除完毕产生相应的事件。

它还有个姐妹函数:

  • fds_file_delete(file_id)

5.8 fds_gc()

执行垃圾回收。异步函数,GC完毕后产生相应的事件。

不要频繁的去执行GC,更不要每次更新或删除Record后都执行GC。GC的一个优势是降低Flash的使用率,过度调用GC,不仅丧失了该优势,也增加了不必要的Flash操作时间。

5.9 fds_reserve(&tok, len_words),
fds_record_write_reserved(&desc, &rec, &tok),
fds_reserve_cancel(&tok)

预留一段空间,等需要时候写入数据。异步函数,类似fds_record_write()。

这几个函数设计思路有意思,假设将要写入20字节数据,但写入内容尚未确定,此时可以先预留一个空间,等数据内容确定了再写入,也可中途取消掉。

感觉没什么卵用。

5.10 其他函数

  • fds_record_iterate:迭代遍历Flash area中的所有Record
  • fds_descriptor_from_rec_id:见名知意
  • fds_record_id_from_desc:见名知意
  • fds_stat:返回FDS area的统计数据,比如有效数据个数、脏数据个数等

6. 实例分析

利用以下命令可以导出芯片Flash 的内容:

nrfjprog --readcode flash.hex

测试工程的核心代码如下:

main() {
    fds_register(evt_handler);
    fds_init();
    rec.data.p_data = "AAAA";
    rec.data.len_words = 1;
    fds_record_write(&desc, &rec);
}

设置工程的FDS_VIRTUAL_PAGES = 3,没有Bootloader。

生成并查看flash.hex,定位到:020000040007F3一行,它代表偏移地址为0x0007,FDS area位于0x0007 0000之后。

按照前面的分析,重点分析三个Page:0x0007 F000、0x0007 E000和0x0007 D000。

0x0007 D000的开头8个字节为:DEC0ADDE FF011EF1。它正是Swap Page Tag(0xDEADC0DE F11E01FF)的Little Endian形式。其后的数据全是0xFF,说明现在尚未进行任何GC操作。

0x0007 E000和0x0007 F000的开头8字节都是:DEC0ADDE FE011EF1。正是Data Page Tag(0xDEADC0DE F11E01FE)。0x0007 F000 仅仅标记了Data Page Tag,没有其他内容。

将0x0007 E000处的关键内容截取出来:

:10E00000DEC0ADDEFE011EF1010001000100A0F541
:10E010000100000041414141FFFFFFFFFFFFFFFF03
  • :10E00000表示:hex行的数据长度、地址、类型
  • DEC0ADDEFE011EF1表示:Data Page Tag
  • 0100表示:Record Key = 0x0001
  • 0100表示:Length words = 0x0001
  • 0100表示:File ID = 0x0001
  • A0F5表示:CRC16
  • 41:hex行的checksum
  • :10E01000表示:hex行的数据长度、地址、类型
  • 01000000表示:Record ID = 0x0000 0001
  • 41414141表示:“AAAA”
  • 后面的FF表示:空白区域

在代码中添加更新Record操作:

main() {
    fds_register(evt_handler);
    fds_init();
    rec.data.p_data = "AAAA";
    rec.data.len_words = 1;
    fds_record_write(&desc, &rec);
    rec.data.p_data = "BBBB";
    fds_record_update(&desc, &rec);
}

重新生成并查看flash.hex,将0x0007 E000处的内容截取出来:

:10E00000DEC0ADDEFE011EF100000100010045ADE5
:10E0100001000000414141410100010001008CC2AA
:10E020000200000042424242FFFFFFFFFFFFFFFFEE

对比前面的输出:

  • 第一行与前面基本一致,但是Record Key变成了0x0000
  • 第二行到41414141(“AAAA”)之前的内容与前面一致,后面的数据是全新的Record,其数据内容为42424242(“BBBB”)。

在代码中添加删除Record操作:

main() {
    fds_register(evt_handler);
    fds_init();
    rec.data.p_data = "AAAA";
    rec.data.len_words = 1;
    fds_record_write(&desc, &rec);
    fds_record_delete(&desc);
}

重新生成并查看flash.hex,将0x0007 E000处的内容截取 出来:

:10E00000DEC0ADDEFE011EF100000100010045ADE5
:10E010000100000041414141FFFFFFFFFFFFFFFF03

对比前面的输出,仅仅是Record Key变成了0x0000,其他不变。

在代码中添加先更新后GC的操作:

main() {
    fds_register(evt_handler);
    fds_init();
    rec.data.p_data = "AAAA";
    rec.data.len_words = 1;
    fds_record_write(&desc, &rec);
    rec.data.p_data = "BBBB";
    fds_record_update(&desc, &rec);
    wait_update_ready();
    fds_gc();
}

重新生成并查看flash.hex, 发现0x0007 F000的Page Tag变成了Swap Page Tag,0x0007 D000的Page Tag变成了Data Page Tag,并且数据内容为更新后的数据“BBBB”。这与上面关于Swap Page机制吻合。

参考资料

  • Infocenter文档:link
  • fds源代码

(完)

测试DFU时候,会生成并下载Bootloader Settings文件,以设置应用程序有效性,本文介绍Bootloader Settings文件的技术细节。

1. 基本信息

Bootloader Setting Page是指Bootloader工程开辟的一段Flash空间,其中保存了固件镜像的信息和DFU进度信息,这些信息称为Bootloader Settings。为了表述方便,Bootloader Settings Page或Bootloader Settings都简称为Settings。

对于nRF52系列芯片,Settings的位置如下图

nRF52832芯片的Flash大小为512 kB(0x0008 0000),Settings位于最顶端(0x0007 F000 – 0x0008 0000),大小为1 Page(4 kB)。

知道了Settings的位置,即可通过nrfjprog --readcode flash.hex读出芯片Flash数据,然后定位到0x0007 F000处读取Settings内容。

在Settings的下方是MBR Param Storage,它的长度也是4 kB,地址为:0x0007 E000 – 0x0007 F000。这块区域用于存放Settings的备份。

infocenter上得知,Bootloader Settings信息包含以下内容:

  • current firmware – size, CRC-32
  • pending firmware – size, CRC-32
  • progress of the firmware update
  • progress of the firmware activation
  • current firmware versions (application and bootloader)
  • transport-specific data

正常执行DFU升级过程中,Bootloader接收固件镜像并会自动计算这些信息,然后写入Flash。

也可以利用固件镜像app.hex手动生成Settings文件(settings.hex),命令如下:

nrfutil settings generate --family NRF52 --no-backup --application app.hex --application-version 0x01 --bootloader-version 0x01 --bl-settings-version 0x02 settings.hex

–no-backup表示不使用备份区域,对于SDK 12.0 ~ SDK 15.0,应该使用该参数以实现兼容性,对于SDK 15.1以上版本,应该忽略该参数。如果不使用该参数,则会在MBR Param Storage区域内保存Settings Page的副本,生成的settings.hex中能够看到0x7E000 – 0x7F000 的内容与0x7F000 – 0x80000的内容相同。如果使用该参数,则不保存备份副本,settings.hex中也没有0x7E000 – 0x7F000的内容。

–bl-settings-version只有两个可选项:0x01和0x02。对于SDK 12.3 ~ SDK 15.2,应该使用0x01,对于SDK 15.3应该使用0x02。如果使用0x02,会在Settings信息中引入 boot_validataion_crc等信息,这些额外的数据导致两个版本不兼容。具体差异其实很简单,打开bl_dfu_sett.py文件,找到BLDFUSettingsStructV2 与 BLDFUSettingsStructV1的类定义,一看便知。

更多的内容请参考官方文档:https://github.com/nordicsemiconductor/pc-nrfutil。

使用以下命令查看Settings.hex的内容:

nrfutil settings display settings.hex

Settings.hex 的内容形式如下:

Bootloader DFU Settings:
* File:                     settings.hex
* Family:                   nRF52
* Start Address:            0x0007E000
* CRC:                      0x740EFA00
* Settings Version:         0x00000002 (2)
* App Version:              0x00000001 (1)
* Bootloader Version:       0x00000001 (1)
* Bank Layout:              0x00000000
* Current Bank:             0x00000000
* Application Size:         0x0000EC84 (60548 bytes)
* Application CRC:          0x9B8FC175
* Bank0 Bank Code:          0x00000001
* Softdevice Size:          0x00000000 (0 bytes)
* Boot Validation CRC:      0xD0E62C99
* SD Boot Validation Type:  0x00000000 (0)
* App Boot Validation Type: 0x00000001 (1)

用文本工具打开Settings.hex文件,其内容如下:

:020000040007F3
:10F0000000FA0E7402000000010000000100000080
:10F01000000000000000000084EC000075C18F9B20
:10F0200001000000000000000000000000000000DF
:10F0300000000000000000000000000000000000D0
:10F0400000000000000000000000000000000000C0
:10F0500000000000000000000000000000000000B0
:10F0600000000000000000000000000000000000A0
:10F070000000000000000000000000000000000090
:10F080000000000000000000000000000000000080
:10F090000000000000000000000000000000000070
:10F0A0000000000000000000000000000000000060
:10F0B0000000000000000000000000000000000050
:10F0C0000000000000000000000000000000000040
:10F0D0000000000000000000000000000000000030
:10F0E0000000000000000000000000000000000020
:10F0F0000000000000000000000000000000000010
:10F1000000000000000000000000000000000000FF
:10F1100000000000000000000000000000000000EF
:10F1200000000000000000000000000000000000DF
:10F1300000000000000000000000000000000000CF
:10F1400000000000000000000000000000000000BF
:10F1500000000000000000000000000000000000AF
:10F16000000000000000000000000000000000009F
:10F17000000000000000000000000000000000008F
:10F18000000000000000000000000000000000007F
:10F19000000000000000000000000000000000006F
:10F1A000000000000000000000000000000000005F
:10F1B000000000000000000000000000000000004F
:10F1C000000000000000000000000000000000003F
:10F1D000000000000000000000000000000000002F
:10F1E000000000000000000000000000000000001F
:10F1F000000000000000000000000000000000000F
:10F2000000000000000000000000000000000000FE
:10F2100000000000000000000000000000000000EE
:10F2200000000000000000000000000000000000DE
:10F2300000000000000000000000000000000000CE
:10F2400000000000000000000000000000000000BE
:10F25000000000000000000000000000992CE6D033
:10F26000000000000000000000000000000000009E
:10F27000000000000000000000000000000000008E
:10F28000000000000000000000000000000000007E
:10F29000000000000000000000000000000000006E
:10F2A000000175C18F9B00000000000000000000FD
:10F2B000000000000000000000000000000000004E
:10F2C000000000000000000000000000000000003E
:10F2D000000000000000000000000000000000002E
:10F2E000000000000000000000000000000000001E
:10F2F000000000000000000000000000000000000E
:10F3000000000000000000000000000000000000FD
:10F3100000000000000000000000000000000000ED
:03F32000000000EA
:00000001FF

Intel hex文件的格式解析请参考:https://en.wikipedia.org/wiki/Intel_HEX 。

第一行为地址偏移,偏移量为0x0007,所以第二行的实际地址为0x0007 F000,这正是Settings的Flash起始地址。

倒数第二行为数据末尾行,可知数据总量为0xF322 + 1 = 803,即Settings.hex 的有效数据总数是803。

有效数据的排列顺序根据BLDFUSettingsStructV2 的定义而来, BLDFUSettingsStructV2 的内容如下:

    def __init__(self, settings_address):
        self.bytes_count = 803 # Entire settings page
        self.crc                  = settings_address + 0x0
        self.sett_ver             = settings_address + 0x4
        self.app_ver              = settings_address + 0x8
        self.bl_ver               = settings_address + 0xC
        self.bank_layout          = settings_address + 0x10
        self.bank_current         = settings_address + 0x14
        self.bank0_img_sz         = settings_address + 0x18
        self.bank0_img_crc        = settings_address + 0x1C
        self.bank0_bank_code      = settings_address + 0x20
        self.sd_sz                = settings_address + 0x34
        self.init_cmd             = settings_address + 0x5C

        self.boot_validataion_crc = settings_address + 0x25C
        self.sd_validation_type   = settings_address + 0x260
        self.sd_validation_bytes  = settings_address + 0x261
        self.app_validation_type  = settings_address + 0x2A1
        self.app_validation_bytes = settings_address + 0x2A2

        self.last_addr            = settings_address + 0x322

首先看到bytes_count=803,这与上面的计算一致。

第一个字段为CRC,本文中CRC为0x740EFA00,换成Little Endian后为
00 FA 0E 74。紧挨着下一个字段的偏移地址为4,所以本字段大小为4字节。

第二个字段为Sett_ver,本文中该值为1,通过下个字段的偏移量得知Sett_ver长度也是4。

以此类推,可以分析大多数字段。

找到sd_sz,该字段与上一字段之间差距为20字节,并不是bank0_bank_code长度是20字节,而是这之间还填充了两个字段:bank1 {img_sz, img_crc, bank_code}和write_offset,二者共16字节,加上bank0_bank_code 所占4字节,共20字节。

bank1与write_offset定义在Bootloader工程中的nrf_dfu_bytes.h -> nrf_dfu_settings_t结构体中。

打开nrf_dfu_settings_t的定义:

typedef struct
{
    uint32_t            crc;
    uint32_t            settings_version;
    uint32_t            app_version;
    uint32_t            bootloader_version;
    uint32_t            bank_layout;
    uint32_t            bank_current;
    nrf_dfu_bank_t      bank_0;
    nrf_dfu_bank_t      bank_1;
    uint32_t            write_offset;
    uint32_t            sd_size;
    dfu_progress_t      progress;
    uint32_t            enter_buttonless_dfu;
    uint8_t             init_command[INIT_COMMAND_MAX_SIZE];
    uint32_t            boot_validation_crc;
    boot_validation_t   boot_validation_softdevice;
    boot_validation_t   boot_validation_app;
    boot_validation_t   boot_validation_bootloader;
    nrf_dfu_peer_data_t peer_data;
    nrf_dfu_adv_name_t  adv_name;
} nrf_dfu_settings_t;

它与上面的 BLDFUSettingsStructV2 基本对应。

观察init_command,它是一个数组,数组长度为512!init_command 的内容请参考上一篇文章“弄懂Init Packet”。

再观察BLDFUSettingsStructV2的init_cmd与boot_validataion_crc的偏移量,二者之差恰好等于512。

后面四个字段与Validation有关,通常不专门设置。值得注意的是,默认情况下,app_validation_type=1,app_validation_bytes=bank0_img_crc,所以在hex文件中看到了一个75C18F9B。

至此,我们完整的分析了Settings.hex 的内容细节。

Bootloader启动时候,会检查Settings中以下信息:

  • bank0_bank_code
  • bank0_img_crc

如果二者都正确,则执行跳转进入Application,否则驻留在Bootloader中执行DFU。

(完)

Nordic Secure DFU拥有签名(Signature)机制,制作升级包时会生成Init Packet,它记录了待升级文件的元信息(Meta)及其数字签名。

在执行Secure DFU过程中,主机程序先发送Init Packet,签名验证通过后再发送Firmware Image,以保证固件的合法性。

1. 基本信息

使用pc-nrfutil生成升级压缩包的命令为:

nrfutil pkg generate --application app.hex --application-version 0x01 --hw-version 52 --sd-req 0xB6 --key-file private_key.pem dfu_pkg.zip

解压dfu_pkg.zip,可以看到三个文件:

  • app.bin
  • app.dat
  • manifest.json

manifest.json是清单文件,记录了dfu_pkg.zip中包含哪些文件。 app.bin是app.hex去掉地址信息后的二进制文件。 app.dat就是Init Packet的二进制形态。

infocenter上得知,Init Packet中包含以下内容:

  • Image type & size & hash (APP, BTL, SD or combination)
  • Version of FW/HW
  • sd_req
  • Signature type & bytes

通过pc-nrfutil命令可以查看这些信息内容:

nrfutil pkg display dfu_pkg.zip

内容截取如下:

$ nrfutil pkg display dfu_pkg.zip

DFU Package: <dfu_pkg.zip>:
|
|- Image count: 1
|
|- Image #0:
   |- Type: application
   |- Image file: app.bin
   |- Init packet file: app.dat
      |
      |- op_code: INIT
      |- signature_type: ECDSA_P256_SHA256
      |- signature (little-endian): 1e6011c9f3787641d920bd0d9f7c41b42f9c05fd9673552c626921f99512f616169533bd9d9c0d4d00a4ba626890d7ab4efbf1d8962afe7bafb96c89dd186fc5
      |
      |- fw_version: 0x00000001 (1)
      |- hw_version 0x00000034 (52)
      |- sd_req: 0xB6
      |- type: APPLICATION
      |- sd_size: 0
      |- bl_size: 0
      |- app_size: 60548
      |
      |- hash_type: SHA256
      |- hash (little-endian): 2a23069c597cd72a15c2a0a24a1a09bbdbbbcab7fa1b68de119a871436d84cc2
      |
      |- boot_validation_type: ['VALIDATE_GENERATED_CRC']
      |- boot_validation_signature (little-endian): ['']
      |
      |- is_debug: False

其中,Hash是对app.bin文件执行的hash结果。

下载openssl命令行工具,在cmd中执行openssl dgst -sha256 app.bin,可获得app.bin文件的hash值。注意,openssl工具获得的hash是big-endian,而nrfutil显示的hash是little-endia,二者数据顺序相反,数据内容相同。

Signature不是根据app.bin文件生成的签名!对于相同的app.hex,每次执行命令生成的签名都不相同。

wikimedia.org的这张图描绘了数字签名校验过程:

数字签名验证过程需要两个步骤:

  1. 使用原始数据文件的Hash值和私钥,生成数字签名。
  2. 对方收到数据明文后,计算Hash值(hash-1)。根据数字签名和公钥生成Hash值(hash-2)。比较二者,如果相等则签名验证通过。

Init Packet将固件镜像的元信息和Hash值放在一起形成结构体,取名为Init Command,再将Init Command序列化(Serialize)成二进制数据,Signature是根据Init Command的二进制数据内容生成的数字签名。

(我希望能够通过openssl模拟生成Signature,然后再对Hash进行验证,但是一直未能操作成功。)

2. 二进制文件

将结构化的数据序列化,Nordic Secure DFU选择了Protocol Buffer的嵌入式方案nano-pb。在许多场景下,Protocol Buffer简写为ProtoBuf或pb。

Protocol Buffer解决了不同平台序列化数据的互通性。比如pc-nrfutil是一个Python工具,它所生成的Init Packet要能够被C语言程序读取,那么二者应遵守相同的约定,包括数据类型定义、数据存储形式、复杂数据描述等。

Protocol Buffer定义了这样一套约定,以*.proto文件保存。不同平台的程序只要使用相同的Protocol Buffer文件,它们之间的序列化数据即可被正确解析。

Init Packet使用了dfu-cc.proto作为格式规范,pc-nrfutil的文件路径为:https://github.com/NordicSemiconductor/pc-nrfutil/blob/master/nordicsemi/dfu/dfu-cc.proto ,Bootloader所使用的dfu-cc.proto路径为:<sdk>\components\libraries\bootloader\dfu\dfu-cc.proto,它们的内容基本一致。

打开dfu-cc.proto文件,定位到文件底部:

// Parent packet type
message Packet {
    optional Command        command         = 1;
    optional SignedCommand  signed_command  = 2;
}

它就是Init Packet的数据原型。

message Packet相当于C语言中的结构体,optional关键字表示该项可以不存在,数字1和2表示该成员在结构体中的位置序号,称为field_num。

Command和SignedCommand本身也是message,所以它们是嵌套结构体。在文件中,也可以找到它们的结构体定义。

在文件中找到Init Command:

// Commands data
message InitCommand {
    optional uint32             fw_version      = 1;
    optional uint32             hw_version      = 2;
    repeated uint32             sd_req          = 3 [packed = true];
    optional FwType             type            = 4;

    optional uint32             sd_size         = 5;
    optional uint32             bl_size         = 6;
    optional uint32             app_size        = 7;

    optional Hash               hash            = 8;

    optional bool               is_debug        = 9 [default = false];
    repeated BootValidation     boot_validation = 10;
}

它就是上面提到的生成Signature用的Init Command。它也是Init Packet的核心内容。

现在我们知道了Init Packet其实就是dfu-cc.proto文件中的变量所包含的信息。

那么它是如何变成app.dat的呢?

Protocol Buffer定义message的构成形式为:<field_num><wire_type>[length]<data_content>

  • field_num是结构体成员序号
  • wire_type是个枚举值,它仅有几种选项,在下面给出
  • length为可选值,如果数据长度不可预先确定,则它存在,否则不存在
  • data_content为数据内容,它使用”Base 128 Varints”数据表示法

根据Protocol Buffer官方文档,可选的wire_type为:

TypeMeaningUsed for
0Varintint32, int64, uint32, uint64, sint32, sint64, bool, enum
164-bitfixed64, sfixed64, double
2Length-delimitedstring, bytes, embedded messages, packed repeated fields
3Start groupgroups (deprecated)
4End groupgroups (deprecated)
532-bitfixed32, sfixed32, float

wire_type仅有6种,可以用3个比特表示。规范中,<field_num>占5 bit,<wire_type>占3 bit,二者共同占用1字节。

Base 128 Varints实现了数据自身携带数据长度信息,如果一个字节的首位比特(msb)为1,表示还有后续字节,如果msb=0,表示这是最后一个字节。比如 0xCC33: 1100 1100 0011 0011,由于第一字节的msb=1,第二字节msb=0,说明它是个双字节数。

计算数据实际值的算法为:(1)去掉各字节的msb,(2)逆序全部字节,(3)合并。那么0xCC33的实际值为:

  1. 去msb:100 1100 011 0011
  2. 逆序:011 0011 100 1100
  3. 合并:0001 1001 1100 1100 = 0x19CC

更详细的规则请阅读官方文档:https://developers.google.com/protocol-buffers/docs/encoding

下面开始分析app.dat的文件内容。

使用十六进制工具打开app.dat。我将其十六进制内容复制到Excel表中方便查看,如下:

[1A] 12 指示signed_command,field_num=2,wire_type=2。

[1B – 1C] 8A 01指示Length=138。138正好是D1-E18之间的数据总数。 由于signed_command本身是个message,后面的数据是它的内容。

[3H] 38 指示app_size。

[4A – 4C] 84 D9 03 指示app_size=60548,根据“Base 128 Varints”算法可以获得该结果。

图中绿色表示数组型数据,蓝色表示长度,橙色表示<field_num>+<wire_type>。

第一个绿色色块[5B – 9A] 指示Hash值,第二个绿色色块[10F – 18E] 指示签名。

使用Protocol Buffer编译器protoc.exe可以将dfu-cc.proto编译成各种平台适用的结构体定义。

对于Nordic SDK的dfu-cc.proto,编译它可以生成dfu-cc.pb.c和dfu-cc.pb.h,后者实现了相同的结构体,但是遵守C语言的规范。 比如dfu-cc.proto文件中的message Packet{}编译后在dfu-cc.pb.h中变成如下形式:

typedef struct {
    bool has_command;
    dfu_command_t command;
    bool has_signed_command;
    dfu_signed_command_t signed_command;
/* @@protoc_insertion_point(struct:dfu_packet_t) */
} dfu_packet_t;

3. 解析过程

执行Secure DFU时候,主机先发送Init Packet,再发送固件镜像。

发送过程如下图:

主机依次执行:

  • Select Request
  • Create Request
  • Write Request
  • Get CRC Requset
  • Execute Request

打开secure_bootloader工程,找到nrf_dfu_req_handler.c,会看到以下函数:

  • on_cmd_obj_select_request
  • on_cmd_obj_create_request
  • on_cmd_obj_write_request
  • on_cmd_obj_crc_request
  • on_cmd_obj_execute_request

它们与上述Request对应,当主机向从机发出Request,从机程序则进入相应的函数中执行。

Init Packet的数据内容在Write Request中发送,如果使用了长包(MTU=247),那么一次即可发送完毕。

执行on_cmd_obj_execute_request时,追踪代码可以找到stored_init_cmd_decode函数,该函数使用pb_decode将二进制数据序列解析成proto文件定义的形式,并保存在mp_init结构体中。

mp_init的结构体定义在dfu-cc.pb.c/h中,它正是上面提到的利用dfu-cc.proto编译生成的C语言代码文件。

参考链接

Protocol Buffer中文介绍: http://bigdata.51cto.com/art/201805/574782.htm

Base 128 Varints中文介绍:https://www.jianshu.com/p/814a5dd86561

(完)