本文基于pc-ble-driver v4.1.1,介绍如何在Windows上搭建开发环境。

以前写过一个入门教程(链接),最近的版本更新改动很大,那篇文章中介绍的方法已经过时。我仍然建议预先读它,然后再看本文。

pc-ble-driver是什么

它是一个PC端的BLE开发平台。

我们在开发板中烧录一个中间层固件,通过PC给固件发指令,与外部BLE设备(比如手机或一个BLE从设备)进行通信。

PC端可以用C/C++、Python、Javascript 语言进行编程。本文介绍的方案是基于C语言。

pc-ble-driver 部署在github中,可以从该页面获得它的全部信息。

生成静态库文件

首先要安装Visual Studio 2019,它有多个开发语言,我们这里只需要C++。

安装好以后,我们能够打开VS的项目文件(.sln)和工程文件(.vcxproj),同时也得到了一个编译工具MSBuild.exe,它的路径应该在:C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe

然后安装cmake工具。

下载pc-ble-driver的源文件:https://github.com/NordicSemiconductor/pc-ble-driver/releases

页面中有多个选项:

  • nrf-ble-driver-4.1.1-win_x86_32.zip
  • nrf-ble-driver-4.1.1-win_x86_64.zip
  • Source code (zip)
  • Source code (tar.gz)

需要下载Source Code (zip)那一项。

将源文件解压到一个开发目录下,我这里是:C:\repo\pc-ble-driver-repos\pbd

开始之前需要明确我们要干什么!

pc-ble-driver 在PC端实现了一个库,这个库可以调用softdevice,实现BLE的功能。我们写的代码直接调用这个库文件暴露的接口。

在上面的下载页面,下载nrf-ble-driver-4.1.1-win_x86_64.zip文件,里面就包含了这个库,理想情况是我们能够拿它直接使用,但奇怪的是我这里使用它的库会报错。无奈只能自己生成一个库来用。

所以我们的目标是,编译源文件,生成一个静态库文件(.lib)。

下载必要的第三方VC库:

  • asio
  • catch2 (只有一个catch.hpp文件)
  • spdlog

将它们放在目录:C:\repo\pc-ble-driver-repos\pbd\ext

注意,官方页面上使用vcpkg来下载它们,还要设置一堆环境变量,把问题复杂化,这里我们直接下需要的库,不管vcpkg。

在pbd下面创建一个build文件夹: C:\repo\pc-ble-driver-repos\pbd\build。打开cmd窗口,cd到该文件夹,然后执行:

cmake -DASIO_INCLUDE_DIR=C:\repo\pc-ble-driver-repos\source\ext\asio-1.12.2\include -DCatch2_DIR=C:\repo\pc-ble-driver-repos\source\ext\catch-2.10.0 -DCONNECTIVITY_VERSION=4.1.1 -DNRF_BLE_DRIVER_VERSION=6.1.1 -G "Visual Studio 16 2019" -A Win32 ..

值得看一下各个参数:

  • -DXXX表示增加一个变量,也可以通过设置环境变量的方式进行(有坑,不推荐)
  • CONNECTIVITY_VERSION和NRF_BLE_DRIVER_VERISON 按实际版本进行配置,因为可能过几天又有新版本了
  • -G “Visual Studio 16 2019″,这个跟官方页面上的-G Ninja不同,我发现用Ninja有坑
  • -A Win32表示生成Win32的库,可选项还包括:x64, ARM, ARM64。(链接)如果这里使用了Win32,后面创建VS工程时候,也得使用Win32。

命令执行完毕,会在build下生成大量文件,我们关心的是:项目文件(nrf-ble-driver.sln) 和好几个工程文件(*.vcxproj)。

这时候可以双击打开sln项目文件,或者用命令:

"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe" nrf-ble-driver.sln

编译完毕,在pbd\build\Debug目录下就能看到多个lib文件,找到:nrf-ble-driver-sd_api_v6-mt-static-gd-6_1_1.lib,它就是我们生成的静态库文件,后面我们利用它来编写代码。

搭建开发环境

在C:\repo\pc-ble-driver-repos目录下新建几个目录:

  • include
  • lib
  • project\hrs和project\hrs_c
  • hex

将pbd\include文件夹下的东西复制到这个include中。其实只复制common和sd_api_v6两个文件夹即可。

将刚才生成的 nrf-ble-driver-sd_api_v6-mt-static-gd-6_1_1.lib 复制到lib中。

将pbd\hex\sd_api_v6下的connectivity_4.1.1_1m_with_s132_6.1.1.hex和connectivity_4.1.1_1m_with_s140_6.1.1.hex复制到hex中,二者分别用于nRF52832和nRF52840芯片。

project\hrs用于放HRS示例工程,project\hrs_c用于放HRS_C示例工程。将 pbd\examples\heart_rate_monitor\main.c 复制到hrs目录下,将 pbd\examples\heart_rate_collector\main.c 复制到hrs_c目录下。

下面介绍如何制作一个HRS工程。

在Visual Studio 2019中,新建一个空白工程,执行以下操作:

先添加main.c到工程。

在工程设置C/C++ -> Additional Include Directories中,添加

  • C:\repo\pc-ble-driver-repos\include\sd_api_v6
  • C:\repo\pc-ble-driver-repos\include\common
  • C:\repo\pc-ble-driver-repos\include\common\config
  • C:\repo\pc-ble-driver-repos\include\common\internal
  • C:\repo\pc-ble-driver-repos\include\common\internal\transport
  • C:\repo\pc-ble-driver-repos\include\common\sdk_compat

C/C++ -> Compile AS设置为Compile As C Code。

C/C++ -> Precompiled Header File设置为Not Using Precompiled Headers

C/C++ -> Process Definitions添加:

  • WIN32
  • _WINDOWS
  • NRF_SD_BLE_API=6
  • _WIN32_WINNT=0x0502
  • NOMINMAX
  • ASIO_STANDALONE
  • SD_RPC_EXPORTS
  • HCI_LINK_CONTROL
  • _CRT_SECURE_NO_WARNINGS
  • PC_BLE_DRIVER_STATIC
  • CMAKE_INTDIR=”Debug”
  • _DEBUG
  • _CONSOLE

Linker -> Additional Dependencies添加:C:\repo\pc-ble-driver-repos\lib\nrf-ble-driver-sd_api_v6-mt-static-gd-6_1_1.lib

Linker -> Generate Debug Info改成:Debug Information optimized for faster links (/DEBUG:FASTLINK)

保存好后打开main.c,按F7编译一下,如果不报错就说明一切完好,可以连上开发板测试了。

hrs_c工程设置过程与之一模一样。

可以从这里下载我配置好的工程作为参考:https://gitee.com/isyq/pc-ble-driver-demo

测试HRS工程

准备一个52开发板,连上电脑。擦除并烧录C:\repo\pc-ble-driver-repos\hex\connectivity_4.1.1_1m_with_s132_6.1.1.hex

查看开发板的串口号,比如是COM58,那么在main.c中,找到DEFAULT_UART_PORT_NAME定义,将其修改为COM58。

然后在VS中按F5进行调试,就会打开有一个cmd窗口,通过打印的内容可以看到它正在广播,如下:

这时候用手机与它连接,发现可以连接但是无法正常发现服务。这是因为这个main.c没有处理Data Length Update事件,导致超时断开。

解决办法是,找到ble_evt_dispatch()函数,增加处理该事件,代码如下:

case BLE_GAP_EVT_DATA_LENGTH_UPDATE_REQUEST:
{
    ble_gap_data_length_params_t dl_params;

    memset(&dl_params, 0, sizeof(ble_gap_data_length_params_t));
    err_code = sd_ble_gap_data_length_update(adapter, p_ble_evt->evt.gap_evt.conn_handle,
        &dl_params, NULL);
    if (err_code != NRF_SUCCESS)
    {
        printf("Data len update error: %x\n", err_code);
        fflush(stdout);
    }
} break;

然后手机就可以跟它进行连接和通信了。

值得注意的是,pc-ble-driver与nRF5 SDK 同根同源,这个版本直接使用SDK 15.3的softdevice,但是二者的编程模型不同,SDK 中使用了大量的XXX_DEF 和XXX_OBSERVER 的编程模型,许多BLE库也都是基于这个实现的,在pc-ble-driver中则完全无法使用,所有的扫描、连接、发现操作都得用最原始的API去实现,给使用带来了不少难度。

另外,pc-ble-driver 与PC 之间使用串口进行通信,尽管采用了1M的比特率,但是仍然不同于单芯片方案,有诸多限制。pc-ble-driver 不支持data length extension, data length只能使用27, 意味着它的通信速度跑不起来。

(完)

如果芯片在上一次操作Flash时遭遇了意外掉电 ,会发生什么呢?

1. 写入过程掉电

一个Record的写入按照如下顺序执行:

  1. TL Part
  2. ID Part
  3. Content
  4. IC Part

如果在前三步意外掉电,IC Part由于没有开始写入,所以File ID仍然是原始值0xFFFF。

File ID = 0xFFFF的数据也会被视为脏数据,在读取、更新、删除操作的时候,脏数据会被忽略。GC 过程会删除脏数据,收回空间。

如果在第四步掉电,IC Part有两种可能:

  • 写入失败:File ID仍然是0xFFFF,情况同上
  • 写入错误:CRC 校验将无法通过,应用代码可以得知

所以一旦我们开启了CRC校验,即可解决第四步出现的意外掉电情况。

(经过我有限的测试,在第四步掉电时,只遇到“写入失败”,完全没有遇到“写入错误”,一个猜测是芯片电源管脚上的电容余电保护Flash将单个Word写入完毕。)

2. 删除、更新过程掉电

FDS 的删除操作实际是将Record Key写成0x0000,使该数据成为脏数据。所以删除操作本质上仍然是写操作。

如果写Record Key失败,它并未变成0x0000,那么下次运行时FDS 会把它识别成有效数据。应用层程序应该有意识的去处理这种“删除失败”的情况。

更新操作实际是先删除原数据,再写入新数据。它的掉电情况与删除和写入一致。

3. 读取过程掉电

无需分析,没有任何影响。

4. GC过程掉电

GC 过程共有四个步骤,在第一步时掉电,GC 尚未开始,没有任何影响。

在其他三个步骤时掉电,都会影响到FDS 数据页和交换页的存在情况。这里使用一个标志位flag来标记各个页的类型,枚举出全部可能的情况:

  • 如果是空白页,则flag = PAGE_ERASED
  • 如果不是空白页 ,页头有效,是数据页,则flag = PAGE_DATA
  • 如果不是空白页,页头有效,是交换页,页内空白,则flag = PAGE_SWAP_CLEAN
  • 如果不是空白页,页头有效,是交换页,页内有内容,则flag = PAGE_SWAP_DIRTY

(其实这里忽略了一种情况,如果不是空白页,但页头无效,则flag = NO_PAGES,FDS 对这种情况是直接忽略该页,就像Windows面对硬盘坏道一样不做处理。为了方便介绍,这里故意跳过它。)

对于FDS_VIRTUAL_PAGE = 3的情况,我们分析它们的flag。每个页有4种可能的情况,3个页总的可能情况是4 x 4 x 4 = 64。

事实上,我们无需枚举这么多情况,因为它只是掉电,FDS的基本结构仍然存在,即:

  • FDS 最多只有一个交换页
  • FDS 可以有多个数据页

如果交换页存在,它只可能存在一个,从代码实现上,不可能出现多个交换页的情况。所以我们更关注数据页和交换页的存在与否,而非具体数目。

使用逻辑“或”操作来枚举各种情况:

  1. 所有页均是擦除页:PAGE_ERASED
  2. 所有页均是数据页:PAGE_DATA
  3. 存在擦除页,存在数据页: (PAGE_ERASED | PAGE_DATA)
  4. 存在擦除页,存在交换页:(PAGE_ERASED | PAGE_SWAP_CLEAN) or ( PAGE_ERASED | PAGE_SWAP_DIRTY)
  5. 存在数据页,存在交换页: (PAGE_DATA | PAGE_SWAP_CLEAN) or ( PAGE_DATA | PAGE_SWAP_DIRTY)
  6. 存在擦除页,存在数据页,存在交换页: ( PAGE_ERASED | PAGE_DATA | PAGE_SWAP_CLEAN) or ( PAGE_ERASED | PAGE_DATA | PAGE_SWAP_DIRTY)

以上覆盖了所有可能出现的GC过程掉电的情况,重启芯片,FDS 在初始化时候对它们进行相应处理:

场景方案
PAGE_ERASED 新建数据页和交换页
PAGE_DATA 报错
PAGE_ERASED | PAGE_DATA 将一个擦除页设为交换页,其他擦除页设为数据页。(这就是GC第四步掉电情况。)
PAGE_ERASED | PAGE_SWAP_CLEAN 将所有擦除页设为数据页
PAGE_ERASED | PAGE_SWAP_DIRTY 将该交换页改成数据页,第一个擦除页设为交换页,其他擦除页为数据页。(如果FDS页数是2的话,它就是GC第三步掉电情况。)
PAGE_DATA | PAGE_SWAP_CLEAN 这是正常运行的情况
PAGE_ERASED | PAGE_SWAP_DIRTY 把交换页擦掉并重新设置交换页。(这就是GC第二步掉电情况,此时我们不是继续往下走,而是回滚,因为不知道GC第一步是否执行完毕。)
PAGE_ERASED | PAGE_DATA | PAGE_SWAP_CLEAN 将擦除页设置成数据页
PAGE_ERASED | PAGE_DATA | PAGE_SWAP_ DIRTY 将该交换页改成数据页,第一个擦除页设为交换页,其他擦除页为数据页。(它就是GC第三步掉电情况)

有了上述分析,再去浏览fds_init() -> pages_init()的源代码,其中的局部变量ret就是我们上面这套分析的代码实现。

现在我们知道了,FDS 的初始化其实做了许多事情,它可能涉及Flash的擦写操作,所以它肯定是异步的,在代码中,务必等收到初始化完成事件后再进行后续的读写擦操作。

(完)

本文介绍FDS库的GC操作。

1. GC是什么

在FDS的概念中,写入Flash的数据以Record的形式保存。Record的格式为:

Flash只能以32-bit的字(Word)为单位进行写操作。Record Header包含三个字,分别是:

  • TL Part: Record Key和Data Length
  • IC Part: File ID和CRC Value
  • ID Part: Record ID

有效的Record Key范围是(0x0001 ~ 0xBFFF)。如果Record Key == 0x0000,表示它是一条无效数据,或脏数据(Dirty Record)。

  • 删除Record,实际是将该数据设置为脏数据。
  • 更新Record,实际是将该数据设置为脏数据,再写入一个新数据。
  • 读取Record,将忽略所有脏数据。

所以,删除和更新Record都会产生脏数据。

经过反复的写入、更新和删除操作,有效数据和脏数据最终占满整个FDS区域,此时我们需要从Flash中删除脏数据以释放空间,这个过程称之为垃圾回收,简称GC(Garbage Collection)。

GC完成以后,FDS区域中的脏数据都被物理删除。

2. GC的步骤

Flash空间物理上分成不同的页,每页起始地址按Word对齐,每页长度固定为4kB(1024 words)。在程序中可以指定若干页为FDS区域(FDS Area)。

FDS在每页的起始地址写入两个字的页头(Page Head),将其标记为有效的FDS页。根据页头的不同,FDS页分为数据页和交换页:

  • 数据页的页头:0xDEADC0DE F11E01FE
  • 交换页的页头: 0xDEADC0DE F11E01FF

二者仅最后一个比特不同,交换页可以通过对该比特写0变成数据页,反过来则不行。

上图中,灰色底色的方块表示脏数据,黄色底色的方块表示有效数据,空白处表示没有数据。

它描述了GC的四个步骤:

  1. 将原数据页中的全部有效数据复制到交换页
  2. 擦除原数据页
  3. 将原交换页的页头改写成数据页
  4. 对原数据页写入交换页的页头

从图中我们可以读到以下有用信息:

(1)脏数据与有效数据可能交替存放,没有固定规律

(2)交换页的实际地址经过GC后会发生变化

(3)有效数据在Flash中的绝对地址经过GC后会发生变化

(4)GC操作实际上执行了一次擦除页和大量的写操作,它是个Flash密集操作行为,所以在程序中不要频繁的执行GC

(5)图中没有明确表达出来但值得注意的是,GC总是一页执行完后再执行下一次,所以不能通过GC将两个数据页的数据“合并”到一个数据页中——这暗含了FDS的设计思路,用户不要关注Record在Flash中的存储细节

3. GC源代码

GC的源码比较繁复,读懂它是一个挑战。

FDS在初始化时候通过page_scan()函数遍历全部数据页,然后在各页中检查所有的Record Header,如果遇到脏数据,则通过全局变量m_pages.can_gc记录它。

在每次执行更新、删除操作产生脏数据的时候也记录在can_gc中。

FDS设计了一套状态机来分布执行GC

  • GC_BEGIN
  • GC_NEXT_PAGE
  • GC_FIND_NEXT_RECORD
  • GC_COPY_RECORD
  • GC_ERASE_PAGE
  • GC_DISCARD_SWAP
  • GC_PROMOTE_SWAP
  • GC_TAG_NEW_SWAP

在 GC_NEXT_PAGE 中,它通过m_pages.can_gc来找到需要执行GC的页。

在 GC_FIND_NEXT_RECORD 中,通过gc_record_find_next()和gc_record_copy()找到的有效数据依次复制到交换页。

处理完全部数据,跳到GC_ERASE_PAGE 中,使用gc_page_erase()将该数据页擦除。

然后进入GC_PROMOTE_SWAP,使用gc_swap_promote()将原交换页的页头改成数据页页头。

然后进入GC_TAG_NEW_SWAP, 使用gc_page_erase() 将刚才擦除的数据页写成交换页。

然后进入GC_NEXT_PAGE,执行下一轮GC。

整个过程在以下两个函数中来回反复跳转:

  • gc_execute()
  • gc_state_advance()

不知道使用了什么设计模式,实现代码很奇怪,两个函数在状态机之间跳来跳去,看的眼睛疼。

(完)