
写这篇东西的起因很简单我手头有个自己写的代理客户端每次一跑起来流量哗哗往上走但任务管理器里只能看到一个总带宽曲线根本分不清是哪个模块在偷跑。后来项目里又要评估一个第三方 exe 的网络行为我不得不想办法把这个 exe 产生的流量单独算出来。折腾了一圈下来最后落地的方案就是 C 调 pcap 抓包再配合进程的网络端点表把包的归属权追到具体 PID 上。这条路线的核心思路不复杂pcap 负责把网卡上的包完整拿到手然后我们自己维护一份“这个 exe 建立了哪些连接、绑定了哪些端口”的映射两者一对照每个包该算到谁的头上就清清楚楚了。这篇博文就是把我从选型、写代码到填坑的完整过程记录下来适合正在做进程级流量统计、客户端网络诊断或者 exe 行为审计的朋友参考。1. 方案选型为什么绕了一圈还是回到 pcap1.1 任务管理器类方案的天然短板最先想到的肯定是系统自带的那套。任务管理器“应用历史记录”里确实有按进程统计的网络使用量但它只给你一个汇总数字没有任何包级别的信息。你想分析这个 exe 在什么时候连了哪些 IP、走的是什么协议、上传和下载各占多少它统统做不到。Windows 资源监视器倒是能按进程列出 TCP 连接但它的更新频率很低很多短连接压根就来不及显示就消失了。资源监视器也基本只有“看着多、抓不住细节”的能力。性能计数器 Performance Counter 也能读到“某进程发送/接收的字节数”问题在于这些计数器的数据源和统计口径不完全透明对于异步 I/O 完成的网络请求计数经常滞后。我试过一次拿它算一个做视频上传的客户端结果跟实际抓包统计出来的数值差了将近 20%。所以系统自带的方案只能算“能用”谈不上“够用”。1.2 三条正经技术路线的对比要做进程级的精确流量统计业内真正值得考虑的无非是三条路性能计数器PerfMon Network Interface / Process 对象优点是接入简单缺点是无法关联到远程地址也无法感知非 TCP 流量的细节WFPWindows Filtering Platform在系统的网络栈里挂一层过滤驱动优点是能拿到真正的进程 ID而且性能和精确度都很好缺点是开发门槛高一个 WFP 驱动项目光是环境配置就够折腾半天而且签名和部署都麻烦pcap 抓包 连接表关联应用层开抓代码量小、可控性强任何类型的包TCP、UDP、ICMP都能拿到缺点是需要自己把“包”映射到“进程”。最后我选了第三条。核心原因很实际我不想为了查一个 exe 的流量去写驱动也不想被系统计数器把判断逻辑绑死。pcap 抓包拿到的原始数据包是全网卡级别的我可以自己定义“什么算下载、什么算上传”也可以随时加过滤条件实时看某个 IP 的往来情况。至于进程关联的问题Windows 自带 iphlpapi一行 API 就能把 TCP 连接表连同进程 ID 一起读出来完全可以弥补 pcap 的短板。这条路对绝大多数做工具类软件、网络审计脚本、以及想搞清楚自家客户端网络行为的人来说是性价比最高的。2. 环境准备Npcap 和 C 项目的最小搭法2.1 为什么必须装 Npcap 而不是 WinPcapWinPcap 已经太久没更新了在 Windows 10 和 Windows 11 上安装时经常会被提示驱动不兼容而且它不支持 Npcap 新增的许多现代网卡特性和回环接口捕获。所以在 2025 年这个时间点不要有任何犹豫直接选 Npcap。Npcap 安装时有两个选项值得注意。第一个是“支持 802.1Q VLAN 标签”如果你的网络环境里有 VLAN建议勾上否则抓到的包可能在链路层解析时出现偏移解析 IP 头就会出错。第二个是“限制 Npcap 在管理员模式下使用”为了开发调试方便这个选项可以不勾。不过即便如此实际抓包时程序还是必须用管理员权限运行这一点后面排查问题的时候再细说。开发包方面Npcap 安装目录下自带 SDK路径一般是 C:\Program Files\Npcap\SDK。里面包括了头文件和静态库主要有 wpcap.lib 和 packet.lib。我习惯把 SDK 里的 Include 和 Lib 目录直接配置进 Visual Studio比手动拷贝 dll 省事。另外pcap 的很多 API 依赖 Winsock 的类型定义所以工程里需要提前包含 Winsock2.h并且链接 ws2_32.lib。2.2 第一个能跑的抓包骨架写一个最小抓包逻辑只需要三步罗列设备、打开设备、循环取包。第一步用 pcap_findalldevs 枚举所有网卡接口。返回的是一个 pcap_if_t 链表每个节点就是一个网卡。我要做的只是找到第一个有 IPv4 地址、且不是回环接口的设备然后把它打印出来。代码长这样#include pcap.h #include winsock2.h #include iphlpapi.h #pragma comment(lib, wpcap.lib) #pragma comment(lib, ws2_32.lib) #pragma comment(lib, iphlpapi.lib) int main() { pcap_if_t* alldevs nullptr; char errbuf[PCAP_ERRBUF_SIZE]; if (pcap_findalldevs_ex(PCAP_SRC_IF_STRING, nullptr, alldevs, errbuf) -1) { printf(枚举设备失败: %s\n, errbuf); return 1; } for (pcap_if_t* dev alldevs; dev; dev dev-next) { if (dev-addresses dev-addresses-addr) { printf(设备名: %s\n, dev-name); printf(描述: %s\n, dev-description ? dev-description : (无)); } } pcap_freealldevs(alldevs); return 0; }第二步选择设备后进行 pcap_open_live。这里有个参数需要解释一下snapshot 长度、混杂模式、超时时间、缓冲区大小。snapshot 长度建议设为 65536保证不会把大包截断混杂模式设置为 1这样能看到不是发给本机 MAC 地址的包但如果你只关注本机进程设不设混杂模式其实影响不大超时时间设为 100 毫秒这个值影响 pcap_next_ex 的阻塞行为——设置太短会频繁空转太长则会让退出响应变慢100 毫秒是很平衡的选择。第三步就是 pcap_next_ex 循环。但直接这么写只能看到一片字节毫无可读性。真正到抓包这一步我们先要把协议解析摆上桌面。3. 核心难点把一个包准确算到某个 exe 头上3.1 为什么 pcap 本身不知道“包属于哪个进程”这是我认为整件事里最需要想明白的一个点。pcap 抓到的只是网卡驱动从链路上复制的原始帧帧头里只有 MAC 地址、IP 地址、端口号完全没有进程信息。操作系统里的 TCP/IP 协议栈知道这个连接是哪个 PID 建立的但 pcap 在协议栈下方工作它拿不到这个信息。所以我们必须绕过一层先问操作系统“当前有哪些 TCP/UDP 连接每个连接属于哪个 PID”然后拿连接信息去对照抓到的包。比如某个包的四元组是 192.168.1.5:52340 → 8.8.8.8:443而系统的连接表里恰好有一条记录本地地址是 192.168.1.5:52340远端地址是 8.8.8.8:443PID 是 1234那这个包就理直气壮地算到 1234 这个 exe 头上。3.2 用 GetExtendedTcpTable 读连接表Windows 提供的接口是 GetExtendedTcpTable通过 iphlpapi.h 调用。我用的方式是先拿表的大小再分配缓冲区最后再调用一次拿到真正的数据。这样比一次性分配一个固定大小的大缓冲区要稳妥。获取 TCP 连接表的代码std::mapstd::pairin_addr, uint16_t, DWORD g_tcpOwner; void RefreshTcpTable() { ULONG size 0; GetExtendedTcpTable(nullptr, size, FALSE, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0); std::unique_ptrBYTE[] buffer(new BYTE[size]); PMIB_TCPTABLE_OWNER_PID table reinterpret_castPMIB_TCPTABLE_OWNER_PID(buffer.get()); if (GetExtendedTcpTable(buffer.get(), size, FALSE, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0) ! NO_ERROR) { return; } g_tcpOwner.clear(); for (DWORD i 0; i table-dwNumEntries; i) { MIB_TCPROW_OWNER_PID row table-table[i]; std::pairin_addr, uint16_t key; key.first.S_un.S_addr row.dwRemoteAddr; key.second ntohs((uint16_t)row.dwRemotePort); g_tcpOwner[key] row.dwOwningPid; } }这里有几个容易被坑的细节。第一个是 MIB_TCPROW_OWNER_PID 里的端口字段是网络字节序直接用会得到一个大得离谱的端口号需要 ntohs 转换。第二个是 dwRemoteAddr 也是网络字节序但如果你要打印的话用 inet_ntoa 是没问题的因为它同样期望网络字节序。第三个是这张表的更新频率和覆盖范围有限制——一个 TCP 连接在 close 之后会从表里消失而我们捕获的数据包可能还残余一些最后发送或重传的包这些包会因为映射缺失而无法归属。实际上这种“连接消失导致包找不到进程”的情况非常常见。因为 pcap 抓包本身就是持续性的而连接表是我们定时去取的快照两者天然有时间差。我的解决办法是把快照刷新周期缩短到 300 到 500 毫秒并且给“找不到进程的包”单独加一个归类和统计这样至少能知道有多少流量是漏掉的不至于把不完整的数字当成最终结果。3.3 UDP 流量的归属问题这一节是很多教程都不会讲的。TCP 连接表非常容易拿到进程因为 TCP 是面向连接的状态管理明确。但 UDP 进程级归属要麻烦得多。Windows 的 GetExtendedUdpTable 返回的是 MIB_UDPTABLE_OWNER_PID这里面只有本地地址和本地端口没有远程地址和远程端口。哪怕你抓到一条 UDP 包去向是 1.1.1.1:53你也无法从这张表里直接知道它是哪个进程发出的因为表里只有“本机 UDP 端口 → PID”的映射。所以如果你的 exe 主要走 UDP 通信比如某些游戏、VoIP 软件、QUIC 类的下载工具只靠 GetExtendedUdpTable 是兜不住的。我在项目里对 UDP 的归属做了兜底处理用“本地端口 → PID”映射来匹配只要一个 UDP 包的源端口或目的端口和表中某个本地端口吻合就把它算到那个进程头上算不上的单独归到 unknown。这个方案在单进程、单端口场景下相当准但如果你开了好几个都依赖 UDP 的软件就会混。这是一个必须接受现实的限制。如果你对 UDP 进程归属的精度有硬性要求那就不该停留在 pcap 这一层了更合适的是走 WFP 或者 ETW那样能直接拿到每个 UDP 发送操作的进程 ID。关于这部分我会在文章后面再介绍一个轻量替代方案。4. 过滤条件与流量计算从裸数据包到“这个 exe 用了多少流量”4.1 写 BPF 过滤表达式拿到目标 exe 的 IP 和端口之后我们就可以在抓包时直接把无关流量过滤掉。pcap_compile 和 pcap_setfilter 组合使用struct bpf_program fp; const char* filter tcp or udp; if (pcap_compile(handle, fp, filter, 1, PCAP_NETMASK_UNKNOWN) -1) { printf(BPF 编译失败: %s\n, pcap_geterr(handle)); return -1; } pcap_setfilter(handle, fp);这里的优化不止一处。如果你已经知道目标 exe 固定连哪几个服务器可以把过滤表达式改成 host 1.2.3.4 or host 5.6.7.8这样抓包数量会骤减CPU 占用也会明显下降。但如果是想统计“这个 exe 所有流量”那你不能只过滤目标主机的 IP因为你不知道它会连哪些 IP——所以更合适的做法是不过滤远端 IP而是只过滤端口关联tcp port 443 or tcp port 80 or udp port 53。或者干脆先不过滤把所有包都抓进来在用户态做归属判定。这里我多说一句 pcap 的过滤机制。BPF 过滤是在内核驱动里完成的能极高地降低用户态接收数据的数量级。如果你的程序声明了要统计整个系统所有进程就不要急着在驱动层过滤因为这样会把本应统计的数据挡在外面。最稳妥的是在驱动层只过滤“跟本机 IP 相关的包”也就是 ip host 192.168.1.5然后在用户态再根据连接表区分进程。这个组合是我实测在千兆网卡满速抓包时还能跑得动的原因——用户态收到的包数量比全量抓包少了至少一个数量级。4.2 如何判定方向上传还是下载判断一个包是上传还是下载理论上有两种做法。第一种是最直观的看源 IP 是不是本机 IP。如果源 IP 是本机说明这是本机发出的包算上传反之算下载。这种方法在绝大多数情况下都正确但有一个例外本机可能有多个网卡比如有线网卡和虚拟网卡流量走了别的接口导致源 IP 判断错误。第二种做法是配合连接表找到匹配的 PID 之后再看这条连接的“本地地址和本地端口”到底是包里的源还是目的。bool IsUpload(const ip_header* ip, const tcp_header* tcp, const std::setstd::pairin_addr, uint16_t localSet) { std::pairin_addr, uint16_t src {ip-src_addr, tcp-src_port}; return localSet.count(src) 0; }第二种在开发中更可靠。因为 GetExtendedTcpTable 返回的 row 里有 dwLocalAddr 和 dwLocalPort 字段这两个组合就是“这 exe 建立的本地端点”。我们把所有要统计进程的本地端点收集到 set 里以后每个包只要匹配源端点在这个集合里就一定是上传否则是下载。采用这套逻辑还能顺带解决多网卡的问题。4.3 字节数怎么算才准确抓到包以后最直接的字节数可以用 pcap_pkthdr.len也就是“线上传输的长度”。但如果要统计应用层产生的流量最好从网络层去算。以太网帧有 14 字节头是链路层的头。IP 头则有可选字段长度不固定所以要把 IP 头的真实长度取出来。size_t GetIpPayloadLength(const uint8_t* pkt, size_t len) { if (len sizeof(ether_header)) return 0; const ip_header* ip (const ip_header*)(pkt sizeof(ether_header)); size_t ipHeaderLen ip-ver_ihl 0x0F; ipHeaderLen * 4; size_t ipTotalLen ntohs(ip-total_length); if (ipTotalLen len - sizeof(ether_header)) { ipTotalLen len - sizeof(ether_header); } return ipTotalLen - ipHeaderLen; }这里有个容易忽略的点。IP header 的 total_length 字段包含 IP 头本身而我们要算的是“这个包对这个进程流量数据量的贡献”。有两种口径口径一算 IP 层总长度包括 IP 头。好处是跟大多数路由器统计口径一致。口径二算 IP 载荷长度即 TCP/UDP 段及其载荷的总长。好处是减去了网络层协议开销。我在实践中用的是口径一也就是把 IP 头也算进流量理由很直接运营商和路由器计算流量时本来就是算 IP 总长overhead 也是真实流量的一部分。如果做的是应用行为分析你可以用口径二代码只需要一行改动。无论用哪种口径一旦选定就全程统一不要混用否则统计结果会无法横向对比。5. 完整实现一个能直接跑起来的进程流量统计工具5.1 程序总体结构一个最小的程序拆成三个模块就够了模块一网络设备枚举和 pcap 打开模块二定时刷新 TCP/UDP 连接表把“远端 IP:端口 → PID”和“本地端点 → PID”的映射整理好模块三pcap 抓包循环每抓到一个包先根据四元组查连接表命中就累加计数并区分上传和下载。我在实际项目中使用一个 std::thread 专门跑抓包循环另一个 std::thread 负责每 300 毫秒刷新连接表。由于抓包回调运行在主抓包线程映射表又是跨线程访问所以必须加锁。用 std::shared_mutex 比较好连接表刷新的线程很少写抓包线程频繁读读写锁能减少锁竞争。/完整代码太长我拆成关键片段给你看。这是抓包累加的核心std::atomicuint64_t g_uploadBytes{0}; std::atomicuint64_t g_downloadBytes{0}; std::mapstd::pairin_addr, uint16_t, DWORD g_ownerMap; std::shared_mutex g_mapMutex; void OnPacket(u_char* user, const pcap_pkthdr* hdr, const u_char* data) { size_t ipLen GetIpPayloadLength(data, hdr-len); auto* ip (const ip_header*)(data 14); auto* tcp (const tcp_header*)(data 14 (ip-ver_ihl 0x0F) * 4); std::pairin_addr, uint16_t remoteKey; remoteKey.first.S_un.S_addr ip-src_addr; remoteKey.second ntohs(tcp-src_port); std::shared_lock lock(g_mapMutex); auto it g_ownerMap.find(remoteKey); if (it g_ownerMap.end()) return; if (it-second ! g_targetPid) return; // 判断方向看本地端口是源还是目的 ... }5.2 测试一个真实 exe 的效果我用自己做的一个网络测试小工具试验了一下它启动后会连接一个 HTTP 服务器下载一个 10 MB 的文件然后上传一个小 JSON。程序输出是这样[12:00:01] 目标 PID: 8124 [12:00:01] 打开设备: \Device\NPF_{...} [12:00:02] 已过滤设备下的流量 [12:00:03] 上传: 2.4 KB 下载: 1.02 MB 未归属: 1.2 KB [12:00:04] 上传: 2.4 KB 下载: 3.89 MB 未归属: 1.2 KB ... [12:00:06] 统计完成 上传总量: 8.9 KB 下载总量: 10.2 MB 未归属流量: 4.3 KB下载数字比 10 MB 略大这是正常现象——TCP 连接建立本身有三次握手包和 ACK 确认包加上可能存在的 HTTP 头部和 TLS 握手实际线路上传的字节一定大于应用层文件大小。而“未归属流量”往往是连接刚关闭、映射还来不及刷新的尾巴流量以及少量发到同一目标但端口查不到的 UDP 包。5.3 如何把结果导出成“exe 流量使用报告”统计完成后输出不能只有汇总数字。我建议至少输出以下内容目标进程的 PID 和进程名上传/下载总字节数TCP/UDP 分别的占比按远端 IP 汇总的流量分布未归属流量的字节数。按远端 IP 汇总特别有用。它能直观告诉你这个 exe 到底在跟哪些服务器通信。我当时统计一个第三方 exe 时发现它有大量流量指向一个本不该出现的云存储 IP这就是一次典型的“偷跑流量”审计。实现很简单在 OnPacket 里再维护一个 std::mapin_addr, uint64_t每次累加时顺便按远端 IP 计数最后输出时用 inet_ntoa 转成点分十进制的字符串即可。6. 连接表快照之外一个更省事的备选方案如果你不想每一步都自己维护连接表Windows 上其实有一个读写更省事的数据来源etsys。严格来说是 ETW 中的 Microsoft-Windows-Kernel-Network 事件它能在每个 TCP/UDP 收发事件上直接带出进程 ID。但这个路线的接入成本比 pcap 高需要解析 ETW 事件流而且不同 Windows 版本的事件布局天差地别。相比之下还有一个折中的办法直接用 netstat 命令行工具的外部输出在脚本里定时跑 netstat -ano然后把结果喂给抓包程序。我早期原型就是这么跑的代码不复杂也能快速验证方案。netstat 的缺点就是文本解析有点丑陋而且每次启动 netstat 都有几十毫秒的额外开销频繁调用会明显增加 CPU。但它的最大好处是零代码试水你可以在动手写大工程之前先用它验证思路是否可行。如果目标 exe 的流量不复杂比如只是简单的短连接 HTTP 请求我建议你先用 netstat 方案验证一天看看能不能满足需求。如果发现连接建立得太快总是抓不到再切换到 GetExtendedTcpTable。不要一上来就写一个几百行的 C 程序。7. 实测中的难点与避坑记录7.1 必须用管理员权限运行这是新手最容易踩的坑。我第一版程序抓不到任何包找了半天才发现是权限问题。pcap_open_live 在普通权限下能打开设备但设置过滤条件后收到的包数量几乎为零Npcap 默认在非管理员模式下会限制数据包进入用户态。所以写完之后务必在调试配置里把“强制以管理员身份运行”打开或者直接右键管理员运行。7.2 抓包缓冲区大小和丢包pcap_open_live 的最后一个参数是缓冲区大小默认我用 1 MB。如果某些网卡流量较大用户态处理不过来驱动就会把缓冲区的旧包丢掉导致统计结果偏小。Npcap 默认支持最高 256 MB 的内核缓冲区在 pcap_open 时可以用 pcap_set_buffer_size 设置。我实测在千兆网卡下把缓冲区加到 64 MB 之后丢包率就从千分之几降到了零。代价是内存占用上升但这在现代 PC 上完全不是问题。具体的设置方式是打开设备之后先调用 pcap_set_buffer_size(handle, 64 * 1024 * 1024)再调用 pcap_setfilter。因为缓冲区的设置在激活阶段生效如果设备已经激活了再设置就无效了。7.3 本地回环流量抓不到Npcap 默认支持抓回环接口的流量你需要专门选 loopback 设备。但这里有个坑Npcap 的回环包跟在真实网卡上抓到的包不同它们是以以太网帧形式模拟出来的。如果你的程序只针对真实网卡写死了 14 字节的链路层偏移抓回环时会解析错位。解决方案是用数据链路层的类型来判断偏移。当前 Npcap 的回环设备多数情况下返回 DLT_EN10MB和有线网卡一样但 Windows 的老版本可以用 DLT_NULL。稳妥的做法是对每个设备读取 pcap_datalink然后根据返回值决定解析函数。如果你的 exe 的通信对象是对本机回环地址的服务器而这个地址又被系统回环驱动接管了那么网卡设备是抓不到的你需要打开 Npcap Loopback Adapter并针对 DLT_EN10MB 做特殊判断。7.4 网卡顺序变了导致设备写死失效pcap_findalldevs 返回的设备名类似于 \Device\NPF_{GUID}这个 GUID 在重启后未改变但如果你在代码里按索引顺序选设备可能今天选到有线网卡明天就变成虚拟网卡。我在一个项目里吃过这个亏后来改成遍历设备列表用设备描述里的关键词匹配来选网卡比如“Ethernet”或“Wi-Fi”匹配不到时再按索引兜底。如果你的运行环境相对固定也可以让程序把网卡名写到配置文件中启动时直接读取这样不会选错。7.5 统计结果的偏差来源最后必须提醒的是任何 pcap 方案都存在统计偏差。一是可能丢包导致统计偏低二是 IP 分片包处理不当导致某个分片没算进数据量三是 TCP 重传包会被多次统计因为我们计算的是“线路上看到的包”而不是应用层实际产生的数据。如果你要的是相对精确的应用层流量重传是个干扰因素现实中的应用层流量统计总是比理论值略高。实际应用时要明白这一点并跟需求方约定好统计口径。7.6 IPv6 流量暂时放弃我上面的代码全部基于 AF_INET 的 IPv4。如果你的目标 exe 大量使用 IPv6 连接pcap 抓包时依然能抓到 IPv6 的包但连接表获取就得换成 AF_INET6 版本的 GetExtendedTcpTable字段也变成了 16 字节的 in6_addr。解析和打印都会复杂不少。我个人的经验是优先支持 IPv4再根据实际需求决定是否补上 IPv6。如果你抓的包里面 IPv6 占比超过 10%建议尽早把 IPv6 解析加进去否则统计结果会少一大块。8. 一个更轻量的补充不抓包也能估算“某个 exe 的流量”如果你只是需要一个“大致可信”的流量数字不要求看到每个包的细节Windows 自带的 PerfMon 性能计数器有一条捷径进程对象的 “IO Read Bytes/sec” 和 “IO Write Bytes/sec” 其实涵盖所有文件与网络 IO混在一起但网络相关的更准确应为 Network Interface 不再按进程。所以这一层要拿到进程粒度网络 IO得借助 WFP 或者 Sysmon。Sysmon 的 Network connection 事件可以被 ETW 订阅里面有完整 PID、目标 IP、端口和传输层协议。如果只是应急快速获得粗粒度数据我试过用 PowerShell 周期读取 Get-NetTCPConnection把 LocalAddress、LocalPort、OwningProcess 以及对应的 TCP 字节统计拼起来。但问题依旧是短连接抓不到而且 UDP 的远程地址信息缺失。用它做长期监控数据缺口会让你头疼。所以我给的建议很明确你的目标是“精确到进程、且能看清每个连接对象”就直接上 pcap 方案目标只是“大概知道这个进程一天跑了几百 MB 上下”用系统自带工具就够了。两条路线的成本差了十倍不要为了一个粗需求投入精致的工具链。最后再分享一个我在项目里常用的调试技巧抓包程序里加一个“详情模式”把每条命中目标进程的包打印出来格式是“远端 IP:端口 方向 字节数 时间戳”。排查问题时开着它跑几十秒几乎一眼就能看出这个 exe 的网络行为模式。等到确认过滤规则没问题再把详情模式关掉改成只统计总量。这个小开关帮我节省了大量验证时间也算是一路踩坑下来的副产品。