数据来源说明:本文基于 Zephyr BLE 协议栈 Host 开源部分(
subsys/bluetooth/host/gatt.c、att.c、include/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_attr(gatt.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_recv(att.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_acl→bt_conn_recv→bt_l2cap_recv(按 CID 找通道)→ops->recv(chan, buf)即bt_att_recv。从空中报文到 ATT 入口,中间经过 Controller、HCI、L2CAP 三层转发,到bt_att_recv才开始按 ATT 协议处理。
五、ATT 分发:handlers[] 表按 opcode 查处理函数
bt_att_recv在att.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_req,BT_ATT_OP_READ_REQ对应att_read_req,BT_ATT_OP_WRITE_REQ对应att_write_req,等等。
这个分发表的设计和上一篇 HCI 事件分发表是一脉相承的思路:用一张静态表把协议码映射到处理函数,收到包后查表分发。ATT 协议有几十种 opcode,全列在这张表里。新增一种 ATT 操作支持,就是在表里加一行,处理逻辑不用动分发框架。这种"表驱动分发"在 Zephyr BLE 协议栈里到处都是,认出这个模式,看代码就快了。
六、读请求的完整链路:从 att_read_req 到你的回调
这是 GATT 收发最核心的一条链,分五步走。
第一步,att_read_req(att.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_rsp(att.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_cb(att.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; />这条链路里最值得记住的是bt_gatt_foreach_attr这个函数。它是 ATT 层和 GATT 数据库之间的唯一接口——ATT 层拿到 handle 后,不直接访问数据库结构,而是通过 foreach 加回调的方式查找。这种设计让 ATT 层和 GATT 层解耦,ATT 只管"我要这个 handle 的属性",怎么找、找到没有,是 GATT 层的事。
七、写请求的完整链路:权限和授权多一道关
写请求的链路和读请求结构上几乎一样,但多了一道授权检查。
第一步,att_write_req(att.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_rsp(att.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_cb(att.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; >八、可靠写: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->write带BT_GATT_WRITE_FLAG_PREPARE标志让应用预检。这一步只准备不提交,数据进队列,应用可以先校验。
att_exec_write_req把队列里所有 prepared write 一次性提交,执行attr->write带BT_GATT_WRITE_FLAG_EXECUTE标志;或者全部丢弃,队列清空。这样要么全写成功,要么全不写,保证了原子性。
这个机制对应 BLE 规范里的 Reliable Writes。实际开发中,写一个需要多包才能传完的配置,或者要保证一组写操作原子生效,就用这套。框架已经帮你接好了,你只要在 write 回调里处理BT_GATT_WRITE_FLAG_PREPARE和BT_GATT_WRITE_FLAG_EXECUTE这两个标志就行。
九、主动上报:Notify 和 Indicate
前面讲的都是对端主动请求、server 被动响应。反过来,server 主动发数据给对端,用的是bt_gatt_notify和bt_gatt_indicate(在gatt.c)。
这两个函数直接构造 ATT PDU(BT_ATT_OP_NOTIFY或BT_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.recv即bt_att_recv()。bt_att_recv查handlers[]表,命中BT_ATT_OP_READ_TYPE_REQ,调att_read_type_req()。att_read_type_req调att_read_type_rsp(),里面调bt_gatt_foreach_attr(start, end, read_type_cb, &data)。GATT 层的 foreach 遍历db链表,handle 命中后调read_type_cb()。read_type_cb调attr->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_CHARACTERISTIC到BT_GATT_ATTRIBUTE,看它怎么变成两个属性。在gatt.c:1743读bt_gatt_service_register,再读gatt_register(1264),看 handle 怎么分配、db链表怎么链。在att.c:2738读handlers[]表,对照att.c:2934的bt_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 #协议栈 #蓝牙开发