简介:围绕3GPP TS 37.324 g20版本整理的SDAP协议详解文档,聚焦5G NR服务数据适配协议,适合协议栈研发、测试验证及无线协议学习者对照查阅。文档以SDAP架构和实体为切入点,系统说明QoS流到DRB的映射关系、UL/DL SDAP数据PDU的构造与解析、末端标记控制PDU的作用,以及反射QoS流到DRB映射中RDI/QFI/RQI字段的处理规则。对于SDAP实体建立与释放、上行默认DRB映射、下行RQI处理等过程,也给出了贴近规范的步骤梳理,能帮助读者快速建立5G用户面数据从QoS流到数据无线承载的完整认知。资源为docx格式,共1个文件,压缩包仅242KB,便于直接阅读或打印。已有584人学习浏览,可作为学习5G NR协议栈时的重点参考资料。
1. 从一份docx认识SDAP:TS 37.324 g20到底解决了什么问题?
调一个5G视频卡顿的问题,抓包看了半天,最后发现是SDAP头里的QFI字段没对齐,UE把带头的包当裸IP解析,整个业务流直接废掉。这种问题在4G时代不存在,因为4G没有SDAP这一层;到了5G,用户面协议栈在PDCP之上多了一个"服务数据适配协议",也就是SDAP,而它的全部行为规则都写在一份叫TS 37.324的3GPP协议文档里。文件名里的37324就是这份规范的编号,g20是版本号(g开头对应Release 16系列),SDAP是协议名,docx是3GPP官网发布的标准文档格式。这篇文章就围绕这份文档,讲清楚SDAP是什么、TS 37.324该怎么读、映射机制怎么落地成代码、参数怎么配、以及实现时最容易踩的五个坑。适合协议栈开发、测试和无线协议优化工程师,目标是让你读完能直接上手,而不是停留在看图说话。
2. 先读懂TS 37.324的文档结构:从目录到关键章节的快速定位
2.1 3GPP协议文档的固定骨架:从Scope到PDU Format
3GPP的协议规范有一套固定的章节结构,TS 37.324也不例外。打开这份docx,第一件事不是从头读,而是先看目录,确认你要的内容落在哪个章节。规范的正文一般从Scope开始,接着是References、Definitions、Symbols and Abbreviations,然后是General、Architecture、Procedures,最后是PDU Format。SDAP的核心内容集中在架构、流程和PDU格式这三块:架构章节告诉你SDAP实体长什么样、放在协议栈哪个位置;流程章节告诉你QoS flow到DRB的映射在上下行各怎么走;PDU格式章节告诉你SDAP头里每一个bit的含义。
这个固定骨架意味着你不需要通读全文。一个做协议栈实现的人,日常只需要反复翻三到五页;一个做测试的人,重点看流程章节和PDU格式章节就够。g20这个版本号也值得留意:3GPP在文件名里用字母加数字标识版本,g开头表示这是Release 16阶段的产物,g20是这一系列里的一个具体修订版。不同小版本之间的差异通常只是措辞澄清和边界条件修正,但后文会讲到,恰恰是这些"澄清"最容易让实现翻车。
2.2 用docx的检索特性快速筛出该读的章节
3GPP官方发布的docx文件有个好处:标题全部用的是Word的标准Heading样式。你在Word里打开这份文档,左侧导航窗格可以按层级展开全部章节,比PDF版本好用得多。很多人不知道的是,Windows资源管理器的搜索框能直接搜docx正文,但3GPP这类长文件名文档经常不被搜索索引覆盖,更靠谱的做法是打开文档后直接Ctrl+F检索关键词。"QFI"、"reflective"、"SDAP header"、"unmapped DRB"这几个词是高频入口,搜到哪就在哪附近精读。
如果你的工作流偏自动化,需要批量提取文档结构,用python-docx按Heading样式遍历是最常见的做法:
from docx import Document doc = Document("37324-g20.docx") for para in doc.paragraphs: # 3GPP官方文档的标题层级映射到Word自带的Heading 1~4 if para.style.name.startswith("Heading"): level = para.style.name.replace("Heading ", "") print(f"{level} {para.text}")这段代码的逻辑很简单:遍历docx里的所有段落,只打印样式名以Heading开头的段落,这些就是文档的标题行。3GPP规范在生成docx时保留了规范自身的层级编号(比如5.2、7.1.3),所以你在输出里看到的会是"5 SDAP Architecture"、"7 SDAP PDU format"这类原生编号,配合Word自带的heading层级一起看,定位速度比PDF快很多。
如果电签工具或在线转换工具把docx转成JSON,也能拿到类似结果,但没必要——协议文档的价值在措辞本身,转成JSON并不会让"shall"和"should"的区别更好理解。常见的做法是保留docx原格式,用Word导航加脚本辅助,两个手段配合。
2.3 SDAP在协议栈里的位置:RRC之下、PDCP之上
SDAP只存在于NR用户面,控制面没有这一层。从下往上看用户面协议栈:MAC处理调度和复用,RLC处理分段和重传,PDCP处理加密和序号管理,SDAP坐在PDCP上面,直接面对IP包。IP层的数据包到了SDAP,SDAP根据QoS flow标识(QFI)决定把它塞进哪个数据无线承载(DRB),然后交给PDCP往下传。换句话说,SDAP解决的是"这个包该走哪条路"的问题,PDCP和它以下解决的是"这条路怎么把包安全送到"的问题。
SDAP实体是按PDU session粒度建立的。一个PDU session对应一个SDAP实体,一个SDAP实体可以管理多个DRB,而一个DRB上可以复用多个QoS flow。理解这个层级关系非常重要,因为后面实现时你会发现,映射表的查询键是QFI,但表本身是挂在SDAP实体下的,不是挂在DRB下的。很多人第一次写代码时把映射表挂在DRB上,两个PDU session一交叉,映射就乱了。
2.4 端到端是错觉:SDAP只管空口这一跳
在4G的EPS架构里,QoS的处理靠bearer,eNB直接在空口上把bearer映射到无线承载,中间没有独立的适配层。到了5G,核心网把QoS粒度细化成了QoS flow,空口这一侧需要有一个机制把flow映射到DRB,SDAP就是为这个需求生出来的。QFI从核心网的下行包GTP-U头里带过来,gNB收到后查映射规则,决定这个flow走哪个DRB,同时把QFI填进SDAP头里发给UE。
这里有个最常见的误解:很多人以为QFI是端到端透传的,UE是从SDAP头里"读"到QFI才知道自己在哪个flow里。实际上QFI的权威来源是核心网,SDAP头里的QFI只是gNB为了让UE做上行映射而附带的信息。UE上行时自己决定某个QFI走哪个DRB,gNB收到上行的SDAP PDU再往核心网转发,此时核心网看的是GTP-U头里的QFI,SDAP头已经没用了。所以SDAP是严格的一跳协议,只管UE和gNB之间这一段,别把它当成端到端协议来设计。
3. SDAP的核心机制:QoS flow到DRB的映射,怎么落地成代码?
3.1 上下行两条路径:gNB决策、UE跟随
下行方向,映射的决策权在gNB。gNB从核心网收到下行数据,GTP-U头里携带QFI,gNB的SDAP实体查本地映射配置,找到这个QFI对应的DRB,把数据包映射到该DRB的PDCP实体。如果RRC配置要求下行带SDAP头,gNB还要在IP包前面加一个SDAP头,头里放QFI,这样UE收到后能知道这个包属于哪个QoS flow。
上行方向,映射的决策权在UE。UE的应用层数据进入SDAP实体时,SDAP根据当前已知的映射关系选择合适的DRB。问题是UE怎么知道"哪个QFI该走哪个DRB"?两种途径:一种是RRC直接在配置里告诉UE,比如"QFI 3、5、7走DRB 2",这是静态映射;另一种是反射映射,UE通过下行收到的SDAP头里的QFI和承载它的DRB反推映射关系,这是动态学习。上行没有映射的flow,默认走default DRB。这张映射表是SDAP实现的核心数据结构,下行靠它查DRB,上行靠它选DRB。
3.2 SDAP数据PDU头格式:从1字节到2字节
SDAP数据PDU的头格式是TS 37.324里最需要抠细节的部分。最小形态是1字节:最高1位是D/C指示(1表示数据PDU,0表示控制PDU),如果没开反射QoS相关特性,剩下的7位里只有低6位是QFI。QFI取值范围是0到63,6个bit刚好够用。开了反射QoS之后,头会扩到2字节,多出来的位分别携带RDI和RSPI两个指示,一个用于下行反射映射学习,另一个用于上行反射策略指示。
用C语言描述这个头结构,常见做法是这样:
typedef struct { uint8_t d_c : 1; // 1: 数据PDU,0: 控制PDU uint8_t rdi : 1; // 反射QoS下行映射指示,1表示UE需要学习 uint8_t rspi : 1; // 反射QoS上行策略指示 uint8_t qfi : 6; // QoS flow标识,0~63 } sdap_data_hdr_t; // 解析SDAP数据PDU头(假设已确认是数据PDU) int sdap_parse_hdr(const uint8_t *buf, sdap_data_hdr_t *hdr) { // 第一个字节的低6位就是QFI hdr->qfi = buf[0] & 0x3F; hdr->d_c = (buf[0] & 0x80) ? 1 : 0; hdr->rdi = (buf[0] & 0x40) ? 1 : 0; hdr->rspi = (buf[0] & 0x20) ? 1 : 0; if (!hdr->d_c) { // 控制PDU不在这里处理,走单独分支 return -1; } return 0; }这段代码演示的是头解析里最容易做错的部分:按位取值。很多人拿到第一个字节直接赋值给uint8_t变量,结果QFI值被高位的D/C、RDI、RSPI污染了。正确做法是先用掩码0x3F把低6位隔离出来。这里有个细节:D/C位在第7位(从0开始数的第7位,也就是bit7),RDI和RSPI紧随其后,不同厂家实现时位域顺序可能不同,但只要是按3GPP规范走,bit分配就是这个顺序。解析函数的返回值用来区分数据PDU和控制PDU,数据PDU走正常映射流程,控制PDU走另外一条极短流程——控制PDU在g20版本里几乎没有存在感,大多数业务流根本用不到。
3.3 反射QoS:省信令的代价是状态同步
反射QoS是SDAP里最有特色也最容易做错的机制。它的设计初衷是省掉RRC重配信令:gNB希望UE把某个QFI的上行流量切到新的DRB时,不需要专门发一条RRC消息,只需要在下行SDAP头里把RDI置1,UE收到后就知道"这个QFI原来走的是这个DRB,以后上行也这么走"。
这个学习过程在实现里是一个查表加写表操作。UE在收到带RDI指示的下行SDAP PDU时,以QFI为键,记录"当前这个QFI是从哪个DRB下来的",写入本地上行映射表。问题是这个表什么时候失效?规范里没有定义反射映射的老化定时器,但实际产品里必须加,否则UE的映射表会随着业务波动越积越大,老映射迟迟不更新,新映射又进不来。常见的做法是给每条反射学习来的映射加一个超时时间,超时后回退到default DRB。这是实现侧的经验值,规范上没有硬性数字,但你不上这个老化机制,现网跑一段时间就会出问题。
3.4 最小实现代码骨架:一个SDAP实体的核心流程
把上文的逻辑串起来,一个最小SDAP实体在数据面也就三个函数:下行入口、上行出口、映射表管理。下行入口负责查表加头,上行出口负责选DRB,映射表管理负责增删查。
typedef struct { uint8_t qfi; uint8_t drb_id; uint8_t learned; // 1表示反射学习,0表示RRC静态配置 uint32_t last_used; // 老化时间戳,反射项专用 } sdap_map_entry_t; typedef struct { sdap_map_entry_t maps[64]; // QFI最多64个,直接数组索引 uint8_t header_dl; // RRC配置:下行是否带头 uint8_t header_ul; // RRC配置:上行是否带头 uint8_t default_drb; // 默认DRB } sdap_entity_t; int sdap_dl_data(sdap_entity_t *ent, uint8_t *pkt, uint16_t len, uint8_t qfi) { // 1. 查映射表,找不到就落到default DRB uint8_t drb = ent->default_drb; for (int i = 0; i < 64; i++) { if (ent->maps[i].qfi == qfi) { drb = ent->maps[i].drb_id; break; } } // 2. 配置了带头才加SDAP头,头放在IP包前面 if (ent->header_dl) { // 这里的pkt要预留1~2字节头部空间 pkt[0] = (1 << 7) | (qfi & 0x3F); pkt += 1; len -= 1; // 实际载荷长度减少 } // 3. 交给对应DRB的PDCP实体发送 return pdcp_send(drb, pkt, len); }这段代码把下行主流程拆成了三步:查映射、加头、下发PDCP。第二步的头部空间是调用方(上层IP协议栈)预先留好的,否则直接移动指针会把缓冲区搞乱。注意这里len -= 1和pkt += 1是配套的,头占了一个字节,有效载荷指针后移,长度也要减掉。实际产品里还要处理头部空间不足的异常情况,以及头部扩展成2字节的分支。这个骨架没有处理控制PDU和反射学习,但主路径已经完整,后续扩展反射QoS只需要在查表之前加一个"学习"分支。
4. SDAP实现的关键参数与配置:哪些参数不调对,上层业务就翻车
4.1 配置从哪里来:RRC里的SDAP-Config
SDAP协议自己没有独立的配置信令,所有参数都由RRC层通过无线资源控制消息下发。具体来说是RadioBearerConfig里带的SDAP-Config,这是TS 38.331里定义的IE,TS 37.324只管SDAP层内部行为,不定义配置语法。这意味着SDAP实现必须要跟RRC模块对接:RRC解析完配置成结构体,SDAP实体拿着这份配置初始化自己。很多做PDCP/RLC出身的人第一次写SDAP时,习惯性地去SDAP规范里找配置结构,翻遍全文找不到,然后才意识到SDAP配置在外头。这个对接点是最容易漏的架构边界。
常见做法是RRC层在建立DRB时,把SDAP-Config解析成一个配置结构体,连同DRB ID一起传给SDAP实体。配置里最关键的三样东西:上下行头开关、default DRB指示、QFI映射列表。这三样决定了SDAP实体能不能正常工作,也是踩坑重灾区。
4.2 三个必调参数:头开关、default DRB、QFI映射
第一个必调参数是上下行SDAP头开关。规范里对应sdap-HeaderDL和sdap-HeaderUL两个字段,取值为present或absent。present表示这一侧要加SDAP头,absent表示不加。这两个字段在UE和gNB两侧必须对齐:gNB下行配置了present,UE解析下行数据时就必须按带头处理;否则UE把SDAP头当成IP头首字节,整个包全错。反过来,UE上行配置了present,gNB在接收方向也要按带头解析。实现时必须检查RRC配置的重配流程,保证切换、重建之后这两个值不被重置成默认值。
第二个必调参数是default DRB。当一个上行QFI在映射表里查不到对应DRB时,SDAP实体把包往default DRB上塞。这个参数给错了,所有未被显式映射的流量会直接走到错误的承载上,QoS优先级完全失效。参数本身是一个DRB ID,RRC在SDAP-Config里通过defaultDRB字段指定。
第三个必调参数是QFI到DRB的映射列表。RRC在配置DRB时,通过mappedQoS-FlowsToDRB字段告诉UE和gNB:"哪些QFI归这个DRB管"。这张表是SDAP实体初始化映射表的唯一来源。这里有一个容易被忽略的边界:一个QFI只能映射到一个DRB,一个DRB可以映射多个QFI,但下行和上行可以各自不同。实现时要支持上下行独立查表。
| 参数 | 作用 | 取值范围 | 常见翻车点 |
|---|---|---|---|
| sdap-HeaderDL / sdap-HeaderUL | 控制数据面是否携带SDAP头 | present / absent | 对端配置不一致,导致整包解析错位 |
| defaultDRB | 未映射QFI的兜底承载 | DRB ID | 指向不存在的DRB,上行丢包 |
| mappedQoS-FlowsToDRB | QFI与DRB的静态映射关系 | QFI列表(0~63) | 同一个QFI出现在多个DRB配置里 |
4.3 头精简:在协议正确性和带宽开销之间取舍
SDAP头不是必须的。如果某个DRB上只有一个QoS flow,或者所有QoS flow的映射关系都已经是确定的,加头反而浪费空口资源。RRC配置里把sdap-HeaderDL和sdap-HeaderUL设为absent,SDAP层就直接透传IP包,不做任何头操作。这是规范允许的,也是大多数业务早期的默认形态。
但在配置为absent时必须注意一个前提:接收方向必须能区分"带SDAP头的包"和"不带SDAP头的包"。这个区分靠的是配置对齐,而不是包内容里的某个标志。也就是说,一个DRB要么全部收带头包,要么全部收不带头包,不能混着收。实际产品里常见的问题是在DRB重配过程中,RRC配置已经改成absent,但gNB侧还在继续加头,UE按新配置解析就错位了。解决方法是重配生效的时机要跟PDCP层同步,确保空中接口上从某个PDCP序号开始统一使用新的头配置格式。
4.4 没有定时器,但有时序约束
TS 37.324正文里没有定义任何SDAP层定时器,这和RLC有重传定时器、PDCP有丢弃定时器很不一样。但这不代表实现侧什么都不用管。反射QoS学习到的映射条目要有老化机制,这一点在3.3节已经讲过;另一个隐性的时序约束是SDAP PDU的处理不能打乱PDCP的序号顺序。SDAP层在数据面上对上层IP包做映射和加头,做完之后交给PDCP,PDCP按到达顺序分配序号。如果SDAP内部因为查表等待或映射表更新出现乱序交付,PDCP层的序号分配就会错乱,接收方向的重排序和重复包检测全都会出问题。所以SDAP实体的处理函数必须是同步且无阻塞的,映射表查询不能加锁等待,更不能用可能在运行时阻塞的操作。
5. SDAP落地避坑:五个让协议栈返工的真实场景
5.1 现象:下行流量全断,UE侧IP层解析失败
原因:gNB侧下行配置了带SDAP头,但UE侧对应DRB的sdap-HeaderDL配置成了absent。UE把收到的SDAP头第一个字节当成IP头版本号来解析,拿到的IP头完全错乱,IP层直接丢包。这类问题在切换场景里特别常见:切换后RRC重配没带SDAP头配置,UE回退到默认值,而gNB没同步回退。
解决:排查时先在UE侧日志里确认该DRB的SDAP头配置值,再对比gNB侧;两个方向的配置必须一致。如果切换后才出现,重点查切换后的RadioBearerConfig里SDAP-Config有没有被正确携带。
5.2 现象:UE上行大量流量走default DRB,QoS weight失效
原因:反射QoS开关没有生效。gNB设备发了带RDI的包,但UE侧SDAP实体的反射学习功能没打开,或者RRC配置里压根没开启反射QoS特性。反射学习不仅仅是"头里有RDI就学",它依赖RRC配置里的总开关,开关关着,RDI置1也白搭。很多人只看到SDAP头格式里有RDI位,以为解析到位就等于生效了,忽略了上层开关。
解决:配置检查两步走:第一步确认RRC里的反射QoS总开关是使能的;第二步确认下行SDAP头里RDI位在实际抓包里确实为1。两步同时满足,UE才会把映射写入表。
5.3 现象:QFI映射错乱,业务走到完全无关的DRB上
原因:QFI解析时没做掩码。QFI是6bit字段,取值范围0到63,但很多实现直接从缓冲区里读一个uint8_t赋值给QFI变量。当头里还有D/C位和RDI位时,读出来的值变成0x80以上的数字,查表直接查空或查到错误条目。这类问题在初期联调阶段极难发现,因为空映射落default DRB,表现只是QoS不对,整体还是通的。
解决:解析入口统一做掩码处理,qfi = byte & 0x3F,同时在校验处拒绝大于63的QFI值。建议在SDAP实体入口加一个断言:任何QFI超过63直接报错返回,也方便测试尽早发现抓包里的异常。
5.4 现象:按旧版本实现的行为,在g20版本设备联调时行为异常
原因:g20版本相对早期g版本,在某些边界条件上做了澄清。典型的是"未定义的QFI值"和"无映射DRB"场景——旧版本里措辞模糊,实现可以做A也可以做B,新版本改用shall明确要求。只读正文不读变更记录,沿用旧行为,就会在联调时对不上设备厂家的实际行为。
解决:拿到g20后先翻变更记录。3GPP的docx版本在文档前部会列出相对上一版本的变更条款,逐条核对哪些内容影响你的实现。经验是每次都把"shall"和"should"的句子单独筛出来过一遍,这些地方是协议测试最喜欢出题的点。
5.5 现象:多个PDU session同时活动时,SDAP映射表互相污染
原因:SDAP实体按PDU session建立,但实际实现里如果多个实体共用一份映射表结构体,或者实体的DRB归属没区分清楚,就会出现session A的QFI映射到session B的DRB上。尤其是双连接或载波聚合场景下,DRB归属跨小区、跨实体管理,代码里一个全局变量随手传错,线上就是疑难杂症。
解决:每个SDAP实体持有自己的映射表,不允许共享。所有接口函数显式传入实体指针,禁止用全局映射表。排查时先确认日志里的实体ID和PDU session ID是不是一一对应,再看映射表条目里记录的是不是本实体的DRB。
6. 用抓包和日志验证SDAP映射是否真的生效
验证SDAP映射不能只靠看代码,最直接的手段是抓包加查日志。空口抓包拿到的是RLC层之后的数据,Wireshark对SDAP的协议解析支持很有限,但你可以用最笨也最可靠的办法手动验证:找到PDCP payload的第一个字节,最高位如果是1,说明SDAP头在,低6位就是QFI。把这套判断固化成一个小的解析脚本,每次联调都跑一遍,比肉眼盯十六进制靠谱得多。
import sys # 读入一段二进制数据,假设第0个字节是SDAP头的第一字节 data = sys.stdin.buffer.read() if not data: sys.exit(0) first = data[0] is_data = (first & 0x80) == 0x80 qfi = first & 0x3F rdi = (first & 0x40) == 0x40 rspi = (first & 0x20) == 0x20 print(f"is_data={is_data}, qfi={qfi}, rdi={rdi}, rspi={rspi}")验证流程建议按四步走。第一步,在核心网侧找到业务流的QFI值,比如在GTP-U头里确认这个流的QFI等于5。第二步,在gNB日志里查这个QFI对应的DRB ID,常见格式是类似qfi: 5, drb: 4, action: map的行。第三步,在空口抓包里找到同一个包的下行数据,解析SDAP头,确认QFI确实是5,且承载它的逻辑信道对应的DRB ID确实是4。第四步,如果开了反射QoS,再发一个上行包,确认UE是否按新学习的映射把包发到DRB 4。
这套流程里最花时间的一般是第三步:抓包里的DRB ID不直观,需要结合RLC header或MAC层的逻辑信道信息来反推。我还在摸索更顺手的自动化方法,目前的经验是gNB侧日志里的映射表打印比抓包解析更快,因为厂家一般会在RRC重配生效时把整张映射表打出来。建议你在测试环境里主动触发一次RRC重配,让某个QFI从DRB A切到DRB B,然后看两点:SDAP头里的QFI是否保持不变,以及承载的逻辑信道是否变了。如果QFI变了,说明映射表更新和头解析之间有时序问题;如果逻辑信道没变,说明PDCP绑定错了。
我最初做SDAP实现时,在头开关对齐上栽过一次:gNB侧配置了带头,UE侧没配,结果整条业务流黑匣子一样什么都看不见。后来养成一个习惯:每接一个新版本协议,第一件事是把变更记录里所有涉及"shall""should"的句子摘出来,第二件事是写一个20行的头解析脚本,每轮联调先跑脚本再调业务。这两个习惯帮我避免了不少返工。希望帮到你。
本文还有配套的精品资源,点击获取