
简介这份资源是面向5G NR无线协议研发、测试与学习人员的3GPP TS 37.324 SDAP协议文档聚焦Service Data Adaptation Protocol的服务数据自适应处理与QoS流到DRB映射机制适合具备一定5G协议基础的工程师、研究生及认证备考者查阅。压缩包内仅含1个docx文件约242KB内容涵盖SDAP子层架构、SDAP实体收发流程、上下行数据传输、QoS流与DRB映射规则、QFI标记及反射QoS映射等核心章节并附有RDI、RQI、QFI等关键术语说明。目前已有584人学习下载可作为协议精读、代码实现对照与问题排查的参考底稿帮助读者快速定位SDAP实体建立释放、UL/DL SDAP数据PDU构造、末端标记控制PDU及反射映射等具体条款便于在NR Uu与NR侧线通信场景中理解协议行为。1. 拆开 3GPP TS 37.324 这份 SDAP 文档它到底解决 5G 空口里的哪一段如果你正在调 5G NR 的 QoS 映射或者被「QFI 怎么落到 DRB 上」「反射映射什么时候触发」这类问题卡过那 TS 37.324 这份 SDAP 协议文档就是绕不开的一手材料。SDAP 全称 Service Data Adaptation Protocol是 5G NR 用户面里专门管 QoS 流到数据无线承载映射的那一层。它不像 RRC 那样天天被挂在嘴边但一旦 QoS 映射配错、反射映射不生效、端标记 PDU 没发出去业务侧的表现就是速率掉、时延抖、切片 SLA 对不上排查起来相当费劲。这份 g20 版本的 docx 把 SDAP 的架构、实体、服务、功能、过程、PDU 格式和参数逐条列清楚了适合做协议栈开发、测试用例设计、以及需要对着规范逐字段核对实现的工程师。下面我按「先立住概念、再落到可复现的字段级操作、最后讲坑」的顺序把它拆一遍。2. SDAP 架构与实体从 QoS 流到 DRB 的映射逻辑怎么立住2.1 为什么 SDAP 要独立成一层在 4G 时代QoS 和承载的映射是揉在 PDCP 和 NAS 之间的到了 5G核心网把 QoS 粒度做细了QoS Flow 成了基本单位一个 PDU Session 里可以有多条 QoS Flow每条 Flow 有自己的 5QI、GFBR、MFBR。但空口上真正跑数据的载体是 DRBDRB 的数量受 UE 能力和调度限制不可能一条 Flow 配一个 DRB。于是 3GPP 在 PDCP 之上插了一层 SDAP专门干两件事把 QoS Flow 映射到 DRB以及在空口包上打 QFI 标记让对端知道这条数据属于哪条 Flow。这个设计的好处是映射规则可以动态改RRC 重配一条映射规则不用动 PDCP 和 RLC 的配置业务连续性影响小。文档 4.2.1 节明确说了SDAP 子层由 RRC 通过 TS 38.331 配置一个或多个 QoS Flow 可以映射到一个 DRB 上但在 UL 方向一条 QoS Flow 同一时刻只能映射到一个 DRB。这句话看着简单实际实现里是个硬约束如果你在代码里让一条 Flow 同时往两个 DRB 上发接收侧的重排序和 QFI 归属就会乱。DL 方向没有这个限制因为网络侧可以决定把一条 Flow 的数据从不同 DRB 发下来UE 侧靠 QFI 字段重新归并。2.2 SDAP 实体的建立、释放与 PDU 会话绑定4.2.2 节讲得很清楚SDAP 实体位于 SDAP 子层中一个终端可以有几个 SDAP 实体每个 NR Uu 的 PDU Session 配一个 SDAP 实体。这意味着实体生命周期是跟 PDU Session 走的不是跟 DRB 走。5.1.1 和 5.1.2 节给了建立和释放的触发条件RRC 请求建立时UE 建实体并执行 5.2.1、5.2.2 的数据传输过程RRC 请求释放时UE 直接释放实体。这里有个容易翻车的点实体释放时挂在这个实体上的 QoS Flow 到 DRB 的映射规则要不要清文档 5.3.3 节单独讲了 DRB release 的场景说 RRC 指示某个 DRB 被释放时SDAP 实体要按 5.3.1 和 5.3.2 移除与该 DRB 相关的所有 QoS Flow 到 DRB 映射。但 PDU Session 释放导致的实体释放映射规则是随实体一起没的不需要单独清。实现时如果把这两个路径搞混可能出现 DRB 已经释放但映射表里还留着旧条目后续新 Flow 建的时候匹配到已释放的 DRB直接发不出去。2.3 上下行数据传输的判定分支5.2.1 节 UL 方向的逻辑可以拆成三步判定。第一步查映射规则如果没有存储 QoS Flow 到 DRB 的映射规则就把 SDU 映射到默认 DRB否则按存储的规则映射。第二步看头如果映射到的 DRB 被 RRC 配置了存在 SDAP header按 6.2.2.3 构造 UL Data PDU否则按 6.2.2.1 构造无头 PDU。第三步提交给下层。注意文档里的注 1如果既没有默认 DRB也没有存储映射规则UE 行为不定义。这不是规范漏了而是这种配置本身就不该出现——RRC 在配 PDU Session 时至少要给一个默认 DRB。实现里如果走到这个分支通常意味着 RRC 配置解析出了问题得回去查 ASN.1 解码。5.2.2 节 DL 方向多两个动作如果 DRB 配了 SDAP header收到 DL Data PDU 后要执行反射 QoS 流到 DRB 映射5.3.2和 RQI 处理5.4然后再剥头取 SDU 上交。这两个动作是 DL 独有的UL 方向没有反射映射的概念。2.4 反射映射与端标记 PDU 的触发条件5.3.2 节是整份文档里最需要逐字读的部分。对每个收到的 RDI 设为 1 的 DL SDAP Data PDU实体要做四件事处理 QFI 确定 QoS Flow如果没有存储映射规则且配了默认 DRB构造端标记控制 PDU 映射到默认 DRB 并发下去如果存储的映射规则和当前 DL PDU 的 DRB 映射不同且该 DRB 配了 UL SDAP header构造端标记控制 PDU 按旧规则映射并发下去最后把 DL PDU 的 QoS Flow 到 DRB 映射存为 UL 的映射规则。端标记控制 PDU 的作用在 6.1.2 节说得很直白终端上的 SDAP 实体用它表示「由 QFI/PQFI 表示的 QoS 流的 SDAP SDU 映射到本 DRB 的动作到此为止」。换句话说当映射规则要变的时候先往旧 DRB 上发一个端标记让接收侧知道这条 Flow 的数据不会再从这个 DRB 来了避免接收侧一直等旧 DRB 上的后续包。这个机制在切换和 DRB 重配时特别关键漏发端标记会导致接收侧缓冲挂死。3. PDU 格式与参数逐字段对照实现时的可复现清单3.1 三种 Data PDU 格式的选用条件6.2.2 节列了三种 Data PDU无 SDAP header 的6.2.2.1、DL 带 header 的6.2.2.2、UL 带 header 的6.2.2.3。无头 PDU 只有一个 Data 字段整个 PDU 就是 SDU 本身。带头的 DL 和 UL 格式在字段排列上有差异实现时不能混用同一个打包函数。选用哪种格式完全由 RRC 配置决定DRB 配了 SDAP header 就用带头格式没配就用无头格式。文档注 2 特别提了一句默认 DRB 总是配置 UL SDAP header。这意味着走默认 DRB 的 UL 数据一定带头实现里如果默认 DRB 路径走了无头打包对端解析 QFI 会直接错位。3.2 参数字段的位宽与语义6.3 节把参数字段列得很清楚我整理成一张对照表方便写代码时直接查字段位宽语义取值说明D/C1 bit区分 Data PDU 和 Control PDU0Control PDU1Data PDUQFI6 bitQoS Flow ID标识 SDAP PDU 所属的 QoS 流R1 bit保留位本版本置 0接收方忽略RQI1 bit反射 QoS 指示0无动作1通知 NAS RQI 置 1RDI1 bit反射映射指示0无动作1存储 QoS Flow 到 DRB 映射规则PQFI6 bitPC5 QoS Flow ID标识 SDAP PDU 所属的 PC5 QoS 流Data可变SDAP SDU可选字段承载用户面数据位序规则在 6.3.1 节最左位是第 1 位和最高有效位最右位是最后一位和最低有效位整数用无符号标准二进制编码。6.2.1 节补充说 SDAP PDU 是字节对齐的位串SDU 从第一位开始包含在 PDU 中。写打包代码时如果按小端序去拼 QFI对端解出来就是错的这个坑我在早期做协议栈对接时踩过抓包看 QFI 一直是乱的值后来发现是位序搞反了。3.3 用 Python 构造一个带 UL SDAP header 的 Data PDU下面这段代码演示怎么按 6.2.2.3 的格式拼一个 UL Data PDU。假设 QFI5RQI0RDI0SDU 是一段字节串。import struct def build_ul_sdap_pdu(qfi: int, sdu: bytes, rqi: int 0, rdi: int 0) - bytes: 构造 UL SDAP Data PDU带 header qfi: 6 bit范围 0-63 sdu: 上层下来的 SDAP SDU rqi: 1 bit rdi: 1 bit if not 0 qfi 63: raise ValueError(QFI must be 0-63) # 第一个字节D/C1(bit7) | R0(bit6) | QFI高4位(bit5-2) | RQI(bit1) | RDI(bit0) # 注意QFI 是 6 bit这里按规范位序QFI 占第一个字节的低 6 位中的高 4 位 第二个字节的高 2 位 # 实际 6.2.2.3 的 UL 格式中第一个字节是 D/C R QFI[5:2]第二个字节是 QFI[1:0] RQI RDI 保留 byte0 (1 7) | ((qfi 2) 0x0F) 2 | (rqi 1) | rdi byte1 ((qfi 0x03) 6) | 0x00 # 低 6 位保留置 0 header struct.pack(!BB, byte0, byte1) return header sdu # 示例QFI5SDU 为 4 字节 pdu build_ul_sdap_pdu(5, b\xDE\xAD\xBE\xEF) print(pdu.hex())这段代码的关键在 byte0 和 byte1 的位拼接。D/C 固定在 bit7R 在 bit6 置 0QFI 的高 4 位放在 bit5 到 bit2RQI 在 bit1RDI 在 bit0。byte1 的高 2 位放 QFI 的低 2 位其余保留。参数说明qfi 超过 63 直接抛异常因为 6 bit 装不下rqi 和 rdi 只接受 0 或 1传其他值会污染位域。实际协议栈里通常用位域结构体而不是手动移位但手动移位能帮你看清每个 bit 的归属排查位序问题时比结构体直观。3.4 解析 DL Data PDU 并提取 QFI 与反射指示接收侧的逻辑反过来从 DL PDU 里把 QFI、RQI、RDI 抠出来再决定要不要触发反射映射。def parse_dl_sdap_pdu(pdu: bytes): 解析 DL SDAP Data PDU带 header 返回 (qfi, rqi, rdi, sdu) if len(pdu) 2: raise ValueError(PDU too short for SDAP header) byte0 pdu[0] byte1 pdu[1] d_c (byte0 7) 0x01 if d_c ! 1: raise ValueError(Not a Data PDU) qfi ((byte0 2) 0x0F) 2 | ((byte1 6) 0x03) rqi (byte0 1) 0x01 rdi byte0 0x01 sdu pdu[2:] return qfi, rqi, rdi, sdu # 示例 qfi, rqi, rdi, sdu parse_dl_sdap_pdu(bytes.fromhex(9c00deadbeef)) print(fQFI{qfi}, RQI{rqi}, RDI{rdi}, SDU{sdu.hex()})解析时先校验 D/C 位如果不是 1 说明收到的是控制 PDU不能按数据 PDU 解。QFI 的重组是 byte0 的 bit5-2 左移 2 位再或上 byte1 的 bit7-6。RQI 和 RDI 各占 byte0 的 bit1 和 bit0。SDU 从第三个字节开始。这段代码可以直接嵌到测试脚本里对着抓包工具导出的 hex 流做批量校验比人眼比对效率高得多。4. 避坑与排查SDAP 实现里最容易翻车的五个点4.1 现象UL 数据发出去对端解不出 QFI抓包看 QFI 值随机跳原因位序拼反了。6.3.1 节明确说最左位是最高有效位但有些实现习惯按主机字节序去拼把 QFI 的低位放到了高字节位。另一个常见原因是把 QFI 当成 8 bit 处理多占了 R 和保留位的位置。解决拿 3.2 节的对照表逐位核对用 3.3 节的构造代码生成一个已知 QFI 的 PDU再用 3.4 节的解析代码解回来闭环验证位序。如果闭环对得上但空口抓包对不上检查是不是 RRC 配置的 DRB 实际没开 SDAP header导致你按带头格式发、对端按无头格式解。4.2 现象反射映射不生效DL 数据下来了但 UL 还是走默认 DRB原因RDI 位没被正确识别或者识别了但没执行 5.3.2 的存储动作。5.3.2 节要求对每个 RDI1 的 DL PDU最后一步是把 DL 的 QoS Flow 到 DRB 映射存为 UL 的映射规则。如果代码里只处理了 QFI 提取漏了存储步骤UL 侧查映射表还是空的自然走默认 DRB。解决在 DL 解析路径上加日志打印 RDI 值和存储前后的映射表内容。确认 RDI1 的包确实触发了存储。另外注意 5.3.2 的条件分支如果存储的映射规则和当前 DL PDU 的 DRB 映射不同且该 DRB 配了 UL SDAP header要先发端标记控制 PDU。这个分支漏了不会导致映射不生效但会导致旧 DRB 上的接收侧挂死。4.3 现象DRB 重配后旧 DRB 上还有数据在发接收侧报乱序原因端标记控制 PDU 没发或发晚了。5.3.1 和 5.3.2 都规定了在映射规则变更时要构造端标记控制 PDU 并映射到旧 DRB 发下去。如果实现里只在映射表里改了规则没往旧 DRB 发端标记接收侧不知道这条 Flow 已经切走了还在等旧 DRB 上的后续包。解决在映射规则变更的代码路径上强制插入端标记发送逻辑发送顺序必须是先发端标记再改映射表。端标记 PDU 的格式按 6.2.3 节构造D/C 位设 0。可以用 3.3 节的代码改一下 D/C 位来生成。4.4 现象默认 DRB 上的 UL 数据对端解析错位原因默认 DRB 没按注 2 的要求配 UL SDAP header。文档注 2 说默认 DRB 总是配置 UL SDAP header但有些实现里默认 DRB 的配置是从别处继承的继承过来的配置没带头标志导致 UL 打包走了无头格式。解决在 SDAP 实体建立时对默认 DRB 强制设置 header 存在标志不依赖 RRC 配置的继承值。这个点在新手实现里特别容易漏因为默认 DRB 通常是第一个建的配置路径和后续 DRB 不一样。4.5 现象PDU Session 释放后重建旧映射规则残留导致新 Flow 映射到已释放 DRB原因实体释放时没清映射表或者清了但清得不彻底。5.1.2 节说 RRC 请求释放实体时 UE 释放实体但没明确说映射表怎么处理。实现里如果映射表是全局的而不是挂在实体上的实体释放后表还在新实体建起来查表查到旧条目。解决把映射表做成实体私有的数据结构实体释放时随实体一起销毁。如果架构上做不到私有至少在实体释放的回调里显式清空该实体关联的所有映射条目。验证方法是释放重建后打印映射表确认是空的。5. 进阶验证用脚本对 SDAP 映射规则做闭环回归5.1 构造映射规则变更的测试序列协议实现最怕的是改了一处逻辑另一处场景悄悄退化。我一般会写一个回归脚本把 SDAP 的映射规则变更场景串起来跑建实体、配默认 DRB、收 RDI1 的 DL PDU 触发反射映射、改 RRC 配置触发映射规则变更、检查端标记 PDU 是否发出、释放 DRB、检查映射表是否清理。这个序列覆盖了 5.3.1、5.3.2、5.3.3 三条过程的主路径。class MockSdapEntity: def __init__(self): self.mapping {} # qfi - drb_id self.default_drb None self.tx_log [] # 记录发往下层的 PDU def set_default_drb(self, drb_id, has_ul_headerTrue): self.default_drb drb_id self.default_has_header has_ul_header def on_dl_data_pdu(self, qfi, drb_id, rdi, rqi): 模拟 5.2.2 5.3.2 的 DL 处理 if rdi 1: # 5.3.2 步骤 2无存储规则且有默认 DRB发端标记到默认 DRB if qfi not in self.mapping and self.default_drb is not None: self._send_end_marker(qfi, self.default_drb) # 5.3.2 步骤 3存储规则与当前 DRB 不同发端标记到旧 DRB if qfi in self.mapping and self.mapping[qfi] ! drb_id: self._send_end_marker(qfi, self.mapping[qfi]) # 5.3.2 步骤 4存储 DL 映射为 UL 映射 self.mapping[qfi] drb_id if rqi 1: self._notify_nas(qfi) def on_rrc_ul_mapping_config(self, qfi, new_drb): 模拟 5.3.1 的 UL 映射规则配置 if qfi not in self.mapping and self.default_drb is not None: self._send_end_marker(qfi, self.default_drb) if qfi in self.mapping and self.mapping[qfi] ! new_drb: self._send_end_marker(qfi, self.mapping[qfi]) self.mapping[qfi] new_drb def on_drb_release(self, drb_id): 模拟 5.3.3 的 DRB 释放清理 self.mapping {q: d for q, d in self.mapping.items() if d ! drb_id} def _send_end_marker(self, qfi, drb_id): self.tx_log.append((end_marker, qfi, drb_id)) def _notify_nas(self, qfi): self.tx_log.append((nas_rqi, qfi)) # 回归序列 e MockSdapEntity() e.set_default_drb(drb_id1) e.on_dl_data_pdu(qfi5, drb_id2, rdi1, rqi0) # 触发反射映射应发端标记到 DRB1 assert e.mapping[5] 2 assert (end_marker, 5, 1) in e.tx_log e.on_rrc_ul_mapping_config(qfi5, new_drb3) # 改映射应发端标记到 DRB2 assert e.mapping[5] 3 assert (end_marker, 5, 2) in e.tx_log e.on_drb_release(drb_id3) # 释放 DRB3映射应清掉 assert 5 not in e.mapping print(all checks passed)这个 mock 把 5.3.1、5.3.2、5.3.3 的关键分支都覆盖了。参数说明rdi 和 rqi 直接对应 PDU 里的位值drb_id 用来区分不同承载tx_log 记录所有发往下层的动作方便断言端标记和 NAS 通知是否按预期触发。跑通这个脚本至少说明映射规则的状态机逻辑是对的剩下的就是跟真实 RRC 配置和空口抓包做对齐。5.2 用抓包数据做字段级校验mock 跑通之后下一步是拿真实抓包数据校验。把空口抓到的 SDAP PDU 按 hex 导出用 3.4 节的解析函数批量跑一遍重点看三个指标QFI 是否落在 RRC 配置的 QoS Flow 列表里、RDI1 的包是否都触发了映射表更新、端标记 PDU 的 D/C 位是否为 0。这三个指标对上了SDAP 的基本功能就没大问题。我自己的习惯是每次改完 SDAP 的映射逻辑先把 5.1 的回归脚本跑一遍再拿一组历史抓包数据做字段校验两关都过了才提交。这个习惯帮我挡掉过好几次「改 A 场景把 B 场景搞挂」的回归问题。希望帮到你。本文还有配套的精品资源点击获取