简介:一份基于QT5与WinPcap实现、功能对标WireShark的网络抓包程序源码包,面向网络协议分析学习者、Windows平台C++开发者及需要快速搭建抓包工具原型的工程师。程序整体仿照WireShark的界面与交互,覆盖数据包捕获、过滤、解析和统计等常见需求,可帮助理解网络数据在网卡层面的流动以及WinPcap API与QT界面如何协同工作。资源共392个文件,压缩包约8.15MB,以C/C++源码、头文件、工程配置(vcproj/dsp/sln)及HTML说明为主,另含UI界面、图标资源与静态库文件,目录结构清晰,便于按模块研读。目前已有197人学习浏览。解压后可按数据包捕获、过滤、解析、展示的链路梳理代码,学习WinPcap底层API的调用方式与Qt事件驱动的界面刷新机制;同时抓包/发包测试程序可帮助快速验证网络数据收发效果,适合作为网络原理实验、毕业设计或协议分析工具二次开发的实战参考。
1. 仿 WireShark 的 QT5 + WinPcap 抓包程序,到底解决什么问题
把“网络抓包程序、仿 WireShark、基于 QT5 和 WinPcap 实现”这个标题拆开看,它要回答的其实是一个很实际的问题:不装完整版 Wireshark,自己用 QT5 画一套界面、再用 WinPcap 拿到底层报文,能不能做出一个能用的轻量抓包工具?答案是能,而且这套组合至今仍是 Windows 平台上做抓包工具最稳的路线之一。它适合两类人:一类是课程设计或毕业设计需要“从零写一个抓包器”的学生,另一类是在内网环境里想要定制抓包逻辑、又不想被 Wireshark 的庞大插件体系拖住的工程师。看完这篇文章,你会明白 WinPcap 这一层到底提供了什么、QT5 界面和抓包线程之间怎么协作,以及真正把包抓上来之后,从原始字节到界面表格之间那几步解析是怎么串起来的。
2. 先搭 QT5 + WinPcap 环境:驱动选型、SDK 目录与 .pro 配置
2.1 WinPcap 还是 NPcap:这个时代怎么选驱动
标题写的是 WinPcap,但你真正去下载的时候会发现在 Windows 10 和 Windows 11 上,官方 WinPcap 安装包经常装不进去,或者装完驱动签名报错。这里要先说明一个背景:WinPcap 的底层基于 libpcap,它定义了一套 BPF 抓包接口,而 NPcap 是它的直接继任者,接口完全兼容,但驱动支持了新版 Windows 的 NDIS 6 和 802.11 无线网卡监控模式。也就是说,如果你的代码按 WinPcap 的 API 写,那么在 NPcap 环境下编译运行基本不用改代码。
从工程角度,我的建议是:代码按 WinPcap API 写,编译和运行环境直接用 NPcap SDK。这样标题里的“基于 WinPcap”没有变味,因为 API 层面就是 pcap_findalldevs、pcap_open_live 那一套;同时你也能绕开 WinPcap 在 Win10 以后驱动签名导致的安装失败问题。最典型的翻车现场就是:程序编译通过,一运行 pcap_findalldevs 返回 0 个设备,或者 pcap_open_live 直接返回 NULL,最后查半天发现是驱动没装上。遇到这种情况不用急着怀疑代码,先打开命令行用管理员身份运行 npcap 安装包,装完后去设备管理器里确认“NPF”相关的网络服务是否在运行。
2.2 配置 Qt 工程:.pro 文件的 INCLUDE 与 LIBS
环境准备好了之后,第一件事是让 Qt 工程能链接到 pcap 的库。你不需要把整个 Wireshark 的源码拖下来,只需要 SDK 里的 include 和 lib。SDK 解压后通常是一个文件夹,里面有 include 和 lib 两个目录,include 下放着 pcap.h、pcap-stats.h 这些头文件,lib 下是 wpcap.lib 和 Packet.lib。下面这份 .pro 配置是常见做法,路径按你自己的 SDK 位置改:
QT += core gui network greaterThan(QT_MAJOR_VERSION, 4): QT += widgets TARGET = mySniffer TEMPLATE = app CONFIG += c++11 CONFIG -= app_bundle # SDK 路径根据自己的实际目录修改 INCLUDEPATH += "C:/npcap-sdk/include" LIBS += -L"C:/npcap-sdk/lib" -lwpcap -lws2_32这段配置里,QT += network 是给后面可能用到的套接字功能预留的,抓包本身不依赖它;LIBS 里的 -lwpcap 是抓包核心库,-lws2_32 容易被忽略但非常重要。pcap_findalldevs 在 Windows 平台上要先调用 WSAStartup 初始化 Winsock,否则返回错误码 -1,而 ws2_32 就是支撑这个初始化的库。另外,如果你的 SDK 里只有 x64 版本的 lib,记得 Qt 编译器套件也要选成 64 位的 MSVC 或 MinGW,混用 32 位库和 64 位编译器会直接链接失败。链接报错的时候,优先检查这里,而不是去怀疑代码逻辑。
2.3 先写一个最小的设备枚举测试
配置好 .pro 之后,不要急着搭界面。先用一个控制台式的最小验证把链路打通,确认驱动和库都没有问题。下面这段代码可以放在 main.cpp 里临时测试:
#include <QCoreApplication> #include <pcap.h> #include <winsock2.h> #include <iostream> int main(int argc, char *argv[]) { QCoreApplication a(argc, argv); WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), &wsaData); // WinPcap 在 Windows 下必须初始化 Winsock pcap_if_t *alldevs = nullptr; char errbuf[PCAP_ERRBUF_SIZE] = {0}; int result = pcap_findalldevs(&alldevs, errbuf); if (result == -1) { std::cerr << "pcap_findalldevs error: " << errbuf << std::endl; return -1; } for (pcap_if_t *dev = alldevs; dev; dev = dev->next) { std::cout << "Device: " << dev->name << std::endl; if (dev->description) { std::cout << " Description: " << dev->description << std::endl; } } pcap_freealldevs(alldevs); return 0; }这段代码的作用是枚举本机所有网卡,输出设备名和描述。pcap_if_t 结构体里的 name 后面要传给 pcap_open_live 使用,注意它可能看起来不像一个正常的设备路径,而是一长串以 rpcap 或 \Device\NPF_ 开头的字符串,这属于正常现象。如果这里输出的设备列表是空的,或者结果等于 -1,说明驱动层有问题,先解决环境再往下一步走。不要一上来就写界面,等这一步能打印出网卡列表了,你对环境才有底。
3. 主窗口骨架:网卡列表、启停按钮和表格区的线程分工
3.1 为什么界面和抓包必须分线程
很多新手写抓包程序时,第一版会把 pcap_loop 直接放在主窗口的一个按钮响应函数里,结果点“开始抓包”之后界面立刻卡死,按钮按不动、窗口拖不动,最后只能强杀进程。原因很简单:pcap_loop 是一个阻塞调用,它会一直停留在回调函数里等待数据包,如果不把它放到独立线程,主线程的事件循环就完全被占住了。
所以工程结构上,我一般会把抓包线程单独抽出来,用 Qt 的信号槽机制把“抓到包了”这个事件发回主线程,由主线程负责刷新表格。这样抓包线程只做最核心的工作:从网卡上拿原始报文、做初步解析;主线程只做界面更新。这里有一个隐含的好处:即使抓包速率很高,界面刷新也不至于把抓包线程拖慢,两者之间的耦合被降到了最低。下面这个线程类的骨架是后面所有功能的基座。
3.2 用代码搭出主窗口:下拉框、按钮和表格区
主窗口布局不复杂,我用 QMainWindow 作为框架,中央部件放一个 QSplitter,左侧是网卡选择下拉框和启停按钮,右侧是 QTableWidget。网格线这个设置很难看,会让表格显得很碎,一般我会用 setShowGrid(false) 关掉,再配合 setAlternatingRowColors(true) 让行有底色间隔,看起来更像 Wireshark 的列表风格。
#include <QMainWindow> #include <QComboBox> #include <QPushButton> #include <QTableWidget> #include <QSplitter> #include <QVBoxLayout> class MainWindow : public QMainWindow { Q_OBJECT public: MainWindow(QWidget *parent = nullptr); private slots: void onStartClicked(); void onPacketArrived(quint64 timestamp, QString src, QString dst, QString proto, int len); private: QComboBox *deviceCombo; QPushButton *startBtn; QTableWidget *packetTable; };MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { auto *central = new QWidget(this); auto *layout = new QVBoxLayout(central); // 左侧控制区:网卡选择 + 启停按钮 auto *controlLayout = new QHBoxLayout; deviceCombo = new QComboBox(central); startBtn = new QPushButton(tr("开始抓包"), central); controlLayout->addWidget(deviceCombo, 1); controlLayout->addWidget(startBtn); // 右侧表格:序号、时间、源地址、目的地址、协议、长度 packetTable = new QTableWidget(central); packetTable->setColumnCount(6); packetTable->setHorizontalHeaderLabels( {"序号", "时间", "源地址", "目的地址", "协议", "长度"}); packetTable->setShowGrid(false); packetTable->setAlternatingRowColors(true); packetTable->horizontalHeader()->setStretchLastSection(true); layout->addLayout(controlLayout); layout->addWidget(packetTable, 1); setCentralWidget(central); connect(startBtn, &QPushButton::clicked, this, &MainWindow::onStartClicked); }这段代码把界面搭起来了,但有个关键动作还没做:网卡下拉框的内容必须从 pcap_findalldevs 的结果里填充。在构造函数里调用一个枚举函数,把每个设备的 description 作为下拉框显示文本、name 存到 itemData 里,后面抓包的时候直接取出来用。另外注意 connect 的写法,使用了新式信号槽语法,避免在槽函数里写字符串形式的 SIGNAL 宏。表格的列我选了 6 列,这是最精简的抓包表结构,Wireshark 默认列表也不外乎这几列加上 Info 一列,早期实现先不加 Info 可以少踩不少解析的坑。
3.3 抓包线程类的声明与启停控制
线程类的核心是要能从外部通知它停止,不能粗暴用 terminate()。pcap_loop 每抓到一个包会回调一次,在回调里检查一个标志位,为 false 时就调用 pcap_breakloop 来终止循环。
#include <QThread> #include <pcap.h> class SnifferThread : public QThread { Q_OBJECT public: SnifferThread(QString deviceName, QObject *parent = nullptr); ~SnifferThread() override; void stopSniffing(); signals: void packetCaptured(quint64 timestamp, QString src, QString dst, QString proto, int len); protected: void run() override; private: QString deviceName; pcap_t *handle; volatile bool running; };run 函数里要做的流程是 pcap_open_live 打开设备、pcap_loop 开始循环、结束时 pcap_close。stopSniffing 里把 running 置 false,并在回调里触发 pcap_breakloop。注意 running 用 volatile 修饰,是为了让回调线程能看到主线程的修改,防止编译器优化把它缓存到寄存器里。这种做法比在回调里调用 QThread::requestInterruption 更直接,因为 pcap_loop 的阻塞发生在内核空间,只有 pcap_breakloop 能打断它。
4. 抓包主流程:从 pcap_loop 回调到报文解析与表格刷新
4.1 枚举网卡到打开设备:pcap_open_live 的参数细节
线程 run 里的第一步是打开设备。pcap_open_live 的签名经历了多次演变,WinPcap 时代最常见的版本是传五个参数。下面这段是我常用的打开方式:
void SnifferThread::run() { char errbuf[PCAP_ERRBUF_SIZE] = {0}; // 第一个参数是设备名,第二个是抓包长度,第三个是否混杂模式, // 第四个是超时时间,第五个是错误缓冲 handle = pcap_open_live(deviceName.toLocal8Bit().constData(), 65536, 1, 1000, errbuf); if (handle == nullptr) { emit errorOccurred(QString("pcap_open_live failed: %1").arg(errbuf)); return; } // 检测数据链路类型 int linkType = pcap_datalink(handle); if (linkType != DLT_EN10MB) { emit errorOccurred(QString("Unsupported datalink type: %1").arg(linkType)); pcap_close(handle); return; } running = true; pcap_loop(handle, 0, &SnifferThread::packetHandler, reinterpret_cast<u_char *>(this)); pcap_close(handle); }这里 65536 表示抓取每个数据包的最大字节数,以太网 MTU 是 1500,加上各种头部不会超过这个值,设置大一点是为了保证 Jumbo Frame 不被截断。混杂模式传 1,意思是网卡会接收所有经过它的报文,而不仅仅是发给本机的,这是抓包工具的基本行为。超时 1000 毫秒的含义是:如果长时间没有包到达,pcap_loop 也会定期返回一次,让程序有机会处理停止请求;如果你设成 0,在部分驱动版本上会导致 pcap_loop 忙等,CPU 占用率直接拉满。设备名用 toLocal8Bit 转换是因为 Windows 设备名里可能包含非 ASCII 字符,直接用 toStdString 在某些编码下会乱掉。
4.2 回调里解析以太网帧与 IP 头,把结果发回主线程
pcap_loop 的回调函数是静态成员,所以没法直接访问实例成员。常见做法是把 this 指针作为 user 参数传进去,在回调里强转回来。下面这段实现了解析以太网帧和 IPv4 头部,并把结果包装成信号发出去:
void SnifferThread::packetHandler(u_char *user, const struct pcap_pkthdr *header, const u_char *packet) { SnifferThread *self = reinterpret_cast<SnifferThread *>(user); if (!self->running) { pcap_breakloop(self->handle); return; } // 以太网帧头固定 14 字节 const struct ether_header *ethHdr = reinterpret_cast<const struct ether_header *>(packet); // 判断帧类型:0x0800 是 IPv4,0x0806 是 ARP unsigned short etherType = ntohs(ethHdr->ether_type); if (etherType == 0x0800 && header->len >= 34) { // 跳过 14 字节以太网头,指向 IP 头 const struct ip_header *ipHdr = reinterpret_cast<const struct ip_header *>(packet + 14); // 从 IP 头里取源、目的地址和协议号 QString src = inet_ntoa(*(struct in_addr *)&ipHdr->ip_src); QString dst = inet_ntoa(*(struct in_addr *)&ipHdr->ip_dst); QString proto = self->protocolName(ipHdr->ip_p); int ipTotalLen = ntohs(ipHdr->ip_len); emit self->packetCaptured(header->ts.tv_sec, src, dst, proto, ipTotalLen); } }解析过程中最容易出错的点是字节序。以太网帧头里的 ether_type 是一个多字节值,在内存里是网络字节序,直接比较会得到错误结果,所以要先 ntohs 转成主机字节序。IP 地址则不需要手工转换字节序,直接用 inet_ntoa 就能得到点分十进制的字符串。header->len 这个字段表示实际抓到的包长度,判断它是否大于 34(14 字节以太网头 + 20 字节最小 IP 头),是为了防止畸形报文导致越界读取。这段逻辑是 Wireshark 那套解析体系的极简版,Wireshark 抓包及分析的能力远不止如此,但作为自研工具,先把这条链路跑通比追求完整协议栈更重要。
4.3 协议映射与列显示:让报文从十六进制变成可读信息
协议号只是一个数字,界面表格里直接显示 6 或 17 可读性太差。我习惯在 SnifferThread 里维护一个静态映射表:
QString SnifferThread::protocolName(unsigned char proto) { static const QHash<unsigned char, QString> protoMap = { {1, "ICMP"}, {6, "TCP"}, {17, "UDP"}, {47, "GRE"}, {50, "ESP"}, {89, "OSPF"} }; return protoMap.value(proto, QString("IP-%1").arg(proto)); }用 QHash 而不是 switch-case 的好处是协议增多的时候,只需要在初始化列表里添加一行,不需要改动函数结构。这里只列举了几个常见协议,实际使用中可以根据需要补充。光有协议名还不够,TCP 和 UDP 的端口也是排查问题时的关键信息,可以在回调里继续解析四层头部:TCP 头偏移 14+20 字节之后就是源端口和目的端口各 2 字节,取出后一并塞进信号里。表格刷新时,把收到的参数按列填充到 QTableWidget 的新行里,注意插入行时要让表格滚动到最新一行,否则抓包速度一快,你看到的永远是最早的几十条记录。
5. 抓包必踩的坑:驱动类型、回环包、过滤器与界面冻结排查
5.1 网络抓包没有任何输出,errbuf 却一直是空的
现象:程序正常启动,网卡也能列出,点开始抓包后界面无任何报文更新,回调函数一次都没进。你打印 errbuf 发现是空字符串。原因:多半出在设备名上。pcap_findalldevs 返回的设备名字段在 Windows 下是一串类似\Device\NPF_{GUID}的长字符串,如果你把它直接传给 pcap_open_live,本身没问题;但有些实现会误把 description 字段当成设备名传进去,而这个字段像“Realtek PCIe GbE Family Controller”这样,自然打开失败。解决:从下拉框的 itemData 里取 name 而不是 displayText。还有一个小概率是你把 isatap 或虚拟网卡的设备排在了前面,选中的默认设备没有实际流量,这类虚拟网卡设备描述里通常带着 Microsoft 或 Virtual 字样,枚举时可以直接过滤掉。
5.2 数据链路类型判断错了,解析出来全是乱码
现象:抓到的包长度正常,计数在涨,但是源地址、目的地址全是乱码,协议类型显示成 IP-255 这种奇怪的值。原因:不是所有网卡的数据链路层都是以太网。比如某些无线网卡驱动在 WinPcap/NPcap 下返回的数据链路类型是 DLT_IEEE802_11(值为 105),帧头不是 14 字节,而是 802.11 的管理帧头,直接按 Ethernet II 解析,从偏移 14 读出来的所谓 IP 头完全是错位的。解决:在 run 函数里用 pcap_datalink 检查链路类型,如果不是 DLT_EN10MB 就主动报错提示,不要硬着头皮解析。之前遇到用 Vivado 装 WinPcap 失败的情况,电脑上残留的驱动版本混杂,导致无线网卡的数据链路类型被识别错,重装 NPcap 并重启后恢复正常。
5.3 过滤器一条都抓不到,或在错误网卡上抓到空包
现象:设置了只抓 TCP 的过滤器,结果启动后一包都进不来;或者过滤器看起来生效了,但抓到的包跟你访问的网站完全对不上。原因有两类:一类是你把过滤表达式写错了,WinPcap 用的是 BPF 语法,字符串里写tcp and port 80没问题,但你要是写成protocol == tcp这种类 C 风格表达式,编译就不通过;另一类是选了错误的网卡,比如访问外部网站的数据包是从物理网卡出去的,你却挂在虚拟网卡上监听。解决:先用不加过滤器的模式抓一把,确认流量确实经过当前网卡,再加过滤:
struct bpf_program filter; // 表达式按 BPF 语法写,编译失败时 pcap_compile 会返回 -1 if (pcap_compile(handle, &filter, "tcp port 80", 1, PCAP_NETMASK_UNKNOWN) == -1) { emit errorOccurred(QString("Filter compile failed: %1") .arg(pcap_geterr(handle))); return; } pcap_setfilter(handle, &filter);pcap_compile 的第四个参数是 netmask,填 PCAP_NETMASK_UNKNOWN 即可,它只有在过滤表达式里包含 broadcast 或 multicast 这类依赖掩码的关键字时才有意义。值得留意的是,过滤器生效后同样会影响回调频率,抓包速度高时不要靠过滤去“节流”,它会无差别丢弃不匹配的包,而这在后续分析时可能让你误判“对方没发包”。
5.4 界面卡死与刷新错乱:在回调里碰 GUI 的下场
现象:抓包一开始,界面立刻进入“未响应”状态,几秒后恢复,抓包停止时表格又刷出一大堆行;或者表格行一直在跳,但顺序是乱的,看起来新旧数据在互相覆盖。原因就一句话:你在 pcap 回调线程里直接操作了 QTableWidget。Qt 的 GUI 类不是线程安全的,所有控件操作必须在主线程执行。新手常犯的错误是在 packetHandler 里调用 self->table->insertRow(),以为这样没关系,实际上会破坏 Qt 内部的事件队列状态,轻则界面卡顿,重则直接崩溃。解决:回调里只发信号,主线程的槽函数里才做表格操作;如果抓包速率极高导致主线程刷新不过来,可以在槽函数里做一个简单的限速,比如距离上次刷新不足 50 毫秒就丢弃这次的界面更新,保留数据但别刷屏。
5.5 WinPcap 服务没启动:老生常谈却最常翻车
现象:代码在别的机器上跑得好好的,换一台电脑就提示 pcap_findalldevs 找不到设备。原因:目标机器的 NPcap 服务没有自动启动,或者安装新版驱动后旧版本残留的 npf.sys 冲突。解决:用管理员身份在命令提示符执行net start npf,然后重新打开程序。如果服务启动失败,去设备管理器里卸载网络适配器驱动,再重新安装 NPcap。这里要提醒一句,如果你是在虚拟机里做实验,宿主机的无线网卡不会被直接透传成可抓包的设备,需要在虚拟机设置里把网卡模式调成“桥接”并安装对应驱动,否则列表里只能看到虚拟网卡,流量环境完全是隔离的。
6. 离线保存与进阶验证:pcap_dump 后置校验的实用技巧
抓包程序做到能实时列表显示,只完成了一半。真正让自己放心,也方便向别人证明“这个程序没有瞎解析”的办法,是把抓到的原始包保存成 pcap 文件,再用 Wireshark 打开做对比。WinPcap 提供了现成的 dump 接口,pcap_dump_open 创建文件,pcap_dump 在回调里写入原始数据,pcap_dump_close 收尾。下面这段是保存逻辑的常见接法:
pcap_dumper_t *dumper = pcap_dump_open(handle, "capture.pcap"); // 在 pcap_loop 的回调里,直接调用 pcap_dump 写入原始报文 pcap_dump((u_char *)dumper, header, packet); // 停止抓包后关闭文件 pcap_dump_close(dumper);pcap_dump 写入的格式和 Wireshark 的 pcap 文件完全兼容,你可以把保存下来的文件直接拖进 Wireshark,逐一对照源地址、目的地址、协议号和长度列。如果两边显示完全一致,说明解析层没有黑匣子;如果列对不上,优先排查字节序问题,再看是不是结构体字段的偏移写错了。这里也顺带解决一个 QT5 无法拖拽文件的问题:想在程序里支持拖入 pcap 文件回放,只需要在主窗口构造函数里调用 setAcceptDrops(true),再重写 dragEnterEvent 和 dropEvent,在 dropEvent 里取到文件路径后调用一个新的解析线程即可。事件过滤器和拖拽之间没有隐式关联,漏掉两个重写函数中的任何一个,拖拽都会没有任何反应。
如果还想进一步压榨性能,可以研究一下 pcap_setmintocopy。这个函数用来设置内核缓冲区到用户态的最小拷贝字节数,默认值在部分驱动上会导致小包高频触发回调,拖慢整个线程。把最小拷贝值调大到 4096 左右,系统会先在缓冲区里攒一批包再交给回调,界面刷新频率会明显下降,CPU 占用率也能低一截。我自己的习惯是保留这个参数配置入口,默认不改,但在抓高频小包时手动调大,效果很直观。整个工具做到这一步,已经不是一个玩具了——能实时抓、能过滤、能保存成标准格式、能被 Wireshark 交叉验证,这套东西的基本盘就已经立住了。希望帮到你。
本文还有配套的精品资源,点击获取