简介:一份基于WinPcap实现的UDP发包程序源码包,面向网络编程初学者、协议分析爱好者及课程设计开发者。程序演示了如何借助WinPcap在驱动层完成UDP数据报的构造与发送,适合用作理解UDP无连接特性和底层封包机制的入门范例,也可延伸至网络监控、安全检测等应用场景。压缩包共349个文件,约5.94MB,以C++工程为主,包含工程配置(.sln、.vcxproj)、源代码与头文件(.cpp、.h),并打包了libwpcap.a、libpacket.a等静态库;同时夹杂部分HTML、JS、CSS、图片等辅助素材,可作为说明文档或界面展示。目录层次覆盖源码、工程文件与编译支持,便于直接打开调试。目前已有187人学习下载。下载后可从源码层面拆解WinPcap发送UDP包的完整调用链,理解驱动级发包与普通socket的差异;也可修改目标地址、端口和载荷内容,快速搭建自定义UDP测试工具,或将相关思路迁移到网络监控、安全测试等实际项目中。
1. 一张网卡能做的事:WinPcap 把 UDP 发包变成可控操作
写网络调试工具的人,大多从 socket 起步,一个 sendto 就能把 UDP 丢出去。可真到了要精确控速、要复现某个报文、要把数据绕过本机协议栈直接打到网卡上的时候,socket 就有点使不上劲了。基于 WinPcap 实现的 UDP 发包程序源码包,解决的正是这个缺口:它枚举本机网卡、构造完整的以太网帧与 IP/UDP 头、自己算校验和,再通过 pcap_sendpacket 把包逐帧送到驱动。拿到这份源码改一改,你就能得到一台受控的 UDP 打流工具——做吞吐测试、协议栈验证、教学实验都合适。适合现场调试的网络工程师,也适合第一次接触 WinPcap 的嵌入式开发。下面从选型理由讲起,再落到能直接抄的代码和参数,最后把装驱动、算校验和这些翻车点逐个说清。
2. 为什么不用 socket 而要用 WinPcap:绕开协议栈的代价与收益
2.1 系统协议栈的「好心办坏事」
用 socket 发包时,数据要走一条很长的路:sendto 进入内核协议栈,协议栈帮你查路由、查 ARP 缓存、算校验和、做分片,最后才把帧交给网卡驱动。这一串对应用来说是个黑盒,多数时候确实是好事,但到了网络测试场景就反过来了。
我做吞吐测试时最头疼的,是速率抖动。socket 发送方的 UDP 数据会先在协议栈的发送缓冲区里排队,应用每调用一次 sendto,内核并不保证马上把包发出去。缓冲区积累过多时,实际出网速率会呈脉冲状,忽快忽慢。想模拟恒定比特率的 UDP 流,用 socket 几乎做不到,除非你把缓冲区调得很小,但那又会放大丢包和延迟抖动。
更麻烦的是字段限制。socket 替你填源端口、目标端口,替你选择出口网卡,你想伪造一个源 IP、想封一个半截 UDP 头、想故意构造错误的校验和来做健壮性测试,协议栈不会让你碰这些。而这些恰恰是网络测试里最常用的手段——验证对端是否校验、验证防火墙规则、验证 DPDK 应用的异常处理。所以结论很直接:越需要贴近线路的测试,越应该绕开系统协议栈。
2.2 从应用到网卡:WinPcap 的发送链路短在哪
WinPcap 的核心是一个叫 NPF(Netgroup Packet Filter)的内核驱动。常见用法是应用通过 pcap_open_live 打开设备句柄,向驱动申请发送缓冲,然后 pcap_sendpacket 把用户态帧拷贝进内核发送队列,驱动再把它写到网卡的发送描述符。这条链路没有路由、没有分片重组、没有传输层状态机,你给什么帧,网卡就发什么帧。
下面这张对照表是我选型时最常给同事看的,能一眼说明白两种方式的差别:
| 对比项 | socket (SOCK_DGRAM) | WinPcap (pcap_sendpacket) |
|---|---|---|
| 发送路径 | 应用→协议栈→路由/ARP/校验和→网卡 | 应用→NPF 驱动→网卡 |
| 能否指定任意源/目的 MAC 与 IP | 不能 | 能,所有字段自填 |
| 发送时间控制 | 受协议栈排队影响,抖动大 | 调用时机即发送时机,较可控 |
| 校验和 | 内核计算 | 应用自己算,算错也照发 |
| 超长包处理 | 协议栈按 MTU 自动分片 | 不分片,超长就发超长帧 |
| 适用场景 | 常规业务通信 | 抓包、造包、打流、协议验证 |
注意最后一行:WinPcap 的设计初衷是抓包,发包只是附加能力,所以它的发送吞吐上限和专用网卡驱动不是一个量级,但精度优于 socket。原因就是链路短,出网前的干预少。
2.3 用这套源码要接受的三个前提
选 WinPcap 之前先想清楚三个代价,否则后面排查问题时会一直怀疑代码写错。
第一,从以太网头到 IP 头到 UDP 头,所有字段必须自己算对。WinPcap 不做任何补全,你填错源 MAC,收端网卡可能直接丢帧;你填错 IP 总长度,抓包工具会给你标一个 invalid 警告,但包还是发得出去。
第二,MTU 分片得自己控制。因为包绕过协议栈,IP 层拆分不会发生,payload 超过 MTU 时,常见行为是网卡驱动截断或这帧直接发不出去,数据还照常返回成功。
第三,别指望对端回包。用 WinPcap 发的包相当于一条独立的全双工线路:出方向完全由你接管,但入方向还是走系统协议栈。对端回的 UDP 应答不会自动关联到你的发包程序上——现场调了一天以为包没发出去,结果是被测试的端口根本没监听的案例我见过不少。
3. 把源码跑起来:从枚举网卡到第一包 UDP 报文
3.1 源码包的主文件与编译前准备
这种 WinPcap 发包源码包通常就一个核心 C 文件加一个头文件,再带一个工程文件。拿到手先别急着编译,把依赖链捋清楚:WinPcap 开发包里的 wpcap.lib、Packet.lib 和头文件是缺一不可的三件套。老版本开发包是 WpdPack,新版本兼容包一般也保留同样的头文件接口。
我习惯用 CMake 而不是 vcxproj 来搭这个工程,换机器和换 VS 版本时少踩很多雷:
cmake_minimum_required(VERSION 3.10) project(udp_sender) set(PCAP_INCLUDE_DIR "C:/WpdPack/Include") set(PCAP_LIB_DIR "C:/WpdPack/Lib/x64") add_executable(udp_sender main.c) target_include_directories(udp_sender PRIVATE ${PCAP_INCLUDE_DIR}) target_link_directories(udp_sender PRIVATE ${PCAP_LIB_DIR}) target_link_libraries(udp_sender wpcap Packet)这段配置说明:WPdCap 的 Lib 目录下分 x64 和 x86,Debug 与 Release 混用会直接链接失败,报 unresolved external symbol。另外 wpcap.lib 建议开启延迟加载(VS 属性里设 /DELAYLOAD:wpcap.dll),这样程序启动时即使驱动没就绪,也能先走到错误提示分支,而不是直接崩溃。
3.2 打开网卡:findalldevs 与 open_live 的选型
发包第一步是选网卡。常见做法是用 pcap_findalldevs 枚举所有适配器,打印出来让用户挑。这一层接口比 pcap_open_live 直接按设备名打开要稳,因为不同机器上 NPF 设备的 GUID 完全不同。
#include <pcap.h> #include <stdio.h> #include <string.h> static int select_device(char *devname, size_t len) { pcap_if_t *alldevs = NULL, *d; char errbuf[PCAP_ERRBUF_SIZE]; if (pcap_findalldevs(&alldevs, errbuf) == -1) { fprintf(stderr, "枚举网卡失败: %s\n", errbuf); return -1; } int idx = 0, target = 0; for (d = alldevs; d != NULL; d = d->next) { printf("%d. %s [%s]\n", idx++, d->name, d->description ? d->description : "未知适配器"); } printf("输入网卡编号: "); if (scanf("%d", &target) != 1) { target = 0; } d = alldevs; for (int i = 0; i < target && d != NULL; i++) { d = d->next; } if (d == NULL) { fprintf(stderr, "网卡编号越界\n"); pcap_freealldevs(alldevs); return -1; } strncpy(devname, d->name, len); pcap_freealldevs(alldevs); return 0; }这段逻辑说明:d->name 形如 \Device\NPF_{A1B2...},这是内核驱动的设备路径,后面的 description 才是人能看懂的网卡名。枚举完必须调用 pcap_freealldevs 释放链表,很多新手在循环里反复枚举然后泄漏内存,发着发着就 out of memory。
拿到设备名后打开设备,这才是真正的关键参数位:
pcap_t *fp = NULL; char errbuf[PCAP_ERRBUF_SIZE]; bpf_u_int32 net = 0, netmask = 0; /* 查 IP 与掩码,查不到不代表不能发包 */ if (pcap_lookupnet(devname, &net, &netmask, errbuf) == -1) { net = 0; netmask = 0; } fp = pcap_open_live(devname, 65536, 0, 50, errbuf); if (fp == NULL) { fprintf(stderr, "打开网卡失败: %s\n", errbuf); return 1; }pcap_open_live 的五个参数里,65536 是抓包快照长度,对发包程序无所谓;第三个参数 promisc 我设 0,因为发包不依赖混杂模式;第四个参数 50 是读取超时毫秒数,它只影响收包和统计线程,对发送路径没有实际约束。这里最容易踩的坑是第三个参数:有人为了发包把混杂模式打开,结果一张共享网卡上其他业务的流量全部被这个程序嗅探到,现场直接翻车。
3.3 校验和与报文构造:最容易算错的一段
UDP 校验和是这套源码里唯一写错也不报错、但收端就是收不到的地方。校验和覆盖三块:伪头、UDP 头、载荷。伪头不真正出现在线路上,只是参与计算,IP 头校验和只覆盖 IP 头本身,这俩必须分清楚。
先写一个通用校验和折叠函数:
static unsigned short checksum(const void *buf, size_t len) { const unsigned short *p = (const unsigned short *)buf; unsigned long sum = 0; while (len > 1) { sum += *p++; len -= 2; } if (len == 1) { sum += *(const unsigned char *)p; } while (sum >> 16) { sum = (sum & 0xffff) + (sum >> 16); } return (unsigned short)(~sum); }这段的逻辑是所有校验和算法的同一套路:按 16 位累加,最后做折叠和取反。取反这步很容易漏,漏了之后算出来的值跟 Wireshark 预期的差一个 0xFFFF,抓包工具会提示 UDP checksum incorrect,但对端内核可能直接丢包。注意函数入参是主机序数据,调用前要把网络序转成主机序。
接着是 UDP 校验和专用函数,难点在伪头构造:
typedef struct pseudo_hdr { uint32_t src_ip; uint32_t dst_ip; uint8_t zero; uint8_t proto; uint16_t udp_len; } pseudo_hdr_t; uint16_t udp_checksum(const uint8_t *payload, size_t plen, uint32_t saddr, uint32_t daddr, uint16_t sport, uint16_t dport) { pseudo_hdr_t ph; ph.src_ip = saddr; /* 已是网络序 */ ph.dst_ip = daddr; ph.zero = 0; ph.proto = 17; /* IPPROTO_UDP */ ph.udp_len = htons((uint16_t)(8 + plen)); unsigned long sum = 0; sum += checksum(&ph, sizeof(ph)); uint8_t udp_hdr[8]; memset(udp_hdr, 0, sizeof(udp_hdr)); memcpy(udp_hdr, &sport, 2); memcpy(udp_hdr + 2, &dport, 2); memcpy(udp_hdr + 4, &ph.udp_len, 2); /* checksum 字段保持 0 */ sum += checksum(udp_hdr, 8); sum += checksum(payload, plen); while (sum >> 16) { sum = (sum & 0xffff) + (sum >> 16); } return htons((uint16_t)~sum); }参数里最容易被忽略的是 ph.udp_len 与 UDP 头里的 length 字段要一致,都等于 8 字节头加载荷长度。protocol 字段写成 17,是 UDP;写成 6 就是 TCP,抓包工具不会报错,但对端协议栈校验必挂。saddr 与 daddr 必须是网络序,从 inet_addr("192.168.1.2") 拿到的值直接填进来即可。
IP 头校验和可以复用第一个 checksum 函数,只算 20 字节的 IP 头,记得先把 header checksum 字段清零。整包构造顺序是:先填以太网头,再填 IP 头,再放 UDP 校验和,最后才轮到 payload——payload 本身不参与 IP 头计算。
3.4 发送循环:pcap_sendpacket 的返回值要看什么
报文构造完,发送循环反而是最简单的一段:
uint8_t frame[1514]; size_t frame_len = 14 + 20 + 8 + payload_len; /* 填完 eth/ip/udp 三个头部后: */ int ret = pcap_sendpacket(fp, frame, (int)frame_len); if (ret != 0) { fprintf(stderr, "发送失败: %s\n", pcap_geterr(fp)); return 1; }pcap_sendpacket 返回 0 表示帧成功提交给 NPF 驱动,-1 表示提交失败。注意「提交给驱动」不等于「已经到线路上」,真正发出是异步的。返回 -1 时一定要看 pcap_geterr,最常见的错误信息是 The NPF driver is not running,这基本等于告诉你驱动层没工作,跟你的帧内容无关,别浪费时间调报文了。
4. 打流参数怎么设:速率、包长与多网卡选择
4.1 发包速率:usleep 的精度陷阱与高精度替代
很多人拿到源码第一件事就是调发送间隔。源码里的常见写法是 usleep(interval_us),在 Linux 上还算凑合,在 Windows 上就非常玄学:usleep 的实际分辨率取决于系统定时器节拍,系统把时间粒度切成 15.6ms 时,你写 usleep(1000) 想睡 1ms,实际可能睡 15ms 甚至更多。
要做稳定的 UDP 打流,我一般推荐两条路。低要求场景直接用 timeBeginPeriod(1) 把系统定时器分辨率改成 1ms,配合 usleep,能把速率误差控制在个位数百分比;高要求场景用 QueryPerformanceCounter 做自旋等待,等到计数差达到目标间隔再发下一帧。后者吃 CPU,但精度能到微秒级。代码里我会把发送间隔做成参数,默认 1000us,跑 1000pps 的场景先用 timeBeginPeriod(1) 顶着,够用且不折腾。
4.2 包长与 IP 分片:什么时候该把 payload 压到 1400
WinPcap 发包不经过 IP 分片逻辑,所以 payload 超 MTU 时,帧会原样交给网卡驱动。标准以太网 MTU 是 1500,减掉以太网头 14、IP 头 20、UDP 头 8,payload 理论上限是 1472 字节。但这个上限在不少驱动和交换机上有兼容性问题,常见做法是直接压到 1400 以内。
我自己的经验值是:payload 超过 1400 就要在发送端先自己做分包。分包不是 IP 分片,而是应用层把数据拆成多段,每段塞进一个 UDP 包,接收端按序号重组。这个方案比依赖 IP 分片干净得多,因为 IP 分片时只要一片丢失,整个数据报就废了,应用层根本不知道发生了什么。
4.3 多网卡挑选与目的 MAC 的来源
笔记本多网卡是常态,无线、有线、虚拟网卡堆在一起,findalldevs 枚举出的顺序不稳定。挑选规则我按三步走:第一,过滤掉 d->name 里含 Loopback 或 NPF_Loopback 的虚拟回环;第二,对剩下的网卡打印 description,让用户按名字挑;第三,挑完后用 pcap_lookupnet 校验有没有 IP,没有 IP 的网卡能发包但收不到回包,要提前提示。
目的 MAC 是另一个常见坑。目标 IP 是程序参数,目标 MAC 可从两条路拿:目标是局域网内主机,发个 ARP 请求,从应答里解析;目标是外网,填默认网关的 MAC。源码包如果没带 ARP 解析,最简单的方式是把目的 MAC 作为命令行参数传进来,先用 ping 把 ARP 缓存填上,再用 arp -a 查出来手填。填错 MAC 的现象非常隐蔽:包发得出,对端网卡直接不认。
5. 驱动安装与流量调试的避坑记录:NPF 不启动到校验和失效
5.1 驱动装不上与 NPF 服务不启动的常见表现
现象:程序启动后立刻报 The NPF driver is not running,或者 pcap_findalldevs 返回的列表是空的。
原因:新版 Windows 对旧版 WinPcap 的驱动签名策略收紧了,装完驱动没真正生效;另一种情况是装驱动前有抓包软件正占着 NPF,装到一半回滚。
解决:先用 sc query npf 看服务状态,如果是 STOPPED,在隔离环境下重装。常见做法是卸载后重启再装,安装前关掉所有抓包工具。还不行就换签名兼容的 Npcap,在安装选项里勾上 WinPcap 兼容模式,接口不变,源码不用动。
提示:驱动重装属于高风险操作,我只在隔离测试环境里做,不要在带生产业务的机器上反复折腾,会把一堆服务的网络句柄一起弄挂。
5.2 抓包工具与发包程序抢同一张网卡
现象:发包程序跑着跑着突然报错,有时是 Send error,有时是 handle 无效,但代码一行没改。
原因:Wireshark 这类抓包工具和发包程序共用 NPF 驱动,抓包状态会占用网卡的部分缓冲和中断处理资源,实时发送的包会被延迟甚至丢弃。
解决:打流时不在这台机器上开抓包验证,抓包放到接收端,或者用第二张网卡抓。我在现场调 UDP 丢包率时,从来都是把发包机和抓包机物理分开,避免这类干扰。
5.3 UDP 校验和算错:现象是「能发不能收」
现象:pcap_sendpacket 返回 0,发送端计数正常,接收端 Wireshark 一个包都看不到,或者看到标红 UDP checksum incorrect。
原因:校验和覆盖范围算错。最常见是只算了 UDP 头加 payload,漏了伪头;其次是伪头里的 proto 写成 6;还有是把 UDP length 字段的网络序写反,导致校验和按错误的长度字段计算结果。
解决:先把接收端 Wireshark 的校验和列显示出来,它会精确告诉你坏在 IP 头还是 UDP 头。再按第三小节的顺序逐段核对:伪头四元组、UDP 头前四个字节、长度字段。这类问题定位时先清空校验和字段发一帧,Wireshark 会标注 checksum offload 或者 incorrect,就能确认帧确实到了线上,问题只在校验和本身。
5.4 发送过快导致 CPU 满载与丢包
现象:把发送间隔调到 0 或 50us,程序 CPU 占用到 100%,实际吞吐反而下降,收端丢包率到了不可用的程度。
原因:pcap_sendpacket 每次调用都穿两次用户态/内核态切换,单次开销一般几十微秒。间隔太小时,时间全耗在系统调用上,自旋等待又额外烧掉一个核,网卡发送队列反而来不及消费。
解决:间隔不要低于 100us,要更高吞吐就走第 6 章的 pcap_sendqueue。另一个技巧是不要用 while(1) 空转,每次循环里至少留一个短 sleep,给驱动一个机会把队列里的帧真正搬出去。
6. 队列式批量发送与回环验证:让这个源码变成可用工具
6.1 用 pcap_sendqueue 降低 CPU 占用
单包循环在打到 8 万 pps 之后就上不去了,瓶颈在系统调用开销。WinPcap 提供 pcap_sendqueue 接口,一次把几百个已构造好的帧提交给驱动:
pcap_sendqueue *sq = pcap_sendqueue_alloc(1024 * 1024); if (sq == NULL) { fprintf(stderr, "发送队列分配失败\n"); return 1; } struct pcap_pkthdr hdr; hdr.len = (bpf_u_int32)frame_len; hdr.caplen = hdr.len; for (int i = 0; i < burst; i++) { if (pcap_sendqueue_queue(sq, &hdr, frame) == -1) { fprintf(stderr, "入队失败\n"); break; } } if (pcap_sendqueue_transmit(fp, sq, 0) == -1) { fprintf(stderr, "批量发送失败\n"); } pcap_sendqueue_destroy(sq);说明:分配 1MB 空间,可以放 600 多个标准 1500 字节帧,一次 transmit 全交出去。第三个参数 sync 传 0 表示立即发送,传非 0 表示要配合内核同步,那个是抓包场景用的,发包程序不要动。批量发送时 CPU 占用明显下降,因为系统调用次数缩小了一个数量级。
6.2 验收方法:两台机器互抓
这套源码改完以后,我在正式使用前必经一轮验收。方法是两台机器用网线直连,A 机跑发包程序,B 机开 Wireshark 抓包。抓包结果里要看三点:一是每秒收到的帧数是否等于配置的速率;二是包里的自增序号是否连续,跳号就是丢了;三是 Wireshark 有没有标 invalid 告警。直连环境避开了交换机转发和广播域干扰,出现问题时定位面最小。
6.3 最后的工程习惯
这几年用 WinPcap 做 UDP 发包,我养成的一个习惯是:新改一次代码就先发广播包验证链路,再发单播验证字段。广播包对目的 MAC 要求低,能快速筛掉驱动和网卡问题;单播能过,说明 MAC 和校验和都对了。这个顺序帮我省掉了大量熬夜排查的时间。毕竟最贵的不是重装驱动那几分钟,而是对着一个「返回成功但收不到」的黑匣子干瞪眼。希望帮到你。
本文还有配套的精品资源,点击获取