简介:packet-pro 是一份面向 Java 与网络数据面开发者的 DPDK 数据包处理框架源码包,适合学习高性能收包、多线程处理及 DPDK 与 Java 互操作的读者。压缩包共 1910 个文件、44.17MB,以 c/h 原生代码、java 业务与框架代码、makefile 构建脚本与 rst 文档为主,另含 sh 部署脚本、py 工具、png/svg 架构图与 pdf 演示文稿。框架按 DPDKJava 与重构后的 DPDKJava-R 两条线组织,并附带 c_apps 原生示例、Java 版防火墙与 PacketCap 抓包应用,便于对照同一场景下 Java 与 C 的实现差异。已有 183 人学习。从 setup.sh 中的大页配置到多版本框架演进,资料在源码之外保留了演示文稿和报告相关文档,可直接用于课程设计、毕业设计或 DPDK 入门实践。
1. 从抓包工具到可编程的数据包处理框架:packet-pro 是什么
排查线上网络问题的时候,最常见的操作是在服务器上跑 tcpdump 或 Wireshark 抓包,然后对着十六进制字节猜协议内容。这套流程很成熟,可一旦遇到“抓下来的包想在业务代码里做实时过滤、统计、入库”这种需求,传统工具就有点使不上劲了。packet-pro 解决的正是这个问题:它在 Java 侧实现了一套完整的数据包捕获、解析与分析链路,底层通过 JNI 调用通用的抓包库拿到原始帧,上层用干净的 Java 对象暴露以太网帧、IP、TCP、UDP 等协议结构,同时支持把原始报文落盘成 PCAP 文件,方便后续用其它工具二次分析。适合正在做网络协议研究、网关监控、流量分析类项目的开发者,也适合想搞懂“网卡上的字节是怎么变成 Java 对象的”这一类问题的同学。
2. 整体架构与技术选型:一条数据包从网卡到业务代码的流动路线
packet-pro 不是一个大而全的中间件,而是一个结构清晰的分层框架。理解它的架构,核心是看懂一条数据包从物理网卡进入进程,到最后变成业务代码里一个 JavaBean 的完整流动过程。这一章先讲清楚整体分层、目录结构和技术选型,后面才能聊核心代码。
2.1 分层架构:链路层捕获、解析、分发、存储四层职责
项目在逻辑上分了四层,每一层只干一件事,层与层之间通过接口解耦。最底层是链路层捕获组件,它封装了与 C 语言库交互的 JNI 代码,负责从网卡上捞原始字节;往上是协议解析层,把字节数组还原成 EthernetPacket、IPv4Packet、TcpPacket 这类对象;再往上叫分发层,它根据规则把解析好的数据包路由到不同的业务处理器;最靠近业务的是存储层,提供 PCAP 落盘、内存缓冲、统计聚合等能力。
分层的设计带来一个明显好处:底层抓包逻辑和上层业务完全隔离。你可以在不改动任何解析代码的前提下,把底层抓包来源从物理网卡换成离线 PCAP 文件,也可以在上层直接替换存储方案。我实际用过一段时间之后的感觉是,这个分层方式在扩展协议解析器时尤其顺手,加一个新的应用层协议解析器只需要在解析层注册进去,完全不用碰 JNI 和抓包线程。
// 数据包在框架内的流转路径示意 PacketCapture capture = new PcapPacketCapture(); FrameParser parser = new EthernetFrameParser(); PacketDispatcher dispatcher = new PacketDispatcher(); dispatcher.registerHandler(new TcpStatsHandler()); dispatcher.registerHandler(new PcapFileHandler()); capture.setListener(rawBytes -> { Frame frame = parser.parse(rawBytes); dispatcher.dispatch(frame); }); capture.start();这段代码是框架主链路的逻辑缩影。PcapPacketCapture是底层抓包实现,通过setListener向解析层投递原始字节,EthernetFrameParser把字节解析成结构化对象,PacketDispatcher再根据内部注册的处理器把对象分发出去。实际工程中,listener的回调线程是抓包库的线程池,所以业务处理器里绝对不能做耗时操作,否则丢包率会直线上升,这一点后面避坑章节会专门讲。
2.2 Maven 工程与目录结构:模块边界放在哪里
packet-pro 的工程结构是标准的 Maven 多模块布局,API 模块、核心实现模块、扩展模块和启动器模块彼此独立。这样的好处是依赖关系非常明确,核心模块不依赖任何扩展模块,扩展模块之间的依赖全部通过 API 模块里定义的接口来传递。
packet-pro ├── packet-pro-api # 接口定义:Frame、Packet、Handler、Parser ├── packet-pro-core # 核心实现:JNI抓包、解析器、分发器 ├── packet-pro-ext # 扩展实现:PCAP存储、统计聚合、规则过滤 ├── packet-pro-bootstrap # 启动入口与命令行配置 └── packet-pro-example # 可运行的示例工程每个模块的职责我理解下来是这样的:API 模块只放接口和类型定义,是整个框架的契约层;core 模块依赖原生库,负责把抓到的字节变成实现类对象;ext 模块是围绕 API 写的一组具体实现,比如 PcapFileHandler 就是在这里;bootstrap 模块承担了应用启动和参数解析的职责。这个结构在二次开发时很方便,想换掉存储层只需要新增一个模块,实现 API 模块里定义好的接口即可。
2.3 技术选型:Java 8 + JNI + 通用抓包库 + 自研解析器
packet-pro 的技术栈选得比较克制,没有引入重量级框架。主体是 Java 8 的 NIO 和并发包,网络库方面通过 JNI 调底层抓包能力,协议解析部分完全没有依赖现成的协议解析框架,而是自己用 ByteBuffer 和位运算写的。这种选型在初期会辛苦一点,但带来的控制力非常强,对想研究协议格式的人来说反而有价值。
选 Java 8 而不是更高版本,主要是考虑兼容性和内存模型熟悉度;JNI 部分用标准的 JNI 规范去桥接底层抓包库,没有用 JNA 或 JNR,是为了在高速抓包场景下减少一次方法调用的开销。抓包线程和业务线程之间用一个有界队列缓冲,避免业务处理抖动反过来阻塞抓包。PCAP 文件格式是网络分析领域的通用格式,自研写文件模块完全对标 PCAP 的文件头和包记录头,这样落盘之后的文件可以被 Wireshark 直接打开。
抓包库原生线程 → JNI 回调 → 有界队列 → 业务分发线程 → 处理器这个链路里的有界队列很关键。抓包线程从网卡上读数据,只负责把原始字节塞进队列就立即返回,后续的协议解析、统计、落盘全交给业务线程池处理。如果业务处理不过来,队列满了以后新旧数据的处理策略需要仔细设计,直接丢弃还是覆盖旧数据会影响抓包完整性,这属于需要按场景定制的策略点。
3. 核心模块拆解:从字节流中读出一个 TCP 会话
上一章讲了架构,这一章落到代码。packet-pro 最核心的部分是 JNI 抓包、协议解析和会话重组这三块,我把关键实现抽出来逐段讲。看完这一章,你应该能理解原始字节是怎么变成一个个带有协议结构的 Java 对象的。
3.1 JNI 抓包:C 语言回调 Java 方法
JNI 是 packet-pro 与底层抓包库之间的桥梁。在 C 语言侧,抓包库的每次回调都携带一个指向原始数据块的指针和数据长度;在 Java 侧,需要提前注册一个接收回调的方法,让 C 代码把这块内存拷贝进 Java 字节数组后回调过来。
public class NativeBridge { static { // 加载编译好的动态链接库,Windows 下是 packet-pro.dll,Linux 下是 libpacket-pro.so System.loadLibrary("packet-pro-native"); } // 由 native 代码调用的回调方法,必须有明确签名 private void onPacketArrive(byte[] packetData, int len) { // 这里拿到的是重建后的 Java 字节数组,交给后续解析线程 dispatcher.submit(packetData, len); } // 启动抓包循环,阻塞当前线程直到调用 stopCapture public native void startCapture(String interfaceName, String filterExpr); public native void stopCapture(); }onPacketArrive是 C 代码回调 Java 的入口,每次抓到包都会调用一次。注意入参里的len而不是直接用packetData.length,这是因为回调里可能为了复用缓冲区而传入一个容量更大的数组,实际有效字节数必须靠len来约束。我一般会让 C 侧直接为每个包新建数组并拷贝,虽然多一次内存拷贝,但避免了缓冲区复用的复杂生命周期。
3.2 以太网帧解析器:位运算与 ByteBuffer 的配合
解析器是 packet-pro 里最容易上手琢磨的部分。以太网帧头的固定结构是 14 字节 —— 6 字节目的 MAC、6 字节源 MAC、2 字节上层协议类型。数据部分从第 14 字节开始,数据链路层解析结束后,根据 EtherType 决定下一步交给 IPv4 还是 IPv6 解析器。
public class EthernetFrameParser implements FrameParser { @Override public Frame parse(byte[] data, int offset, int len) { // 帧头固定 14 字节,不足则直接标记为截断包 if (len < 14) { return new MalformedFrame(len, "frame too short"); } ByteBuffer buf = ByteBuffer.wrap(data, offset, len).order(ByteOrder.BIG_ENDIAN); byte[] dstMac = new byte[6]; byte[] srcMac = new byte[6]; buf.get(dstMac); buf.get(srcMac); // EtherType 是两字节无符号整数,常见值 0x0800 为 IPv4,0x86DD 为 IPv6 int etherType = buf.getShort() & 0xFFFF; Frame body; if (etherType == 0x0800) { body = new Ipv4Parser().parse(data, offset + 14, len - 14); } else if (etherType == 0x0806) { body = new ArpParser().parse(data, offset + 14, len - 14); } else { body = new UnknownPayload(etherType, buf.slice()); } return new EthernetFrame(dstMac, srcMac, etherType, body); } }这段代码有几点值得注意。ByteOrder.BIG_ENDIAN是网络字节序的标准,以太网头部的多字节字段都是大端排列,这里设错会导致 MAC 地址顺序错乱。解析完头部之后,offset + 14是关键参数,它确保后续解析层不会重复读取帧头。etherType要按无符号数处理,因为getShort()返回的是有符号数,0x0800 不会遇到问题,但 0x86DD 之类的值用& 0xFFFF处理最稳妥。
3.3 TCP 解析与伪校验和计算:字节序里的坑
IP 层和 TCP 层解析的逻辑结构相似,都是先读固定头再分发给上层。TCP 头的复杂度在于它的报头长度是可变长的,由数据偏移字段决定。另一个必须注意的点是伪校验和,因为 TCP 校验和的范围包含了 IP 头里的源地址、目的地址和协议号,无法在 TCP 解析器里独立完成。
public class TcpParser implements PacketParser<TcpPacket> { @Override public TcpPacket parse(byte[] data, int offset, int len) { if (len < 20) { return new MalformedTcpPacket(len, "tcp header too short"); } ByteBuffer buf = ByteBuffer.wrap(data, offset, len).order(ByteOrder.BIG_ENDIAN); int srcPort = buf.getShort() & 0xFFFF; int dstPort = buf.getShort() & 0xFFFF; long seq = buf.getInt() & 0xFFFFFFFFL; long ack = buf.getInt() & 0xFFFFFFFFL; // 数据偏移:高 4 位表示 TCP 头长度(单位 4 字节),低 4 位是保留位 int dataOffset = (buf.get() >> 4) & 0x0F; int headerLength = dataOffset * 4; if (headerLength > len) { return new MalformedTcpPacket(len, "tcp header length out of range"); } byte[] payload = new byte[len - headerLength]; System.arraycopy(data, offset + headerLength, payload, 0, payload.length); return new TcpPacket(srcPort, dstPort, seq, ack, headerLength, payload); } }TCP 头解析里的两个位数处理经常让人栽跟头。seq和ack是无符号 32 位整数,Java 的int装不下,必须用& 0xFFFFFFFFL转成long,否则超过 2GB 的序列号会变成负数。dataOffset的单位是 4 字节,所以实际头长度要乘以 4,这个字节移位和掩码的操作看起来简单,但边界写错会产生非常隐蔽的解析错乱。
3.4 会话重组:把零散 IP 分片还原成完整 TCP 流
抓包抓到的常常是乱序和分片的包,要做应用层分析必须把同一 TCP 会话的包重组起来。packet-pro 用一个以连接四元组为 key 的哈希表维护会话状态,每来一个包就查找或新建会话,然后把载荷按照序列号放入有序缓冲区。
public class TcpSessionTable { private final Map<SessionKey, TcpSession> sessions = new ConcurrentHashMap<>(); public void handle(TcpPacket packet, IPv4Packet ip) { SessionKey key = SessionKey.of( ip.getSrcAddress(), packet.getSrcPort(), ip.getDstAddress(), packet.getDstPort() ); TcpSession session = sessions.computeIfAbsent(key, k -> new TcpSession(key)); session.append(packet.getSeq(), packet.getPayload()); // 触发上层业务回调 if (session.isComplete()) { session.emitAppPayload(); } } }这里没有用全局锁,而是利用ConcurrentHashMap的原子能力按连接维度并发处理。session.append内部会根据序列号检查是否出现序列号回绕的情况,处理回绕的方式是按 32 位无符号整数比较序列号差值,而不是直接用普通整数的加减。这一块是网络协议栈的经典难点,代码量不大但逻辑非常绕。
4. 构建、运行与验证:在 Windows 和 Linux 上跑起来
看完核心代码,这一章解决一个务实的问题:怎么把这个项目在本地跑起来。重点放在构建步骤、原生库加载原理和启动参数上,再给一个用 Wireshark 验证抓包结果的完整流程。
4.1 Maven 构建与原生库加载顺序
packet-pro 的 Java 部分用标准 Maven 构建,但 JNI 层的动态链接库需要在编译后手动放到特定目录,或者在运行时通过java.library.path显式指定。第一次跑这个项目的人,百分之八九十的问题出在原生库加载阶段。
# 构建 Java 模块 mvn clean package -DskipTests # 将编译产出的 JNI 动态库放到项目的 libs 目录下 cp native/libpacket-pro-native.so libs/ # 启动时强制指定 native 库搜索路径 java -Djava.library.path=./libs -jar packet-pro-bootstrap/target/packet-pro-bootstrap.jarSystem.loadLibrary在运行时查找动态库的路径顺序是先看java.library.path,然后看 JVM 的默认路径。如果直接双击 jar 运行而没设置-Djava.library.path,大概率会抛UnsatisfiedLinkError。排查这个错误时先确认路径拼写,再确认动态库确实是针对当前系统架构编译的,x86_64 的库在 aarch64 上永远加载不起来。
4.2 启动参数与过滤规则
packet-pro-bootstrap 模块接收一组命令行参数,指定要监听的网卡、抓包过滤规则、输出文件路径等。这些参数和 tcpdump 的过滤语法非常接近,如果之前用过 tcpdump,上手成本很低。
java -Djava.library.path=./libs -jar packet-pro-bootstrap.jar \ --interface eth0 \ --filter "tcp port 8080 and host 192.168.10.20" \ --output /tmp/capture.pcap--interface指定物理网卡名称,Linux 下用ip link查看,Windows 下通常是\\Device\\NPF_{GUID}格式。--filter的语法遵从抓包库的 BPF 过滤规则,tcp port 8080表示只抓 TCP 且端口为 8080 的报文,host关键字限定源或目的地址。注意这里有个容易混淆的地方:BPF 的and是关键字,不要写成&&,在 shell 里&&会被解析成命令连接符。
4.3 验证抓包结果:文件头与包记录对照 Wireshark
跑起来之后,怎么确认框架真的正常工作?最直观的办法是把生成的 PCAP 文件用 Wireshark 打开,同时用项目的统计模块对着协议树做交叉验证。
# 在另一个终端制造 HTTP 流量 curl http://192.168.10.20:8080/health # 停止抓包后,用 capinfo 工具查看 pcap 文件里的包数量与链路类型 capinfos /tmp/capture.pcap如果 Wireshark 能正常解析出TCP、HTTP等层级,说明抓包和解析链路基本正常。如果 Wireshark 报Unknown encapsulation,问题几乎都在 PCAP 写入模块的链路类型字段上:以太网帧对应的链路类型是 1(LINKTYPE_ETHERNET),如果写成了 0(LINKTYPE_NULL),Wireshark 会把以太网帧头当成系统状态头解析,整个包结构全乱掉。
5. 避坑指南:丢包、线程阻塞与 JNI 崩溃的排查记录
跑这种偏底层的项目,遇到问题基本靠经验一点点摸。我把自己在 packet-pro 使用和调试过程中踩过最深的几个坑整理出来,每一条都是真实发生过的现象,照着排查能省不少时间。
5.1 抓包结果出现大量丢包
现象:在千兆网卡上抓流量,小包场景下抓到的包数远低于网卡实际转发的包数,统计结果偏差很大。原因:抓包线程回调进 Java 方法后,在业务侧做了字节数组拷贝和对象创建,然后直接在线程回调里执行了文件写入操作,导致回调本身成为瓶颈。解决:把抓包回调和数据写文件拆成两个线程,中间用无锁环形队列传递byte[]引用,避免在抓包线程里做任何 IO 或锁等待。调整之后的丢包率明显下降,验证方法是打印网卡驱动层的丢包计数和抓包库的统计值做差值对比。
5.2 JNI 回调导致 JVM 崩溃
现象:程序运行一段时间后直接 Segment Fault,崩溃日志里没有任何 Java 异常,hs_err 文件里指向onPacketArrive附近的 native 代码。原因:C 侧在回调 Java 方法之前必须通过GetPrimitiveArrayCritical或AttachCurrentThread正确绑定当前线程到 JVM,如果抓包库使用了独立线程而这一步没做对,JVM 直接崩溃。解决:在 native 层初始化时先保存 JavaVM 指针,每次回调前检查线程是否已附加,是则直接进入,否则先附加再回调。从那以后我每次改 JNI 层的线程模型,都会强制走一遍这个校验流程。
5.3 过滤语法生效但抓到的包数量异常
现象:配置了tcp port 8080,启动后确实只抓到了 8080 端口的包,但端口明明是 8081 的流量也会在统计中出现少数。原因:过滤是在数据链路层解析之后而非应用层做的,如果环境里存在 VLAN 标签,过滤规则会因字节偏移错乱而部分失效。解决:确认网卡上是否启用了 VLAN offload,如果是,在抓包设置里打开 VLAN 标签解析选项,让过滤引擎首先识别 802.1Q 头。这个问题在虚拟机环境里尤其常见,物理机反而出现得少。
5.4 连接正常但会话重组一直不触发超时
现象:抓包能抓到完整的 TCP 三次握手和挥手包,但会话重组的回调就是迟迟不触发。原因:会话复制模式配置过于保守,TCP 连接在超过配置的闲置时间后仍驻留在内存中,占用的内存越来越大,新连接反而被挤掉。解决:为会话表增加基于最后活跃时间的淘汰器,超过闲置时间就主动清理并回调业务层处理半关闭状态的数据。这里还涉及序列号回绕的边界处理,一般把判断条件设成相对序号大于某个阈值就强制触发一次部分重组,能解决问题八成以上的不触发场景。
5.5 PCAP 文件可正常写入但字节序反了
现象:Wireshark 能打开文件,但每个包的时间戳和长度字段数值巨大,看上去完全不可信。原因:PCAP 文件头里的magic_number字段写成了小端值,Wireshark 按大端去解析,整个文件的所有数值字段都被反向解读。解决:严格按照标准定义写入0xa1b2c3d4,并让读端根据实际收到的 magic 字节逆推字节序,类似DataInputStream的readInt后再做字节反转。这个坑如果不遇到 Wireshark 解析结果的异常,单看二进制很难发现。
6. 二次开发:在 packet-pro 上扩展自定义协议解析器
packet-pro 留给二次开发的接口很清楚,核心在 API 模块的PacketParser和Handler两个接口上。想支持一个自定义的应用层协议,典型做法是新增一个 parser 类,在里面处理载荷的协议头和数据分帧,然后在分发器里注册对应的 handler。
public class MyProtocolParser implements PacketParser<MyPacket> { @Override public MyPacket parse(byte[] data, int offset, int len) { // 自定义协议头前 4 字节通常是魔数,用于校验合法性 ByteBuffer buf = ByteBuffer.wrap(data, offset, len).order(ByteOrder.BIG_ENDIAN); short magic = buf.getShort(); if (magic != 0x4D50) { return null; } short bodyLen = buf.getShort(); byte[] body = new byte[bodyLen]; buf.get(body); return new MyPacket(body); } }注册方式是在初始化时把新的 parser 挂在 TcpParser 的 payload 解析器链上,按端口号或按载荷特征路由。如果协议本身具备明确的特征字节,建议优先用特征字节匹配而不是端口号,因为端口号在 NAT 和代理场景下不可靠。扩展完 parser 之后,存储层可以复用现有的 PcapFileHandler,把自定义协议的数据原样写入 PCAP 扩展字段。
我自己的习惯是在动手改代码之前,先写一个由模拟抓包文件驱动的单元测试,用协议构造工具生成特定报文序列,直接喂给解析器验证输出结构是否正确。这样能避免每次都到真实网卡上反复抓包调试,调试效率会高很多。另外在做协议扩展时,尽量让新增的解析器保持无状态设计,把会话状态全部交给会话重组层管理,这样既容易写单元测试,也方便后续把会话状态换成 Redis 这类外部存储做分布式扩展。
那之后我对 JNI 回调线程、原生库加载这些偏底层的部分都多留了个心眼,每次改代码都会先把线程模型和字节序这两个最容易出问题的地方在文档里单独标记出来,再进行开发。希望这些记录能帮你在 packet-pro 上少踩几个坑,顺利跑通自己的抓包分析需求。
本文还有配套的精品资源,点击获取