4、粘包现象的GDB复现:编写一个会粘包的Server/Client程序、使用GDB设置断点观察send/recv行为、分析缓冲区数据合并过程
好,咱们今天来点真格的。
前面几章讲了TCP的流式特性、Nagle算法、缓冲区机制,说白了都是在为这一刻做准备。今天我们要亲手制造一个粘包,然后用GDB把它抓个现行。我个人觉得,这是理解粘包最直观的方式——没有之一。
4.1 先写一个会粘包的程序
我习惯从最简单的场景入手。咱们写一个Server和Client,Server每收到数据就打印出来,Client连续发送三条消息。注意,这里我们不刻意做任何拆包处理,就是为了让粘包自然发生。
先看Server端代码:
// server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8888
#define BUFFER_SIZE 1024
int main() {
int server_fd, client_fd;
struct sockaddr_in address;
int opt = 1;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE] = {0};
// 创建socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
bind(server_fd, (struct sockaddr *)&address, sizeof(address));
listen(server_fd, 3);
printf("Server listening on port %d\n", PORT);
client_fd = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);
while (1) {
int valread = read(client_fd, buffer, BUFFER_SIZE);
if (valread > 0) {
buffer[valread] = '\0';
printf("Received: %s (len=%d)\n", buffer, valread);
} else if (valread == 0) {
printf("Client disconnected\n");
break;
}
}
close(client_fd);
close(server_fd);
return 0;
}
再看Client端:
// client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8888
int main() {
int sock = 0;
struct sockaddr_in serv_addr;
sock = socket(AF_INET, SOCK_STREAM, 0);
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);
connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
printf("Connected to server\n");
// 连续发送三条消息,不做任何延迟
send(sock, "Hello", 5, 0);
send(sock, "World", 5, 0);
send(sock, "Test!", 5, 0);
printf("Sent three messages\n");
sleep(1);
close(sock);
return 0;
}
编译运行一下:
gcc -o server server.c
gcc -o client client.c
./server &
./client
你猜Server端会打印什么?
实际输出:
Server listening on port 8888
Received: HelloWorldTest! (len=15)
看到了吗?三条消息被合并成了一条。这就是典型的粘包现象。我当年第一次看到这个输出时,心里咯噔一下——原来书上说的都是真的。
4.2 用GDB设置断点,观察send/recv行为
光看到结果还不够,咱们得用GDB看看底层到底发生了什么。我个人习惯在关键的系统调用上打断点,这样能看清数据流动的每一步。
先启动Server的GDB调试:
gdb ./server
(gdb) break recv
(gdb) run
Server会停在recv调用处。然后另开一个终端启动Client:
gdb ./client
(gdb) break send
(gdb) run
好,现在两个进程都被GDB控制住了。咱们一步步看。
4.3 逐步观察:Client端的三次send
在Client的GDB中,按c(continue)执行第一次send:
(gdb) c
Continuing.
Breakpoint 1, __libc_send (fd=3, buf=0x7fffffffe0a0, n=5, flags=0) at ../sysdeps/unix/sysv/linux/send.c:28
28 ../sysdeps/unix/sysv/linux/send.c: No such file or directory.
(gdb) print (char*)buf
$1 = "Hello"
第一次send,数据是"Hello",长度5字节。继续按c:
(gdb) c
Continuing.
Breakpoint 1, __libc_send (fd=3, buf=0x7fffffffe0a0, n=5, flags=0) at ../sysdeps/unix/sysv/linux/send.c:28
(gdb) print (char*)buf
$2 = "World"
第二次send,数据是"World"。再按c:
(gdb) c
Continuing.
Breakpoint 1, __libc_send (fd=3, buf=0x7fffffffe0a0, n=5, flags=0) at ../sysdeps/unix/sysv/linux/send.c:28
(gdb) print (char*)buf
$3 = "Test!"
三次send都独立触发了断点。这说明在应用层,Client确实发送了三条独立的消息。那问题出在哪?
4.4 观察Server端的recv行为
切换到Server的GDB窗口,按c:
(gdb) c
Continuing.
Breakpoint 1, __libc_recv (fd=4, buf=0x7fffffffe090, n=1024, flags=0) at ../sysdeps/unix/sysv/linux/recv.c:28
(gdb) print valread
$1 = 15
(gdb) x/15bx buf
0x7fffffffe090: 0x48 0x65 0x6c 0x6c 0x6f 0x57 0x6f 0x72
0x7fffffffe098: 0x6c 0x64 0x54 0x65 0x73 0x74 0x21
看到了吗?Server只调用了一次recv,就收到了15个字节。这15个字节就是"HelloWorldTest!"的ASCII码。
关键发现:
Client调用了3次send,但Server只调用了1次recv。这就是粘包的本质——多个send的数据在TCP缓冲区里合并了,被一次recv全部取走。
4.5 分析缓冲区数据合并过程
为什么会这样?咱们来画个图,把整个过程理清楚。
从这张图可以看得很清楚:Client的三次send在内核缓冲区里就已经合并了。然后通过网络传输到Server端,Server一次recv就把所有数据都拿走了。
注意:
粘包不是必然发生的。如果你在Client的每次send之间加个sleep(1),或者关闭Nagle算法(设置TCP_NODELAY),粘包可能就不会出现。但问题是——网络环境不可控,你不能指望每次发送之间都有延迟。所以,必须从协议层面解决粘包问题。
4.6 用GDB验证缓冲区合并的细节
咱们再深入一点。我想看看Server的recv缓冲区里到底有什么。在Server的GDB中,设置一个条件断点,只当recv返回大于5字节时触发:
(gdb) break recv if nbytes > 5
(gdb) run
然后重新运行Client。这次Server会在recv返回时停住。我们看看缓冲区内容:
(gdb) x/20bx buf
0x7fffffffe090: 0x48 0x65 0x6c 0x6c 0x6f 0x57 0x6f 0x72
0x7fffffffe098: 0x6c 0x64 0x54 0x65 0x73 0x74 0x21 0x00
0x7fffffffe0a0: 0x00 0x00 0x00 0x00
前15个字节就是合并后的数据。注意看,0x48是'H',0x65是'e',0x6c是'l',0x6f是'o',然后紧接着0x57是'W'——中间没有任何分隔符。这就是粘包的直接证据。
我曾经在一个项目中遇到过类似的问题。当时是一个游戏服务器,客户端连续发送了位置更新和攻击指令,结果服务端一次recv收到了两条消息,解析时直接崩溃了。嗯,从那以后,我再也不敢相信TCP能保持消息边界了。
4.7 总结:粘包的本质
通过今天的GDB实战,我们可以总结出粘包的核心要点:
| 层面 | 现象 | 原因 |
|---|---|---|
| 应用层 | Client调用了3次send | 应用认为发送了3条独立消息 |
| 内核缓冲区 | 数据在发送缓冲区合并 | Nagle算法 + 连续发送无延迟 |
| 网络传输 | 作为一个TCP段传输 | TCP是流式协议,无消息边界 |
| 接收端 | Server只调用1次recv | 缓冲区足够大,一次读完 |
说白了,粘包就是TCP的流式特性在作祟。它不管你应用层怎么划分消息,只管把数据当成一个连续的字节流来传输。所以,解决粘包问题的关键,就是在应用层定义好消息边界——这就是我们下一节要讲的内容。
不过在那之前,我建议你自己动手跑一遍今天的代码。用GDB一步步观察,比看十遍文章都管用。相信我,当你亲眼看到那15个字节连续排列在内存中时,你对粘包的理解会上一个台阶。
课后练习:
- 在Client的send之间加sleep(1),看看粘包是否还会发生
- 在Client中设置TCP_NODELAY选项,观察效果
- 修改Server的recv缓冲区大小为5字节,看看会发生什么(拆包)
可可精品资料库