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写操作的基础代码:
类似的,擦除操作也是三步:打开擦操作,擦除某Page,关闭擦操作。
对应的代码如下:
底层驱动并未提供读操作的代码,因为读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来选择底层驱动库:
观察nrf_fstorage中的写Flash函数,其代码实现如下:
因为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)如下:
结构体提供了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结构体:
一次查找只能返回一个Record Descriptor,如果有多个匹配的Record,需要迭代查找以返回所有的Record Descriptor。从代码实现上考虑,迭代操作需要一个“游标”来记录迭代进度,在FDS中,这个“游标”是token:
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初始化过程也是个耗时操作,需要根据异步事件来判断初始化完成。
- 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的统计数据,比如有效数据个数、脏数据个数等
- 实例分析
利用以下命令可以导出芯片Flash 的内容:
nrfjprog --readcode flash.hex
测试工程的核心代码如下:
设置工程的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操作:
重新生成并查看flash.hex,将0x0007 E000处的内容截取出来:
:10E00000DEC0ADDEFE011EF100000100010045ADE5
:10E0100001000000414141410100010001008CC2AA
:10E020000200000042424242FFFFFFFFFFFFFFFFEE
对比前面的输出:
第一行与前面基本一致,但是Record Key变成了0x0000
第二行到41414141(“AAAA”)之前的内容与前面一致,后面的数据是全新的Record,其数据内容为42424242(“BBBB”)。
在代码中添加删除Record操作:
重新生成并查看flash.hex,将0x0007 E000处的内容截取 出来:
:10E00000DEC0ADDEFE011EF100000100010045ADE5
:10E010000100000041414141FFFFFFFFFFFFFFFF03
对比前面的输出,仅仅是Record Key变成了0x0000,其他不变。
在代码中添加先更新后GC的操作:
重新生成并查看flash.hex, 发现0x0007 F000的Page Tag变成了Swap Page Tag,0x0007 D000的Page Tag变成了Data Page Tag,并且数据内容为更新后的数据“BBBB”。这与上面关于Swap Page机制吻合。
参考资料
Infocenter文档:link fds源代码