写网络工具的老哥们,肯定绕不开pcap这个库。它是libpcap在Windows平台上的继承者,虽然很多老教程还习惯写WinPcap,但今天要说的Npcap才是更新、更靠谱的选择。整件事说白了就是:用C++配合pcap驱动,在系统网卡上抓数据包,再把抓到的包和某个exe进程对上号,最后统计出这个进程到底上传了多少、下载了多少。标题里写的pacp其实是笔误,正确写法是pcap,这个后面就按pcap来讲。
这个需求看似简单,真要实现就会发现pcap只是负责把网络上的原始数据包交给你,它压根不知道这些包属于哪个程序。所以整个项目的核心难点不在抓包本身,而在于如何把数据包映射回exe进程。这篇博文会把完整思路、踩坑过程和可落地的代码都讲一遍,适用场景包括:自己开发的工具上线前想看它的网络行为、分析某个商业软件启动后偷偷连了哪些地址、给测试部门做一个自动化的进程流量统计工具。
1. 明确目标:从数据包到exe进程名
开始写代码之前,先把目标拆清楚。我们最终想要的是:屏幕上输出一个表格,里面写清楚target.exe从运行开始到现在,累计上传了多少字节、下载了多少字节,最好还能看到实时速率。这里有两个核心问题要解决:第一个是流量从哪来,第二个是流量属于谁。
1.1 这个需求到底在解决什么问题
很多人第一反应是用任务管理器。Windows的任务管理器确实能看进程的网络占用,但那个数字是系统估算的,更新慢,而且只能看个大概,拿来做精准的流量统计完全不够。Wireshark倒是能抓包,也可以按ip、tcp port过滤,但它没法直接回答"这个exe总共走了多少流量",因为它不知道一个个数据包背后的进程归属。
所以最直接的办法就是自己用pcap抓原始数据包,然后自己维护一张"端口到进程"的映射表。为什么是端口?因为TCP/UDP连接靠四元组唯一标识,而四元组里有端口,Windows系统又提供了API能从端口反查进程ID。这个思路绕开了内核态过滤驱动之类复杂的东西,完全用用户态代码就能实现。选择这个方案的另一个理由是pcap的生态成熟,跨平台代码不用大改,linux下也有libpcap。
1.2 可行路线:为什么最终选择pcap加端口关联
我试过几种替代方案,比如用ETW(Event Tracing for Windows)直接监听网络事件,那个方案不经过pcap,数据也干净,但ETW的编程模型比较复杂,会话管理、实时订阅、事件解析一套下来,新手很容易被绕晕。还有用性能计数器PDH的方式获取进程网卡计数,写起来快,但是拿不到目标进程的IP地址和端口细节,只能得到一个累加值。
最后我选了pcap加端口映射的组合,原因很实在:
- pcap只负责抓包,API简单,核心概念就几个:设备、句柄、数据包回调;
- 端口映射用GetExtendedTcpTable这个现成API就能拿到,属于Windows内置接口,不需要装额外的驱动;
- 这个方案能看到每一个数据包的五元组细节,后面想扩展按域名统计、按连接统计都方便。
整体架构就是三条线并行:抓包线程不停读取网卡数据、解析线程把数据包里的源端口和目的端口提取出来、关联逻辑通过一张在后台不断刷新的映射表把端口对应到PID,再由PID对应到exe文件名。
2. 环境配置:Npcap不是装完就完事
Windows下使用pcap,第一步就是选对驱动。老教程大概率会提到WinPcap,那个东西2013年之后就不再维护了,新系统上装驱动经常蓝屏或者抓不到包。现在微软和libpcap社区都推荐Npcap,它兼容WinPcap的API,同时改进了性能和稳定性。
2.1 Npcap和WinPcap的差别
简单说WinPcap是旧时代的产物,Npcap是它的接棒者。Npcap默认支持环回(loopback)流量,也就是抓"本机进程访问本机服务"这种数据包,而WinPcap不支持。这在我们这个场景里很关键,因为很多exe会访问127.0.0.1上的本地服务,要是驱动不支持环回,这些流量就像蒸发了一样。
另一个要注意的点是安装Npcap时,那个"Support loopback traffic"和"Install Npcap in WinPcap API-compatible Mode"勾选框得选上。后者是兼容老程序用的,我们把勾选上,是因为很多编译工具链里的头文件还是按WinPcap的老路径去找wpcap.lib,不勾选可能编译直接报错找不到库。
2.2 安装与VS工程设置
下载Npcap安装包时会自动安装驱动和SDK。我做项目时习惯把SDK里的Include和Lib文件夹单独复制到项目的third_party目录下,这样不会污染系统目录,换个机器也能编译。
VS工程配置是很多新手卡住的地方:
- C/C++ -> 常规 -> 附加包含目录:填入SDK下的Include目录;
- 链接器 -> 常规 -> 附加库目录:填入SDK下的Lib/x64目录;
- 链接器 -> 输入 -> 附加依赖项:wpcap.lib;Packet.lib;ws2_32.lib;iphlpapi.lib。
这里有个经验:如果编译出的程序要跑在32位系统,就选x86配置,而且Lib目录要对应选x86版本。别图省事直接把lib文件拷贝到工程目录,因为wpcap.lib依赖Packet.lib,两个库的顺序,错了链接时各种LNK2019报错。
工程属性里最好把字符集设为"使用多字节字符集",或者用宽字符API,避免生硬拼路径时和Unicode冲突。另外,运行程序时必须以管理员身份运行,为什么后面在常见问题里细说。
3. 数据包捕获与解析
环境配置好,就开始写第一个核心模块:抓包。pcap的编程模型是:先找设备,再打开设备,然后循环读包。整个过程和读取文件流的思路很像。
3.1 设备选择与打开
pcap_findalldevs会返回一个pcap_if_t的链表,每个节点就是一块网卡。执行前一般先调用pcap_findalldevs_ex,是更灵活的版本。我的习惯是把设备名字和描述都打印出来,然后让用户输入编号选择,或者程序自动选一个非环回且正在工作的设备。
打开设备的函数是pcap_open_live,参数四个:设备名、抓包长度、混杂模式、超时时间。抓包长度我建议设65535,这样能保证拿到完整的数据包,不丢失IP头和负载;如果只关心统计流量大小,设成128都够,因为后面统计长度用的是原始包长hdr->len而不是caplen。混杂模式这个话题值得单独提一下:抓本机进程流量时,设成0就行,因为目标是这个exe发出的或者收到的包,本来就发给本机网卡;设成混杂模式反而会收到大量无关的包,干扰过滤。
超时时间设成1000毫秒,意思是pcap_next_ex等待数据包最多1秒,超时返回0,让我们有机会在此间隙刷新端口表。如果你不用多线程,就开着抓包循环,那么端口映射表的刷新就是一个问题。所以我强烈建议:开一个独立线程来维护端口映射表,抓包主线只管闷头收包。
3.2 从以太网帧里取出IP和端口
pcap回调函数里拿到的数据包是完整的链路层帧,从网卡角度看,最外层是以太网头。以太网头的偏移是固定的,14字节,分别是6字节目的MAC、6字节源MAC、2字节上层协议类型字段。协议类型0x0800代表IPv4,0x86DD是IPv6,还有0x8100是VLAN标记。
这里有一个常见的坑:如果网络里启用了VLAN,那么真实的IP头会从18字节偏置开始,而不是14字节。判断方法就是看协议类型是不是0x8100,如果是,往后再跳过4字节。这个细节我是在实际抓一个带tag的交换机流量时才发现的,导致前面解析全错了。
IP头里需要关心的是:首部长度字段在第一个字节的低4位,单位是4字节;协议字段在第9字节,6表示TCP,17表示UDP。TCP头里的源端口和目的端口在TCP头的前4个字节,各占两个字节。所有这些字段在网络上都是大端字节序,也就是高字节在前,所以解析端口时要用(packet[offset] << 8) | packet[offset + 1],不能直接按小端方式强制转换。
3.3 关于过滤规则
pcap支持BPF过滤语法,可以在内核层就把不相关的包过滤掉,省得用户态处理。比如只需要TCP和UDP的包,可以编译规则"tcp or udp"。
这里建议把过滤规则用在抓包前:先用pcap_compile编译BPF,再用pcap_setfilter设置。另外,我们也可以过滤掉非本机IP的数据包,但因为最终要做端口匹配,直接按tcp or udp过滤就够了。我试过在回调函数里用if语句手动过滤,效率远低于BPF,流量一高CPU占用直接窜到80%。
4. 端口与进程的对应关系
现在最关键的一步:怎么知道一个数据包属于哪个exe。思路是借助Windows自身的TCP连接表。
4.1 TCP连接的OWNER_PID
Windows内核维护着一张TCP连接表,里面有一个扩展版本叫MIB_TCPTABLE_OWNER_PID,记录了每条TCP连接对应的进程ID。调用GetExtendedTcpTable,指定AF_INET和TCP_TABLE_OWNER_PID_ALL,就能拿到全量连接。
第一次调用时传一个空指针,函数返回ERROR_INSUFFICIENT_BUFFER,同时把需要的缓冲区大小填好,然后动态分配再调用一次。这个套路和很多Windows API一样,先查大小再分配。表中的每条记录是MIB_TCPROW_OWNER_PID,包含本机地址、本机端口、远端地址、远端端口、连接状态、属主PID。
这里有个极其容易被坑的点:表中dwLocalPort和dwRemotePort是以网络字节序存储的,和你直接在网卡上看到的TCP端口原始字节一模一样。所以要么在查表时配合ntohs转换,要么在解析数据包时保留网络序,两个口径要保持一致。我是直接把从数据包解析出的端口原始值作为key去map里查找,不做转换,简单又不会错。
4.2 双向四元组匹配
每条TCP连接就是一个四元组:源IP、源端口、目的IP、目的端口。当这个连接是目标进程发起的,pcap抓到的包可能正好是这个方向,也可能是对方回包的相反方向。所以在保存映射关系时,我不仅保存一条正向key,还同时保存一条反向key,value里标明方向:1代表上行,2代表下行。这样pcap回调里拿到一个数据包后,直接用它的四元组去查表就能命中,同时也就知道了这条包该计入上行还是下行。
这里要注意,四元组匹配比只匹配端口要精确。为什么?因为同一个exe可能开多个连接,本地端口各不相同;而如果两个不同的连接恰好共用一个本地端口,只是远端地址不同,那只看端口就会统计串线。用四元组完全避开这个问题。
4.3 UDP怎么处理
UDP没有连接状态表,GetExtendedUdpTable只能返回本地地址、本地端口和PID,没有远端信息。所以要统计UDP流量得变通:
- 如果目标进程是UDP客户端,它发出去的包源端口是固定的,那可以直接按源端口匹配;
- 如果目标进程是UDP服务端,客户端发来的包目的端口是固定的,那按目的端口匹配。
但在某些情况下,一个进程既发又收,而且源端口和目的端口不一样,就有点麻烦。我的建议是主体逻辑先把TCP跑通,UDP单独做按端口匹配,能满足大部分场景。文章后面代码里我主推TCP统计,UDP部分会留一个扩展点。
5. 完整示例代码
下面给出一套可编译运行的示例代码,分几个模块展示。整体逻辑:管理后台线程刷新端口映射表,主线程抓包解析并统计。
5.1 框架与数据结构
#define WIN32_LEAN_AND_MEAN #include <winsock2.h> #include <windows.h> #include <iphlpapi.h> #include <psapi.h> #include <tlhelp32.h> #include <pcap/pcap.h> #include <cstdio> #include <cstring> #include <string> #include <vector> #include <map> #include <mutex> #include <thread> #include <chrono> #include <atomic> #pragma comment(lib, "ws2_32.lib") #pragma comment(lib, "iphlpapi.lib") #pragma comment(lib, "wpcap.lib") // 方向常量 enum { DIR_UPLINK = 1, DIR_DOWNLINK = 2 }; // 四元组key,直接保存网络序字节 struct ConnKey { ULONG srcAddr; ULONG dstAddr; USHORT srcPort; USHORT dstPort; bool operator<(const ConnKey& o) const { if (srcAddr != o.srcAddr) return srcAddr < o.srcAddr; if (dstAddr != o.dstAddr) return dstAddr < o.dstAddr; if (srcPort != o.srcPort) return srcPort < o.srcPort; return dstPort < o.dstPort; } }; // 方向-进程映射表:key -> (pid, direction) struct MapValue { DWORD pid; int direction; }; class ConnTable { public: void setTargetPid(DWORD pid) { std::lock_guard<std::mutex> lock(mutex_); targetPid_ = pid; } void refresh() { std::map<ConnKey, MapValue> newTable; buildTcpOwnerTable(newTable); std::lock_guard<std::mutex> lock(mutex_); table_.swap(newTable); } // 查表:成功返回PID,失败返回0 MapValue lookup(const ConnKey& k) const { std::lock_guard<std::mutex> lock(mutex_); auto it = table_.find(k); if (it != table_.end()) return it->second; return {0, 0}; } private: void buildTcpOwnerTable(std::map<ConnKey, MapValue>& out) { DWORD size = 0; GetExtendedTcpTable(nullptr, &size, FALSE, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0); if (size == 0) return; std::vector<BYTE> buf(size); PMIB_TCPTABLE_OWNER_PID tcpTable = reinterpret_cast<PMIB_TCPTABLE_OWNER_PID>(buf.data()); if (GetExtendedTcpTable(tcpTable, &size, FALSE, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0) != NO_ERROR) { return; } DWORD targetPid = targetPid_; for (DWORD i = 0; i < tcpTable->dwNumEntries; i++) { const auto& row = tcpTable->table[i]; if (row.dwOwningPid != targetPid) continue; // 正向:本机 -> 远端,数据包源是本机 = 上行 ConnKey upKey; upKey.srcAddr = row.dwLocalAddr; upKey.dstAddr = row.dwRemoteAddr; upKey.srcPort = static_cast<USHORT>(row.dwLocalPort); upKey.dstPort = static_cast<USHORT>(row.dwRemotePort); out[upKey] = {row.dwOwningPid, DIR_UPLINK}; // 反向:远端 -> 本机,数据包源是远端 = 下行 ConnKey downKey; downKey.srcAddr = row.dwRemoteAddr; downKey.dstAddr = row.dwLocalAddr; downKey.srcPort = static_cast<USHORT>(row.dwRemotePort); downKey.dstPort = static_cast<USHORT>(row.dwLocalPort); out[downKey] = {row.dwOwningPid, DIR_DOWNLINK}; } } mutable std::mutex mutex_; std::map<ConnKey, MapValue> table_; DWORD targetPid_ = 0; }; ConnTable gConnTable; std::atomic<unsigned long long> gUploadBytes{0}; std::atomic<unsigned long long> gDownloadBytes{0}; std::atomic<unsigned long long> gPacketCount{0}; std::atomic<bool> gRunning{true};上面这个类是连接表的核心。第一次调用GetExtendedTcpTable只是为了拿大小,第二次调用才真正拿数据。指针强转成MIB_TCPTABLE_OWNER_PID后,table数组每条就是一条TCP连接。注意IP地址字段也是网络序,和pcap包里的IP地址字节序天然一致,所以直接拿来做匹配非常舒服。
5.2 抓包回调
抓包解析函数会在每次拿到一个数据包时被调用:
void processPacket(const u_char* packet, const struct pcap_pkthdr* hdr) { int caplen = hdr->caplen; if (caplen < 14) return; USHORT etherType = (packet[12] << 8) | packet[13]; int ethOffset = 14; if (etherType == 0x8100) { // VLAN if (caplen < 18) return; esterType = 0; etherType = (packet[16] << 8) | packet[17]; ethOffset = 18; } if (etherType != 0x0800) return; // 只处理IPv4 const u_char* ip = packet + ethOffset; if (caplen < ethOffset + 20) return; int ipHdrLen = (ip[0] & 0x0f) * 4; if (ipHdrLen < 20 || caplen < ethOffset + ipHdrLen) return; if (ip[9] != 6 && ip[9] != 17) return; // TCP或UDP ConnKey k; memcpy(&k.srcAddr, ip + 12, 4); memcpy(&k.dstAddr, ip + 16, 4); const u_char* tp = ip + ipHdrLen; if (caplen < ethOffset + ipHdrLen + 4) return; k.srcPort = (tp[0] << 8) | tp[1]; k.dstPort = (tp[2] << 8) | tp[3]; MapValue val = gConnTable.lookup(k); if (val.pid == 0) return; if (val.direction == DIR_UPLINK) gUploadBytes += hdr->len; else gDownloadBytes += hdr->len; gPacketCount++; }统计流量用的hdr->len是数据包在链路层的原始长度,哪怕我们的snapshot length设得很小导致caplen只记录了一部分字节,hdr->len依然可信。这一点可以放心用来统计。
5.3 主程序与后台线程
主程序负责解析命令行参数、发现设备、打开网卡,然后启动刷新线程并进入抓包循环:
DWORD findProcessIdByName(const std::string& name) { HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snap == INVALID_HANDLE_VALUE) return 0; PROCESSENTRY32 pe = {0}; pe.dwSize = sizeof(pe); if (Process32First(snap, &pe)) { do { if (_stricmp(pe.szExeFile, name.c_str()) == 0) { CloseHandle(snap); return pe.th32ProcessID; } } while (Process32Next(snap, &pe)); } CloseHandle(snap); return 0; } void printDevices(pcap_if_t* devs) { int idx = 0; for (pcap_if_t* dev = devs; dev; dev = dev->next) { printf("%d. %s", idx++, dev->name ? dev->name : "(null)"); if (dev->description) printf(" -- %s", dev->description); printf("\n"); } } int main(int argc, char* argv[]) { if (argc < 2) { printf("Usage: %s <exe_name> [device_index]\n", argv[0]); return -1; } std::string targetExe = argv[1]; pcap_if_t* allDevs = nullptr; char errbuf[PCAP_ERRBUF_SIZE] = {0}; pcap_findalldevs(&allDevs, errbuf); int devIndex = 0; if (argc >= 3) { devIndex = atoi(argv[2]); } else { printDevices(allDevs); printf("请选择网卡编号: "); scanf("%d", &devIndex); } int idx = 0; pcap_if_t* dev = allDevs; while (dev && idx < devIndex) { dev = dev->next; idx++; } if (!dev) { printf("无效的设备编号\n"); return -1; } pcap_t* fpcap = pcap_open_live(dev->name, 65535, 0, 1000, errbuf); if (!fpcap) { printf("pcap_open_live failed: %s\n", errbuf); return -1; } // 编译过滤规则:只抓TCP和UDP包 struct bpf_program fcode; if (pcap_compile(fpcap, &fcode, "tcp or udp", 1, PCAP_NETMASK_UNKNOWN) < 0) { printf("pcap_compile failed: %s\n", pcap_geterr(fpcap)); return -1; } pcap_setfilter(fpcap, &fcode); pcap_freecode(&fcode); // 刷新线程:每2秒刷新一次端口映射表 std::thread refreshThread([&targetExe]() { while (gRunning) { DWORD pid = findProcessIdByName(targetExe); if (pid != 0) { gConnTable.setTargetPid(pid); gConnTable.refresh(); } else { gConnTable.setTargetPid(0); } std::this_thread::sleep_for(std::chrono::seconds(2)); } }); // 每秒打印一次统计 std::thread reportThread([]() { auto lastTime = std::chrono::steady_clock::now(); unsigned long long lastUp = 0, lastDown = 0; while (gRunning) { std::this_thread::sleep_for(std::chrono::seconds(1)); auto now = std::chrono::steady_clock::now(); double seconds = std::chrono::duration<double>(now - lastTime).count(); unsigned long long up = gUploadBytes.load(); unsigned long long down = gDownloadBytes.load(); double upSpeed = (up - lastUp) / seconds / 1024.0 / 1024.0; double downSpeed = (down - lastDown) / seconds / 1024.0 / 1024.0; printf("[%s] up=%llu bytes (%.2f MB/s), down=%llu bytes (%.2f MB/s), packets=%llu\n", targetExe.c_str(), up, upSpeed, down, downSpeed, gPacketCount.load()); lastUp = up; lastDown = down; lastTime = now; } }); // 主循环抓包 while (gRunning) { struct pcap_pkthdr* hdr; const u_char* data; int ret = pcap_next_ex(fpcap, &hdr, &data); if (ret == 1) { processPacket(data, hdr); } else if (ret == -1) { printf("pcap_next_ex error: %s\n", pcap_geterr(fpcap)); break; } // ret == 0 表示超时,继续循环即可 } gRunning = false; refreshThread.join(); reportThread.join(); pcap_close(fpcap); pcap_freealldevs(allDevs); return 0; }这个主程序完整可跑,命令如pcapmonitor.exe myapp.exe 0就启动了对myapp.exe的流量监控。设备编号如果不知道,可以先运行一次不加编号,程序会列出所有网卡。注意Npcap有环回适配器,如果目标是本机访问本机,选它。
6. 常见问题与排错实录
这部分内容真的是我做这个项目时一点点攒出来的。网上很多教程只放核心代码,把运行时的坑一带而过,结果新手照抄一遍跑不起来还不知道去哪查。下面几个问题按我遇到的频率排序。
6.1 抓包失败的原因
最常见的报错是pcap_open_live返回NULL,errbuf提示"Error opening adapter: The system cannot find the device specified"。通常三个原因:
- Npcap驱动没装好,或者程序不是管理员权限运行。pcap必须持有管理员令牌才能打开网卡。解决办法是在cmd里"以管理员身份运行"或者VS里设置调试时"以管理员身份调试";
- 设备名选错了。用pcap_findalldevs枚举出来的设备名是
\Device\NPF_{GUID}这种格式,直接手工敲很容易敲错; - 装了Npcap但没装.NET框架兼容层之类的情况很罕见,但试过也没关系。
还要注意,如果编译选项里没有包含Npcap SDK的头文件路径,连pcap.h都找不到,这是配置问题不是运行问题,按前面2.2节的配置重新检查。
6.2 有包但统计为0
现象是程序正常运行,设备也打开了,但输出的统计始终是0。首先要确认设备选对了,如果本机有虚拟机,VBox或VMware的虚拟网卡也可能出现在设备列表里,选错了连包都看不到。要选承载真实网络流量的物理网卡。
其次是端口映射表没有命中。GetExtendedTcpTable在不同Windows版本上的表现略有差异,部分老的系统需要先用GetTcpTable2之类的API确认内核表可用。但在我测试的Win10、Win11都没问题。
需要特别注意的是,一段TCP连接进入TIME_WAIT状态后,GetExtendedTcpTable默认还是能查到的,但如果你指定了TCP_TABLE_OWNER_PID_CONNECTIONS而不是ALL,可能会漏掉部分状态。所以代码里必须用TCP_TABLE_OWNER_PID_ALL。别问我怎么知道的,我第一次写成CONNECTIONS就漏了一个正在下载大文件的连接。
还有一个隐蔽的问题:当exe进程是Administrator权限,而你的监控程序是普通用户启动时,GetExtendedTcpTable里虽然能看到连接,但dwOwningPid对应的进程可能访问不了路径名。不过我们这里只按PID匹配,拿不到路径名也不影响统计。如果后续需要显示进程名,就得提升监控程序的权限。
6.3 程序崩溃与性能问题排查
这个项目的崩溃大多来自数据包解析越界。真实抓包时,网卡帧长度不一定都够我们读取,比如一个广播帧可能只有60字节,而你在解析时不检查caplen就继续往IP头后面读,直接内存越界或者读取到无效数据。解决的办法就是回调函数里每步都加边界检查,我上面代码里每个if都是必需的。看起来啰嗦,实则是保平安。
性能问题的源头往往是BPF过滤设置不当,或者caplen设得过大。如果只统计字节数,snapshot length设到256足够,因为统计完全依赖hdr->len,不在乎内容是否完整。另外如果程序CPU占用很高,检查一下是不是把pcap_open_live的timeout设成了0,那样pcap_next_ex会一直阻塞直到有包,而在空闲网络下等于空转。
还有一种情况是目标exe流量很大,连接数上千,每2秒刷新一次映射表会占用一定资源。这时可以改成只在查不到端口时才刷新,或者降低刷新频率到5秒,但那样短连接统计会不准,得自行权衡。我实际测试下来2秒能覆盖大多数HTTP短连接的存活时间,是可以接受的折中。
7. 个人实践中的几点体会
这个项目我最初是给公司内部一个通信组件做的,当时组件的同事说客户端启动后流量异常,怀疑有模块在后台疯狂重传数据包。用这个pcap监控工具跑了几分钟,清清楚楚看到某个版本的exe在持续向服务器发送探测包,每条TCP连接都是SYN_SENT然后被重置,方向全是上行。问题定位到是服务器端的防火墙策略变更导致的,一般的流量监控工具根本看不出这个现象。
还有一次我用它对比了一个软件在不同网卡上的表现,发现它对虚拟网卡也会尝试建立连接,白白浪费了带宽。这种细节层面的分析,靠任务管理器是干不了的。所以如果你也需要做同类事情,核心就是先把端口映射这张表维护好,后面的统计和分析自然就顺了。
代码里的UDP扩展点和四元组匹配思路,我已经留了清晰的注释,有需要可以继续往上报域名、按时间聚合,甚至导出pcap文件给Wireshark二次分析。整个方案胜在闭环且可控,任何时候想验证抓到的流量是不是真的,回到Wireshark里对着同一个包看一遍就行。