本文用通俗易懂的方式,讲解如何把网络原始数据包实时捕获到环形缓冲区中 ------ 这是网络安全、流量监控、抓包分析领域的核心技术,不依赖复杂框架,纯 C 语言实现轻量级、高性能的数据包缓存方案,解决高流量下抓包丢包、存储卡顿的问题。
一、这东西到底解决了什么问题?
想象你是一名网络管理员,某天服务器突然出问题,你想查原因,但故障只持续了几秒钟。等你反应过来启动抓包工具,黄花菜都凉了。或者你24小时不间断抓包,硬盘很快就被塞爆。
这时候你就需要一种"循环录音带"式的抓包方案:一直在录,但只保留最近的一段时间/一定量的数据。当故障发生时,你只要按下"保存"键,就能把刚才那段数据存下来。这就是环形缓冲区(Ring Buffer)的核心思想。
二、核心概念:什么是环形缓冲区?
2.1 生活中的类比
想象一个只能容纳10个球的管道:
- 普通队列:球从左边进,从右边出。管道满了就塞不进去,必须等前面的球出去
- 环形缓冲区:管道是环状的,装满后继续放新球,新球会把最老的球顶掉
这就是"先进先出,满了覆盖"的机制。
2.2 数据结构长什么样?
c
struct ringbuf {
size_t size_max; // 缓冲区最大容量(比如50MB)
size_t size_curr; // 当前已用空间
size_t num_elems; // 当前存储的元素个数
struct r_list *first; // 最老的元素(下一个要被覆盖的)
struct r_list *last; // 最新的元素
};
// 每个元素是个链表节点
struct r_list {
void *elem; // 实际数据(这里是网络包)
size_t size; // 数据大小
struct r_list *prev;
struct r_list *next;
};
关键点 :用双向链表实现,插入删除都是 O(1) 复杂度。
三、系统架构全景图
c
...
struct ringbuf {
size_t size_max;
size_t size_curr;
size_t num_elems;
struct r_list {
void *elem;
size_t size;
struct r_list *prev;
struct r_list *next;
} *first;
struct r_list *last;
};
extern void *ringbuf_last(struct ringbuf *, size_t *);
extern int ringbuf_resize(struct ringbuf *, size_t);
extern void *ringbuf_first(struct ringbuf *, size_t *);
extern int ringbuf_add(struct ringbuf *, void *, size_t);
extern struct ringbuf *ringbuf_init(size_t);
extern const void *ringbuf_peek_last(struct ringbuf *);
extern const void *ringbuf_peek_first(struct ringbuf *);
...
static void usage(const char *pname)
{
printf("\n-=[ Capture packets into a fixed size ring buffer ]=-\n");
printf("Usage: %s <dumpdir> [Option(s)] [expression]\n", pname);
printf("Buffer will be written to <dumpdir> when SIGUSR1 is received\n");
printf("Options:\n");
printf(" -d - Debug, do not become daemon\n");
printf(" -f logfile - Logfile, default is %s\n", LOGFILE);
printf(" -i iface - Listen for packets on interface iface\n");
printf(" -m max - Maximum size of packet buffer, default is %s bytes\n",
str_hsize(DEFAULT_MAX_SIZE_BYTES));
printf(" -p pidfile - PID file, default is %s\n", PIDFILE);
printf(" -P - Do not listen in promiscuous mode\n");
printf(" -v - Be verbose, repeat to increase\n");
printf("\n");
exit(EXIT_FAILURE);
}
int main(int argc, char *argv[])
{
...
if ((argv[1] == NULL) || (argv[1][0] == '-'))
usage(opt.argv0);
opt.dumpdir = argv[1];
argc--;
argv++;
if (!isdir(opt.dumpdir))
exit(EXIT_FAILURE);
while ( (i = getopt(argc, argv, "dvp:m:i:Pf:")) != -1) {
switch(i) {
case 'v': opt.verbose++; break;
case 'P': opt.promisc = 0; break;
case 'f': opt.logfile = optarg; break;
case 'm':
if ( (opt.ringbuf_max = str_to_size(optarg)) == 0)
errx("Failed to convert max buffer size\n");
break;
case 'p': opt.pidfile = optarg; break;
case 'd': opt.debug = 1; break;
case 'i': opt.iface = optarg; break;
default: usage(opt.argv0);
}
}
if (opt.debug == 0) {
int fd;
if (daemonize() < 0)
exit(EXIT_FAILURE);
/* printf("[%d]\n", getpid()); */
if ( (fd = open(opt.logfile, O_RDWR|O_CREAT|O_APPEND, 0600)) < 0)
err_errnox("open(%s)\n", opt.logfile);
fflush(stdout);
fflush(stderr);
dup2(fd, STDOUT_FILENO);
dup2(fd, STDERR_FILENO);
logpid(opt.pidfile);
atexit(unlink_pidfile);
}
close(STDIN_FILENO);
for (i=STDERR_FILENO+1; i<1024; i++)
close(i);
verbose(0, "+-+-+-+-+-+ Capture Started +-+-+-+-+-+\n");
if ( (cap = cap_open(opt.iface, opt.promisc)) == NULL)
errx("Failed to open device.\n");
if (argv[optind] != NULL) {
opt.filter = str_join(" ", &argv[optind]);
if (cap_setfilter(cap, opt.filter) < 0)
exit(EXIT_FAILURE);
}
verbose(0, "Dump directory: %s\n", opt.dumpdir);
if (!opt.debug) {
verbose(0, "Log file: %s\n", opt.logfile);
verbose(0, "PID file: %s\n", opt.pidfile);
}
verbose(0, "Buffer size: %s bytes\n", str_hsize(opt.ringbuf_max));
if (opt.filter)
verbose(0, "Filter: %s\n", opt.filter);
else
verbose(0, "No capture filter\n");
if (opt.verbose)
verbose(1, "Status log interval %s [%u seconds]\n",
str_hms(STAT_SEC_INTERVAL), STAT_SEC_INTERVAL);
if ( (rbuf = ringbuf_init(opt.ringbuf_max)) == NULL)
exit(EXIT_FAILURE);
signal(SIGUSR1, dumppackets);
signal(SIGUSR2, write_status);
if (!opt.debug) {
signal(SIGTERM, exit_handler);
signal(SIGSEGV, exit_handler);
signal(SIGBUS, exit_handler);
signal(SIGILL, exit_handler);
signal(SIGPIPE, exit_handler);
signal(SIGQUIT, SIG_IGN);
signal(SIGABRT, SIG_IGN);
signal(SIGHUP, SIG_IGN);
signal(SIGINT, SIG_IGN);
}
if (opt.verbose)
signal(SIGALRM, sigalrm_handler);
for (;;) {
size_t retry_time;
if (opt.verbose) {
char buf[256];
snprintf(buf, sizeof(buf), "%s", str_time(time(NULL) +
(STAT_SEC_INTERVAL - (time(NULL) % STAT_SEC_INTERVAL)), NULL));
verbose(1, "First status output aligned to %s\n", buf);
alarm(STAT_SEC_INTERVAL - (time(NULL) % STAT_SEC_INTERVAL));
}
pcap_loop(cap->c_pcapd, -1, capture_pkts, (u_char *)cap);
retry_time = 10;
for (;;) {
if (opt.verbose)
alarm(0);
warn("pcap_loop: '%s', retrying in %u seconds\n",
pcap_geterr(cap->c_pcapd), retry_time);
if (cap != NULL) {
cap_close(cap);
cap = NULL;
}
sleep(retry_time);
if ( (cap = cap_open(opt.iface, opt.promisc)) != NULL)
break;
retry_time += 10;
if (retry_time > 300)
retry_time = 300;
}
}
exit(EXIT_FAILURE);
}
If you need the complete source code, please add the WeChat number (c17865354792)
┌─────────────────────────────────────────────────────────────┐
│ 用户空间 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ 信号处理 │ │ 状态输出 │ │ 数据包转储 │ │
│ │ SIGUSR1/2 │ │ SIGALRM │ │ dumppackets() │ │
│ └──────┬──────┘ └──────┬──────┘ └────────┬────────┘ │
│ │ │ │ │
│ └──────────────────┼────────────────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 环形缓冲区 │ │
│ │ (ringbuf) │ │
│ │ ┌───┐┌───┐┌───┐│ │
│ │ │包1│→│包2│→│包3││ ← 满了就覆盖最老的 │
│ │ └───┘└───┘└───┘│ │
│ └────────┬────────┘ │
│ │ │
└────────────────────────────┼───────────────────────────────┘
│
┌────────────────────────────┼───────────────────────────────┐
│ 内核空间 │
│ ▼ │
│ ┌─────────────────┐ │
│ │ libpcap │ │
│ │ (数据包捕获库) │ │
│ └────────┬────────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ │ 网卡驱动/NIC │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘
四、代码核心逻辑拆解
4.1 数据包捕获流程
c
// 每当网卡收到一个包,pcap_loop 就会调用这个回调函数
static void capture_pkts(u_char *arg,
const struct pcap_pkthdr *pkthdr, const u_char *packet)
{
char buf[8192];
// 1. 把包头和包体拼在一起
memcpy(buf, pkthdr, sizeof(struct pcap_pkthdr)); // 元信息(时间戳、长度等)
memcpy(&buf[sizeof(struct pcap_pkthdr)], packet, pkthdr->len); // 实际数据
// 2. 塞进环形缓冲区
ringbuf_add(rbuf, buf, pkthdr->len + sizeof(struct pcap_pkthdr));
}
设计要点:
- 用
pcap_pkthdr结构保存包头信息(时间戳、捕获长度、原始长度) - 包头+包体一起存储,方便后续直接写入 pcap 文件
4.2 环形缓冲区的"添加"逻辑
虽然你没提供 ringbuf.c 的实现,但从使用方式可以推断出核心逻辑:
c
int ringbuf_add(struct ringbuf *r, void *data, size_t size) {
// 情况1:空间足够,直接追加到链表尾部
if (r->size_curr + size <= r->size_max) {
创建新节点 → 插入到 last 后面 → 更新 last
r->size_curr += size;
r->num_elems++;
return 成功;
}
// 情况2:空间不够,删除最老的元素直到够空间
while (r->size_curr + size > r->size_max) {
删除 first 节点 → first 指向 next → 释放内存
r->size_curr -= first->size;
r->num_elems--;
}
// 现在空间够了,执行情况1的操作
...
}
精髓:始终保持缓冲区不超过设定上限,新数据来了老数据自动退场。
4.3 信号驱动的转储机制
这是整个系统最巧妙的设计------用 Unix 信号实现"拍照"功能:
c
// 注册信号处理器
signal(SIGUSR1, dumppackets); // 用户自定义信号1:转储数据
signal(SIGUSR2, write_status); // 用户自定义信号2:输出状态
// 管理员在 shell 中执行:
// kill -USR1 <pid> → 触发数据转储
// kill -USR2 <pid> → 查看当前缓冲区状态
转储流程:
收到 SIGUSR1
│
▼
┌─────────────┐
│ 忽略新信号 │ ← 防止转储过程中被打断
│ signal(IGN) │
└──────┬──────┘
▼
┌─────────────┐
│ 创建临时文件 │ ← 避免转储失败留下半成品
│ xxx.pid │
└──────┬──────┘
▼
┌─────────────┐
│ 循环读取缓冲区│ ← 从 first 到 last,逐个写入 pcap
│ ringbuf_first()
└──────┬──────┘
▼
┌─────────────┐
│ 关闭并重命名 │ ← xxx.pid → eth0_20240115_10:00:00-10:05:30.pcap
│ 文件名包含 │ 设备名_开始时间-结束时间.pcap
│ 时间范围 │
└──────┬──────┘
▼
┌─────────────┐
│ 恢复信号处理 │ ← 重新监听 SIGUSR1
└─────────────┘
4.4 优雅的故障恢复
网络接口可能会断开(比如网线被拔),程序不能就此罢工:
c
for (;;) { // 外层死循环:永久捕获
pcap_loop(cap->c_pcapd, -1, capture_pkts, ...); // 开始捕获
// 如果执行到这里,说明 pcap_loop 异常返回了
retry_time = 10;
for (;;) { // 内层循环:重连逻辑
关闭旧连接 → 等待 retry_time 秒 → 尝试重新打开网卡
成功则跳出,失败则 retry_time += 10(最长5分钟)
}
}
设计智慧:指数退避(Exponential Backoff)避免疯狂重试耗尽 CPU。
五、涉及的技术领域知识
5.1 网络数据包捕获(Packet Capture)
| 概念 | 说明 |
|---|---|
| libpcap | Unix/Linux 平台标准的数据包捕获库,Win 平台对应 WinPcap/Npcap |
| BPF | Berkeley Packet Filter,内核级过滤,只把感兴趣的包传给用户态 |
| 混杂模式 | Promiscuous Mode,网卡接收所有经过的帧,不只是发给自己的 |
| pcap 文件格式 | 行业标准格式,Wireshark、tcpdump 都能打开 |
5.2 进程管理相关
- 守护进程(Daemon) :
daemonize()函数让程序脱离终端后台运行 - 信号处理:Unix 经典的进程间通信方式,USR1/USR2 留给用户自定义
- PID 文件 :把进程号写入
/var/run/xxx.pid,方便外部管理(停止、发信号)
5.3 内存管理
- 零拷贝思想:虽然这里做了拷贝(从 libpcap 缓冲区到环形缓冲区),但避免了频繁写磁盘
- 动态内存分配 :每个包大小不同(64B ~ 1518B),需要
malloc/free灵活管理
5.4 时间处理
代码里大量用到时间戳处理:
c
// 计算缓冲区"时间跨度"
last->ts.tv_sec - first->ts.tv_sec
// 生成文件名中的时间
str_time(pkthdr->ts.tv_sec, "%Y%m%d_%H:%M:%S")
注意 :tv_sec 是 Unix 时间戳(1970年以来的秒数),tv_usec 是微秒。
六、设计亮点总结
- 空间换时间:内存比磁盘快几个数量级,先把数据存内存,需要时再落盘
- 信号即接口 :不需要 RPC、HTTP API,简单的
kill命令就能控制程序 - 故障自愈:网卡断了自动重连,无需人工干预
- 原子性转储:先写临时文件,成功后再重命名,防止产生损坏的 pcap 文件
- 资源受限:严格限制内存使用,绝不会因为流量突增导致 OOM(Out of Memory)
七、适用场景与局限性
适合用:
- 7×24 小时监控,但只关心"刚才发生了什么"
- 嵌入式设备或硬盘空间有限的环境
- 需要低延迟抓包,不能频繁写磁盘
不适合用:
- 需要保存所有历史数据(这是全量抓包工具如 tcpdump 的场景)
- 数据包太大的场景(如千兆网满流量,50MB 缓冲区可能只存几毫秒数据)
- 需要复杂查询分析(应该直接存数据库或 Elasticsearch)
八、程序运行测试
抓包必须用 root,否则打不开网卡!
最基础运行命令(直接复制)
bash
sudo mkdir -p /tmp/pcapdump
sudo ./ringcapd /tmp/pcapdump -d
命令解释
/tmp/pcapdump:抓包导出的存放目录-d:调试模式,不后台运行,直接在前台打印日志(最适合测试)
运行成功你会看到:
+-+-+-+-+-+ Capture Started +-+-+-+-+-+
Dump directory: /tmp/pcapdump
Buffer size: 50.0M bytes
No capture filter
说明正在抓包了!
测试导出抓包文件
程序收到 SIGUSR1 信号就会把缓冲区的包导出成 pcap 文件。
新开一个终端,输入:
bash
sudo pkill -SIGUSR1 ringcapd
回到运行 ringcapd 的终端,你会看到类似日志:
Dumped 128KB bytes with 30 packets from 2026-03-30 12:00:00 to 2026-03-30 12:01:00
去目录看文件:
bash
ls /tmp/pcapdump
你会看到:
any_20260330_12:00:00-20260330_12:01:00.pcap
直接用 Wireshark 打开就能看包!
常用测试命令大全
1. 指定网卡抓包(比如 eth0)
bash
sudo ./ringcapd /tmp/pcapdump -i eth0 -d
2. 修改缓冲区大小(比如 10MB)
bash
sudo ./ringcapd /tmp/pcapdump -m 10M -d
3. 不开启混杂模式(不监听别人的包)
bash
sudo ./ringcapd /tmp/pcapdump -P -d
4. 开启详细日志(更清楚看到缓存状态)
bash
sudo ./ringcapd /tmp/pcapdump -v -d
5. 后台守护运行(不占终端)
bash
sudo ./ringcapd /tmp/pcapdump
6. 查看状态(每秒打印缓存信息)
bash
sudo pkill -SIGUSR2 ringcapd
7. 停止程序
bash
sudo pkill ringcapd
最完整的一条测试命令
bash
sudo ./ringcapd /tmp/pcapdump -i any -m 20M -v -d
含义:
- 监听所有网卡
- 缓冲区 20MB
- 打印详细日志
- 前台调试运行
总结
这个工具的核心哲学是:"我宁愿丢掉 99% 的数据,也要确保关键时刻的数据在手边"。环形缓冲区就是这个哲学的完美载体------它像一位不知疲倦的守门员,永远准备着把最近发生的事情告诉你,而不管那些陈年旧事。
在网络安全、故障排查、性能分析等领域,这种"滑动窗口"式的数据保留策略比盲目全量存储要实用得多。理解了这个设计,你也能在自己项目中灵活运用环形缓冲区解决类似问题。
Welcome to follow WeChat official account【程序猿编码】