数据来源说明 :本文基于 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;
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_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;
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->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 #协议栈 #蓝牙开发