【从零开始】手写BLE协议栈(1-2)Hello World:BLE广播

Hello World:BLE 广播

前提知识

阅读本文需要具备以下基础:

  • 1-1 的 TASK/EVENT 模型:本文的所有硬件操作(启动晶振、触发发送、等待完成)都基于往 TASK 寄存器写 1 触发动作、读 EVENT 寄存器确认完成的模式
  • 1-1 的 SHORTS 机制:本文的 send_on_channel() 直接使用 READY→START 和 END→DISABLE 两条硬件捷径驱动发送,不理解 SHORTS 就无法理解为什么 CPU 只需要写一个寄存器就能完成整个发送流程
  • HEX 与二进制表示:需要读懂字节级的数据结构和位域定义

不需要提前了解 BLE 广播规范的细节——本文会从头解释每一个字段的含义和设计原因。


一、空气中发生了什么

在手机上打开 nRF Connect App 点击扫描。屏幕上会跳出一列设备,每条记录后面跟着一个随时间变动的 RSSI 值(如 -52 dBm)。这个数字反映的是接收信号强度:距离越近数字越大,越远越小。

image

这些设备之所以能被扫到,是因为它们在持续地向四周广播数据包,大约每 100 ms 发一次(广播间隔可以在 20 ms 到 10.24 s 之间配置)。BLE 规范为了对抗 2.4 GHz 频段的拥堵,把广播固定在三个信道上轮流发送,而不是随机选一个频率乱发:

  • Channel 37 — 2402 MHz:紧贴 Wi-Fi 信道 1 的左侧边缘
  • Channel 38 — 2426 MHz:Wi-Fi 信道 1 与信道 6 之间的空隙
  • Channel 39 — 2480 MHz:Wi-Fi 信道 11/13 的右侧边缘

这三个位置经过精心选择,尽可能地绕开最常用的 Wi-Fi 信道。每次广播事件,设备依次在 37、38、39 上各发一帧,遭遇干扰时对方至少能在另外两个频道收到信号。

image

二、广播包的结构

物理层看到的空中数据只是一串比特流。BLE 规范规定了这串比特流在逻辑上的分层结构。一个完整的空中帧由硬件自动补全的首尾字段和软件负责填写的 PDU 组成:
image

Preamble(前同步码) 是固定序列 0xAA,让接收端的时钟恢复电路锁定比特率,由 RADIO 硬件自动发送。

Access Address(接入地址) 是 BLE 广播信道的"通行密码",固定值 0x8E89BED6。所有 BLE 设备在广播信道上都用同一个接入地址,这样手机的射频芯片才知道哪段信号是 BLE 包而不是其他 2.4 GHz 设备的干扰。

PDU(协议数据单元) 分为两层:

  • Header 占 2 字节,包含 PDU 类型(4 bit)、发送方地址类型(1 bit)、接收方地址类型(1 bit)和 Payload 长度(8 bit)等
  • Payload 以 6 字节的设备 MAC 地址(AdvA)开头,后面跟最多 31 字节的广播数据(AdvData)

CRC 是 24 位循环冗余校验,由 RADIO 硬件在发送最后一字节后自动计算并附加。接收端也由硬件自动核验,结果反映在 RADIO->CRCSTATUS 寄存器中。

三、在内存中构建 PDU

理解了逻辑结构之后,我们来看软件如何把数据写进内存,再交给 RADIO 的 DMA 引擎。

3.1 AdvData 数组

我们的广播数据包含两个 AD 结构体:一个 Flags,一个设备名称。

static const uint8_t adv_data[] = {
    /* AD 结构体 1:Flags */
    0x02,   /* Length = 2:Type 1B + Value 1B */
    0x01,   /* Type = 0x01:Flags */
    0x06,   /* Value:LE General Discoverable | BR/EDR Not Supported */

    /* AD 结构体 2:Complete Local Name */
    0x0A,   /* Length = 10:Type 1B + 9 字符名称 */
    0x09,   /* Type = 0x09:Complete Local Name */
    'Z', 'e', 'p', 'h', 'y', 'r', 'R', 'a', 'w'
};

Flags 占 3 字节(Length + Type + Value),名称占 11 字节(Length + Type + 9 字符),共 14 字节。

3.2 组装完整 PDU

Zephyr BLE Controller 源码提供了 struct pdu_adv 来精确映射广播 PDU 的内存布局。RADIO 的 DMA 引擎直接读这块内存,要求地址必须 4 字节对齐,否则 DMA 读取错位会导致发出的字节序出错。

PDU Header 里的关键字段:typePDU_ADV_TYPE_NONCONN_IND(不可连接广播,值 0x02),表示设备只广播不接受连接请求;tx_addr = 1 表示使用随机地址,开发测试时直接硬编码即可,不需要向 IEEE 申购全球唯一的公共地址(OUI)。

Payload 分两段:AdvA(6 字节设备地址)和 AdvData,用 memcpy 依次拷贝进去即可。BLE 规范要求设备地址以小端序(Least Significant Byte First)存储,所以 nRF Connect App 里显示的地址字节顺序和数组里是反的:数组 {0xE1, 0xC2, 0xD3, 0xE4, 0xA5, 0xC6} 对应显示地址 C6:A5:E4:D3:C2:E1

static struct pdu_adv pdu __aligned(4);

static void build_adv_pdu(void)
{
    memset(&pdu, 0, sizeof(pdu));

    pdu.type    = PDU_ADV_TYPE_NONCONN_IND;
    pdu.tx_addr = 1;   /* Random Address */
    pdu.rx_addr = 0;
    pdu.len     = BDADDR_SIZE + ADV_DATA_LEN;   /* 6 + 14 = 20 */

    memcpy(pdu.adv_ind.addr, adv_addr, BDADDR_SIZE);
    memcpy(pdu.adv_ind.data, adv_data, ADV_DATA_LEN);
}

pdu 对象在内存中的实际布局:

字节偏移  内容   说明
[0]      0x42   Header[7:0]:type=NONCONN_IND(0x02), TxAdd=1 → 0b0100_0010
[1]      0x14   LENGTH = 20(0x14)
[2]      0xE1   AdvA[0](地址低字节,LSB first)
[3]      0xC2
[4]      0xD3
[5]      0xE4
[6]      0xA5
[7]      0xC6   AdvA[5](地址高字节,App 显示 C6:A5:E4:D3:C2:E1 即为倒序)
[8]      0x02   Flags AD:Length=2
[9]      0x01   Flags AD:Type=0x01
[10]     0x06   Flags AD:Value=0x06
[11]     0x0A   Name AD:Length=10
[12]     0x09   Name AD:Type=0x09(Complete Local Name)
[13..21] ZephyrRaw(9 字节设备名)

包格式引擎的 DMA 就是从 &pdu 这个地址开始,按照 PCNF0/PCNF1 的配置顺序(参见 1-1 第十节)读出这些字节,发到空气中。


四、配置与发送

4.1 启动外部晶振

在使用 RADIO 外设之前,必须先启动芯片的外部晶振(HFXO)。要理解为什么,需要先明白芯片的时钟架构。

NRF 52 内部有两个高频时钟源可以选择:

HFINT(片内 RC 振荡器):上电即可使用,不需要任何外部元件,启动时间为零。但 RC 振荡器的频率精度非常差,大约 ±2%。对于 2.4 GHz 的 BLE 载波来说,±2% 的误差意味着频率可能偏移高达 ±48 MHz——这已经不在同一个频道了。

HFXO(外部 16 MHz 晶振):nRF 52 DK 板子上焊有一颗 16 MHz 的石英晶振。石英晶体的频率精度可以达到 <±50 ppm(每百万分之一),在 2.4 GHz 上的误差 <±120 kHz。BLE 规范(Vol 6, Part B, 4.4.3)要求载波频率偏差小于 ±150 kHz,HFXO 满足这个要求,HFINT 则差了约 320 倍。

RADIO 外设内部有一个 PLL(锁相环),它以 HFXO 的 16 MHz 为基准,通过倍频产生 2.4 GHz 的精确载波。如果 HFXO 没有启动,PLL 就没有稳定的基准,发出的载波频率乱飘,接收端根本收不到信号。

启动 HFXO 的代码很简单:

static void hfclk_start(void)
{
    NRF_CLOCK->EVENTS_HFCLKSTARTED = 0;   // 清除上次的残留事件
    NRF_CLOCK->TASKS_HFCLKSTART    = 1;   // 命令:启动外部晶振

    while (NRF_CLOCK->EVENTS_HFCLKSTARTED == 0) {
        // 等待晶振起振稳定,典型时间约 0.4 ms
    }
}

注意第一行清除 EVENTS_HFCLKSTARTED 是必须的,正是 EVENT 粘性特性的体现:如果上次启动的事件还留着,下面的 while 会立刻退出,但此时晶振可能根本还没启动。

hfclk_start();        /* 等待 HFXO 稳定,约 0.4 ms */
radio_configure();    /* 一次性写入所有射频参数 */
build_adv_pdu();      /* 在 RAM 中组装 PDU */

4.2 radio_configure ():告诉 RADIO 怎么发送

配置好时钟之后,就要告诉 RADIO 外设一系列参数:使用什么调制方式、发射功率是多少、数据包的格式是什么……这些都是在 radio_configure() 中完成的。我们分概念逐一讲解。


调制方式(PHY / GFSK)

无线电发送信息的本质,是把 0 和 1 的数字信号"编码"到电磁波上。BLE 1 M PHY 使用的调制方式叫做 GFSK(高斯频移键控):发送逻辑 1 时,载波频率向上偏移 250 kHz;发送逻辑 0 时,频率向下偏移 250 kHz。每个 bit 持续 1 微秒,即码率为 1 Mbit/s。

"高斯"指的是在频率切换时做了高斯曲线平滑,防止频率跳变过于陡峭,使发射频谱更集中,减少对相邻信道的干扰。


发射功率(TX Power)

发射功率决定信号能传多远。NRF 52832 支持从 -40 dBm(最弱,约 0.0001 mW)到 +4 dBm(最强,约 2.5 mW)的多个档位。0 dBm(1 mW)是调试时常用的值,室内大约 10 米覆盖。

接入地址(Access Address,AA)

BLE 规范规定,每一帧无线数据包在前导码之后都要有一个 4 字节的"同步序列",叫做接入地址(AA)。这个设计是为了让接收方区分"这个信号是针对我的 BLE 网络的"。

对于广播包,所有 BLE 设备都使用同一个固定的接入地址:0x8E89BED6(由 BLE 规范规定,Vol 6, Part B, 2.1.2)。我们不用选择,直接填这个值就行。

对于连接中的数据包,AA 是在建立连接时双方协商的随机值,用来区分不同的连接。


CRC(循环冗余校验)

CRC 是一种错误检测码。发送方在数据末尾附上一段用特定多项式计算出来的校验码,接收方用同样的多项式重新计算,如果结果不吻合,说明数据在传输中发生了翻转,接收方丢弃这包数据。

BLE 使用 24 bit 的 CRC(3 字节),采用规范指定的生成多项式,广播包的初始值固定为 0x555555。RADIO 外设的包格式引擎全自动处理 CRC 的计算和追加,软件完全不需要手动做任何运算。


现在可以看 radio_configure() 的代码了。这里每行代码背后的含义已经在上面逐一解释过,代码本身就是刚才文字描述的直接映射:

static void radio_configure(void)
{
    radio_phy_set(0, 0);   // BLE 1M PHY,GFSK,1 Mbit/s

    radio_tx_power_set(0); // 发射功率 0 dBm

    radio_pkt_configure(               // 配置包格式引擎
        RADIO_PKT_CONF_LENGTH_8BIT,    // LENGTH 字段 8 bit
        PDU_AC_LEG_PAYLOAD_SIZE_MAX,   // 最大载荷 37 字节
        RADIO_PKT_CONF_FLAGS(
            RADIO_PKT_CONF_PDU_TYPE_AC,
            RADIO_PKT_CONF_PHY_LEGACY,
            RADIO_PKT_CONF_CTE_DISABLED));

    uint32_t aa = sys_cpu_to_le32(PDU_AC_ACCESS_ADDR);  // AA = 0x8E89BED6
    radio_aa_set((const uint8_t *)&aa);

    radio_crc_configure(PDU_CRC_POLYNOMIAL, PDU_AC_CRC_IV);  // 24-bit CRC,初始值 0x555555
}

4.3 send_on_channel ():SHORTS 驱动的完整发送

在发送每一包数据之前,还有三个参数需要根据目标信道动态设置:射频频率、数据白化种子和 DMA 指针。然后配置好 SHORTS、触发 TASKS_TXEN,剩下的交给硬件。


数据白化(Data Whitening)

想象一条消息全部由重复的 0(或 1)组成。在射频信道上,这样的数据流会产生一个频谱上的尖峰,对附近频率造成干扰,同时接收端的时钟恢复电路也难以从单调的信号中提取时钟。

BLE 规范要求所有数据在发送前必须经过数据白化处理:使用一个 7 位线性反馈移位寄存器(LFSR)生成伪随机序列,与原始数据逐 bit 做 XOR。XOR 之后,原本规律的数据看起来就"随机"了,频谱变得平坦,消除了尖峰。接收方用同样的信道号作为初始值,做相同的操作,XOR 两次还原原始数据。

BLE 规范规定:白化的初始值(IV,Initial Vector)等于当前信道号的低 6 位。发送信道 37 时,IV = 37;发送信道 39 时,IV = 39。RADIO 硬件自动完成白化,软件只需提前把信道号写入 DATAWHITEIV 寄存器。


DMA 指针

RADIO 外设通过 DMA(直接内存访问)引擎从 RAM 读取数据,整个过程不占用 CPU。你只需要把 pdu 对象的内存地址告诉 RADIO,放进 PACKETPTR 寄存器,触发发送后 DMA 引擎会自己去取数据。这等于把一个指针交给硬件,说"去这里取数据"。


现在看 send_on_channel() 的代码,每个操作步骤都是对以上概念的直接应用:

static void send_on_channel(uint32_t channel)
{
    uint32_t freq;
    switch (channel) {
    case 37: freq = 2;  break;
    case 38: freq = 26; break;
    case 39: freq = 80; break;
    default: return;
    }

    radio_freq_chan_set(freq);       // 设置射频频率偏移
    radio_whiten_iv_set(channel);   // 设置数据白化初始值
    radio_pkt_tx_set(&pdu);         // 设置 DMA 源地址

    radio_status_reset();           // 清除所有残留的 EVENT 标志
    NRF_RADIO->INTENCLR = 0xFFFFFFFFUL;  // 禁用所有中断(我们用轮询)

    // 配置 SHORTS:在 TASKS_TXEN 之前!(原因见下文)
    NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
                        RADIO_SHORTS_END_DISABLE_Msk;

    radio_tx_enable();              // 触发:写 TASKS_TXEN = 1,启动整个流程

    while (!radio_has_disabled()) { }  // 等待 SHORTS 自动走完全程

    NRF_RADIO->SHORTS = 0;          // 清除 SHORTS,防止残留影响下次操作
}

这里有一个顺序铁律NRF_RADIO->SHORTS 必须在 radio_tx_enable()(即 TASKS_TXEN)之前配置好。原因是:TXRU 阶段只有约 40 微秒。如果先写 TASKS_TXEN 再设置 SHORTS,而 PLL 恰好在这 40 微秒内就锁定了,EVENT_READY 会先于 SHORTS 配置发生;由于 SHORTS 捕获不到已经发生过的"历史事件",TASKS_START 就永远不会被自动触发,RADIO 会卡死在 TXIDLE 状态。

4.4 主循环

while (1) {
    send_on_channel(37);
    send_on_channel(38);
    send_on_channel(39);

    k_msleep(100);  /* 广播间隔 100 ms */
}

三次调用构成一次完整的广播事件。广播间隔越短,手机越快发现设备,但功耗越高;BLE 规范允许范围为 20 ms 到 10.24 s。


五、验证

把上面的逻辑烧录进 nRF52 DK。打开手机上的 nRF Connect App 扫描,屏幕上会出现名为 "ZephyrRaw" 的设备,MAC 地址与代码中写入的 6 字节对应(注意字节序反转)。
image

如果有 Wireshark + nRF Sniffer,可以在广播信道上抓到完整的 ADV_NONCONN_IND 帧,验证 Header 字段、AdvA 地址和 AdvData 内容是否与构建的 PDU 一致。


本系列教程同款硬件:👇
芯片: nRF 52832 开发板
工具: nRF 52840 BLE Dongle 蓝牙嗅探器
工具: 逻辑分析仪
工具: BPA low energy 蓝牙分析仪

posted @ 2026-03-27 13:29  ixbwer  阅读(15)  评论(0)    收藏  举报