简介:gtp-u.rar是一份面向移动通信协议开发者与4G/5G核心网研究者的GTP-U协议栈实现工程,聚焦Linux环境下GTP-U用户面数据的编解码、隧道建立与会话生命周期管理,可帮助读者从代码层面理解GTP-U与GTP-C在控制面、用户面的分工协作。压缩包共34个文件,以C源文件和头文件为主,辅以编译生成的目标文件、依赖文件及Makefile,整体约130KB,小巧完整,适合直接阅读工程结构和重新编译验证。目前已有724人学习下载,是同类GTP-U开源资料中较受关注的资源。通过查看源码,读者可学习GTP-U报文头的构造与解析、TEID隧道端点标识的分配机制,以及会话建立、修改、释放等核心流程;结合描述中P-GW与UE之间的隧道场景,还能深入体会GTP-U如何承载VoIP、互联网浏览等用户数据,并与GTP-C信令流程相互配合,为后续网络功能开发或协议测试提供扎实参考。
1. 能编译的 GTP-U 源码包:比协议文档更值得先跑起来
网上讲 GTP-U 的文章,十有八九停在“GTP-C 管信令、GTP-U 管数据”这种一句话结论上。真到要在 Linux 环境里调一个用户面隧道,处理 UDP 2152 端口上收上来的奇怪报文,或者排查 TEID 对不上导致的数据不通时,能直接上手的参考很少。这个 gtp-u.rar 的价值在于它不是 PPT,是一套可编译的 C 源码:gtpucif、gtpudif、gtpucontext、gtpu_ha、gtpus 对应着真实 SGSN/PGW 网元里的控制接口、数据接口、隧道上下文和高可用切换,还自带 makefile 和 gtputest 联调程序。拆一遍它,你会对 GTP-U 的收包路径、编解码和会话生命周期有具体认知。适合正在做核心网网元、边缘网关或想用模拟器复现隧道行为的一线工程师。
2. 拆包看结构:从文件清单反推 GTP-U 数据面骨架
解压后你不会看到一堆散装文件,而是inc/和src/两个目录:头文件统一放在inc/,实现和 makefile 在src/。我第一次看到这份清单的直观感受是:它不是一个玩具 demo,而是照着网元分层拆出来的工程。把命名稍微整理一下,模块边界就出来了。
2.1 文件清单对应功能模块:这个包到底包含什么
先把混排的文件名还原成模块对应表:
| 文件 | 角色定位 |
|---|---|
| gtpucif.c/h | 控制接口层:接收 SGSN 控制面下发的隧道管理请求,创建/删除隧道上下文 |
| gtpudif.c/h | 数据接口层:GTP-U 报文的解析入口和封装出口 |
| gtpucontext.c/h | 隧道上下文:TEID 到会话属性的核心映射,数据面查找中枢 |
| gtpu_ha.c/h | 高可用:主备节点间上下文同步 |
| gtpus.c | 服务入口:绑定 UDP 2152 的 socket 主循环与分发逻辑 |
| gcu.c/h | 通用控制单元:独立于协议细节的控制处理 |
| smgw_share.c/h | 共享内存模块:跨进程共享配置、计数和状态 |
| gtputest.c | 联调测试程序,用来模拟对端发包或回显请求 |
| makefile | 构建脚本,直接决定编译产物 |
这套命名风格在 SGSN/GGSN 的 C 实现里很典型。注意src/里还保留了sgsn_gtpu.o,说明它经常被当作 SGSN 数据面的一部分联编,而不只是独立进程。所以你读gtpucif时不要只想着“用户面协议栈里的一个函数”,它是控制面与用户面之间的一道闸门。
2.2 一条用户数据在源码里怎么走:从 recvfrom 到隧道上下文
了解这段代码最快的方式,是看数据流。上行方向(从基站/RNC 到核心网),报文先进 UDP 2152 对应 socket,由gtpus.c的主循环收包,gtpudif.c负责解出 GTP-U 头,再用 TEID 去gtpucontext.c的哈希表里找会话。找到之后解封装并把内部 IP 包交给本地转发或共享内存。下行方向反过来:业务侧要给某个用户发数据,先查上下文拿到远端地址和 TEID,封装好 GTP-U 头再从 socket 发出去。
下面这段骨架只表达职责分工,不是包内源码的逐行抄录,但处理顺序和模块边界是一致的:
/* gtpus.c 主循环的常见骨架 */ static void gtpus_loop(int fd) { struct sockaddr_in peer; socklen_t peer_len = sizeof(peer); uint8_t buf[65536]; for (;;) { ssize_t n = recvfrom(fd, buf, sizeof(buf), 0, (struct sockaddr *)&peer, &peer_len); if (n < (ssize_t)sizeof(gtphdr_t)) continue; gtphdr_t *hdr = (gtphdr_t *)buf; if (hdr->version != 1) continue; switch (hdr->msg_type) { case GTPU_ECHO_REQ: gtpu_send_echo_rsp(fd, &peer); continue; case GTPU_GPDU: break; default: continue; /* 未知消息类型,丢弃 */ } uint32_t teid = ntohl(hdr->teid); gtpucontext_t *ctx = gtpucontext_find(teid); if (!ctx) continue; /* 未知隧道,直接丢弃 */ gtpu_passthrough(ctx, buf + sizeof(gtphdr_t), n - sizeof(gtphdr_t)); } }recvfrom拿到的数据先按版本过滤,msg_type决定走信令分支还是数据分支。GTP-U 的 Echo 请求比 GTP-C 的路径短得多,它不需要命中隧道上下文,直接回一个应答就能完成 UDP 层的连通性探测。真正的 G-PDU 必须走gtpucontext_find这一步,找不到上下文就丢,这是 GTP-U 数据面最基本的保护逻辑。
下行封装时,TEID 不再从报文里取,而是从上下文里取。这就引出了本包最重要的两组函数:gtpucontext_init/create/release和gtpucontext_find。前者管生命期,后者管查找效率。Linux 上调优时,我一般先看哈希桶数量和锁粒度,这两个参数直接决定隧道多的时候会不会锁竞争。
2.3 makefile 依赖顺序:从编译产物反推调用关系
压缩包里本身就带了被编译出来的.o和.d文件。.d是编译器生成的依赖清单,不是手工维护的。读依赖顺序比读代码更直接:gtpu.o依赖gtpu.h、gtpucontext.h;gtpudif.o依赖gtpudif.h和gtpucontext.h;gtpucif.o依赖gtpucif.h和gtpucontext.h。三个主要模块的共同点都落在gtpucontext.h上,这再次确认了上下文是整个数据面查找的中枢。
验证依赖关系,不需要真正编译,make 提供了一种查阅模式:
cd gtp-u/src make -n | head -n 20-n只打印将要执行的命令,不实际执行。看到第一条编译命令里-I是否指向../inc,就能判断头文件路径是否正常。若 makefile 被压缩包里的路径弄坏,-n的输出会少很多行,排查范围一下子就缩小了。
3. 在 Linux 上编译并跑通:从 7z 解压到看到第一个回显应答
这部分解决的是最现实的问题:拿到一个.rar,在 Linux 上怎么打开、怎么编、怎么让它先转起来。很多人卡在这里不是因为 C 代码难,而是被 RAR 格式和 makefile 的路径问题拦住了。
3.1 解压:不要跟 RAR 文件较劲,工具用对
不要在 Linux 上死磕unar命令,装 7-Zip 更省事。7-Zip 一直支持解 RAR 读取,绝大多数 Linux 发行版都能直接装 p7zip 系工具。我常用的命令:
# 推荐:p7zip 读 RAR 足够用 7z x gtp-u.rar -o./gtp-u如果目标机器上只有 unrar,也不要慌,功能等价:
unrar x gtp-u.rar解压完成后确认顶层目录名和inc、src两个子目录是否完整。若 7z 解出来文件权限丢了很多,下一步执行make必失败。检查一下 src 下是否有 makefile,没有就先chmod +x或从上级目录复制过来。这一步翻车最常见的原因不是补丁,而是解压工具把长文件名的路径截断了。
3.2 编译:先看清 makefile 再动手
进入src/,先看头部,不要急着 make。里面如果已经带了.d文件,意味着 IDE 或过往构建已经生成过依赖,平台一致性好的话直接就能编。但不同 Linux 发行版的头文件位置不同,老工程常有硬编码路径。碰到右边这类报错就加-I:
cd gtp-u/src make clean make CFLAGS="-g -O0 -I../inc" 2>&1 | tail -n 20加-g -O0的原因是:这是我要长期调试的代码,调优信息必须保留,-O0避免变量被优化掉导致 gdb 里看不了结构体内容。-I../inc指定头文件搜索路径,覆盖 makefile 里可能写死的旧路径。
编译输出里若出现fatal error: gtpu.h: No such file or directory,多半就是头文件相对路径不对,而不是系统缺库。GTP-U 是纯用户态协议栈时,不需要额外安装内核头文件;如果遇到和 socket 相关的类型未定义,再补build-essential和常规的libc开发包。
编译成功后你会在src/下看到新增的.o文件,其中gtputest.o是测试程序的编译产物,sgsn_gtpu.o这类旧产物说明它常和 SGSN 一起联编。此时可以顺手看依赖生成是否完整:
cat gtpu.d如果.d文件内容为空,说明该平台的 make 没有开启自动依赖生成,后面改字段后增量编译不可靠,建议主动全量重编一次。
3.3 回环冒烟测试:第一次用 tcpdump 看到 GTP-U 包
不接任何真实基站,在 lo 接口上做回环测试是最快的验证手段。做法是起两个进程,一个发 GTP-U Echo 请求,一个回 Echo 应答。Echo 请求消息类型是 0x01,应答是 0x26,这两个包头不含数据也能完成链路探测。
先在窗口 1 抓包:
sudo tcpdump -i lo -nn 'udp port 2152' -XX窗口 2 运行测试程序:
# 参数格式以实际 --help 为准,思路是对端指向 127.0.0.1 ./gtputest --local 127.0.0.1 --peer 127.0.0.1 --type echotcpdump 输出里看到IP 127.0.0.1.xxxx > 127.0.0.1.2152这类五元组,且报文长度在 8 字节左右,就说明 GTP-U socket 已经能收发。再用 tcpdump 的-Ov单独解出 GTP 头:
sudo tcpdump -i lo -nn 'udp port 2152' -Ovtcpdump 对 GTP-U 的解码支持很成熟,能直接打印消息类型和 TEID。这是整个调测链路里成本最低的一步。只要把回环跑通,后面所有问题都从“能不能编译、能不能发包”过渡到“协议行为对不对”。
4. 编解码与会话管理的源码细节:TEID 生命周期和接口分工
看完流程再看编码,你才能理解 gtpudif 里那一堆掩码和移位是在干什么。GTP-U 数据面用的是 GTPv1-U 封装,用户数据包外面套一层 UDP,里面是 8 字节基础头。控制面信令通常走 GTP-C(4G 核心网里更多是 GTPv2-C,端口 2123),而用户面 GTP-U 一直是 v1,这是很多新人不理解的地方。
4.1 8 字节基础头才是你要编码解码的部分
先给一张必须印在脑子里的字段表:
| 字段 | 位宽 | 说明 |
|---|---|---|
| 版本号 | 3 bit | GTPv1 固定为 1 |
| PT | 1 bit | 协议类型,GTP-U 为 1 |
| E | 1 bit | 扩展头标志 |
| S | 1 bit | 序列号标志 |
| PN | 1 bit | N-PDU 编号标志 |
| 消息类型 | 8 bit | 0x01 回显请求 / 0x26 回显应答 / 0x32 G-PDU |
| 长度 | 16 bit | 从消息类型之后算起的 GTP 头部与负载总长度 |
| TEID | 32 bit | 隧道端点标识,G-PDU 和回显报文中按规范存在 |
写头函数时注意力全在这个 8 字节上:
/* GTPv1-U 基础头的编码函数(对应 TS 29.281 头格式) */ static void gtpu_write_header(uint8_t *out, uint32_t teid, uint8_t msg_type, uint16_t total_len) { out[0] = 0x30; /* 版本=1,PT=1,无扩展/序列号/N-PDU */ out[1] = msg_type; /* 0x32=G-PDU, 0x01=回显请求 */ out[2] = (total_len >> 8) & 0xFF; out[3] = total_len & 0xFF; out[4] = (teid >> 24) & 0xFF; /* TEID 大端序输出 */ out[5] = (teid >> 16) & 0xFF; out[6] = (teid >> 8) & 0xFF; out[7] = teid & 0xFF; }0x30写成二进制是0011 0000,高三位 001 是版本 1,第四位 1 是协议类型 GTP-U,后面四位都是标志位且为 0。注意这里没把 8 字节头之外的内容算进total_len:标准无扩展的 G-PDU 基础头总共 8 字节,长度字段只填 4,表示消息类型之后还有 4 字节。这个细节和 UDP 包长度是两回事,新手拿长度字段去对比 IP 报文长度经常会错。
解码是编码的逆操作,但多了两个坑:一是必须先校验版本号和 PT 位,二是 TEID 要从网络序转主机序再查表。如果包内有扩展头,E 位为 1 时还要跳过变长扩展头才能拿到用户数据,这通常也是 gtpudif 里最绕的部分。
4.2 TEID 生命周期:分配、哈希查找、回收
TEID 不是随便取的随机数。数据面侧从控制面收到“创建会话”命令时,分配一个未被占用的 TEID,把它写进gtpucontext结构体,再挂到哈希表里。此后所有上行包的查表键就是这个 TEID,下行包则在发送前根据目的侧隧道对端地址查表拿到对方 TEID。
按 3GPP 约定,0xFFFFFFFF专门保留给控制消息使用,用户数据隧道不会分配这个值,所以哈希表里可以用它做无效标记。常见实现是用一个池子管理 TEID,范围从 1 到 65533 或者更大,分配策略随机化或低位递增都可以,核心是保证并发时刻不重复。
上下文生命周期按状态推进:创建成功是“已建立”,收到首个真实数据包之后可切到“激活”,空闲超时或控制面删除时回收 TEID 并释放哈希项。gtpucontext.c的主要工作就是这四种操作。调测时直接在创建函数里加打印,把 TEID、远端 IP、远端端口全打出来,比事后抓包猜要快得多。
高可用切换是gtpu_ha.c的存在原因。主备之间定期同步上下文,主节点宕机后备用节点恢复最近一次 checkpoint,继续收包。跑主备联调时要注意:两个进程不能同时 bind 同一个 UDP 2152 端口,否则第二个必报Address already in use。测试场景可以用 VRRP 虚拟 IP 轮换绑定来解决。
4.3 gtpucif 和 gtpudif 的分工:别让数据面渗到控制面
控制接口和数据接口是两回事,这一点决定整个业务流程。gtpucif只管接收控制面决策,比如 SGSN 通过 GTP-C 信令下发的创建/删除隧道请求,它负责把决策转成对gtpucontext的增删操作。gtpudif只处理 UDP 2152 上的用户面报文,收到 G-PDU 后查上下文、解封装、转发,不做任何隧道管理动作。
这两套职责交叉最容易出 bug 的地方是锁。gtpucif可能在删除上下文时,gtpudif正在使用同一个gtpucontext。如果上下文结构体里没有引用计数或读写锁,就会出现访问已释放内存。排查这类问题时,优先在gtpucontext_find和release函数里加日志,看对象地址是否重复释放。
GTP-C 与 GTP-U 的关系可以这样理解:GTP-C 是“指挥员”,创建、修改、释放隧道都由它下发;GTP-U 是“执行者”,只负责在已建好的隧道里搬运用户数据。网关上两边都可以存在,但 2152 和 2123 两个端口必须严格隔离,抓包时也要分开过滤。
5. 避坑记录:GTP-U 调试中最常见的 5 个问题现象
前面是正常流程,这一章全是翻车现场。下面 5 个坑我在调 Linux 数据面时都真实遇到过,每条按现象、原因、解决来写,你可以直接对号入座。
5.1 编译期头文件找不到:只盯着 src 目录看不到 inc
现象:make报fatal error: gtpu.h: No such file or directory,编译提前终止。
原因:源码包是inc/和src/两层结构,makefile 里默认的-I路径用了相对路径,换了解压目录或跨机器后路径失效;还有可能是解压软件把符号链接或长路径名截断了。
解决:先看报错时的工作目录,再检查 makefile 里的INCLUDE变量。我一般直接指定-I../inc强制覆盖,编译命令变为make CFLAGS="-g -O0 -I../inc"。如果inc/下缺少某个头文件,确认是不是解压失败,重解一次不要用中文路径。
5.2 编译通过但一跑就段错误:上下文表没有初始化
现象:gtputest运行几毫秒后Segmentation fault,gdb 回溯停在哈希查找函数。
原因:数据面函数假定gtpucontext_init()已经把哈希表初始化和锁都建好了,但测试程序绕过了初始化直接调用查找,哈希表指针为空。
解决:在 main 函数最开始强制调用初始化函数,并检查返回值。养成习惯:每个测试用例开头打印context entries=0,确认创建一两个隧道后计数有变化。这套思路对后续集成其他协议栈同样适用——初始化失败绝不要继续跑。
5.3 隧道建立成功但 ping 不通:IP 转发没开
现象:用gtputest建了两条隧道,双方都能互相收发 GTP-U 报文,但用户面 IP 包 ping 不通。
原因:GTP-U 解封装后要把内部 IP 包路由到业务网络,Linux 默认的 IPv4 转发是关闭的。报文解出来之后被内核直接丢弃,回显请求自然出不去。
解决:临时打开转发开关,注意这步不能在容器里随便试:
sudo sysctl -w net.ipv4.ip_forward=1同时确认防火墙没拦转发的包。很多“隧道通但业务不通”的假象,最后都是这一步的问题。如果现场测试后要还原,记得改回 0。
5.4 抓包看到 TEID 前后颠倒:字节序没统一
现象:tcpdump 抓到 TEID 是0x01000000,代码里明明填的是0x00000001。
原因:TEID 在 GTP 头里按网络序传输,从 socket 读出来如果直接强转给结构体,小端机器上读到的字节序列跟人脑期待的顺序相反。编码端和解码端只要有一处没做 htonl/ntohl,就会错位。
解决:收包后用ntohl(hdr->teid)再查表,发包前用htonl(id)再填头。一致性检查可以写单元测试,用htons/ntohs处理长度字段,用htonl/ntohl处理 TEID,不混用。这个坑尤其隐蔽,因为 8 字节头里 TEID 正好排最后,很多人只看端口不看 TEID。
5.5 回显请求发出去了,对端不应答:socket 绑定的源地址不对
现象:tcpdump 能看到127.0.0.1.12345 > 192.168.1.10.2152发包,但永远收不到回显应答。
原因:回环测试时测试端绑定了 loopback 地址,却把对端地址填成了实际网卡的 IP。Linux 路由从 lo 接口发出的包到达不了远端主机,对端即使收到也不会向 loopback 回包。
解决:同一台机器做回环联调,源目都填127.0.0.1;跨机联调时绑定的源 IP 必须是自己本机可达地址,而且对端路由要能回得来。建议在测试程序启动时用bind()强制绑定源 IP,不要依赖系统自动选择,因为多网卡机器上自动选择常常不准。
6. 用 tshark 回放验证自己改的 GTP-U:一条命令看隧道内容
编译过了、程序跑了,不等于协议行为正确。我验证改动最常用的方式是 tshark 抓包直接解码,避免在代码里加了一堆打印却看不懂报文结构。先抓 UDP 2152 上的流量:
sudo tshark -i eth1 -f "udp port 2152" -d udp.port==2152,gtp \ -Y "gtp.message_type == 0x32" \ -T fields -e gtp.teid -e ip.src -e ip.dst-d参数把 2152 端口强制解码为 GTP,-Y只过滤 G-PDU,-T fields输出结构化字段。当你看到一串 TEID 和源目地址时,说明封装、转发链路已经通。若只看基础五元组,就说明 tshark 被当作普通 UDP 包解了,那就是端口识别问题,不是协议问题。
Echo 回显验证更适合做故障定位:
sudo tshark -i eth1 -f "udp port 2152" -d udp.port==2152,gtp \ -Y "gtp.message_type == 0x01 or gtp.message_type == 0x26"这条命令同时滤出请求和应答,一眼能看出对端有没有回应。连发 100 个 Echo 请求,把应答延时求平均,还能顺便评估数据面时延稳定性。我一般会在gtputest里循环发送,然后把这组 tshark 输出存成 pcap 回放,避免反复重放现场不可控的流量。
从那以后,我每次拆一个 GTP-U 源码包,都强制走一遍“解压、编译、回环、抓包”四步。先让代码跑起来,再用 tshark 观察真实报文,所有关于字段顺序、消息类型的争论都会在一分钟内结束。希望帮到你。
本文还有配套的精品资源,点击获取