Zephyr BLE GATT Server 注册到收发调用链源码解析(NCS v3.2.1)

数据来源说明 :本文基于 Zephyr BLE 协议栈 Host 开源部分(subsys/bluetooth/host/gatt.catt.cinclude/zephyr/bluetooth/gatt.h)撰写。GATT/ATT 均为 Host 开源代码,可对照源码阅读。文中行号依据 NCS v3.2.1 研究文档标注,因本机联网限流(raw.githubusercontent.com 返回 429)未对每一行独立联网核对,以实际源码为准。SoftDevice Controller 部分闭源,本文不涉及,不冒充看过其源码。

做 Zephyr BLE 开发,你一定写过类似这样的代码:用 BT_GATT_SERVICE_DEFINE 定义一个服务,里面塞几个 BT_GATT_CHARACTERISTIC,给每个特征挂上 read/write 回调,然后对端就能读写你的数据了。这套写法用起来很简单,但背后从"宏定义一个服务"到"对端一个写请求最终调到你的回调",中间隔着 GATT、ATT、L2CAP 三层,还有链接器段、分发表、属性数据库这些机制。这些搞不清楚,遇到"特征值读出来是空的""写回调没被调""权限报错"这类问题,就只能瞎猜。

这篇把 Zephyr BLE 协议栈 GATT Server 从注册到收发的完整调用链拆开讲清楚。代码基于 NCS v3.2.1 / nRF54L15,行号可对照源码(部分行号因联网限流未独立核对,以实际源码为准)。

一、全景图:GATT 建在 ATT 之上,两层职责要分清

理解 GATT 收发的关键是分清两层。GATT 建立在 ATT(Attribute Protocol)之上,ATT 负责属性协议的收发,GATT 负责属性数据库的组织和高层语义。

应用层用 BT_GATT_SERVICE_DEFINE(...) 静态注册一个服务(编译期),或者用 bt_gatt_service_register() 在运行期动态注册。往下到 GATT 层,gatt.c 里的 gatt_register() 把属性链入全局 db 链表并分配 handle,bt_gatt_foreach_attr() 按 handle 范围遍历属性。再往下到 ATT 层,att.c 里的 bt_att_recv() 是 L2CAP 收到 CID=ATT 的 PDU 的入口,handlers[] 分发表按 opcode 找处理函数,att_read_req/att_write_req 解析请求,read_cb/write_cb 调用属性的 read/write 回调。最底下是属性回调本身,attr->read()/attr->write() 就是应用层在宏里注册的函数。

这里有个核心认知值得记住:GATT 的"数据库"本质上就是一个属性数组加一个全局链表 db。ATT 层收到请求后,用 bt_gatt_foreach_attr(handle, handle, cb, &data) 在这个数据库里按 handle 查找属性,找到后调用属性自带的 read/write 回调。GATT 层本身几乎不做协议处理,它只是个"属性数据库管理器"。真正干活的是 ATT 层的协议解析和分发,以及属性自带的回调。把这点想通,后面看任何 GATT 代码都不会绕晕。

二、静态注册:BT_GATT_SERVICE_DEFINE 宏背后做了什么

位置在 zephyr/include/zephyr/bluetooth/gatt.h:856。宏展开后做两件事:定义一个 bt_gatt_attr 数组 attr_##_name,再用 STRUCT_SECTION_ITERABLE 把一个 bt_gatt_service_static 结构放进链接器 section。

复制代码
#define BT_GATT_SERVICE_DEFINE(_name, ...)
    const struct bt_gatt_attr attr_##_name[] = { __VA_ARGS__ };
    const STRUCT_SECTION_ITERABLE(bt_gatt_service_static, _name) =
                                    BT_GATT_SERVICE(attr_##_name)

关键机制在 STRUCT_SECTION_ITERABLE。它把静态服务塞进名为 bt_gatt_service_static 的链接器段。Host 初始化时(上一篇讲过的 bt_gatt_init)会遍历这个段,把所有静态服务自动注册进数据库------所以静态服务不需要你手动调用 register。你写 BT_GATT_SERVICE_DEFINE 的时候可能觉得"我没调注册函数它怎么就生效了",答案就在这个链接器段里,编译期就埋好了,初始化时自动收割。

数组里每个元素是一个 bt_gatt_attrgatt.h:227),核心字段六个:

复制代码
struct bt_gatt_attr {
    const struct bt_uuid *uuid;            // 属性类型 UUID
    uint8_t  perm;                         // 权限(READ/WRITE/ENCRYPT...)
    bt_gatt_attr_read_func_t  read;        // 读回调
    bt_gatt_attr_write_func_t write;       // 写回调
    void *user_data;                        // 属性值
    uint16_t handle;                        // 句柄(注册时分配)
};

还有个容易踩坑的点:BT_GATT_CHARACTERISTIC 宏会展开成两个属性。一个声明特征(UUID 是 BT_UUID_GATT_CHRC,值是特征声明结构),另一个才是真正的值属性------你的 read/write 回调挂在这个值属性上。所以你在服务定义里写一个特征,数据库里实际多了两条属性记录,handle 也是连着分配两个。调试时如果按 handle 数属性对不上数,多半是忘了这个。

三、动态注册:bt_gatt_service_register

静态注册靠链接器段,动态注册就走 bt_gatt_service_register,位置在 zephyr/subsys/bluetooth/host/gatt.c:1743

复制代码
int bt_gatt_service_register(struct bt_gatt_service *svc)
{
    ...
    bt_gatt_service_init();          // 确保核心服务(GAP/GATT)已初始化
    ...
    k_sched_lock();
    err = gatt_register(svc);        // ★ 真正的注册
    ...
    sc_indicate(svc->attrs[0].handle, ...);  // 发 Service Changed indication
    db_changed();
    k_sched_unlock();
    return 0;
}

流程是:先调 bt_gatt_service_init() 确保核心服务已初始化,加调度锁,调 gatt_register(svc) 做真正的注册,注册完发 Service Changed indication 通知对端数据库变了,最后解锁返回。

真正干活的 gatt_register()gatt.c:1277,做两件事:遍历 svc->attrs,为每个属性分配 handle(从上一个最大 handle 加 1 开始递增);把 svc 节点加入全局链表 db(这是个 sys_slist)。注册完成后,属性的 handle 字段就被填上了,ATT 层后续就是靠这个 handle 来定位属性的。

动态注册和静态注册最终都汇到 gatt_register 这一个函数,区别只是属性来源不同------静态的从链接器段来,动态的从你传进来的 svc 来。注册完之后它们在数据库里没区别,都是 db 链表上的节点。

四、收发入口:ATT 怎么收到 PDU

ATT 在 L2CAP 里注册为一个固定通道,CID 是 0x0004,也就是 BT_L2CAP_CID_ATT。注册位置在 zephyr/subsys/bluetooth/host/att.c:3507

复制代码
BT_L2CAP_CHANNEL_DEFINE(z_att_fixed_chan, BT_L2CAP_CID_ATT, bt_att_accept, NULL);

bt_att_accept 在连接建立时被调用,设置通道回调,其中 recv 字段指向 bt_att_recvatt.c:3839):

复制代码
static struct bt_l2cap_chan_ops ops = {
    ...
    .recv = bt_att_recv,   // att.c:3839 ← L2CAP 收到 ATT 数据就调这个
};

所以当 L2CAP 解析出一个 CID=ATT 的 PDU,就调用 bt_att_recv(chan, buf)。这是 ATT 层收数据的统一入口,所有 ATT 请求都从这里进。

这条链路接上上一篇讲的四类回调里的第四类:hci_aclbt_conn_recvbt_l2cap_recv(按 CID 找通道)→ ops->recv(chan, buf)bt_att_recv。从空中报文到 ATT 入口,中间经过 Controller、HCI、L2CAP 三层转发,到 bt_att_recv 才开始按 ATT 协议处理。

五、ATT 分发:handlers\[\] 表按 opcode 查处理函数

bt_att_recvatt.c:2934,核心逻辑是取出 PDU 第一个字节作为 opcode,然后遍历 handlers[] 分发表按 opcode 查匹配的处理函数,查到就调用:

复制代码
static int bt_att_recv(struct bt_l2cap_chan *chan, struct net_buf *buf)
{
    struct bt_att_hdr *hdr;
    const struct att_handler *handler;

    hdr = net_buf_pull_mem(buf, sizeof(*hdr));   // 取出第一个字节 = opcode

    for (i = 0, handler = NULL; i < ARRAY_SIZE(handlers); i++) {
        if (hdr->code == handlers[i].op) {        // ★ 按 opcode 查表
            handler = &handlers[i];
            break;
        }
    }
    ...
    err = handler->func(att_chan, buf);           // ★ 调用对应处理函数
    ...
}

分发表 handlers[]att.c:2738,是一个 {opcode, 期望长度, 类型, 处理函数} 的数组:

复制代码
static const struct att_handler {
    uint8_t op;
    uint8_t expect_len;
    att_type_t type;
    uint8_t (*func)(struct bt_att_chan *chan, struct net_buf *buf);
} handlers[] = {
    { BT_ATT_OP_MTU_REQ,    ..., ATT_REQUEST, att_mtu_req },
    { BT_ATT_OP_READ_REQ,   ..., ATT_REQUEST, att_read_req },
    { BT_ATT_OP_READ_BLOB_REQ, ..., ATT_REQUEST, att_read_blob_req },
    { BT_ATT_OP_WRITE_REQ,  ..., ATT_REQUEST, att_write_req },
    { BT_ATT_OP_PREPARE_WRITE_REQ, ..., ATT_REQUEST, att_prepare_write_req },
    { BT_ATT_OP_EXEC_WRITE_REQ, ..., ATT_REQUEST, att_exec_write_req },
    ...
};

表里列着各种 ATT 操作:BT_ATT_OP_MTU_REQ 对应 att_mtu_reqBT_ATT_OP_READ_REQ 对应 att_read_reqBT_ATT_OP_WRITE_REQ 对应 att_write_req,等等。

这个分发表的设计和上一篇 HCI 事件分发表是一脉相承的思路:用一张静态表把协议码映射到处理函数,收到包后查表分发。ATT 协议有几十种 opcode,全列在这张表里。新增一种 ATT 操作支持,就是在表里加一行,处理逻辑不用动分发框架。这种"表驱动分发"在 Zephyr BLE 协议栈里到处都是,认出这个模式,看代码就快了。

六、读请求的完整链路:从 att_read_req 到你的回调

这是 GATT 收发最核心的一条链,分五步走。

第一步,att_read_reqatt.c:1689)解析出 handle。它把 buf 里的数据强转成 bt_att_read_req 结构,取出 handle 字段(小端转主机序),然后调 att_read_rsp

复制代码
static uint8_t att_read_req(struct bt_att_chan *chan, struct net_buf *buf)
{
    struct bt_att_read_req *req = (void *)buf->data;
    uint16_t handle = sys_le16_to_cpu(req->handle);
    return att_read_rsp(chan, BT_ATT_OP_READ_REQ, BT_ATT_OP_READ_RSP, handle, 0);
}

第二步,att_read_rspatt.c:1644)调用 bt_gatt_foreach_attr 遍历数据库。注意这里传的 start 和 end 都是同一个 handle,意思是精确查找这一个 handle 对应的属性:

复制代码
static uint8_t att_read_rsp(struct bt_att_chan *chan, uint8_t op, uint8_t rsp,
                            uint16_t handle, uint16_t offset)
{
    struct read_data data;
    ...
    bt_gatt_foreach_attr(handle, handle, read_cb, &data);  // ★ 查找属性
    ...
}

第三步,read_cbatt.c:1606)找到属性后,先查权限再调属性回调。权限不过就直接返回 BT_GATT_ITER_STOP 并把错误码记进 data->err,权限过了才调 attr->read,也就是你注册的那个读回调:

复制代码
static uint8_t read_cb(const struct bt_gatt_attr *attr, uint16_t handle, void *user_data)
{
    struct read_data *data = user_data;
    data->err = bt_gatt_check_perm(conn, attr, BT_GATT_PERM_READ_MASK); // 权限检查
    if (data->err) return BT_GATT_ITER_STOP;
    ...
    read = attr->read(conn, attr, data->buf->data, len, data->offset);  // ★ 调你的回调
    ...
}

第四步,attr->read 就是你用 BT_GATT_CHARACTERISTIC 注册的读函数,或者框架内置的 bt_gatt_attr_read_chrc 这类。它把值写进 buf,返回长度。

第五步,att_read_rsp 把 buf 封装成 BT_ATT_OP_READ_RSP 响应 PDU,通过 bt_att_chan_send_rsp 发回去,然后是 L2CAP → controller → 空中,对端就收到读响应了。

这条链路里最值得记住的是 bt_gatt_foreach_attr 这个函数。它是 ATT 层和 GATT 数据库之间的唯一接口------ATT 层拿到 handle 后,不直接访问数据库结构,而是通过 foreach 加回调的方式查找。这种设计让 ATT 层和 GATT 层解耦,ATT 只管"我要这个 handle 的属性",怎么找、找到没有,是 GATT 层的事。

七、写请求的完整链路:权限和授权多一道关

写请求的链路和读请求结构上几乎一样,但多了一道授权检查。

第一步,att_write_reqatt.c:2146)从 buf 取出 handle,调 att_write_rsp。注意写请求除了 handle 还带着要写的 value 和 len:

复制代码
static uint8_t att_write_req(struct bt_att_chan *chan, struct net_buf *buf)
{
    uint16_t handle = net_buf_pull_le16(buf);
    return att_write_rsp(chan, BT_ATT_OP_WRITE_REQ, BT_ATT_OP_WRITE_RSP,
                         handle, 0, buf->data, buf->len);
}

第二步,att_write_rspatt.c:2091)同样调 bt_gatt_foreach_attr 查找属性:

复制代码
static uint8_t att_write_rsp(struct bt_att_chan *chan, uint8_t req, uint8_t rsp,
                             uint16_t handle, uint16_t offset, const void *value, uint16_t len)
{
    struct write_data data;
    ...
    bt_gatt_foreach_attr(handle, handle, write_cb, &data);  // ★ 查找属性
    ...
}

第三步,write_cbatt.c:2049)比 read_cb 多一步。先查写权限,权限不过返回 BT_GATT_ITER_STOP;权限过了还要调 attr_write_authorize 做授权检查------这是给应用一个拦截机会,应用可以注册授权回调决定这个写操作允不允许。授权也过了,才调 attr->write,也就是你注册的写回调:

复制代码
static uint8_t write_cb(const struct bt_gatt_attr *attr, uint16_t handle, void *user_data)
{
    struct write_data *data = user_data;
    data->err = bt_gatt_check_perm(data->conn, attr, BT_GATT_PERM_WRITE_MASK); // 权限
    if (data->err) return BT_GATT_ITER_STOP;
    if (!attr_write_authorize(data->conn, attr)) { ... }                  // 授权
    ...
    write = attr->write(data->conn, attr, data->value, data->len,
                        data->offset, flags);   // ★ 调你的写回调
    ...
}

写比读多一道授权,是因为写操作改的是设备状态,风险更高。权限检查是"这个属性本身允不允许这种访问"(由 perm 字段决定),授权检查是"这次具体的访问要不要放行"(由应用运行时决定)。两层防护,符合 BLE 安全模型。

八、可靠写:Prepare Write + Exec Write 两步走

对于长数据或需要原子性的写,单条 Write Request 不够用------ATT 的 PDU 有长度限制,而且多个单独写中间断了会留下半成品状态。ATT 用 Prepare Write + Exec Write 两步解决这个问题。

att_prepare_write_req 把数据先存进 prep_pool 队列(net_buf_alloc(&prep_pool, ...)att.c:2205),并调用 attr->writeBT_GATT_WRITE_FLAG_PREPARE 标志让应用预检。这一步只准备不提交,数据进队列,应用可以先校验。

att_exec_write_req 把队列里所有 prepared write 一次性提交,执行 attr->writeBT_GATT_WRITE_FLAG_EXECUTE 标志;或者全部丢弃,队列清空。这样要么全写成功,要么全不写,保证了原子性。

这个机制对应 BLE 规范里的 Reliable Writes。实际开发中,写一个需要多包才能传完的配置,或者要保证一组写操作原子生效,就用这套。框架已经帮你接好了,你只要在 write 回调里处理 BT_GATT_WRITE_FLAG_PREPAREBT_GATT_WRITE_FLAG_EXECUTE 这两个标志就行。

九、主动上报:Notify 和 Indicate

前面讲的都是对端主动请求、server 被动响应。反过来,server 主动发数据给对端,用的是 bt_gatt_notifybt_gatt_indicate(在 gatt.c)。

这两个函数直接构造 ATT PDU(BT_ATT_OP_NOTIFYBT_ATT_OP_INDICATE),通过 bt_att_chan_send 发出去,不需要请求-响应配对。区别在于:Notify 是发了就完事,对端不确认;Indicate 需要等对端回 BT_ATT_OP_CONFIRM 确认,没收到会重发。

这是 GATT 数据流里唯一方向相反的路径------前面读写的方向是对端到 server,Notify 和 Indicate 是 server 到对端。应用里做心率上报、传感器数据推送,都用这两个 API。用的时候注意 CCC(Client Characteristic Configuration)描述符,对端要先写 CCC 订阅,server 才能发 Notify 或 Indicate,否则发了也没人收。

十、一张图总结 GATT 收发

把整条链路串起来看一次,以对端发 Read By Type Request 为例。

对端发 Read By Type Request,经 radio → controller → hci_acl → host 的 hci_acl()bt_l2cap_recv()。L2CAP 按 CID=ATT 找到 att 通道,调 ops.recvbt_att_recv()bt_att_recvhandlers[] 表,命中 BT_ATT_OP_READ_TYPE_REQ,调 att_read_type_req()att_read_type_reqatt_read_type_rsp(),里面调 bt_gatt_foreach_attr(start, end, read_type_cb, &data)。GATT 层的 foreach 遍历 db 链表,handle 命中后调 read_type_cb()read_type_cbattr->read(),也就是你注册的读回调。回调返回值后,把值编码进 Read By Type Response PDU,经 bt_att_chan_send_rsp() → l2cap → controller → 空中发回对端。

这条链路把前面讲的所有环节串起来了:L2CAP 通道分发、ATT handlers 分发表、GATT foreach 查属性、属性回调。每一环都是注册-触发模型,初始化时注册好,数据到来时按表分发。你写的代码只在两端------注册时定义服务和回调,运行时在回调里处理数据,中间七八层转发协议栈全帮你接好了。

十一、动手跟读建议

想真正吃透这条链路,建议照着源码跟读一遍。

打开 zephyr/samples/bluetooth/peripheral/src/main.c,看 BT_GATT_SERVICE_DEFINE 怎么用,这是最直观的入口。然后在 gatt.h:856 看宏展开,追 BT_GATT_CHARACTERISTICBT_GATT_ATTRIBUTE,看它怎么变成两个属性。在 gatt.c:1743bt_gatt_service_register,再读 gatt_register(1264),看 handle 怎么分配、db 链表怎么链。在 att.c:2738handlers[] 表,对照 att.c:2934bt_att_recv 分发逻辑。然后跟读读请求 att_read_req(1707)到 att_read_rsp(1662)到 read_cb(1624)到 attr->read,再跟读写请求 att_write_req(2177)到 att_write_rsp(2123)到 write_cb(2081)到 attr->write

跟读的时候重点抓三个东西:handle 在哪里分配、怎么用 handle 在 db 链表里查属性、权限和授权在哪一步检查。这三点搞清楚,GATT 收发的骨架就立起来了。

写在最后

GATT Server 的收发链路看起来长,拆开看就一条主线:属性在注册时进数据库分配 handle,ATT 层收到请求后用 handle 查属性,查到就调属性自带的回调。GATT 层只是数据库管理器,ATT 层才是协议处理的核心。把 BT_GATT_SERVICE_DEFINE 的链接器段机制、handlers[] 分发表、bt_gatt_foreach_attr 查属性这三点想通,后面调试任何 GATT 收发问题,都能顺着这条链路定位到是哪一环出了问题。

下一篇会展开讲编译一个 demo 程序的整个框架,看 Zephyr 的构建系统怎么把你的应用代码、协议栈、Controller、链接脚本拼成一个可烧录的固件。

如果你正在啃 Zephyr BLE 协议栈,建议照着源码行号跟读一遍这条 GATT 收发链路,从 BT_GATT_SERVICE_DEFINE 一直跟到 attr->write,走一遍比看十遍文档都管用。


标签:#Zephyr #BLE #GATT #ATT #嵌入式开发 #NCS #nRF54L #协议栈 #蓝牙开发

相关推荐
K成长日志3 天前
BLE不可连接状态--广播态
物联网·网络协议·蓝牙·低功耗·iot·ble
K成长日志17 天前
BLE链路层--比特流处理
网络·物联网·网络协议·蓝牙·iot·ble·无线
iini24 天前
nRF Connect SDK 开发新体验:VS Code + Claude Code + DeepSeek + Nordic MCP 全流程(蓝牙开发实例)
agent·ai编程·nrf connect sdk·zephyr·蓝牙开发·deepseek·mcp·claude code·nordic mcp
K成长日志1 个月前
BLE链路层空口包--数据物理信道PDU
网络·物联网·网络协议·嵌入式·蓝牙·iot·ble
fitpolo1 个月前
详解Zephyr设备树与设备驱动模型
zephyr
乐鑫科技 Espressif1 个月前
用 AI 开发 Zephyr-IoT 应用
人工智能·物联网·esp32·乐鑫科技·zephyr
K成长日志1 个月前
BLE链路层空口包--LE Uncoded PHY
物联网·嵌入式·蓝牙·iot·ble·无线·通信
六bring个六1 个月前
连接模块中的BLE方式GATT协议分析
ble·open harmony·gatt·分布式软总线
固执的你2 个月前
串口中断接收协议数据并用状态机解析_printk()打印内容
zephyr