简介:基于Npcap与Qt框架组成的网络抓包工具完整工程,外观与操作方式模仿Wireshark,适合需要以编程方式捕获、过滤并解析网络数据包的开发者,也是学习Windows底层网络编程、Qt图形界面开发以及系统调用设计的良好范例。压缩包共包含86个文件,整体大小约2.48MB,覆盖C++源码、Qt工程配置、Visual Studio构建文件,以及生成的exe、pdb、obj、日志等产物,同时还带有txt说明与png图标资源,源码、资源、构建脚本和运行程序齐全,方便直接打开工程查看效果并继续二次开发。到目前为止,已有379人学习下载。工程将npcap库调用、协议数据解析、Qt界面交互结合到一起,完整实现了类似Wireshark的抓包列表、过滤与详细分析功能,并保留了项目目录结构和界面布局资源;从环境配置、依赖引入,到界面绘制与数据包展示,整个流程体现了网络编程、系统编程、GUI编程的综合运用,对于准备深入研究网络工具实现或需要定制抓包功能的开发者,具有明确的借鉴意义和实用价值。
1. 仿Wireshark的Qt抓包工具:从哪来,能解决什么
我第一次拆这个项目,是想给内部运维工具加一个流量诊断入口,又不想每次排查都要切出去开一遍Wireshark。基于npcap+Qt开发的sniffer,本质上是把Wireshark最核心的那条链路拆了出来:底层抓包、逐层解析、包列表与协议树联动显示。它不是重做Wireshark,而是给你一套能跑、能改、能集成进自己系统的骨架源码。适合这么几类人:想把抓包能力嵌进现有Qt程序里的开发者,想看懂数据包从网卡到UI完整路径的学生,以及被Wireshark那套复杂脚本体系劝退、想要干净C++代码的人。我花了两天把它跑通,结论是:底子很扎实,但坑也比想象中多。这篇就把编译、核心代码、踩坑和封装思路一次讲清楚。
2. 从npcap到数据帧:抓包链路与模块边界
2.1 为什么选npcap而不是WinPcap
先把选型这件事说透。早期Windows平台做抓包基本绕不开WinPcap,但这东西已经停更多年,在Win10/11上偶尔会出现驱动装不上、蓝屏、以及跟杀毒软件打架的问题。npcap是它的现代替代品,API层面基本兼容libpcap/pcap风格,函数名和调用方式几乎可以无缝迁移。
npcap比WinPcap多了几个很实际的能力:支持Loopback回环流量抓取、支持802.11无线帧的原始捕获、对Win10/11的适配更完整,还默认支持将抓包限制在管理员权限下运行。对这类仿Wireshark工具来说,回环流量支持尤其重要——你要调试本机进程间的通信时,抓不到127.0.0.1的包会非常痛苦。
整个抓包链路可以概括如下:npcap驱动从网卡拿到原始帧,通过pcap API交给上层应用,应用再按以太网、IP、TCP/UDP逐层剥开。sniffer的代码结构基本是按这条链路组织的:设备枚举、打开网卡、丢进抓包循环、回调里解析、解析结果塞进界面模型。理解这条链路之后,你会发现自己改起来完全不用从头看Wireshark源码。
提示:装npcap时建议勾选“Support loopback traffic”和“Admin-only”,前者是为了回环抓包,后者能避免驱动被普通进程滥用。
2.2 抓包工具的整体模块划分
打开sniffer源码后,先别从头一行行读。我按功能把代码切成四块来看,效率会高很多。
| 模块 | 对应功能 | 关键依赖 |
|---|---|---|
| 采集层 | 枚举网卡、打开设备、循环抓包 | npcap(pcap_findalldevs、pcap_open_live、pcap_next_ex) |
| 解析层 | 以太网/ARP/IP/TCP/UDP头部解析 | 纯C++手工解析,无第三方库 |
| 存储层 | 将原始包写入pcap文件 | pcap_dump_open、pcap_dump |
| 界面层 | 包列表、协议树、十六进制内容窗格 | Qt Widgets(QTreeView、QStandardItemModel) |
采集层负责从系统拿到一块原始数据,也就是内存里的一段字节流。这段字节流里没有“这是IP包”的语义,解析层要自己判断以太网类型字段是0x0800(IPv4)还是0x0806(ARP),然后决定按哪种结构去读。界面层则扮演消费方,把解析出来的字段映射到表格行和树上。
2.3 数据包解析与存储的默认策略
这个项目的解析策略是先做单包解析,不做TCP流重组。这一点很关键,因为Wireshark的“流重组”功能其实非常重,需要维护每个五元组的双向缓冲、序列号整理、超时回收,单独做可能要写几千行。
sniffer走的是一条务实路线:每个包独立解析、独立显示,把IP分片和TCP流重组留作扩展点。实际排查问题时,单包视图已经能覆盖大部分场景,比如看握手包、看HTTP请求头、看DNS查询响应。
存储方面用pcap_dump_open + pcap_dump写文件,这样抓下来的文件可以直接拖进Wireshark或tshark回放。有些新手会自己拼一个文件头往磁盘写,特别容易在字节序和magic number上翻车,直接用pcap库的转储接口明显更稳妥。
3. 跑通sniffer工程:编译环境与核心代码解析
3.1 环境准备与依赖版本
先明确一下需要准备的环境。sniffer.sln是Visual Studio解决方案,代码用的是C++,不是Java;摘要里提到的Java标签大概率指的是通过JNI调用npcap这种跨语言思路,但这份源码本身是标准C++工程。如果你打算用Java重写一遍,npcap的DLL得走JNI封装,那是另一套工程,后面第6章再展开说。
我使用的环境组合如下:
| 组件 | 建议版本 |
|---|---|
| Visual Studio | VS2022,勾选“使用C++的桌面开发” |
| Qt | Qt 5.15.2 MSVC 2019 64位 |
| Qt VS Tools | 2.4.3及以上,用于打开.pro或直接加载.sln |
| Npcap SDK | 从npcap官网下载SDK包 |
| 操作系统 | Windows 10/11 x64 |
打开sniffer.sln后第一件事是检查解决方案平台。默认可能是Win32,但项目正文里明确出现了x64目录,说明工程是按64位构建的。建议直接切换到x64,不然链接wpcap.lib时十有八九会报架构不匹配。
3.2 引入npcap:头文件、库与运行库放置
npcap SDK目录下有Include和Lib两个文件夹。在VS工程属性里,需要把Include目录加进“附加包含目录”,Lib/x64加进“附加库目录”。这里有一个容易忽略的点:npcap的导入库文件名是wpcap.lib和Packet.lib,其中wpcap.lib对应主抓包API,Packet.lib对应内核驱动的底层扩展接口。sniffer主要用到前者。
#include <pcap.h> #pragma comment(lib, "wpcap.lib") #pragma comment(lib, "Packet.lib")很多编译失败都是因为只加了wpcap.lib而漏了Packet.lib,导致出现一堆无法解析的外部符号。如果你在工程里看到报错像unresolved external symbol _PacketOpenAdapter,别怀疑,就是少链了后者。另外,npcap SDK的头文件依赖Windows SDK,所以Windows SDK的包含路径不能去掉。
运行时还需要npcap.dll。装过npcap系统服务的话,DLL已经在System32里;如果是免安装模式,需要把npcap-sdk/Lib/x64下的DLL复制到exe同目录,否则启动时直接报“找不到wpcap.dll”弹窗。
3.3 打开网卡并进入抓包循环
抓包主流程的代码结构比较固定,我建议直接按这个模板走。需要注意的是抓包必须在一个独立线程里跑,不能在GUI线程里阻塞,否则界面会卡死。
pcap_if_t* alldevs; char errbuf[PCAP_ERRBUF_SIZE]; if (pcap_findalldevs(&alldevs, errbuf) == -1) { qDebug() << "枚举设备失败:" << errbuf; return; } pcap_t* handle = pcap_open_live( deviceName, // 设备名,如 "\\Device\\NPF_{xxx}" 65535, // snaplen,捕获每个包的最大字节数 1, // promisc为1表示混杂模式 1000, // 读取超时,单位毫秒 errbuf ); if (!handle) { qDebug() << "打开设备失败:" << errbuf; return; } struct pcap_pkthdr* header; const u_char* pkt_data; while (running) { int ret = pcap_next_ex(handle, &header, &pkt_data); if (ret == 1) { // 拿到一个完整数据包,交给解析函数 parsePacket(pkt_data, header->len, header->ts); } else if (ret == 0) { // 超时,继续循环 QThread::msleep(1); } else { qDebug() << "抓包错误:" << pcap_geterr(handle); break; } } pcap_close(handle);几个参数值得单独解释。snaplen设置65535是标准做法,表示保存整个帧不截断;如果你只关心头部,也可以设成128来省内存。promisc设为1是把网卡置为混杂模式,能收到非本机目的地址的帧;不过需要管理员权限,普通用户模式下打开会失败。to_ms设1000毫秒,表示缓冲区超过这个时间即使没满也交上来,太小会频繁唤醒线程,太大则实时性变差。
pcap_next_ex的启动时也可以通过pcap_setdirection(handle, PCAP_D_INOUT)限制只抓某个方向。sniffer默认双向都抓,但你要是做HTTP监控,通常只关心入站和出站两个方向,这个函数值得在代码里留个开关。
3.4 数据包头部解析的一个最小实现
拿到原始字节流之后,解析层的任务就是按协议规则逐层剥皮。最常写的解析代码是:先看以太网帧头,再判断网络层类型,接着进传输层。下面是sniffer里我梳理出来的最小解析逻辑。
#pragma pack(push, 1) struct EthernetHeader { u_char destMac[6]; u_char srcMac[6]; u_short etherType; // 0x0800=IPv4, 0x0806=ARP }; struct IPv4Header { u_char verIhl; // 高4位版本,低4位头部长度(单位4字节) u_char tos; u_short totalLen; u_short id; u_short fragOffset; u_char ttl; u_char protocol; // 6=TCP, 17=UDP, 1=ICMP u_short checksum; u_char srcAddr[4]; u_char destAddr[4]; }; struct TCPHeader { u_short srcPort; u_short destPort; u_int seq; u_int ack; u_char offsetReserved; u_char flags; u_short window; }; #pragma pack(pop) void parsePacket(const u_char* data, int len, struct timeval ts) { if (len < sizeof(EthernetHeader)) return; const EthernetHeader* eth = reinterpret_cast<const EthernetHeader*>(data); if (ntohs(eth->etherType) == 0x0800 && len >= sizeof(EthernetHeader) + sizeof(IPv4Header)) { const IPv4Header* ip = reinterpret_cast<const IPv4Header*>(data + sizeof(EthernetHeader)); if (ip->protocol == 6) { const TCPHeader* tcp = reinterpret_cast<const TCPHeader*>( data + sizeof(EthernetHeader) + (ip->verIhl & 0x0F) * 4); emit packetReady(eth, ip, tcp, ts); } } }我加#pragma pack(push, 1)是因为网络协议头部是紧凑排列的,不能有对齐填充;如果去掉pack,编译器可能在结构体字段间塞padding,解析结果直接错位。verIhl & 0x0F拿到的是IP头长度,单位是4字节,所以要乘4才是偏移量。
字节序的坑也藏在这里。以太网类型、端口、长度字段在网络字节序(大端)里传输,x86机器是主机字节序(小端),必须先调用ntohs转换。很多新手解析出IP长度为0或者端口号是16384,十有八九就是漏了这一步。
4. 界面复刻:协议树、过滤框与包列表的Qt实现
4.1 包列表与协议树的联动设计
Wireshark的界面三件套是包列表、协议树和Hex窗格。sniffer的界面层用Qt Widgets实现,核心是把解析结果映射到QStandardItemModel,再显示到QTableView上。
包列表我建议用QTableView而不是QTreeView,因为数据包是平坦的一行行记录,除非你要做TCP流分组,否则树形展示没有意义。协议树则需要QTreeView,因为协议字段天然是分层的——以太网头下面挂着IP头,IP头下面挂着TCP头。
联动逻辑是:点击包列表某一行,触发currentRowChanged信号,然后重新解析这个包并填充协议树。这里有个性能细节:点击一次同步重新解析没有问题,但全选或者滚动时会频繁触发,可以在信号处理函数里加一个isProcessing标志位去抖动。sniffer里这部分的默认实现不算复杂,我一般会在内部维护一个QVector<PacketRecord>缓存解析结果,避免重复解析。
一个值得调整的位置是列宽策略。Wireshark默认是“时间、源地址、目的地址、协议、长度、信息”六列。sniffer复刻时至少要把源和目的IP拆成两列,别合成一个长字符串,否则排序和过滤都麻烦。
4.2 BPF过滤表达式的接入
这里要接入的是Wireshark同款的BPF语法,也就是在过滤框里输入tcp port 443这类表达式。npcap直接提供了编译和生效的API,不用自己写解析器。
struct bpf_program fcode; QByteArray filterBytes = ui->filterEdit->text().toUtf8(); if (pcap_compile(handle, &fcode, filterBytes.constData(), 1, PCAP_NETMASK_UNKNOWN) == 0) { if (pcap_setfilter(handle, &fcode) == 0) { qDebug() << "过滤生效:" << filterBytes; } pcap_freecode(&fcode); } else { qDebug() << "过滤表达式非法:" << pcap_geterr(handle); }pcap_compile的第四个参数是optimize,填1表示让库自动优化过滤逻辑;第五个netmask传PCAP_NETMASK_UNKNOWN是常规做法,如果传0,某些涉及广播地址的过滤条件会误判,就这一点老版本WinPcap和npcap行为还不一致,属于典型的玄学问题。
pcap_setfilter是运行时动态生效的,不用重新打开设备。如果你的逻辑是先抓一段时间再启用过滤,可以直接调这段代码。一个可用性细节:非法表达式时pcap_compile会返回-1,界面要弹出提示而不是静默失败,否则用户以为过滤生效了,实际还在抓全量包。
常见过滤表达式对照如下:
| 输入 | 效果 |
|---|---|
| tcp port 443 | 只抓TCP 443端口流量 |
| host 192.168.1.1 | 抓与该IP通信的包 |
| udp and port 53 | 只看DNS查询 |
| ip[6] == 0x11 | 等价于udp的字节码过滤 |
最后这种ip[6]写法是BPF的原始字节匹配,要求你清楚IP头偏移位置。普通场景用tcp/udp/host关键字就够了,字节码过滤更适合做Wireshark教不出来的精细控制。
4.3 一个简易的端点统计视图
仿Wireshark的工具通常还带一个统计面板,显示各IP的包数、字节数和占比。这个功能实现起来不复杂,核心是维护一个QHash<QString, StatItem>。
struct StatItem { qint64 packets = 0; qint64 bytes = 0; QByteArray ipBytes; }; void addToStat(const QByteArray& srcIp, int packetLen) { StatItem& item = statMap[srcIp]; item.packets++; item.bytes += packetLen; } // UI线程每2秒刷新一次 void onRefreshTimer() { ui->statTable->setRowCount(statMap.size()); int row = 0; for (auto it = statMap.constBegin(); it != statMap.constEnd(); ++it, ++row) { ui->statTable->setItem(row, 0, new QTableWidgetItem(it.key())); ui->statTable->setItem(row, 1, new QTableWidgetItem(QString::number(it->packets))); ui->statTable->setItem(row, 2, new QTableWidgetItem(QString::number(it->bytes))); } }这里最关键的是刷新时机。抓包线程每来一个包就调用addToStat,但UI刷新不能每个包都触发,否则界面会肉眼可见地卡。我一般用QTimer设2秒刷一次,或者只在包数变化超过100时才刷新。统计Map过大会消耗内存,可以定期清理低于阈值的键,或者保留最近N个活跃IP。sniffer里这个统计功能不算重,但对排查“哪个IP在持续发包”这类问题很实用。
5. 避坑:npcap+Qt开发中的六个典型翻车点
5.1 抓包列表空白:管理员权限与npcap服务状态
现象:程序能正常启动,界面不报错,但包列表永远是空的。
原因:两种可能最多。一是没以管理员身份运行,npcap默认只允许管理员进程打开设备;二是npcap服务没有启动,驱动层没接管网卡。
解决:第一步,右键exe选“以管理员身份运行”,或者给VS的调试启动里加上管理员权限清单。第二步,命令行执行sc query npcap看服务状态,如果显示STOPPED,执行net start npcap。注意新版npcap在服务名上稍有变化,如果查不到npcap,先重装npcap SDK。
5.2 混杂模式打开失败
现象:pcap_open_live返回设备句柄,但设置混杂模式时返回权限错误。
原因:npcap的混杂模式仅对Administrators组成员开放,普通用户被拒。
解决:在调用pcap_open_live时,先按普通模式打开(promisc传0),如果后续有切换混杂的需求,再通过pcap_setdirection或重新打开设备。对于仿Wireshark这类工具,开发期最好直接用管理员权限跑,否则排查权限问题比写代码还耗时间。真实部署时可以做成开关,默认关混杂,按需开启。
5.3 界面卡死:在GUI线程里跑pcap_next_ex
现象:抓包按钮点下去,界面立刻假死,点哪里都没反应。
原因:把pcap_next_ex的循环直接写在了mainwindow的按钮槽函数里,这个循环是阻塞式的,GUI事件循环被卡死。
解决:把抓包循环放进QThread子类或QtConcurrent。sniffer里正确的做法是线程里跑pcap_next_ex,通过信号把封装好的数据包对象传给UI线程。这个“抓包线程+GUI线程”的分离是基本架构,别偷懒。
注意:即使pcap_next_ex的超时设为1秒,它仍然是同步阻塞API;不能在QTimer回调里跑。
5.4 x64工程引用了x86的lib
现象:链接时报LNK2038 不匹配或Mismatch detected for RuntimeLibrary,代码明明没问题。
原因:IDE默认或者某些教程用了Win32平台,而wpcap.lib和Qt库是64位版本。
解决:解决里平台切到x64后重新生成。同时检查Qt的编译器版本是不是MSVC64,如果Qt安装的是MinGW版本,而VS用的是MSVC编译器,ABI不兼容也链接不上,那是一个更大的坑。
5.5 网卡明明在列表里却打不开
现象:pcap_findalldevs枚举出来一个设备,传入pcap_open_live后返回“无法打开”,并且设备列表里有大量名称奇怪的项。
原因:设备名里带有{GUID}等大括号,可能是蓝牙网络适配器、虚拟网卡,或者已失效的NDIS设备。普通进程没有权限打开一部分虚拟网卡。
解决:设备枚举时过滤掉描述为空或名称含“bluetooth”“virtual”的项,只保留真实的以太网和无线网卡。必要时pcap_open_live之前先调用pcap_lookupdev确认默认设备可用。我把这个过滤条件总结成一条经验:展示给用户的设备列表只留PCAP_IF_UP且PCAP_IF_RUNNING的接口,状态不正常的直接置灰。
5.6 退出时直接崩溃或卡住
现象:关闭窗口时程序偶发崩溃,或者卡在结束界面好几秒。
原因:抓包线程还在pcap_next_ex里阻塞着,主窗口先销毁了,线程访问到已释放的界面指针;或者线程没有收到停止信号就被强制结束。
解决:mainwindow的析构函数里要先置running标志为false,然后调用pcap_breakloop(handle)强制让pcap_next_ex返回-1,再等待线程退出。这里不建议用terminate强行杀线程,会造成资源泄漏甚至驱动句柄没有释放。正确的退出顺序是:置标志→breakloop→wait线程退出→pcap_close→销毁界面。
6. 进阶:把sniffer封装成可复用的抓包模块
6.1 独立线程封装:SnifferWorker
把抓包逻辑从mainwindow里抽出来,做一个独立的SnifferWorker类,是复用的第一步。这样无论你是用Qt Widgets还是QML,都能挂载它。
class SnifferWorker : public QThread { Q_OBJECT public: void setDevice(QString dev) { deviceName = dev; } void stop() { running = false; pcap_breakloop(handle); } signals: void packetCaptured(PacketRecord record); void errorOccurred(QString msg); protected: void run() override { handle = pcap_open_live(deviceName.toStdString().c_str(), 65535, 1, 1000, errbuf); while (running) { int ret = pcap_next_ex(handle, &header, &pkt_data); if (ret == 1) { PacketRecord rec = parseToRecord(pkt_data, header->len); emit packetCaptured(rec); } } pcap_close(handle); } };这个封装里有一个不错的细节:emit packetCaptured(rec)传的是值类型PacketRecord,不是指针。这样UI线程拿到的是拷贝,就算抓包线程继续跑也不会出现悬垂指针。PacketRecord内部用QByteArray保存原始字节和关键字段,信号队列自动把数据从抓包线程跨线程搬到UI线程,代码干净且安全。
需要支持启动参数时,可以再加一个setFilter(QString)方法,内部调用pcap_compile和pcap_setfilter。这种设计下,sniffer就从一个“单窗口工具”变成了一个可以嵌入任意Qt应用的抓包组件。
6.2 让转储文件可以被Wireshark后续回放
调试抓包模块时,我一般会顺手把原始数据写进pcap文件。这样可以在不重新抓包的情况下回放同一段流量,验证解析逻辑是否正确。
pcap_dumper_t* dumper = pcap_dump_open(handle, "capture.pcap"); if (dumper) { // 在pcap_next_ex返回包后调用 pcap_dump((u_char*)dumper, header, pkt_data); } // 停止时释放 pcap_dump_close(dumper);写完文件后用一条命令就能确认格式无误:
tshark -r capture.pcap | head如果tshark能正常解析出协议列表,说明文件格式没问题;如果报错或显示“Unrecognized”,说明写入时机或文件头有问题。这里有一个血泪经验:pcap_dump_open必须在pcap_open_live之后调用,并且沿用同一个handle,因为文件头里的链路层类型和快照长度是从设备句柄读取的;如果用另一个句柄写,抓的是以太网却声明成别的链路类型,回放时Wireshark会乱认协议。
另一个小技巧是双开抓包:一边在sniffer里做界面实时分析,一边写pcap文件存档。这样既能看到实时流量,也能把原始证据留档,排查问题、事后对比都方便。从那以后我每次抓包都会强制走一遍“存pcap文件+tshark验证”的流程,确认转储没问题再继续调试界面层。希望帮到你。
本文还有配套的精品资源,点击获取