)
数据来源说明本文基于 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 建立在 ATTAttribute Protocol之上ATT 负责属性协议的收发GATT 负责属性数据库的组织和高层语义。应用层用BT_GATT_SERVICE_DEFINE(...)静态注册一个服务编译期或者用bt_gatt_service_register()在运行期动态注册。往下到 GATT 层gatt.c里的gatt_register()把属性链入全局db链表并分配 handlebt_gatt_foreach_attr()按 handle 范围遍历属性。再往下到 ATT 层att.c里的bt_att_recv()是 L2CAP 收到 CIDATT 的 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:1743int 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 怎么收到 PDUATT 在 L2CAP 里注册为一个固定通道CID 是 0x0004也就是BT_L2CAP_CID_ATT。注册位置在zephyr/subsys/bluetooth/host/att.c:3507BT_L2CAP_CHANNEL_DEFINE(z_att_fixed_chan, BT_L2CAP_CID_ATT, bt_att_accept, NULL);bt_att_accept在连接建立时被调用设置通道回调其中recv字段指向bt_att_recvatt.c:3839static struct bt_l2cap_chan_ops ops { ... .recv bt_att_recv, // att.c:3839 ← L2CAP 收到 ATT 数据就调这个 };所以当 L2CAP 解析出一个 CIDATT 的 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_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_rspstatic 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; />这条链路里最值得记住的是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 和 lenstatic 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; >八、可靠写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 PDUBT_ATT_OP_NOTIFY或BT_ATT_OP_INDICATE通过bt_att_chan_send发出去不需要请求-响应配对。区别在于Notify 是发了就完事对端不确认Indicate 需要等对端回BT_ATT_OP_CONFIRM确认没收到会重发。这是 GATT 数据流里唯一方向相反的路径——前面读写的方向是对端到 serverNotify 和 Indicate 是 server 到对端。应用里做心率上报、传感器数据推送都用这两个 API。用的时候注意 CCCClient 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 按 CIDATT 找到 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_register1264看 handle 怎么分配、db链表怎么链。在att.c:2738读handlers[]表对照att.c:2934的bt_att_recv分发逻辑。然后跟读读请求att_read_req1707到att_read_rsp1662到read_cb1624到attr-read再跟读写请求att_write_req2177到att_write_rsp2123到write_cb2081到attr-write。跟读的时候重点抓三个东西handle 在哪里分配、怎么用 handle 在db链表里查属性、权限和授权在哪一步检查。这三点搞清楚GATT 收发的骨架就立起来了。写在最后GATT Server 的收发链路看起来长拆开看就一条主线属性在注册时进数据库分配 handleATT 层收到请求后用 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 #协议栈 #蓝牙开发