数据来源说明 :本文上行链路以 Zephyr 软链路层(
controller/ll_sw/,开源)为主线讲清机制,关键函数已用 gitee 镜像 main 分支核对(https://gitee.com/zephyrproject-rtos/zephyr/raw/main/)。gitee 镜像只有 main 分支,ncs-v3.2.1 tag 不在镜像上,文中行号以 main 为参考并标注版本差异(main 与 v3.2.1 行号有偏移,函数名与机制一致)。nRF54L15 实际用的是 SoftDevice Controller(闭源库),其 radio→链路层→HCI 全过程在库内完成,本文只讲它对外的 HCI 边界,不冒充看过其源码。
做 Zephyr BLE 开发,写一个 bt_gatt_attr_read 回调,对端发个 Read Request,回调就触发了。但这个 Read Request 从空中射频信号进来,到你的回调被调,中间跨了多少层、几个上下文、几条队列,多数人没追过。遇到"收包延迟抖动""某个事件没上来""改了 controller 行为没反应"这类问题,就只能瞎猜。
这篇把一个空中报文从天线到应用回调的完整上行链路拆开。主线是 Zephyr 软链路层的 split 架构,关键文件已对照 gitee 镜像 main 分支核对,代码基于 NCS v3.2.1 / nRF54L15。
一、全景:上行 10 个阶段,跨 3 个上下文
先建立全景。一个空中报文上行到应用回调,要过 10 个阶段,分处三个执行上下文。
radio 收到空中 PDU 触发中断,这是 LLL(Lower Link Layer)中断上下文,优先级最高。LLL 处理 PDU 封装成 node_rx,通过 mayfly 跨上下文唤醒 ULL(Upper Link Layer)。ULL 在线程上下文做 rx_demux 按 type 分发,再交给 HCI 层。HCI 层把 node_rx 编码成标准 HCI event/acl 包,hci_driver 通过 recv 回调上交给 host------这一步是 controller 和 host 的边界。host 侧 bt_recv 按 H:4 包类型分发,ACL 数据走 hci_acl 到 bt_conn_recv,再到 bt_l2cap_recv 按 CID 分发,CID=ATT 的进 bt_att_recv,经 handlers 分发到 att_read_req,最后 bt_gatt_foreach_attr 调你的 attr->read 回调。
记住三个上下文的划分:LLL 是中断里跑的时序关键路径,ULL 是 mayfly 调度的高优先级线程,HCI 收发是普通线程。mayfly 是 LLL 到 ULL 跨上下文传递数据的核心手段。这三层加上 host 侧的 workqueue,构成完整上行链路。

二、三个执行上下文:理解链路的钥匙
Zephyr 软链路层用 split 架构,核心是三个上下文加 mayfly 跨上下文调度,这是理解整条链路的钥匙。
LLL 上下文跑 radio ISR,是时序关键路径,优先级最高,代码在 controller/ll_sw/nordic/lll/。ULL 高优先级上下文跑 mayfly 调度过来的处理,优先级次高,代码在 controller/ll_sw/ull.c。ULL 低优先级上下文跑 HCI 收发线程,普通优先级,代码在 controller/hci/hci_driver.c。
mayfly 是 Zephyr controller 自研的轻量级延迟函数调用机制。说穿了就是:在一个上下文里把一个函数指针加进队列,让它在另一个上下文执行。LLL 中断里不能做的耗时活儿(比如解析 PDU、分发),就排个 mayfly 扔给 ULL 线程做。这是中断上下文到线程上下文传递数据的标准手段,比直接在工作队列里丢 k_work 更轻量,专门为 controller 的时序要求设计。
理解 mayfly 后面整条链路才看得懂。LLL 在中断里处理完 PDU,不直接调 ULL 的函数(会破坏中断时序),而是 ull_rx_sched() 排个 mayfly,让 rx_demux 在 ULL 上下文跑。这种"排个队让另一个上下文执行"的模式,在链路里反复出现。
三、阶段①②:radio ISR 到 LLL 处理 PDU
radio 中断入口在 controller/ll_sw/nordic/lll/lll.c(已对照 gitee 镜像 main 分支核对,main 分支在 radio_nrf5_isr,v3.2.1 行号有偏移):
ISR_DIRECT_DECLARE(radio_nrf5_isr)
{
DEBUG_RADIO_ISR(1);
lll_prof_enter_radio();
isr_radio(); // → lll 的具体 isr 处理
ISR_DIRECT_PM();
lll_prof_exit_radio();
DEBUG_RADIO_ISR(0);
return 1;
}
radio_nrf5_isr 是直接中断(ISR_DIRECT_DECLARE),进去就调 isr_radio()。注意这里没有任何 if/switch 判断当前是 adv 还是 scan 还是 conn,它只调一个函数指针。
LLL 在 radio ISR 里直接处理 PDU,对应 lll_adv.c、lll_scan.c、lll_conn.c 各自的实现。处理完把收到的 PDU 包装成 node_rx_pdu,通过 ull_rx_put_sched() 上交给 ULL。node_rx 是 controller 内部的接收节点结构(struct node_rx_hdr 加 pdu),是 controller 内部流转数据的基本单位,不是 HCI 包------这点容易混,后面会反复强调。
四、isr_radio 怎么认出 adv/scan/conn:函数指针分发
这里有个容易看错的点。前面说 isr_radio() 调 isr_cb,那 isr_cb 怎么知道当前是 adv 还是 scan 还是 conn?第一反应是里面有个 switch 分支。不是。
isr_radio() 本身不认识 adv/scan/conn,它只做一件事:调用一个全局函数指针 isr_cb。谁在 radio 事件开始前把 isr_cb 指向自己的 isr 函数,radio 中断就执行谁的处理逻辑。这是运行时动态绑定,不是编译期分发。
isr_radio() 的实现(HAL 层 controller/ll_sw/nordic/hal/nrf5/radio/radio.c,已对照 gitee main 核对):
void isr_radio(void)
{
if (radio_has_disabled()) { // radio DISABLED 事件触发
isr_cb(isr_cb_param); // 函数指针分发
}
}
void radio_isr_set(radio_isr_cb_t cb, void *param)
{
irq_disable(HAL_RADIO_IRQn);
isr_cb_param = param; // 上下文(哪个 lll 实例)
isr_cb = cb; // 哪个 isr 处理函数
nrf_radio_int_enable(NRF_RADIO, HAL_RADIO_INTENSET_DISABLED_Msk);
NVIC_ClearPendingIRQ(HAL_RADIO_IRQn);
irq_enable(HAL_RADIO_IRQn);
}
isr_cb 和 isr_cb_param 是 radio.c 里的全局静态变量。radio_isr_set() 是设置入口。关键洞察:isr_radio() 到 isr_cb() 这一步是运行时绑定。adv/scan/conn 能共用同一个 radio 中断入口,根本原因就是 isr_cb 这个函数指针在每次 radio 事件开始前被重新指向。
谁调 radio_isr_set() 把各自的 isr 注册进去?每个 LLL 模块在自己的 prepare_cb(事件准备阶段)里调。adv 的 prepare_cb 注册 isr_tx,scan 的 prepare_cb 注册 isr_rx,conn 的 prepare_cb 注册 lll_conn_isr_rx。
还有个状态机细节:adv 事件先发广播包,所以 prepare 阶段先注册 isr_tx,在 isr_tx 内部收到 scan_req/conn_ind 后再切到 isr_rx。scan 事件先听,一开始就注册 isr_rx。同一个 radio 中断,在不同时刻指向不同的 isr 函数,这是协议状态机驱动的动态绑定,不是一次注册到底。

五、prepare_cb 怎么被调起来:ticker 加 mayfly 的跨上下文投递
上一节说 prepare_cb 里注册 isr,那 prepare_cb 自己又是怎么跑起来的?这是"关联"的另一半,靠 ticker 加 mayfly 从 ULL 投递到 LLL。
ULL 侧的 ticker_cb(比如 ull_adv.c 里的)到点触发,把 lll_adv_prepare 包进一个 mayfly,mayfly_enqueue 从 ULL_HIGH 上下文投递到 LLL 上下文。mayfly 跨上下文执行后,LLL 侧 lll_adv_prepare 调 lll_prepare,lll_prepare 调 lll_prepare_resolve,lll_prepare_resolve 执行 prepare_cb,prepare_cb 里调 radio_isr_set(isr_xxx, lll) 把本模块的 isr 写进全局 isr_cb,然后 lll_prepare_done 启动 radio 等空中报文。
一句话总结这条绑定链:ticker 触发 ULL 的 ticker_cb,ticker_cb 用 mayfly 把 lll_xxx_prepare 投递到 LLL;prepare 阶段执行 prepare_cb,prepare_cb 里 radio_isr_set 把本模块 isr 写进全局 isr_cb;空中 PDU 到来触发 radio DISABLED 中断,radio_nrf5_isr 到 isr_radio 到 isr_cb,此时 isr_cb 已被绑成 adv/scan/conn 之一的 isr。全程没有 if/switch 分支,纯函数指针分发。
这里要分清一件事:上面这些 lll.c、ull.c、radio.c 全是 BT_LL_SW_SPLIT(Zephyr 软链路层)的代码,nRF54L15 实际用的是 SoftDevice Controller(闭源库),这套代码不参与编译。但"radio 中断按当前事件类型、经函数指针分发到不同处理函数"这个设计是相通的,SDC 只是不把 isr_cb 这个函数指针暴露出来而已。
六、阶段③④:mayfly 唤醒 ULL 到 rx_demux
LLL 处理完 PDU,要交给 ULL。LLL 调 ull_rx_sched()(controller/ll_sw/ull.c,已对照 gitee main 核对)把 rx_demux 排进 mayfly,从 LLL 上下文切到 ULL 高优先级上下文:
void ull_rx_sched(void)
{
static struct mayfly mfy = {0, 0, &link, NULL, rx_demux};
mayfly_enqueue(TICKER_USER_ID_LLL, TICKER_USER_ID_ULL_HIGH, 1, &mfy);
}
ull_rx_put() 把 node_rx 入队 memq_ull_rx:
void ull_rx_put(memq_link_t *link, void *rx)
{
memq_enqueue(link, rx, &memq_ull_rx.tail);
}
rx_demux() 在 ULL 上下文从 memq_ull_rx 取出 node_rx,调 rx_demux_rx() 按 rx->type 分发:
static inline int rx_demux_rx(memq_link_t *link, struct node_rx_hdr *rx)
{
...
switch (rx->type) {
case NODE_RX_TYPE_EVENT_DONE: ... rx_demux_event_done(...); break;
case NODE_RX_TYPE_EXT_1M_REPORT: ... ll_rx_put_sched(link, rx); break;
case NODE_RX_TYPE_CONNECTION: ... ll_rx_put_sched(link, rx); break;
...
}
}
大部分类型最终走 ll_rx_put_sched(),把 node_rx 放进 memq_ll_rx 队列。注意这里有两个队列:memq_ull_rx 是 LLL 到 ULL 的队列,memq_ll_rx 是 ULL 到 HCI 层的队列。node_rx 一路出队入队,跨上下文往上送。
七、阶段⑤⑥:ll_rx_get 到 HCI 编码
HCI 层通过 ll_rx_get() 从 memq_ll_rx 取出 node_rx:
uint8_t ll_rx_get(void **node_rx, uint16_t *handle)
{
struct node_rx_pdu *rx;
...
*node_rx = rx;
return num_cmplt; // 顺带返回完成的包数
}
ll_rx_get 的消费者是 HCI 收发线程。它把 node_rx 按 user_meta(由 hci_get_class() 分类的类型)编码成对应的 HCI event(如 LE Advertising Report、LE Connection Complete)或 HCI ACL data,封装成 net_buf。
这一步是数据形态的关键转换:从 controller 内部的 node_rx_pdu 变成标准的 HCI 包。前面反复强调 node_rx 不是 HCI 包,到这里才变成 HCI 包。这个转换是 controller 对外呈现标准接口的必要步骤。
八、阶段⑦:hci_driver 上交 host,controller 到 host 的边界
这是整条链路最关键的衔接点。controller/hci/hci_driver.c 里的 prio_recv_thread 是 controller 侧的 RX 线程(已对照 gitee main 核对):
static void prio_recv_thread(void *p1, void *p2, void *p3)
{
while (1) {
struct node_rx_pdu *node_rx;
struct net_buf *buf;
uint8_t num_cmplt;
uint16_t handle;
// 取 Number of Completed Packets,编码成 HCI 事件
while ((num_cmplt = ll_rx_get((void *)&node_rx, &handle))) {
buf = bt_buf_get_evt(BT_HCI_EVT_NUM_COMPLETED_PACKETS, ...);
hci_num_cmplt_encode(buf, handle, num_cmplt);
bt_recv_prio(buf); // 上交 host
}
// 取一个 node_rx,编码成对应 HCI event
if (node_rx) {
node_rx->hdr.user_meta = hci_get_class(node_rx);
buf = encode_node(node_rx, evt_flags); // node_rx → HCI event net_buf
if (buf) {
bt_recv_prio(buf); // 优先级事件直接上交
}
...
}
k_sem_take(&sem_prio_recv, K_FOREVER); // 等 ULL mayfly 唤醒
}
}
bt_recv_prio 最终调用 host 在 open() 时注册的 recv 回调。这就是 controller 和 host 的边界:controller 把内部 node_rx 编码成标准 HCI event/acl 包(H:4 编码,带包类型字节前缀),通过 recv 回调交给 host。无论 controller 是 Zephyr 软链路层还是 SoftDevice,这个边界都是标准 HCI。
这条边界是整条链路的"翻译点"。controller 内部用 node_rx 这种私有结构流转,到了边界统一翻译成标准 HCI 包。host 只认 HCI 包,不关心 controller 内部用什么结构。这种"内部私有、边界标准"的设计,是 Zephyr 能用同一套 host 配合各种 controller 的根本原因。

九、SoftDevice Controller 的差异:边界以上一样,边界以下闭源
nRF54L15 实际用的是 SoftDevice Controller(闭源库 nrfxlib/softdevice_controller/lib/nrf54l/),接入点是 nrf/subsys/bluetooth/controller/hci_driver.c。它和软链路层的关键差异要讲清楚。
它不跑 Zephyr 的 LLL/ULL 代码,那些是 BT_LL_SW_SPLIT 才用的。它调用 sdc_hci_* API(sdc.h/sdc_hci.h)与预编译库交互。SoftDevice 库内部自己完成 radio 到链路层到 HCI 的全过程,闭源。hci_driver.c 的 hci_driver_send(发 HCI 给 controller)调 sdc_hci_data_put/sdc_hci_cmd_put;接收时 controller 库通过中断回调把 HCI 包交给 driver,driver 再调 host 的 recv。
所以对 SDC 而言,前面阶段①到⑥全在闭源库里完成,hci_driver.c 只负责把库的 HCI 输出转成 Zephyr 的 bt_hci_driver_api(open/send/close),阶段⑦以后完全一样。这就是为什么这篇以软链路层代码讲机制,但讲完要补一节 SDC 差异------机制相通,边界一致,只是中间那段闭源看不到。
十、阶段⑧⑨:host bt_recv 到 L2CAP 分发
回到 host 侧。hci_core.c 的 RX 处理按 H:4 包类型分发:
type = net_buf_pull_u8(buf); // 取 H:4 包类型字节
switch (type) {
case BT_HCI_H4_ACL: hci_acl(buf); break; // ACL 数据
case BT_HCI_H4_ISO: hci_iso(buf); break; // ISO 数据
case BT_HCI_H4_EVT: hci_event(buf); break; // HCI 事件
}
hci_acl 解析 ACL 头,找到对应 bt_conn,调 bt_conn_recv(conn, buf, flags) 到 bt_l2cap_recv(conn, buf, ...)(host/l2cap.c,已对照 gitee main 核对)。
bt_l2cap_recv 解析 L2CAP 头,按 CID 查找注册的固定通道:
Z_STRUCT_SECTION_FOREACH(bt_l2cap_fixed_chan, fchan) { // 遍历所有固定通道
if (fchan->cid == cid) {
// 找到匹配的 CID,调用其 accept → recv
}
}
这里用 Z_STRUCT_SECTION_FOREACH 遍历链接脚本 section 里所有注册的固定通道,按 CID 匹配。这种用 section 遍历做注册表的设计,和 GATT 用 bt_gatt_service_register 挂链表是两套机制,别混。
十一、阶段⑩:L2CAP 到 ATT 到 GATT 回调
ATT 在启动时通过 BT_L2CAP_CHANNEL_DEFINE 注册为 CID=0x0004 的固定通道(host/att.c,已对照 gitee main 核对):
BT_L2CAP_CHANNEL_DEFINE(att_fixed_chan, BT_L2CAP_CID_ATT, bt_att_accept, NULL);
L2CAP 收到 CID=ATT 的 PDU,调 bt_att_accept 设置 .recv = bt_att_recv,再调 bt_att_recv(chan, buf),经 handlers[] 分发到 att_read_req/att_write_req,调 bt_gatt_foreach_attr,触发 read_cb/write_cb,最终调你的 attr->read/attr->write 应用回调。
到这里,一个空中报文完成了从天线到应用回调的完整旅程。从 radio ISR 到应用回调,跨了 LLL 中断、ULL 线程、HCI 线程、host workqueue 四个上下文,过了 memq_ull_rx、memq_ll_rx 两条队列,经历了 node_rx_pdu 到 net_buf(H:4)到 L2CAP PDU 到 ATT PDU 四种数据形态。
十二、完整链路一图流
把整条链路串起来看一次,以对端发 Read Request 为例:
对端 RF 信号
│
▼ ① radio ISR (lll.c radio_nrf5_isr → isr_radio)
│
▼ ② LLL 处理 PDU (lll_conn.c),封装 node_rx_pdu
│
▼ ull_rx_put() 入 memq_ull_rx ; ull_rx_sched() mayfly 排队 rx_demux
│
▼ ③ mayfly 切到 ULL 上下文,执行 rx_demux (ull.c)
│
▼ ④ rx_demux_rx 按 rx->type 分发 → ll_rx_put_sched() 入 memq_ll_rx
│
▼ ⑤ prio_recv_thread 调 ll_rx_get() 取 node_rx
│
▼ ⑥ encode_node() 把 node_rx 编码成 HCI ACL Data net_buf
│
▼ ⑦ bt_recv_prio → recv 回调 ← controller→host 边界(HCI ACL 包)
│
▼ ⑧ host rx: type=BT_HCI_H4_ACL → hci_acl() → bt_conn_recv
│
▼ ⑨ bt_l2cap_recv 解析 L2CAP,按 CID=0x0004 找到 att 通道
│
▼ ⑩ bt_att_recv → handlers[] → att_read_req
│
▼ att_read_rsp → bt_gatt_foreach_attr → read_cb → attr->read() ← 你的回调
这条链路里,前 7 个阶段在 controller 侧(SDC 下闭源),后 3 个阶段在 host 侧(开源可读)。controller 到 host 的边界是标准 HCI,这是整条链路唯一的标准接口,其它都是 Zephyr 内部机制。

十三、关键数据结构对照
一路走下来,数据形态变了四次,理清这个变化链很关键。
阶段①到⑥是 node_rx_pdu,controller 内部接收节点,含 node_rx_hdr 加 PDU,这是 controller 私有结构。阶段⑦是 net_buf(H:4 编码),标准 HCI 包,带包类型前缀字节,这是跨 controller/host 边界的标准格式。阶段⑧是 net_buf(去前缀),host 解出 ACL/ISO/EVT。阶段⑨是 L2CAP PDU in net_buf,带 L2CAP 头,含 CID。阶段⑩是 ATT PDU in net_buf,去 L2CAP 头后剩 ATT opcode 加 payload。
记住这条形态变化链:node_rx_pdu(controller 私有)→ H:4 net_buf(标准 HCI)→ ACL net_buf → L2CAP PDU → ATT PDU。每次形态变化都对应一次"剥头"或"翻译",边界处的翻译(node_rx → HCI)是最关键的一次。
十四、动手跟读建议
想真正吃透这条上行链路,建议照着源码跟读一遍。
第一步,读 controller/ll_sw/nordic/lll/lll.c 的 radio_nrf5_isr 和 isr_radio,理解 radio 中断入口。第二步,读 controller/ll_sw/nordic/hal/nrf5/radio/radio.c 的 isr_radio 和 radio_isr_set,理解函数指针分发。第三步,读 ull.c 的 ull_rx_put 和 ull_rx_sched,理解 mayfly 跨上下文。第四步,读 ull.c 的 rx_demux 和 rx_demux_rx,看按 rx->type 分发。第五步,重点读 controller/hci/hci_driver.c 的 prio_recv_thread 和 bt_recv_prio,这是 controller 到 host 的边界。第六步,读 host/hci_core.c 的 RX 分发和 hci_acl。第七步,读 host/l2cap.c 的 bt_l2cap_recv 和固定通道遍历。第八步,读 host/att.c 的 ATT 通道注册到 bt_att_recv 到 handlers。第九步,对照 nrf/subsys/bluetooth/controller/hci_driver.c 看 SDC 如何用 sdc_hci_* 实现同样的 bt_hci_driver_api。
跟读时重点抓三个东西:mayfly 怎么跨上下文传递数据、node_rx 怎么在边界翻译成 HCI 包、L2CAP 怎么按 CID 分发到 ATT。这三点搞清楚,上行链路的骨架就立起来了。注意行号会随版本变,gitee 镜像是 main 分支,和 v3.2.1 有偏移,别死记某一行,抓函数名和机制。
写在最后
上行链路看起来阶段很多,拆开看就一条主线:radio 收包经函数指针分发到 LLL 处理,mayfly 跨上下文送到 ULL 分发,编码成标准 HCI 包过边界交给 host,host 按 H:4 类型到 L2CAP 按 CID 到 ATT 到 GATT 回调。把"函数指针分发、mayfly 跨上下文、node_rx 到 HCI 的边界翻译、CID 分发"这四点想通,后面遇到任何"收包没上来""某个事件丢失"的问题,都能顺着这条链路定位是哪一环出了问题。
如果你正在啃 Zephyr BLE 的收发链路,建议照着软链路层源码把这条上行链路跟读一遍,从 radio_nrf5_isr 一直跟到 attr->read,走一遍比看十遍文档都管用。
标签:#Zephyr #BLE #协议栈 #Controller #Host #HCI #软链路层 #嵌入式开发 #NCS #nRF54L