Zephyr BLE 上行链路源码拆解:一个空中报文怎么从 radio 跑到应用回调(NCS v3.2.1)

数据来源说明 :本文上行链路以 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.clll_scan.clll_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_cbisr_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_preparelll_preparelll_preparelll_prepare_resolvelll_prepare_resolve 执行 prepare_cbprepare_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_isrisr_radioisr_cb,此时 isr_cb 已被绑成 adv/scan/conn 之一的 isr。全程没有 if/switch 分支,纯函数指针分发。

这里要分清一件事:上面这些 lll.cull.cradio.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.chci_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.cradio_nrf5_isrisr_radio,理解 radio 中断入口。第二步,读 controller/ll_sw/nordic/hal/nrf5/radio/radio.cisr_radioradio_isr_set,理解函数指针分发。第三步,读 ull.cull_rx_putull_rx_sched,理解 mayfly 跨上下文。第四步,读 ull.crx_demuxrx_demux_rx,看按 rx->type 分发。第五步,重点读 controller/hci/hci_driver.cprio_recv_threadbt_recv_prio,这是 controller 到 host 的边界。第六步,读 host/hci_core.c 的 RX 分发和 hci_acl。第七步,读 host/l2cap.cbt_l2cap_recv 和固定通道遍历。第八步,读 host/att.c 的 ATT 通道注册到 bt_att_recvhandlers。第九步,对照 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

相关推荐
WWJA王文举2 天前
FreeRTOS动态创建任务和静态创建任务详解:xTaskCreate与xTaskCreateStatic区别
操作系统·freertos·嵌入式开发
dozenyaoyida2 天前
Zephyr BLE GATT Server 注册到收发调用链源码解析(NCS v3.2.1)
ble·zephyr·gatt·ble协议栈
俊基科技3 天前
A-59U工业级多模语音处理模块在矿山矿井通信与呼叫报警系统中的应用
嵌入式硬件·嵌入式开发·硬件开发·ai降噪·回音消除·拾音降噪
禅口魔心5 天前
RK3588硬件 Agent 代码详解
rk3588·嵌入式开发·硬件agent
禅口魔心5 天前
RK3588(Rock 5T)硬件 Agent 搭建实录:从环境到跑通第一个闭环
rk3588·嵌入式开发
K成长日志5 天前
BLE不可连接状态--广播态
物联网·网络协议·蓝牙·低功耗·iot·ble
派勤电子8 天前
工控主板串口数量上限是多少?X86与ARM不同板型原生串口数量详情
嵌入式开发·工业控制·工业自动化·工控主板·arm主板·工控主板串口·x86主板串口
BW.SU8 天前
RUI Studio--嵌入式 UI 开发新范式
单片机·ui·嵌入式开发
dozenyaoyida8 天前
无外网 Linux 服务器离线安装 VS Code Remote-SSH 指南(新版双包机制 + commit 一致)
ssh·vs code·嵌入式开发·clangd·离线安装·remote-ssh