简介:SDAP(Service Data Adaptation Protocol)是5G NR用户面协议栈中的关键适配层,负责将QoS流映射到数据无线承载(DRB)。一份3GPP 37324-g20 SDAP协议规范详解文档,面向5G NR协议开发、测试与科研人员,系统梳理SDAP子层的架构、实体、服务与核心流程。压缩包内含1个docx文件,包体大小仅242KB,正文以图文和条款结合形式呈现,便于快速查阅和定位标准原文。已有582人学习/下载,适合需要深入理解5G用户面协议栈的中高级工程师。文档从RRC配置的SDAP子层出发,详细说明SDAP实体如何在收发端构造与解析SDAP数据PDU,并重点解析上行/下行QoS流到DRB映射、反射映射、QFI标记及RQI处理等关键机制,同时结合规范条款梳理了UE侧SDAP实体建立、释放、数据传输以及末端标记控制PDU构造的具体步骤,例如上行会按映射规则选择DRB,下行则通过SDAP头中的QFI与RDI执行反射映射;读者可借此理解SDAP与下层DRB之间的协同关系,快速掌握相关协议流程。
1. 抓 5G 数据面先从 37324-g20 看起
现网 QA 同事发来一份抓包文件,问为什么同一个 QoS flow 的报文既出现在 DRB1 又出现在 DRB2 里。这个问题在 4G 时代根本不存在,因为 4G 的 EPS Bearer 从核心网到空口是一条路走到黑;到了 5G,核心网侧是 QoS flow,空口侧是 DRB,中间负责把两边映射关系讲清楚的,就是 SDAP。而 SDAP 的规范文本,恰恰就写在 3GPP TS 37.324 里,g20 是它的一个修订版本号。
这篇文章要解决三件事:SDAP 在整个 5G 用户面协议栈里到底站在哪一层、TS 37.324-g20 的协议字段怎么读、以及当你手里只有一份抓包或一份日志时,怎么用规范文本反推现网行为。新手能照着文章把协议头解析出来,熟手也能在反射 QoS 和端到端映射这类细节上找到值得再抠一遍的地方。
2. 37324-g20 在 5G 协议栈里的位置,以及 SDAP 的职责边界
2.1 为什么核心网和空口之间非要加一个 SDAP
4G 的承载模型里,EPS Bearer 从 SGW/PGW 一路延伸到手机,空口 DRB 和核心网承载是一一对应的。到了 5G,核心网侧引入了 QoS flow 这个概念,一个 PDU Session 里最多可以建几百个 QoS flow,但空口侧 DRB 的数量受限于 RLC 实例和逻辑信道配置,通常只有个位数。一对多还是多对一,必须有协议层来做翻译,这就是 SDAP 出现的根本原因。
从协议栈位置看,SDAP 在 PDCP 之上、IP 之下,只存在于用户面。控制面没有 SDAP,因为控制面的信令走 SRB,不需要做 QoS flow 到 DRB 的映射。SDAP 层在 NR 用户面协议栈中直接对接 PDCP,一个 SDAP 实体对应一个 PDU Session,而一个 PDU Session 下面可以挂多个 DRB。这个 1 对 N 的关系,就是整个映射机制的基础。
2.2 g20 版本修订了什么
TS 37.324 的版本号序列里,g20 对应的是 3GPP Release 17 中期的维护版本。协议规范每隔一段时间会做一次文字修订和错误修正,g20 不属于引入全新功能的版本,但它把前几个版本里容易产生歧义的描述做了收敛,尤其是反射 QoS 的定时器行为和 SDAP 头格式的边界条件。
提 g20 的意义在于,以它作为配置依据时,终端和基站的行为预期是明确的。比如 3GPP 37.324-g20 中明确了 SDAP 头的 D/C 位和 RDI 位在特定配置下的取值规则,以及 QFI 为全 1 时的保留含义。如果你手里的设备日志写的规范版本是 g00 或 g10,遇到同样的 SDAP PDU 时解析结果可能有细微偏差。实际工作中,我一般会在抓包的同时核对一下设备上报的协议版本号,避免拿旧版文本套新版设备的行为。
3. 从 3GPP 协议 37324 文本里拆出 SDAP 的 PDU 格式和字段语义
3.1 协议文档里必须精读的三个段落
TS 37.324 正文不算长,但真正在开发排错时反复要翻的,不是前面的总述,而是几个特定章节。第一处是 SDAP PDU 格式的定义,通常出现在第 6 章附近,规定 SDAP Data PDU 和 SDAP Control PDU 的结构;第二处是 QoS flow 到 DRB 映射规则的描述,搞清楚了才知道什么时候该查映射表、什么时候该看 RRC 重配消息;第三处是反射 QoS 的触发条件。
用 TS 37.324-g20 查 SDAP 头定义时,常见做法是直接翻到 SDAP Data PDU 的表格说明。下面是我从协议 37324-g20 中提取的关键字段汇总(字段名和位宽依据规范文本常见定义列出):
| 字段 | 位宽 | 取值含义 |
|---|---|---|
| D/C | 1 bit | 0 表示 Data PDU,1 表示 Control PDU |
| RDI | 1 bit | Reflect QoS 指示,1 表示触发射映 QoS |
| RQI | 1 bit | Reflect QoS 请求,1 表示请求对端开启反射 QoS |
| QFI | 6 bit | QoS Flow ID,取值 0~63 |
| PDU Type | 4 bit | 仅在 Control PDU 中出现,标识控制指令类型 |
其中 QFI 的 6 bit 是关键中的关键。QFI 取值为 0~63 不代表协议允许 64 个 QoS flow,实际可用数量受限于 PDU Session 建立时的配置。而 D/C 位决定了你看到的是数据面还是控制面的 SDAP PDU,抓包里 90% 以上是 Data PDU。
3.2 从 TS 37.324 文本提取 SDAP Data PDU 的位布局
TS 37.324 的规范文本在描述 SDAP Data PDU 时,通常使用表格方式逐段列出各字段的位序。拿 g20 版本来说,SDAP Data PDU 头有两种形态:带 RDI 和不带 RDI。不带 RDI 的头占 1 字节,8 bit 分别是 D/C(1 bit)、RQI(1 bit)、QFI(6 bit);带 RDI 的头占 2 字节,多出一个 RDI 位。
以下是我从 37324-g20 中提取的 SDAP Data PDU 头结构对应的 C 语言描述,用位域方式表达:
typedef struct __attribute__((packed)) { uint8_t DCI : 1; // D/C: 0=Data PDU, 1=Control PDU uint8_t RQI : 1; // RQI: 反射 QoS 请求 uint8_t QFI : 6; // QoS Flow ID } sdap_data_pdu_header_1byte; typedef struct __attribute__((packed)) { uint8_t DCI : 1; // D/C uint8_t RDI : 1; // RDI: 反射 QoS 指示 uint8_t RQI : 1; // RQI uint8_t QFI : 5; // QFI 只有 5 bit,因为 RDI 占了 1 bit } sdap_data_pdu_header_2byte;注意第二个结构体里 QFI 的位宽变了,这是一个很容易踩的坑。当 SDAP 头扩展到 2 字节时,QFI 的位序仍然从字节的低位开始排,但可表示范围因为 RDI 位被压缩到了 5 bit。协议里对这种情况有专门说明,实际解析时不能写死 6 bit,必须先看 RRC 配置里 SDAP 头是否带 RDI 字段,再决定用哪种结构体去解。
3.3 为什么 SDAP 没有重传机制
读 3GPP 协议 37324 文本时,有人会问为什么 SDAP 的 PDU 格式里看不到序列号。这就要从 SDAP 的定位说起了。SDAP 不做重传、不做排序、不需要 ARQ 反馈,因为它的上层 IP 包即便丢了,也由 TCP 或应用层去兜底;而排序和重传是 PDCP 在管的事。SDAP 在发射端只有两个动作:标记 QFI、决定映射到哪个 DRB;在接收端则是读取 QFI、把包交到上层对应 QoS flow 的缓冲队列里。
如果把 SDAP 看作一个带状态的标签打印机,它的状态只在映射关系变化时更新。这意味着 SDAP 的处理时延可以做到微秒级,不引入额外缓存。但到抓包分析时,你就看不到 SDAP 层有类似 PDCP SN 这样的递进数字可以跟;做丢包统计必须依赖 PDCP 层或者 IP 层的信息。
4. 用 37324-g20 规范落地的核心映射机制,以及抓包里的判定方法
4.1 QoS flow 到 DRB 的映射表建在哪里
SDAP 实体维护一张映射表,这张表的内容来自 RRC 重配置消息里的 sdap-Config。TS 37.324 协议 37324 文本规定 SDAP 支持两种映射模式:显式映射和默认映射。显式映射要求每一条 QoS flow 至少对应到一个 DRB,可以是多对一,但一条 DRB 可以承载多个 QoS flow;默认映射则让 SDAP 在找不到明确条目时使用默认 DRB。
映射关系的建立过程是:gNB 在下发 RRCReconfiguration 时携带 sdap-Config,里面包含 mappedQoS-FlowsToDRB 之类的字段,终端收到后以此更新 SDAP 映射表。关键点是,映射表在下行和上行方向是独立的。下行方向 gNB 决定哪个 QoS flow 的数据映射到哪个 DRB,终端只能被动遵守;上行方向终端必须按照映射表里的规则把 IP 包放到正确的 DRB 上,如果找不到映射条目,就丢包或使用默认 DRB。
4.2 在一个真实抓包里定位 SDAP 头和映射关系
要用 Wireshark 或其他工具定位 SDAP 层,关键过滤条件是数据面的 GTP-U 隧道和协议类型。5G 空口侧的 SDAP 数据面帧在 Wireshark 里不会被自动解析,需要手工扩展或借助 5G 相关解析插件。最常见的做法是先用 udp.port 或者 gtpu 过滤找到用户面数据,再往协议栈上层翻。
用 tshark 在抓包文件里找 SDAP 相关报文的典型命令如下:
# 过滤 GTP-U 隧道内的 5G 用户面数据 tshark -r capture.pcapng -Y "gtpu" -T fields -e frame.number -e ip.src -e ip.dst -e gtpu.teid # 当 SDAP 解析插件可用时,直接过滤 SDAP 层 tshark -r capture.pcapng -Y "sdap.qfi" -T fields -e frame.number -e sdap.qfi -e sdap.dci第一条命令用于先找到 GTP-U 的 TEID,TEID 对应到 PDU Session 的某个方向;第二条命令则在解析插件支持 SDAP 的时候直接按 QFI 维度做筛选。实际抓包中,如果 tshark 解析不到 sdap 层,先确认一下 Wireshark 版本是否支持 5G 协议,再确认抓包点是否在 GTP-U 隧道解封装之后的位置。
从逻辑上讲,判断一个包属于哪个 QoS flow 有两种路径:路径一是在空口侧看 SDAP 头的 QFI,路径二是在核心网侧看 GTP-U 扩展头里的 PDU Session Container。双端对不上,通常意味着 SDAP 映射表和核心网侧的 QoS flow 绑定关系不一致,这种场景在切换和负载均衡时最容易出现。
4.3 反射 QoS 的实际用途和触发机制
37324-g20 里对反射 QoS 的描述值得单独拎出来说。反射 QoS 的作用是让终端通过观察下行包的 SDAP 头,学习上行包应该走哪条 DRB。它的触发场景通常是 gNB 希望把某个下行 QoS flow 转移到新的 DRB,而又不想再走一轮 RRC 重配流程。gNB 在 SDAP 头里把 RDI 位置 1,终端看到之后,自动建立一条从下行 QoS flow 到下行 DRB 的反向映射。
反射 QoS 的坑在于定时器。协议规定终端收到 RDI 置 1 的包后启动反射映射定时器,定时器超时后必须删除对应的映射条目。如果你的测试环境里发现上行包突然走了默认 DRB,一种很大可能就是这个定时器已经超时。排查思路是确认 gNB 是否周期性重发带 RDI 的包,否则终端学到的映射就是临时有效。
下面是一段模拟反射 QoS 学习的伪代码,帮助理解逻辑:
def process_sdap_pdu(sdap_header, drb_id): if sdap_header.dci == 0: # Data PDU qfi = sdap_header.qfi if sdap_header.rdi == 1: # 下行方向反射映射:下行 QFI 出现在下行 DRB 上 reflective_table[qfi] = drb_id start_timer(qfi, reflective_timer_ms) else: # Control PDU pass这段逻辑里,reflective_table 只在收到 RDI=1 的下行数据时更新。从参数角度看,定时器时长由 RRC 配置里的 sdap-Config 中的 reflective QoS 定时器给出,不是 SDAP 协议本身写死的值。这意味着 gNB 侧配置决定终端行为,排查时要先看配置再怀疑协议实现。
4.4 映射表异常时先看 RRC 重配还是先看 SDAP 头
当一个业务流程出现丢包或 QoS 降级,拿到抓包后的第一个问题往往不是 SDAP 层怎么解析,而是到底要不要查 SDAP 头。我的习惯是三层排查法:先看 IP 五元组和 DSCP,判断业务类型;再看 GTP-U TEID 和 QFI,确认核心网给这个业务分配的 QoS flow;最后才看 SDAP 头,确认空口侧实际走的 DRB。如果 QFI 对得上但 DSCP 对不上,是核心网侧 QoS 映射的问题;如果 QFI 对得上但 SDAP 头没解析出来,才是终端或基站 SDAP 处理的问题。
这个顺序的底层原因是,SDAP 的映射表只是最终执行者,决定映射的是核心网的 QoS 规则和 RRC 配置。抓包时你看到的 SDAP 头是结果而非原因。
5. 一份 37324-g20 版协议文本对应到用户面组包的具体解析函数实现
5.1 把 SDAP 头从以太网帧里剥出来
在写解析代码之前,先说 SDAP 的用户面报文在以太网环境里的承载路径。空口侧 RLC 解包后交给 PDCP,PDCP 解出 SDAP PDU,随后 SDAP 去掉头后把 IP 包往上送。但在有线侧抓包时,你看到的是 GTP-U 封装,SDAP 头在 GTP-U 的 Payload 里。所以解析函数必须知道当前抓包点是在空口侧还是 N3 接口侧,否则偏移量是错的。
以下是一段针对 N3 接口抓包的 SDAP 头解析函数,C 语言实现,入参是 GTP-U 解封装后的 IP 包:
int parse_sdap_header(const uint8_t *buf, size_t len, sdap_info_t *out) { if (len < 1) return -1; uint8_t byte0 = buf[0]; out->dci = (byte0 >> 7) & 0x1; if (out->dci == 0) { // Data PDU out->rqi = (byte0 >> 6) & 0x1; out->qfi = byte0 & 0x3F; // Data PDU 是否含 RDI 由 RRC 配置决定 // 默认无 RDI 时,QFI 占完整 6 bit out->header_len = 1; } else { // Control PDU 结构按 37324-g20 第 6 章解析 out->pdu_type = (byte0 >> 4) & 0xF; out->header_len = 1; } return out->header_len; }这段函数的逻辑非常简单,但实际工程里要处理的边界条件不少。第一是 len 是解封装后的长度,如果小于 1 字节说明包已经被截断;第二是 Data PDU 的 RDI 扩展头是否存在,要由外层 RRC 配置决定,函数里不判断等于没做完整。更稳妥的做法是把 RDI 配置作为入参传入,代码根据配置决定是否继续读第二字节。
5.2 对齐 37324-g20 的版本差异做兼容
不同版本字号的 TS 37.324 之间,SDAP 头定义的差别主要出现在 Control PDU 的具体语法和反射 QoS 行为的措辞上。g20 之前的一些草稿版本里,对 D/C 位的描述有模糊之处,g20 做了收敛。如果你在一个老设备上解析出 DCI=1 但 PDU Type 为保留值的包,要么是设备实现没有严格遵循 g20,要么是抓包点抓到了非 SDAP 数据。此时不要急着改解析代码,先确认设备侧协议版本号。
在实际项目里,我一般会把协议版本号和 SDAP 解析器版本做绑定,像下面的配置方式:
# sdap_parser.conf # 绑定协议版本和解析行为 protocol_version = TS37.324-g20 default_header_mode = 1byte rdi_supported = true control_pdu_timeout_ms = 5000这样在设备升级协议版本后,只需更新配置文件而不用重新编译解析器。特别注意 rdi_supported 这个参数,如果协议版本是 g10 而设备固件不支持 RDI 扩展头,解析器要主动避开第二字节的读取,否则会把 IP 头的第一个字节误当成 SDAP 头处理。
5.3 解析完 QFI 之后,验证它和 DSCP 的一致性
拿到 QFI 后,一个很有价值的验证动作是将 SDAP 头里的 QFI 和 IP 层 DSCP 对应起来,确认上下行映射是否符合预期。5G 的标准做法是 gNB 根据 QoS 规则里的 QFI 到 DSCP 映射关系来标记下行包。下面是一段用 Python 做交叉验证的脚本:
import dpkt def check_qfi_dscp(pcap_file): with open(pcap_file, 'rb') as f: pcap = dpkt.pcap.Reader(f) for ts, buf in pcap: eth = dpkt.ethernet.Ethernet(buf) if not hasattr(eth.data, 'data'): continue ip = eth.data # 剥掉 UDP 和 GTP-U 头后取 SDAP 头 udp = ip.data gtpu_payload = udp.data[8:] # GTP-U 头固定 8 字节 if len(gtpu_payload) < 1: continue dci = (gtpu_payload[0] >> 7) & 0x1 if dci != 0: continue qfi = gtpu_payload[0] & 0x3F dscp = (ip.tos >> 2) & 0x3F if qfi_to_dscp.get(qfi) != dscp: print(f"mismatch: qfi={qfi} dscp={dscp}")脚本的逻辑是遍历 pcap 包、剥到 GTP-U payload、读第一个字节解出 QFI,再把 IP 头的 DSCP 拿出来对比。qfi_to_dscp 的映射字典来自核心网侧的 QoS 规则。如果大量 mismtach 出现,说明 gNB 的下行标记规则和核心网策略冲突,这是比 SDAP 头解析更值得上报的现网问题。
6. 快速验证你的 SDAP 解析器是否贴合 TS 37.324-g20
解析器写完,最怕的就是自认为对,遇到真实报文却全部错位。这里给一个成本很低的验证方法。从现网抓包里挑 10 到 20 个 GTP-U 承载的包,手工确认它们的 SDAP 头第一个字节:D/C 位为 0 的包占比应该在 95% 以上,QFI 落在核心网分配的范围内,且带 RDI 的包只在配置了反射 QoS 的 PDU Session 中出现。
验证命令可以这样写:把一个 pcap 文件里所有 SDAP 头的 QFI 出现次数统计出来,和核心网侧的会话信息对比。用 tshark 搭配一段简单统计:
tshark -r sample.pcapng -Y "gtpu" -T fields -e sdap.qfi | sort | uniq -c如果在支持 SDAP 解析的环境里直接能得到按 QFI 的分布,马上就能看出有没有 QFI 越界或者分布异常。若字段显示为空,确认抓包点是否正确,以及 Wireshark 的 5G 协议解析插件是否加载。越界情况最常见的原因不是 SDAP 解析器写错了,而是抓包时上层数据不是 SDAP PDU,比如直接把 GTP-U 里的 IP 包当 SDAP 解了。这时候回到上一章的偏移量检查,比继续调解析器有用得多。
另一个容易漏的细节是 SDAP 头是否带 RDI 的判定。先用 RRC 配置确认 PDU Session 的 SDAP 头模式,如果模式是 1 字节但抓包里出现 2 字节,说明要么是异常包,要么是工具配置错误。解析器里加上模式校验,比事后手工查报错日志节省的时间多得多。
本文还有配套的精品资源,点击获取