【从零开始】手写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)。这个数字反映的是接收信号强度:距离越近数字越大,越远越小。

这些设备之所以能被扫到,是因为它们在持续地向四周广播数据包,大约每 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 上各发一帧,遭遇干扰时对方至少能在另外两个频道收到信号。

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

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 里的关键字段:type 选 PDU_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 字节对应(注意字节序反转)。

如果有 Wireshark + nRF Sniffer,可以在广播信道上抓到完整的 ADV_NONCONN_IND 帧,验证 Header 字段、AdvA 地址和 AdvData 内容是否与构建的 PDU 一致。
本系列教程同款硬件:👇
芯片: nRF 52832 开发板
工具: nRF 52840 BLE Dongle 蓝牙嗅探器
工具: 逻辑分析仪
工具: BPA low energy 蓝牙分析仪
本文版权归作者:ixbwer所有,转载请注明原文链接:https://www.cnblogs.com/ixbwer/p/19781641,否则保留追究法律责任的权利。

浙公网安备 33010602011771号