Linux(二) C++ TCP/UDP网络编程
前置知识:Linux网络收包链路、sk_buff、协议栈封装解封装、Socket内核对象
目录
- TCP与UDP核心本质对比(OS角度)
- Socket内核对象 + 收发缓冲区深度详解
- TCP、UDP适用场景与选型决策
- UDP编程:特性、API全解、坑点、使用注意事项
- TCP编程:字节流本质、粘包半包、长短连接工程实现
- 完整可编译代码:UDP服务端/客户端(带详细注释)
- 完整可编译代码:TCP服务端/客户端(带详细注释)
- send/recv 内核缓冲区行为细节与返回值全解
- TCP长短连接:工程实现、心跳机制、异常处理
- 高频踩坑清单(含错误码排查)
- 调试与观测工具
- 面试核心总结
1. TCP与UDP核心本质对比(OS角度)
| 对比维度 | UDP(用户数据报协议) | TCP(传输控制协议) |
|---|---|---|
| 连接模型 | 无连接。无需握手、无需挥手,发送方随时可以发包,接收方随时可以收包。 | 面向连接。必须通过三次握手建立连接后才能传输数据,断开必须四次挥手。 |
| 报文模型 | 数据报(Datagram)。内核严格保留报文边界,一个报文是一个独立单元。 | 字节流(Stream)。内核不保留任何报文边界,所有数据被视为连续的字节序列。 |
| 可靠性保障 | 无任何可靠性机制。不保证报文到达、不保证顺序、不重复、出错不重传。 | 全链路可靠。超时重传、ACK确认、序号排序、去重、滑动窗口流控、拥塞控制。 |
| 传输层头部大小 | 固定8字节 | 最小20字节,带选项可达60字节 |
| 头部核心字段 | 源端口、目的端口、报文长度、校验和 | 源端口、目的端口、序号seq、确认号ack、数据偏移、标志位、窗口大小、校验和、紧急指针 |
| 内核分用匹配规则 | 仅通过目的端口匹配socket。同一个端口可以收到来自任意源地址的UDP报文。 | 通过**四元组(源IP+源端口+目的IP+目的端口)**精确匹配socket。 |
| 缓冲区行为 | 一次recvfrom读取一整个完整报文;缓冲区小于报文时,多余字节直接丢弃。 |
字节流模式,recv读取当前缓冲区中可用的任意字节数,可多可少。 |
| 流量控制 | 无。发送方可以全速发包,不管接收方是否处理得过来。 | 有。滑动窗口机制,接收方通告窗口大小,发送方严格按窗口发送。 |
| 拥塞控制 | 无。会直接打满链路带宽。 | 有。慢启动、拥塞避免、快速重传、快速恢复。 |
| 广播/组播 | 支持 | 不支持 |
| 内核处理开销 | 极低,协议逻辑简单 | 高,维护复杂状态机、重传队列、乱序队列 |
| 延迟 | 极低,无握手等待 | 相对高,有握手开销、重传等待 |
📌 协议栈分用完整链路回顾
- 链路层解析以太网头,读取
h_proto字段,0x0800向上交付IP层;- IP层解析IP头,读取
protocol字段,6交付TCP,17交付UDP;- 传输层根据端口/四元组,将payload写入对应socket的接收缓冲区;
- 唤醒socket等待队列上阻塞的进程。
2. Socket内核对象 + 收发缓冲区深度详解
Socket本质上是内核中的一个结构体对象,用户态进程通过系统调用拿到的int fd只是一个文件描述符编号,指向内核中的这个对象。
每个独立的socket内核对象,都维护两套完全独立的环形缓冲区:
2.1 接收缓冲区(Receive Buffer)
- 数据来源:网卡DMA收包 → 软中断协议栈逐层解析 → 剥离协议头 → 将纯payload拷贝进该socket的接收缓冲区。
- 数据去向:用户调用
recv()/read()/recvfrom()系统调用,陷入内核,执行copy_to_user(),将内核缓冲区的数据拷贝到用户进程的虚拟地址空间。 - 阻塞行为:若缓冲区为空,阻塞模式下的进程会被挂到socket内部的等待队列上,状态变为
TASK_INTERRUPTIBLE,让出CPU;当新数据到达填充缓冲区后,内核唤醒等待队列中的进程。 - 满溢处理:
- TCP:接收缓冲区满时,内核会通过TCP头部窗口字段通告对端窗口为0,对端停止发送,不会丢包。
- UDP:接收缓冲区满时,新来的UDP报文直接被内核静默丢弃,应用层无任何感知,也不会给发送方返回任何错误。
2.2 发送缓冲区(Send Buffer)
- 数据来源:用户调用
send()/write()/sendto(),执行copy_from_user(),将用户数据拷贝进内核发送缓冲区。 - 数据去向:内核协议栈从发送缓冲区取数据,封装TCP/UDP头、IP头、以太网头,交由网卡DMA发送。
- 阻塞行为:若发送缓冲区已满,阻塞模式下
send会阻塞挂起,直到内核把数据发出去、腾出空闲空间。 - ⚠️ 最容易误解的点:
send()函数成功返回,绝不等于对方已经收到数据,甚至不等于数据已经被送到网卡上。它仅仅代表:数据已经成功从用户内存拷贝到了内核发送缓冲区。后续何时发送、是否重传,完全由内核协议栈异步控制。
2.3 缓冲区大小配置
缓冲区大小不是固定的,可以通过setsockopt系统调用调整:
// 设置接收缓冲区大小
int rcv_buf = 128 * 1024; // 128KB
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &rcv_buf, sizeof(rcv_buf));
// 设置发送缓冲区大小
int snd_buf = 128 * 1024;
setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &snd_buf, sizeof(snd_buf));
注意:内核不会完全按照你设置的值生效,通常会将设置值翻倍,且不能超过系统最大值。
查看系统默认与最大值:
# TCP接收缓冲区配置:最小值、默认值、最大值
sysctl net.ipv4.tcp_rmem
# TCP发送缓冲区配置
sysctl net.ipv4.tcp_wmem
# 全局socket最大接收缓冲区
sysctl net.core.rmem_max
# 全局socket最大发送缓冲区
sysctl net.core.wmem_max
2.4 如何观测缓冲区占用
# 查看所有TCP连接的收发缓冲区队列长度
ss -itn
# Recv-Q:接收缓冲区中未被应用读取的字节数
# Send-Q:发送缓冲区中已拷贝但未被确认的字节数
3. TCP、UDP适用场景与选型决策
3.1 UDP 适用场景
UDP的核心哲学是:允许丢包,但绝不能等。它把可靠性的控制权完全交给应用层。
典型场景:
- 实时音视频通话、直播:丢几帧只会画面花一下,重传延迟会造成严重卡顿,完全不可接受。
- 实时网络游戏帧同步:位置信息时效性极强,重传过来的旧位置毫无意义。
- DNS查询:单次请求-应答模式,请求本身很短,超时应用层自己重试即可,无需建立连接的开销。
- 局域网广播/组播:设备发现、同屏多播等场景。
- 隧道协议:如VXLAN、GRE,在内核之上再封装一层。
❌ 绝对不适合UDP的场景:
- 文件传输、支付订单、数据库通信、日志落盘——任何不允许数据丢失、错乱的业务。
3.2 TCP 适用场景
TCP的核心哲学是:宁可慢一点,也要保证数据准确、有序、不丢。所有可靠性内核帮你做好。
典型场景:
- HTTP/HTTPS网页、RESTful API:绝大多数Web业务。
- 数据库连接(MySQL、Redis):命令与结果绝不允许出错。
- 文件传输(FTP、SFTP):文件必须完整无损。
- 消息队列、日志传输:保证消息不丢。
- 绝大多数后端服务间RPC调用。
❌ 不适合TCP的场景:
- 极致低延迟、可容忍少量丢包的实时场景;
- 广播、组播场景。
3.3 选型决策口诀
- 能接受丢包、追求最低延迟 → UDP + 应用层自定义可靠机制;
- 数据绝不能丢、必须有序 → 直接选TCP,不要自己造轮子。
4. UDP编程:特性、API全解、坑点、使用注意事项
4.1 UDP核心特性再强调
- 无连接:没有建立连接的过程,调用
sendto指定地址就可以直接发包。 - 报文边界保留:发送方发一个100字节的包,接收方一定读到完整的100字节,不会读一半,也不会和下一个包粘在一起。
- 支持一对多:一个服务端socket可以和无数个客户端通信,只靠
recvfrom拿到的地址区分。
4.2 UDP也可以调用 connect()
很多人以为只有TCP能connect,其实UDP也可以调用connect(),但它不会发送任何网络数据包,没有握手过程。
它的作用只有一个:在内核socket对象上记录一个固定的对端IP+端口。
- connect之后,可以直接用
send()/recv(),不用每次都传地址参数; - connect之后,内核会过滤掉源地址不匹配的报文,只有指定对端发来的数据才会放进接收缓冲区;
- connect之后,如果端口不可达,ICMP端口不可达错误会返回给应用进程,未connect的UDP通常收不到这个错误。
UDP connect可以重复调用,用来修改目标地址,不需要close。
4.3 核心API参数详解
socket() 创建UDP套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
AF_INET:IPv4协议族;AF_INET6对应IPv6。SOCK_DGRAM:数据报类型,固定对应UDP。- 第三个参数
protocol:填0即可,内核自动选择UDP。 - 返回值:成功返回非负文件描述符;失败返回-1,
errno记录错误码。
bind() 绑定地址端口
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 服务端必须bind,固定监听端口;
- 客户端通常不需要bind,内核会自动分配一个随机源端口。
INADDR_ANY表示监听本机所有网卡IP。
recvfrom() 接收数据
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
buf:用户提供的接收缓冲区指针;len:缓冲区最大长度;flags:常用标志位:0:默认阻塞模式;MSG_PEEK:偷窥数据,读完不把数据从缓冲区移除,下次还能读到;MSG_DONTWAIT:本次调用非阻塞,没数据立刻返回错误。
src_addr:输出参数,返回发送方的IP和端口;addrlen:传入传出参数,传入结构体大小,返回实际地址长度。
sendto() 发送数据
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
dest_addr:目标地址,每次发送都可以不同;- 返回值:成功返回发送的字节数;UDP要么全发成功要么失败,不会出现部分发送。
4.4 UDP重要注意事项与坑点
-
接收缓冲区不足,数据静默截断丢弃
例:一个UDP报文1500字节,你传入的buf只有1000字节。
recvfrom会返回1000,剩下500字节直接被内核丢弃,永远找不回来。
✅ 工程规范:接收缓冲区大小必须大于等于业务最大报文长度。 -
IP分片风险
UDP报文如果加上IP头后超过链路MTU(以太网默认1500字节),IP层会分片。只要其中任意一片丢失,整个UDP报文全部丢弃。
✅ 工程建议:以太网环境下,UDP单包payload控制在1472字节以内(1500 - 20IP头 - 8UDP头),避免分片。 -
没有流量控制,发太快必然丢包
UDP没有滑动窗口,接收方处理不过来就直接丢包。高速发送场景必须应用层做流控。
-
sendto成功不代表对方收到
只是放进了内核发送队列。甚至网卡没插上,sendto也可能成功返回。
-
UDP不会返回0
TCP recv返回0代表对端关闭连接;UDP没有连接概念,永远不会返回0,除非你传了len=0。
-
不存在TIME_WAIT
关闭socket直接释放资源,没有连接状态机。
5. TCP编程:字节流本质、粘包半包、长短连接工程实现
5.1 TCP字节流与粘包问题
什么是粘包、半包
- 粘包:发送方发了两个独立数据包,接收方一次recv全部读出来,合在了一起,分不清边界。
- 半包:发送方发了一个完整数据包,接收方一次recv只读到了一部分。
这两个现象本质上是同一个原因:TCP是字节流,没有报文边界。内核只负责搬运字节,不关心业务报文怎么划分。
粘包产生的三个常见原因
- 发送方:Nagle算法合并小数据包,攒一波一起发,减少网络小包数量;
- 传输链路:网络中报文被合并;
- 接收方:接收不及时,多个报文堆积在socket缓冲区,一次recv读到多个。
粘包不是bug,是TCP为了提升传输效率的正常行为。
半包产生的原因
- 接收缓冲区剩余空间不足,只能放下一部分数据;
- 用户传入的recv缓冲区小于一个完整业务报文。
标准解决方案:应用层自定义协议
业界最通用、最稳妥的方案:固定长度包头 + 变长包体。
┌─────────────────┬──────────────────┐
│ 4字节 包体长度N │ N字节 业务数据 │
└─────────────────┴──────────────────┘
读取逻辑:
- 循环读取,先凑齐4字节的长度字段;
- 解析出包体长度N;
- 继续循环读取,凑齐N字节的包体;
- 处理完一个完整报文后,重复上述步骤。
UDP永远不需要处理粘包,内核保证报文边界。
5.2 TCP核心API详解
socket()
int fd = socket(AF_INET, SOCK_STREAM, 0);
SOCK_STREAM:字节流类型,对应TCP。
bind()
服务端绑定端口,和UDP用法一致。
listen() 转为监听状态
int listen(int sockfd, int backlog);
- 将socket从主动套接字变为被动监听套接字;
backlog:全连接队列(已完成三次握手、等待accept取出的连接)的最大长度;- 内核还有一个半连接队列(SYN_RECV状态),长度由
net.ipv4.tcp_max_syn_backlog控制; - 队列满了之后,新来的SYN会被直接丢弃。
accept() 取出新连接
int accept(int listenfd, struct sockaddr *addr, socklen_t *addrlen);
- 阻塞等待,从全连接队列中取出一个已经完成三次握手的连接;
- 返回值是全新的文件描述符conn_fd,专门代表这条TCP连接,后续收发全部用这个fd;
- 原来的listen_fd继续用来监听新连接,绝对不能用listen_fd来收发数据。
connect() 发起连接
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 客户端调用,触发内核发送SYN报文,开始三次握手;
- 阻塞模式下,会一直等到握手成功或超时失败才返回;
- 失败常见原因:端口没开、网络不通、超时。
send() / recv()
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
- flags常用:
0:默认阻塞;MSG_WAITALL:recv会等待,直到读满len字节才返回(信号或出错除外);MSG_DONTWAIT:本次非阻塞。
5.3 TCP长连接 vs 短连接
短连接
生命周期:每次业务请求前建立连接 → 收发一次数据 → 立刻关闭连接。
- 优点:实现简单,服务端不用管理连接状态,用完即释放;
- 缺点:每次请求都有三次握手+四次挥手开销,延迟高;高频场景产生大量TIME_WAIT状态连接,消耗内核资源;
- 适用场景:低频访问、HTTP/1.0默认模式、一次性脚本。
长连接
生命周期:建立一次连接 → 在连接上进行N次数据收发 → 空闲超时/程序退出才关闭。
- 优点:省去握手挥手开销,延迟低,吞吐高;
- 缺点:服务端要维护大量连接状态,占用文件描述符;需要处理连接异常断开、空闲释放;
- 适用场景:数据库连接、RPC、游戏服务器、HTTP/1.1、WebSocket。
长连接必备机制:心跳检测
TCP本身没有内置心跳机制。如果对端机器突然断电、网线被拔掉,本端内核不会立刻感知到连接已断,recv会一直阻塞。
工程上两种心跳方案:
- 应用层心跳:双方约定,空闲N秒后,一端发一个心跳包,另一端回复心跳应答;连续M次没收到应答则判定连接断开,主动关闭。
优点:可控、灵活、和业务结合紧密;工业界标准做法。
- TCP Keepalive:操作系统内核层面的探测机制,通过
setsockopt开启。缺点:默认间隔时间太长(通常2小时),只能检测连接死活,不能携带业务信息。一般作为兜底。
5.4 TCP状态机简述
核心状态:
LISTEN:服务端监听状态;SYN_SENT:客户端已发SYN,等待对端ACK;SYN_RECV:服务端收到SYN,已回复SYN+ACK;ESTABLISHED:连接建立,正常数据传输状态;FIN_WAIT_1、FIN_WAIT_2、TIME_WAIT:主动关闭方状态;CLOSE_WAIT、LAST_ACK:被动关闭方状态。
TIME_WAIT:主动关闭方最后停留的状态,持续2MSL(通常1-2分钟)。作用是保证最后一个ACK能到达对端,防止旧连接的迟到报文影响新连接。
服务端重启报“Address already in use”,基本都是TIME_WAIT占用端口导致,开启SO_REUSEADDR即可解决。
6. 完整可编译代码:UDP服务端/客户端(带详细注释)
udp_server.cc
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <cerrno>
#define LISTEN_PORT 8888
#define BUFFER_SIZE 1472 // 以太网MTU下UDP最大安全payload
int main()
{
// 1. 创建UDP套接字:AF_INET表示IPv4,SOCK_DGRAM表示数据报(UDP)
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
std::cerr << "create socket failed, errno=" << errno
<< " msg:" << strerror(errno) << std::endl;
return -1;
}
// 2. 构造服务端地址结构体
sockaddr_in serv_addr{};
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡
serv_addr.sin_port = htons(LISTEN_PORT); // 端口号必须转网络字节序
// 3. 绑定端口和地址
if (bind(sockfd, reinterpret_cast<sockaddr*>(&serv_addr), sizeof(serv_addr)) < 0) {
std::cerr << "bind failed, errno=" << errno
<< " msg:" << strerror(errno) << std::endl;
close(sockfd);
return -1;
}
std::cout << "UDP server started, listening on port " << LISTEN_PORT << std::endl;
char recv_buf[BUFFER_SIZE];
sockaddr_in client_addr{};
socklen_t cli_addr_len = sizeof(client_addr);
while (true) {
// 4. 阻塞接收数据,同时获取客户端地址信息
ssize_t recv_len = recvfrom(sockfd, recv_buf, BUFFER_SIZE, 0,
reinterpret_cast<sockaddr*>(&client_addr),
&cli_addr_len);
if (recv_len < 0) {
std::cerr << "recvfrom error, errno=" << errno << std::endl;
continue;
}
// 手动补字符串结束符,方便打印
recv_buf[recv_len] = '\0';
// 打印客户端信息:IP转字符串,端口转主机字节序
std::cout << "[" << inet_ntoa(client_addr.sin_addr)
<< ":" << ntohs(client_addr.sin_port) << "] "
<< "recv " << recv_len << " bytes: " << recv_buf << std::endl;
// 5. 回射:把收到的数据原样发回给客户端
ssize_t send_len = sendto(sockfd, recv_buf, recv_len, 0,
reinterpret_cast<sockaddr*>(&client_addr),
cli_addr_len);
if (send_len < 0) {
std::cerr << "sendto error, errno=" << errno << std::endl;
}
}
close(sockfd);
return 0;
}
udp_client.cc
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <cerrno>
#define SERVER_IP "127.0.0.1"
#define SERVER_PORT 8888
#define BUFFER_SIZE 1472
int main()
{
// 1. 创建UDP socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
perror("socket");
return -1;
}
// 2. 构造服务端地址
sockaddr_in serv_addr{};
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(SERVER_PORT);
// 点分十进制IP转网络字节序二进制
if (inet_pton(AF_INET, SERVER_IP, &serv_addr.sin_addr) <= 0) {
std::cerr << "invalid server ip" << std::endl;
close(sockfd);
return -1;
}
const char* send_msg = "Hello, UDP Server!";
// 3. 直接发送数据,无需连接
ssize_t sent = sendto(sockfd, send_msg, strlen(send_msg), 0,
reinterpret_cast<sockaddr*>(&serv_addr),
sizeof(serv_addr));
if (sent < 0) {
perror("sendto");
close(sockfd);
return -1;
}
std::cout << "sent " << sent << " bytes to server" << std::endl;
// 4. 接收服务端回射
char recv_buf[BUFFER_SIZE];
sockaddr_in from_addr{};
socklen_t from_len = sizeof(from_addr);
ssize_t recv_len = recvfrom(sockfd, recv_buf, BUFFER_SIZE, 0,
reinterpret_cast<sockaddr*>(&from_addr),
&from_len);
if (recv_len > 0) {
recv_buf[recv_len] = '\0';
std::cout << "recv from server: " << recv_buf << std::endl;
} else {
perror("recvfrom");
}
close(sockfd);
return 0;
}
编译运行:
g++ udp_server.cc -o udp_server -std=c++17 -Wall
g++ udp_client.cc -o udp_client -std=c++17 -Wall
./udp_server
./udp_client
7. 完整可编译代码:TCP服务端/客户端(带详细注释)
tcp_server.cc
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <cerrno>
#define LISTEN_PORT 9999
#define BUFFER_SIZE 1024
#define BACKLOG 8
int main()
{
// 1. 创建TCP监听套接字
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket");
return -1;
}
// 2. 设置地址复用,解决TIME_WAIT导致重启失败
int reuse = 1;
if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) < 0) {
perror("setsockopt SO_REUSEADDR");
}
// 3. 绑定地址端口
sockaddr_in serv_addr{};
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(LISTEN_PORT);
if (bind(listen_fd, reinterpret_cast<sockaddr*>(&serv_addr), sizeof(serv_addr)) < 0) {
perror("bind");
close(listen_fd);
return -1;
}
// 4. 开启监听,backlog为全连接队列长度
if (listen(listen_fd, BACKLOG) < 0) {
perror("listen");
close(listen_fd);
return -1;
}
std::cout << "TCP server listening on port " << LISTEN_PORT << std::endl;
while (true) {
sockaddr_in client_addr{};
socklen_t cli_len = sizeof(client_addr);
// 5. 阻塞等待新连接,从全连接队列取出
int conn_fd = accept(listen_fd, reinterpret_cast<sockaddr*>(&client_addr), &cli_len);
if (conn_fd < 0) {
perror("accept");
continue;
}
std::cout << "new client connected: "
<< inet_ntoa(client_addr.sin_addr) << ":"
<< ntohs(client_addr.sin_port)
<< ", fd=" << conn_fd << std::endl;
char buf[BUFFER_SIZE];
// 6. 循环读取客户端数据,回射
while (true) {
ssize_t n = recv(conn_fd, buf, BUFFER_SIZE, 0);
if (n < 0) {
// 出错
if (errno == EINTR) continue; // 被信号打断,重试
perror("recv");
break;
}
if (n == 0) {
// 对端正常关闭连接(收到FIN)
std::cout << "client closed connection, fd=" << conn_fd << std::endl;
break;
}
buf[n] = '\0';
std::cout << "recv " << n << " bytes: " << buf << std::endl;
// 原样发回
ssize_t sent = send(conn_fd, buf, n, 0);
if (sent < 0) {
perror("send");
break;
}
}
// 7. 关闭连接套接字
close(conn_fd);
std::cout << "connection fd=" << conn_fd << " released" << std::endl;
}
close(listen_fd);
return 0;
}
tcp_client.cc
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <cerrno>
#define SERVER_IP "127.0.0.1"
#define SERVER_PORT 9999
#define BUFFER_SIZE 1024
int main()
{
// 1. 创建TCP socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket");
return -1;
}
// 2. 构造服务端地址
sockaddr_in serv_addr{};
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(SERVER_PORT);
if (inet_pton(AF_INET, SERVER_IP, &serv_addr.sin_addr) <= 0) {
std::cerr << "invalid server ip" << std::endl;
close(sockfd);
return -1;
}
// 3. 发起连接,触发三次握手
if (connect(sockfd, reinterpret_cast<sockaddr*>(&serv_addr), sizeof(serv_addr)) < 0) {
perror("connect");
close(sockfd);
return -1;
}
std::cout << "connected to server" << std::endl;
// 4. 发送数据
const char* msg = "Hello, TCP Server!";
ssize_t sent = send(sockfd, msg, strlen(msg), 0);
if (sent < 0) {
perror("send");
close(sockfd);
return -1;
}
std::cout << "sent " << sent << " bytes" << std::endl;
// 5. 接收回射
char buf[BUFFER_SIZE];
ssize_t n = recv(sockfd, buf, BUFFER_SIZE, 0);
if (n > 0) {
buf[n] = '\0';
std::cout << "recv: " << buf << std::endl;
} else if (n == 0) {
std::cout << "server closed connection" << std::endl;
} else {
perror("recv");
}
// 6. 关闭连接,触发四次挥手
close(sockfd);
std::cout << "connection closed" << std::endl;
return 0;
}
编译运行:
g++ tcp_server.cc -o tcp_server -std=c++17 -Wall
g++ tcp_client.cc -o tcp_client -std=c++17 -Wall
./tcp_server
./tcp_client
8. send/recv 内核缓冲区行为细节与返回值全解
8.1 recv 返回值完整解读
| 返回值 | 含义 | 处理方式 |
|---|---|---|
> 0 |
成功读取到n字节数据 | 正常业务处理 |
== 0 |
对端发送了FIN,连接正常关闭 | 关闭本端socket,退出循环 |
< 0 |
发生错误 | 检查errno |
常见errno:
EINTR:被信号中断,不是真错误,循环重试即可;EAGAIN / EWOULDBLOCK:非阻塞模式下,没有数据可读,正常情况,稍后再试;ECONNRESET:连接被重置,对端异常关闭或发了RST,直接关闭连接。
8.2 send 返回值完整解读
TCP的send返回值可能小于你传入的长度,这是非常重要的细节。
- 发送缓冲区还有足够空间:全部拷贝进去,返回len;
- 发送缓冲区空间不足:能装多少装多少,返回实际拷贝的字节数;
- 阻塞模式下,如果缓冲区完全满了,send会阻塞等待,直到腾出空间。
⚠️ 工程代码绝对不能只调用一次send就完事。必须封装一个循环发送函数,保证所有数据全部写入内核缓冲区才算成功。
示例:可靠发送封装
ssize_t send_all(int fd, const char* buf, size_t len)
{
size_t total_sent = 0;
while (total_sent < len) {
ssize_t n = send(fd, buf + total_sent, len - total_sent, 0);
if (n < 0) {
if (errno == EINTR) continue;
return -1; // 真正错误
}
total_sent += n;
}
return total_sent;
}
UDP的sendto不存在部分发送,要么整个报文发出去,要么失败返回-1。
8.3 阻塞与非阻塞模式
- 阻塞模式(默认):缓冲区没数据/没空间时,函数不返回,进程挂起睡眠,不占CPU。
- 非阻塞模式:立刻返回,没数据返回-1,errno=EAGAIN。配合IO多路复用(select/poll/epoll)使用。
设置非阻塞方式:
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
9. TCP长短连接:工程实现、心跳机制、异常处理
9.1 短连接适用工程场景
- 客户端数量极大,但每个客户端很久才请求一次;
- 定时任务、脚本、一次性数据上报;
- 对延迟不敏感,追求实现简单。
9.2 长连接工程实现要点
- 连接管理:服务端维护一个连接表,记录每个连接的fd、IP、最后活跃时间;
- 心跳机制:
- 约定心跳包格式,例如长度为0的特殊包;
- 服务端超时检测:N秒没收到数据,主动踢掉连接;
- 客户端心跳发送:空闲M秒发一次心跳;
- 断线重连:客户端检测到连接断开后,延时重试连接;
- 半包粘包处理:必须有完整的应用层协议解析器。
9.3 常见连接异常处理
- 对端正常close:recv返回0,优雅关闭;
- 对端崩溃/断网:长时间收不到数据,靠心跳超时检测;
- 本端写一个已关闭的连接:内核会触发
SIGPIPE信号,默认会杀死进程。工程上一般忽略SIGPIPE,通过send返回值判断错误。// 忽略SIGPIPE,防止进程被杀死 signal(SIGPIPE, SIG_IGN);
10. 高频踩坑清单
TCP常见坑
- ❌ 用listen_fd收发数据:listen_fd只负责接连接,通信必须用accept返回的conn_fd。
- ❌ 不处理粘包半包:直接把一次recv的数据当完整业务报文。
- ❌ send只调用一次,不处理部分返回。
- ❌ 长连接不做心跳,异常断开永远感知不到。
- ❌ 不设置SO_REUSEADDR,服务端重启失败。
- ❌ 不忽略SIGPIPE,写关闭的socket导致进程意外退出。
- ❌ 忘记转网络字节序,端口号直接填数字。
UDP常见坑
- ❌ 接收缓冲区太小,数据被静默截断。
- ❌ 误以为sendto成功就是对方收到。
- ❌ 发超大包导致IP分片,丢包率飙升。
- ❌ 用UDP传文件、传关键数据,不做可靠性保障。
11. 调试与观测工具
# 查看所有TCP连接状态、缓冲区
ss -titn
# 查看UDP socket
ss -uin
# 实时抓包,分析报文
tcpdump -i lo port 9999 -nn -X
# 查看系统socket缓冲区参数
sysctl -a | grep -E "rmem|wmem|somaxconn"
# 查看TCP连接状态统计
netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
# 跟踪系统调用
strace ./tcp_server
12. 面试核心总结
- 本质区别:UDP是无连接数据报,TCP是面向连接字节流。
- 粘包:TCP字节流特性导致,应用层自定义长度字段解决;UDP不存在粘包。
- send成功:只代表数据进了内核发送缓冲区,不代表对端收到。
- recv返回0:TCP对端关闭连接;UDP永远不会返回0。
- 长连接:必须心跳保活;短连接:频繁创建销毁,有TIME_WAIT问题。
- UDP connect:不发包,只是内核记录对端地址,过滤报文。
- 缓冲区满:TCP流控不丢包;UDP直接丢包。
- listen backlog:全连接队列长度,不是最大连接数。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)