简介:这份资源是基于npcap与Qt开发的仿Wireshark网络抓包工具源码包,面向具备一定网络编程与C++基础的开发者,以及需要将抓包分析能力集成到自有系统中的技术人员。它借助npcap访问底层网络流量,实现数据包捕获、过滤与解码,并通过Qt构建图形界面,在操作方式上模仿Wireshark,同时保留更高的定制与扩展空间。压缩包共86个文件,约2.48MB,以cpp与h源码、vcxproj与sln工程文件、obj与tlog等编译中间产物为主,另含ui界面文件、qrc资源文件、png图标及exe可执行程序,整体构成一套可直接编译运行的完整工程。目前已有378人学习下载。读者可从中了解npcap API的引入方式、Qt信号与槽驱动的事件处理、数据包解析与界面展示的实现思路,并参考工程目录组织与构建配置,为二次开发或集成到更大监控系统提供可复用的基础。
1. 从零手搓一个 Sniffer:为什么我不建议你直接改 Wireshark 源码
很多人第一次动了“自己写个抓包工具”的念头,都是因为 Wireshark 太重了。装完几百兆,打开要等,过滤表达式记不住,想给同事演示一个“只抓某个端口、实时看 payload”的小工具,结果对方先被界面劝退。于是就有了这个标题:基于 npcap + qt 开发的、模仿 wireshark 的网络抓包工具。它要解决的不是“替代 Wireshark”,而是“把抓包能力嵌进你自己的 Qt 桌面程序里”,做一个能定制协议解析、能按业务过滤、能直接对接后端分析的轻量 sniffer。
npcap 是 Windows 上目前最省心的抓包驱动层,Wireshark 官方在 Windows 平台也依赖它;Qt 负责界面和跨平台构建。两者组合,你能拿到网卡列表、开混杂模式、收原始帧,再自己决定怎么解析、怎么展示。适合谁?做工业协议调试、做私有协议逆向、做网络教学质量工具、或者单纯想搞懂 Wireshark 背后那套 pcap 流程的 Qt 开发者。这篇笔记按“环境搭起来 → 抓包线程跑通 → 解析与展示 → 避坑 → 进阶”的顺序走,代码能直接抄,参数会讲清楚为什么这么设。
2. 环境与工程骨架:npcap 装完不等于 Qt 就能用
2.1 npcap 安装时那个勾,决定了你后面能不能编译
npcap 安装包默认只装运行库,开发用的头文件和 lib 需要手动勾选。安装向导里有一项 “Install Npcap in WinPcap API-compatible Mode”,如果你要兼容老代码可以勾,但新项目我一般不建议勾,直接用原生 npcap API。真正关键的是安装完成后,去安装目录(默认C:\Program Files\Npcap)确认有没有pcap.h、wpcap.lib、Packet.lib。没有这几个文件,Qt 里#include <pcap.h>一定报错。
安装完把C:\Program Files\Npcap加入系统环境变量INCLUDE和LIB,或者更稳妥的做法是在 Qt 的.pro文件里写绝对路径。我习惯后者,因为换机器时路径一目了然。
# sniffer.pro QT += core gui widgets INCLUDEPATH += "C:/Program Files/Npcap/Include" LIBS += -L"C:/Program Files/Npcap/Lib" -lwpcap -lPacket # 如果编译报 undefined reference to pcap_open_live # 检查是否漏了 -lwpcap,以及 lib 目录下有没有 wpcap.lib逻辑说明:INCLUDEPATH让编译器找到pcap.h,LIBS告诉链接器去哪个目录找wpcap.lib和Packet.lib。-lwpcap对应wpcap.lib,-lPacket对应Packet.lib,后者在部分 npcap 版本里是收包辅助库。参数上唯一要注意的是路径里的空格,qmake 里用引号包住即可,不要用反斜杠。
提示:如果你装的是 npcap 1.x 且勾了 WinPcap 兼容模式,
wpcap.lib会同时存在,但头文件里的函数签名可能和原生模式有细微差别,新项目统一用原生模式,避免后面调pcap_setmode时出现奇怪返回值。
2.2 用 Qt 枚举网卡:别用 QNetworkInterface 代替 pcap_findalldevs
很多人第一反应是用QNetworkInterface::allInterfaces()拿网卡列表,然后拿名字去开 pcap。这条路能走通,但会漏掉一些虚拟网卡和 npcap 自己识别的设备名。正确做法是直接用pcap_findalldevs,它返回的name字段才是pcap_open_live真正认的标识。
// devicelist.cpp #include <pcap.h> #include <QDebug> QStringList listDevices() { QStringList result; pcap_if_t *alldevs = nullptr; char errbuf[PCAP_ERRBUF_SIZE] = {0}; if (pcap_findalldevs(&alldevs, errbuf) == -1) { qDebug() << "pcap_findalldevs failed:" << errbuf; return result; } for (pcap_if_t *d = alldevs; d != nullptr; d = d->next) { // d->name 是给 pcap_open_live 用的 // d->description 是给人看的 QString desc = d->description ? QString::fromLocal8Bit(d->description) : "No description"; result << QString("%1|%2").arg(QString::fromLocal8Bit(d->name), desc); } pcap_freealldevs(alldevs); return result; }逻辑说明:pcap_findalldevs分配了一个链表,必须用pcap_freealldevs释放,否则每次刷新网卡列表都会泄漏。errbuf大小固定用PCAP_ERRBUF_SIZE,不要自己写 256。返回的字符串我拼成name|description,界面里只显示 description,实际打开时取 name。参数上pcap_findalldevs第二个参数是错误缓冲区,传nullptr也可以,但出错时你就拿不到原因了,血泪经验是永远传。
2.3 工程目录怎么分:把抓包线程和 UI 彻底隔开
抓包是阻塞操作,pcap_next_ex在没包的时候会卡住。如果你在 UI 线程里直接循环调用,界面必卡,这是 Qt 新手最常见的翻车点。我的目录结构固定为三层:core/放 pcap 封装和线程,parser/放协议解析,ui/放窗口和模型。线程用QThread子类或者moveToThread都行,我倾向后者,因为抓包对象本身不需要事件循环,只是被线程驱动。
// captureworker.h #include <QObject> #include <pcap.h> class CaptureWorker : public QObject { Q_OBJECT public: explicit CaptureWorker(QObject *parent = nullptr); ~CaptureWorker(); public slots: void startCapture(const QString &devName); void stopCapture(); signals: void packetReady(const QByteArray &rawData, const QString &info); void errorOccurred(const QString &msg); private: pcap_t *m_handle = nullptr; bool m_running = false; };逻辑说明:startCapture和stopCapture作为槽,方便从 UI 线程通过信号触发。m_running用std::atomic<bool>更稳,但单线程内读写用普通 bool 也够。packetReady发原始字节和摘要信息,UI 侧再决定怎么解析。参数上devName就是前面pcap_findalldevs返回的 name,不要传 description。
3. 抓包线程与实时收包:pcap_next_ex 和超时该怎么配
3.1 pcap_open_live 的四个参数,每个都影响你抓不抓得到包
pcap_open_live(device, snaplen, promisc, to_ms, errbuf)这四个参数里,最容易设错的是snaplen和to_ms。snaplen是每个包最多抓多少字节,设 65535 能抓全,但内存和 UI 压力大;设 96 只抓头部,适合只看五元组。我一般设 65535,因为后面要做 payload 解析,截断了就得后悔药。promisc设 1 开混杂模式,能抓到经过网卡但不是发给本机的包,交换机镜像口场景必须开。to_ms是超时毫秒数,设 1000 表示pcap_next_ex最多阻塞 1 秒就返回,方便线程检查退出标志。
// captureworker.cpp void CaptureWorker::startCapture(const QString &devName) { char errbuf[PCAP_ERRBUF_SIZE] = {0}; m_handle = pcap_open_live(devName.toLocal8Bit().constData(), 65535, // snaplen 1, // promisc 1000, // to_ms errbuf); if (!m_handle) { emit errorOccurred(QString("open failed: %1").arg(errbuf)); return; } m_running = true; while (m_running) { struct pcap_pkthdr *header = nullptr; const u_char *data = nullptr; int ret = pcap_next_ex(m_handle, &header, &data); if (ret == 1) { QByteArray raw(reinterpret_cast<const char*>(data), header->caplen); emit packetReady(raw, QString("len=%1").arg(header->len)); } else if (ret == 0) { continue; // 超时,正常 } else { emit errorOccurred("pcap_next_ex error"); break; } } pcap_close(m_handle); m_handle = nullptr; }逻辑说明:pcap_next_ex返回值 1 表示抓到包,0 表示超时,-1 表示错误,-2 表示读到 EOF(离线文件场景)。header->caplen是实际抓到的长度,header->len是原始包长,两者在 snaplen 截断时不一样。QByteArray拷贝一份数据,因为data指向 pcap 内部缓冲区,下一轮循环会被覆盖。参数上to_ms设 1000 是折中,设 0 会一直阻塞直到有包,线程无法及时退出;设太小比如 10,CPU 空转明显。
3.2 用 QThread 驱动 worker:moveToThread 的正确姿势
moveToThread的坑在于:worker 对象必须在堆上创建,且不能有父对象,否则moveToThread会失败。另外,startCapture是个死循环,一旦进入就不会返回,所以它不能通过信号槽的队列连接来触发,否则线程事件循环被占死,stopCapture永远排不上。我的做法是:用QThread::started信号直接连一个 lambda,在 lambda 里调startCapture,stopCapture只改m_running标志。
// mainwindow.cpp 片段 QThread *thread = new QThread(this); CaptureWorker *worker = new CaptureWorker(); worker->moveToThread(thread); connect(thread, &QThread::started, worker, [worker, devName]() { worker->startCapture(devName); }); connect(worker, &CaptureWorker::packetReady, this, &MainWindow::onPacket); connect(worker, &CaptureWorker::errorOccurred, this, &MainWindow::onError); thread->start(); // 停止时 worker->stopCapture(); // 直接调用,改原子标志 thread->quit(); thread->wait(2000);逻辑说明:thread->started触发后,lambda 在子线程里执行startCapture,死循环开始。stopCapture从 UI 线程直接调用,修改m_running,循环退出后startCapture返回,子线程事件循环恢复,thread->quit()才能生效。参数上wait(2000)给 2 秒超时,避免线程卡死导致程序退不出。注意worker不要设 parent,否则moveToThread报 “Cannot move object with a parent”。
3.3 收包到 UI 的传递:别在信号里传裸指针
packetReady信号里我传的是QByteArray和QString,都是值语义,跨线程队列连接会自动拷贝,安全。如果你传const u_char*,接收方拿到时数据可能已经被 pcap 复用,这是黑匣子级别的 bug,现象是“偶尔解析出乱码”。另外,高频抓包时信号发射频率可能上万次每秒,UI 侧如果每次都刷新表格,必卡。常见做法是 worker 侧做批量聚合,比如每 100 个包或每 50ms 发一次QList<PacketInfo>。
struct PacketInfo { QByteArray raw; quint64 timestamp; quint32 caplen; quint32 len; }; Q_DECLARE_METATYPE(PacketInfo) Q_DECLARE_METATYPE(QList<PacketInfo>)逻辑说明:自定义类型要跨线程信号槽传递,必须Q_DECLARE_METATYPE并在连接前qRegisterMetaType。timestamp用header->ts.tv_sec * 1000000 + header->ts.tv_usec拿微秒级时间。参数上caplen和len都保留,UI 里可以显示“抓取/原始”对比,方便判断是否截断。
4. 协议解析与界面展示:从以太头到 QTableView 的完整链路
4.1 以太网帧解析:14 字节头里藏着 VLAN 的坑
拿到QByteArray后,第一步是解析以太网头。标准以太头 14 字节:6 字节目的 MAC、6 字节源 MAC、2 字节类型。类型字段 0x0800 是 IPv4,0x0806 是 ARP,0x86DD 是 IPv6。但如果有 VLAN 标签,类型会变成 0x8100,后面跟 2 字节 TCI,再跟真正的类型,总长度变成 18 字节。很多新手写的解析器遇到 VLAN 就翻车,现象是“IP 地址解析成 0.0.0.0”。
// parser.cpp #pragma pack(push, 1) struct EthernetHeader { quint8 dstMac[6]; quint8 srcMac[6]; quint16 etherType; }; #pragma pack(pop) QString parseEthernet(const QByteArray &data, int &offset) { if (data.size() < 14) return "too short"; const EthernetHeader *eth = reinterpret_cast<const EthernetHeader*>(data.constData()); quint16 type = qFromBigEndian(eth->etherType); offset = 14; // VLAN 处理 while (type == 0x8100 && data.size() >= offset + 4) { quint16 vlanTci = qFromBigEndian(*reinterpret_cast<const quint16*>(data.constData() + offset)); Q_UNUSED(vlanTci) type = qFromBigEndian(*reinterpret_cast<const quint16*>(data.constData() + offset + 2)); offset += 4; } if (type == 0x0800) return "IPv4"; if (type == 0x0806) return "ARP"; if (type == 0x86DD) return "IPv6"; return QString("EtherType 0x%1").arg(type, 4, 16, QChar('0')); }逻辑说明:#pragma pack(push,1)保证结构体按 1 字节对齐,否则编译器可能插入填充字节,导致字段错位。qFromBigEndian处理网络字节序。VLAN 用 while 循环是因为 QinQ 可能有多层。offset是引用传递,解析完以太头后指向下一层。参数上data.size()每次都要检查,防止越界读,这是安全底线。
4.2 用 QAbstractTableModel 承载万级数据包,别用 QTableWidget
热词里“qt 表格大数据卡顿优化 tablewidget 到 qtableview + 自定义 model”说的就是这个场景。抓包工具每秒可能来几千个包,QTableWidget每行都是一个 widget,内存和刷新开销随行数线性增长,几千行就开始卡。正确做法是QTableView+ 自定义QAbstractTableModel,数据存在QVector<PacketInfo>里,视图只渲染可见的几十行。
// packetmodel.h class PacketModel : public QAbstractTableModel { Q_OBJECT public: enum Column { Time, Src, Dst, Proto, Length, Info, ColumnCount }; int rowCount(const QModelIndex &parent = QModelIndex()) const override { return parent.isValid() ? 0 : m_packets.size(); } int columnCount(const QModelIndex &parent = QModelIndex()) const override { return parent.isValid() ? 0 : ColumnCount; } QVariant data(const QModelIndex &index, int role) const override { if (!index.isValid() || role != Qt::DisplayRole) return {}; const PacketInfo &p = m_packets[index.row()]; switch (index.column()) { case Time: return p.timestamp; case Src: return p.src; case Dst: return p.dst; case Proto: return p.proto; case Length: return p.len; case Info: return p.info; } return {}; } QVariant headerData(int section, Qt::Orientation o, int role) const override { if (role != Qt::DisplayRole || o != Qt::Horizontal) return {}; static const char *names[] = {"时间","源","目的","协议","长度","信息"}; return names[section]; } void appendPackets(const QList<PacketInfo> &batch) { beginInsertRows(QModelIndex(), m_packets.size(), m_packets.size() + batch.size() - 1); m_packets.append(batch); endInsertRows(); } private: QVector<PacketInfo> m_packets; };逻辑说明:beginInsertRows/endInsertRows必须成对调用,否则视图不会更新。批量追加比逐个追加效率高,因为只触发一次布局。data()里只处理Qt::DisplayRole,背景色、字体等角色按需扩展。参数上m_packets用QVector而不是QList,前者内存连续,遍历更快。如果包量超过几十万,考虑环形缓冲,只保留最近 N 条。
4.3 实时过滤:用 BPF 还是自己写解析器
Wireshark 的过滤表达式很强,但自己实现一套语法不现实。npcap 支持 BPF 过滤,在pcap_open_live之后、开始收包之前,用pcap_compile+pcap_setfilter把过滤规则下推到驱动层,这样不匹配的包根本不会到用户态,性能最好。
// 在 pcap_open_live 成功后 struct bpf_program fp; if (pcap_compile(m_handle, &fp, "tcp port 8080", 0, PCAP_NETMASK_UNKNOWN) == -1) { emit errorOccurred("compile filter failed"); return; } if (pcap_setfilter(m_handle, &fp) == -1) { emit errorOccurred("set filter failed"); return; } pcap_freecode(&fp);逻辑说明:pcap_compile把字符串编译成 BPF 字节码,pcap_setfilter下推到内核。PCAP_NETMASK_UNKNOWN用于非 IPv4 场景,IPv4 可以传实际掩码。pcap_freecode释放编译结果,但pcap_setfilter之后 filter 已经生效,释放不影响。参数上过滤字符串语法和 Wireshark 抓包过滤器一致,比如host 192.168.1.1 and tcp。注意这是抓包过滤,不是显示过滤,改了要重新打开网卡。
5. 避坑与排查:那些让我加班到凌晨的 sniffer 问题
5.1 现象:程序启动报 “wpcap.dll not found”,但 npcap 明明装了
原因:npcap 安装时只装了驱动,没有把wpcap.dll放到系统 PATH,或者你编译的是 64 位程序但 npcap 装的是 32 位版本。解决:确认C:\Windows\System32\wpcap.dll存在,且位数和你的 Qt 构建套件一致。用dumpbin /headers wpcap.dll看 machine 字段,x64 对应 64 位。如果缺失,重新运行 npcap 安装包,勾选 “Install Npcap in WinPcap API-compatible Mode” 会同时装wpcap.dll。
5.2 现象:能抓到包但全是自己的,抓不到其他机器的流量
原因:promisc参数设了 1,但网卡本身在交换机环境下默认只能收到发给自己的单播和广播。解决:确认你连的是交换机镜像口,或者用 hub。如果是虚拟机,把虚拟网卡设为混杂模式。另外检查pcap_open_live的promisc是否真的传了 1,有些封装库默认传 0。
5.3 现象:抓包线程退出后程序崩溃,报 access violation
原因:pcap_close之后 worker 对象还被信号槽引用,或者m_handle被重复关闭。解决:stopCapture里先置m_running = false,等循环退出后再pcap_close,并把m_handle置 nullptr。thread->wait()确保线程完全结束后再销毁 worker。如果 worker 是new出来的,用deleteLater而不是delete。
5.4 现象:QTableView 滚动时卡顿,CPU 占用高
原因:data()里做了耗时解析,或者每次追加都触发全量刷新。解决:解析在 worker 线程做完,model 里只存结果。追加用批量beginInsertRows。另外设置tableView->setUniformRowHeights(true)和verticalHeader()->setDefaultSectionSize(20),减少布局计算。如果还是卡,用QSortFilterProxyModel做过滤时注意它会对每行调用filterAcceptsRow,数据量大时换手动过滤。
5.5 现象:时间戳显示成 1970 年,或者毫秒微秒对不上
原因:pcap_pkthdr.ts是struct timeval,tv_sec是秒,tv_usec是微秒。有人直接拿tv_usec当毫秒用,结果差 1000 倍。解决:quint64 us = header->ts.tv_sec * 1000000ULL + header->ts.tv_usec;然后转成QDateTime::fromMSecsSinceEpoch(us / 1000)。注意tv_sec在某些平台是 32 位,2038 年问题,用quint64接。
6. 进阶技巧:把 sniffer 变成能落地的协议分析器
6.1 用 QChart 做实时流量曲线,但别每包都重绘
热词里“qchart实现图片缩放+qt”和“qt绘图”在这个场景很实用。抓包工具加一个实时吞吐曲线,能直观看到流量突发。但QChart每追加一个点就重绘一次,几千包下来界面直接卡死。我的做法是:worker 侧每秒统计一次字节数,发trafficUpdate(quint64 bytesPerSec)信号,UI 侧用QLineSeries追加,并限制只保留最近 60 个点,超出就remove(0)。这样每秒最多重绘一次,CPU 占用可以忽略。
// 每秒统计 void CaptureWorker::flushStats() { quint64 now = QDateTime::currentMSecsSinceEpoch(); if (now - m_lastStatTime >= 1000) { emit trafficUpdate(m_bytesSinceLast); m_bytesSinceLast = 0; m_lastStatTime = now; } }逻辑说明:m_bytesSinceLast在每次packetReady时累加header->len。flushStats在收包循环里每轮调用一次,因为pcap_next_ex超时是 1000ms,所以统计精度大约 1 秒。参数上m_lastStatTime初始化为当前时间,避免第一秒统计到启动前的数据。
6.2 保存 pcap 文件:pcap_dump 比你自己写文件头靠谱
想复现问题,把抓到的包存成.pcap文件是最稳的。npcap 提供pcap_dump_open/pcap_dump/pcap_dump_close,写出来的文件 Wireshark 能直接打开。不要自己拼 pcap 文件头,字节序和魔数容易写错。
pcap_dumper_t *dumper = pcap_dump_open(m_handle, "capture.pcap"); // 在收包循环里 if (dumper) { pcap_dump(reinterpret_cast<u_char*>(dumper), header, data); } // 停止时 pcap_dump_close(dumper);逻辑说明:pcap_dump_open的m_handle必须是已经打开的 pcap 句柄,它从中取链路类型和 snaplen。pcap_dump的header和data就是pcap_next_ex返回的那两个指针,直接传。参数上文件名用绝对路径,避免工作目录变化导致找不到。如果同时要抓包和存文件,注意磁盘 IO 可能成为瓶颈,高频场景建议加缓冲或异步写。
6.3 一个我坚持了三年的习惯:先抓 100 个包验证解析器
每次新写一个协议解析器,我不会直接上界面看,而是先写个单元测试,用pcap_open_offline读一个已知的 pcap 文件,跑 100 个包,把解析结果和 Wireshark 的显示对比。这一步能拦住 90% 的字节序、偏移量、VLAN 问题。pcap_open_offline的用法和pcap_open_live几乎一样,只是不需要网卡名,传文件路径即可。解析器验证通过后再接到实时抓包链路上,省得在 UI 和线程之间来回排查。这个习惯让我少加了很多班,希望帮到你。
本文还有配套的精品资源,点击获取