Socket 基础

本文以 POSIX Socket API 为主,适用于 Linux、macOS 等 Unix-like 系统。Windows 使用 Winsock,函数名大多相同,但需要 WSAStartup() / closesocket() 等额外处理。

什么是 Socket

Socket(套接字)是操作系统提供给应用程序的网络通信接口。对程序而言,它通常以文件描述符(int fd)的形式存在,因此可以创建、读写、关闭。

对于一条已经建立的 TCP 连接,通常用以下四元组唯一标识:

1
源 IP、源端口、目的 IP、目的端口

例如,客户端 192.168.1.100:50000 连接服务器 192.168.1.10:8080。即使两个客户端连接同一台服务器的同一端口,它们的源端口不同,仍是两条不同的连接。

常见 Socket 类型:

类型 特性 常见协议
SOCK_STREAM 面向连接、可靠、有序的字节流 TCP
SOCK_DGRAM 无连接、尽力而为、保留数据报边界 UDP
SOCK_RAW 原始套接字,直接处理更底层的报文,通常需要高权限 IP / ICMP 等

TCP 保证字节可靠、有序到达,但不保证应用层消息边界;UDP 保留一条数据报的边界,但可能丢包、乱序或重复。

TCP 与 UDP 的通信流程

TCP 服务端和客户端的典型流程:

1
2
服务端:socket → setsockopt(可选)→ bind → listen → accept → recv/send → close
客户端:socket → connect → recv/send → close
1
2
3
4
5
6
7
8
9
客户端                                服务端
| |
|------------- connect -------------->|
| accept |
|------------- send ----------------->|
| recv |
|<------------ send / recv -----------|
| |
|------------- close ---------------->|

listen_fd 只用于监听和 accept();每次 accept() 成功都会返回一个新的 client_fd,它才用于和该客户端收发数据。

UDP 没有连接建立过程,典型流程为:

1
UDP:socket → bind(接收方通常需要)→ sendto/recvfrom → close

UDP 客户端也可以先 connect() 固定默认对端;这不会建立 TCP 那样的连接,只是让之后可以使用 send() / recv(),并过滤非该对端的数据报。

开始前:头文件、地址结构与字节序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <arpa/inet.h>
#include <cerrno>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <iostream>
#include <fcntl.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

核心类型和常量:

名称 含义
AF_INET / AF_INET6 IPv4 / IPv6 地址族
AF_UNIX(或 AF_LOCAL) 本机进程间通信地址族
sockaddr 通用地址结构,作为 API 参数类型
sockaddr_in / sockaddr_in6 IPv4 / IPv6 的实际地址结构
IPPROTO_TCP / IPPROTO_UDP TCP / UDP 协议号
fd Socket 文件描述符,失败通常为 -1

IPv4 地址通常这样初始化:

1
2
3
4
5
6
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(8080); // 端口必须使用网络字节序
if (inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) != 1) {
// IP 文本不合法,或当前地址族不受支持
}

网络协议使用大端序(网络字节序)。端口和自定义协议中的多字节整数应转换:

函数 作用
htons() / ntohs() 16 位主机序 ↔ 网络序;常用于端口
htonl() / ntohl() 32 位主机序 ↔ 网络序;常用于长度字段
1
2
std::uint32_t length = 1024;
std::uint32_t network_length = htonl(length);

INADDR_ANY(即 0.0.0.0)表示监听本机所有 IPv4 网卡:

1
addr.sin_addr.s_addr = htonl(INADDR_ANY);

若只希望本机访问,绑定 127.0.0.1;若绑定某个具体 IP,该 IP 必须配置在本机网卡上。端口 0 表示让系统选择可用的临时端口,可再通过 getsockname() 查询实际端口。

常用 API

socket():创建套接字

1
2
3
4
5
6
int socket(int domain, int type, int protocol);

int fd = socket(AF_INET, SOCK_STREAM, 0); // IPv4 TCP
if (fd == -1) {
perror("socket");
}

domain 指定地址族,type 指定通信语义,protocol 通常传 0,由系统根据前两项选择默认协议。成功时返回文件描述符,失败时返回 -1 并设置 errno。

参数说明:

参数 说明 常用值
domain 地址族,决定地址的表示方式 AF_INET(IPv4)、AF_INET6(IPv6)、AF_UNIX(本机 IPC)
type 套接字通信语义 SOCK_STREAM(TCP 字节流)、SOCK_DGRAM(UDP 数据报)
protocol 具体协议号 通常为 0;也可显式传 IPPROTO_TCP 或 IPPROTO_UDP

domain 必须和后续地址结构的 sin_family / sin6_family 一致。例如创建 AF_INET socket 后,应传入 sockaddr_in,并将 sin_family 设为 AF_INET。

setsockopt():配置选项

1
2
int setsockopt(int sockfd, int level, int optname,
const void* optval, socklen_t optlen);

常用选项:

选项 层级 作用与注意事项
SO_REUSEADDR SOL_SOCKET 通常在 bind() 前设置,便于服务端重启后重新绑定;不同系统语义略有差异
SO_REUSEPORT SOL_SOCKET 多个监听 socket 共享端口、内核分流;平台差异较大
SO_RCVTIMEO / SO_SNDTIMEO SOL_SOCKET 设置收发等待超时;超时后常见 EAGAIN / EWOULDBLOCK
SO_KEEPALIVE SOL_SOCKET 启用 TCP 保活;默认探测周期通常很长
TCP_NODELAY IPPROTO_TCP 关闭 Nagle 算法,可能降低小包延迟,也可能降低吞吐效率
1
2
3
4
int enabled = 1;
if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &enabled, sizeof(enabled)) == -1) {
perror("setsockopt SO_REUSEADDR");
}

不要把 SO_REUSEADDR 理解为“多个程序可以任意抢占同一地址端口”。TCP_KEEPIDLE、TCP_KEEPINTVL、TCP_KEEPCNT 等细化保活选项在 Linux 常见,但不具备完全可移植性。

参数说明:

参数 说明
sockfd 要配置的 socket。与绑定相关的选项通常应在 bind() 前设置。
level 选项所属层级:通用 socket 选项使用 SOL_SOCKET,TCP 专用选项使用 IPPROTO_TCP。
optname 要设置的选项名,例如 SO_REUSEADDR、SO_RCVTIMEO、TCP_NODELAY。
optval 指向选项值的指针;多数开关选项传 int*,超时选项传 timeval*。
optlen optval 所指对象的字节数,例如 sizeof(enabled)。

例如接收超时使用 timeval:

1
2
3
4
5
timeval timeout{};
timeout.tv_sec = 5;
if (setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) == -1) {
perror("setsockopt SO_RCVTIMEO");
}

bind():绑定本地地址

1
2
3
4
5
6
7
8
9
10
11
int bind(int sockfd, const sockaddr* addr, socklen_t addrlen);

sockaddr_in server_addr{};
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(8080);

if (bind(fd, reinterpret_cast<const sockaddr*>(&server_addr),
sizeof(server_addr)) == -1) {
perror("bind");
}

服务端通常必须在 listen() 前调用 bind();UDP 接收端也通常要绑定端口。常见错误包括:

错误 常见原因
EADDRINUSE IP:端口已被占用,或旧连接仍影响重绑
EACCES 无权限绑定特权端口(通常小于 1024)
EADDRNOTAVAIL 要绑定的 IP 不属于本机

sockaddr_in 到 sockaddr* 的转换只是在适配统一 API;sizeof 应传实际地址结构的大小。

参数说明:

参数 说明
sockfd socket() 成功返回的描述符。TCP 服务端通常绑定监听 socket;UDP 接收端绑定自己的 UDP socket。
addr 指向本地地址结构。IPv4 使用 sockaddr_in,调用时转换为 const sockaddr*。
addrlen addr 指向的实际地址结构大小,例如 sizeof(sockaddr_in)。

IPv4 的关键字段如下:

字段 说明
sin_family 必须为 AF_INET。
sin_addr 要绑定的本地 IPv4 地址;INADDR_ANY 表示全部网卡。
sin_port 网络字节序端口,使用 htons(端口号) 设置。

listen():让 TCP Socket 进入监听状态

1
int listen(int sockfd, int backlog);
1
2
3
if (listen(fd, SOMAXCONN) == -1) {
perror("listen");
}

listen() 仅用于 TCP 等面向连接的 socket。它必须在服务端完成 socket()、setsockopt()(如需要)和 bind() 后调用。

参数 说明
sockfd 已成功 bind() 的 TCP socket。不能传给某个客户端通信用的 client_fd。
backlog 内核中等待程序 accept() 的连接队列上限。它不是最大在线客户端数;通常使用 SOMAXCONN 或项目规定的合理值。

成功返回 0,失败返回 -1。服务端只需调用一次 listen(),随后可以反复调用 accept()。

accept():取出一个已建立的 TCP 连接

1
int accept(int sockfd, sockaddr* addr, socklen_t* addrlen);
1
2
3
4
5
6
sockaddr_in client_addr{};
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(fd, reinterpret_cast<sockaddr*>(&client_addr), &client_len);
if (client_fd == -1) {
perror("accept");
}
参数 说明
sockfd 已调用过 listen() 的监听 socket。它在整个服务周期内继续用于后续 accept()。
addr 输出参数,用于接收客户端地址。IPv4 时传入 sockaddr_in 的地址并转换为 sockaddr*;不关心地址时可传 nullptr。
addrlen addr 的长度指针,是输入输出参数。调用前填 sizeof(client_addr),调用后是系统实际写入的地址长度;若 addr 为 nullptr,此参数也传 nullptr。

成功时返回一个新的非负 client_fd;应使用这个新描述符与该客户端 send() / recv(),并在结束时 close(client_fd)。失败返回 -1。阻塞 socket 在没有连接到来时会阻塞;被信号中断时常见 errno == EINTR,通常可重试。

connect():连接服务器

1
int connect(int sockfd, const sockaddr* addr, socklen_t addrlen);

对 TCP,connect() 发起三次握手;成功后可 send() / recv()。阻塞 socket 常见错误是 ECONNREFUSED(端口没有监听者)和 ETIMEDOUT(网络或防火墙导致超时)。

非阻塞 socket 上,connect() 返回 -1 且 errno == EINPROGRESS 表示仍在连接,不是最终失败。等待其可写后,通过 getsockopt(fd, SOL_SOCKET, SO_ERROR, ...) 检查结果:值为 0 才代表连接成功。

参数 说明
sockfd 客户端通过 socket() 创建的描述符。TCP 连接成功后,它就是通信 socket。
addr 目标服务器的地址。IPv4 常用 sockaddr_in,转换成 const sockaddr* 传入。
addrlen 目标地址结构大小,例如 sizeof(server_addr)。

成功返回 0,失败返回 -1 并设置 errno。对 UDP 调用 connect() 不会发起三次握手,只是记录默认对端和改变后续收发行为。

getsockopt():读取 Socket 选项或状态

1
2
int getsockopt(int sockfd, int level, int optname,
void* optval, socklen_t* optlen);

getsockopt() 的参数含义与 setsockopt() 类似,但 optval 用于接收结果,optlen 是输入输出参数:调用前填入结果缓冲区大小,调用后得到实际结果长度。

它最常见的用途是确认非阻塞 connect() 的最终结果:

1
2
3
4
5
6
7
8
int socket_error = 0;
socklen_t error_len = sizeof(socket_error);
if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &socket_error, &error_len) == -1) {
perror("getsockopt");
} else if (socket_error != 0) {
errno = socket_error;
perror("connect");
}
参数 说明
sockfd 要查询的 socket。
level / optname 选项所在层级和名称;上例是 SOL_SOCKET / SO_ERROR。
optval 接收查询结果的可写内存地址。
optlen optval 的长度指针,调用前后分别表示容量和实际长度。

注意:getsockopt() 返回 0 只表示“查询成功”,还必须检查 socket_error 是否为 0。

send():向已连接的 Socket 发送数据

1
ssize_t send(int sockfd, const void* buf, size_t len, int flags);

send() 用于已连接的 TCP socket:服务端通常传 accept() 返回的 client_fd,客户端传 connect() 成功后的 socket。它的返回值可能小于请求长度,因此必须循环处理。

返回值 含义
> 0 本次实际成功写入内核发送缓冲区的字节数,可能小于 len。
0 本次没有发送数据;len == 0 时允许出现。
-1 发送失败;EINTR 通常可重试,非阻塞时 EAGAIN / EWOULDBLOCK 表示暂时不可写。
参数 说明
sockfd 已连接的通信 socket,而不是监听 socket。
buf 待发送数据的内存首地址。它可以是字节数组、std::string::data() 或序列化后的二进制数据。
len 本次希望发送的字节数,不自动包含字符串结尾的 \0。若协议不要求结尾符,发送 message.size() 而不是 message.size() + 1。
flags 一般传 0。Linux 可用 MSG_NOSIGNAL 抑制 SIGPIPE;该标志不是所有系统都支持。

可靠地发送完整缓冲区:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
bool send_all(int fd, const void* data, std::size_t length) {
const auto* p = static_cast<const char*>(data);
std::size_t sent = 0;

while (sent < length) {
const ssize_t n = send(fd, p + sent, length - sent, 0);
if (n > 0) {
sent += static_cast<std::size_t>(n);
} else if (n == -1 && errno == EINTR) {
continue;
} else {
return false;
}
}
return true;
}

在 Linux 上可使用 MSG_NOSIGNAL 防止向已关闭连接发送时触发 SIGPIPE。macOS 常见替代方案是对 socket 设置 SO_NOSIGPIPE。这两个方案都属于平台相关行为。

recv():从已连接的 Socket 接收数据

1
ssize_t recv(int sockfd, void* buf, size_t len, int flags);
参数 说明
sockfd 已连接的通信 socket。
buf 可写的接收缓冲区。网络数据不会自动补 \0;若要作为 C 字符串使用,应预留一个字节并在收到后自行写入终止符。
len 缓冲区可容纳的最大字节数。它是上限,不保证本次一定收到这么多数据。
flags 一般传 0;MSG_PEEK 表示查看数据但不取走,MSG_WAITALL 尝试读满 len,容易长时间阻塞,应谨慎使用。
返回值 TCP 中的含义
> 0 本次收到的有效字节数;只有 buf[0] 到 buf[n - 1] 有效。
0 对端已按序关闭发送方向(收到 FIN);当前连接通常应结束读取。
-1 出错;EINTR 可重试,非阻塞时 EAGAIN / EWOULDBLOCK 表示暂时无数据。
1
2
3
4
5
char buffer[4096];
const ssize_t n = recv(fd, buffer, sizeof(buffer) - 1, 0);
if (n > 0) {
buffer[n] = '\0'; // 只有把它当作文本时才需要
}

TCP 没有消息边界

客户端连续执行:

1
2
send(fd, "hello", 5, 0);
send(fd, "world", 5, 0);

服务端可能一次收到 helloworld,也可能分多次收到 hel 和 loworld。因此应用协议必须自行定义边界,常见方案:

  • 定长消息;
  • 分隔符,例如一行一条消息;
  • 长度前缀,例如 [4 字节网络序长度][正文]。

若把收到的数据当作 C 字符串处理,要自行预留末尾并补 \0;网络数据本身不会自动终止。

sendto():向指定地址发送 UDP 数据报

1
2
ssize_t sendto(int sockfd, const void* buf, size_t len, int flags,
const sockaddr* dest_addr, socklen_t addrlen);
1
2
3
4
5
6
7
8
sockaddr_in peer{};
peer.sin_family = AF_INET;
peer.sin_port = htons(9000);
inet_pton(AF_INET, "127.0.0.1", &peer.sin_addr);

const char message[] = "hello udp";
const ssize_t n = sendto(udp_fd, message, sizeof(message) - 1, 0,
reinterpret_cast<const sockaddr*>(&peer), sizeof(peer));

一次 sendto() 对应一个完整 UDP 数据报。和 TCP 不同,UDP 发送结果若成功,通常应等于 len;小于 len 应视为异常处理。

参数 说明
sockfd 由 socket(AF_INET, SOCK_DGRAM, 0) 创建的 UDP socket。
buf / len 待发送数据及其字节数。
flags 通常传 0。
dest_addr 目标地址;IPv4 时是 sockaddr_in 转换得到的 const sockaddr*。
addrlen 目标地址结构的大小,例如 sizeof(peer)。

成功返回发送字节数,失败返回 -1。UDP 也可先 connect() 固定目标,此后用 send(),但初学阶段使用 sendto() 更直观。

recvfrom():接收 UDP 数据报并获得发送方地址

1
2
ssize_t recvfrom(int sockfd, void* buf, size_t len, int flags,
sockaddr* src_addr, socklen_t* addrlen);
1
2
3
4
5
6
char buffer[4096];
sockaddr_in peer_addr{};
socklen_t peer_len = sizeof(peer_addr);

const ssize_t n = recvfrom(udp_fd, buffer, sizeof(buffer), 0,
reinterpret_cast<sockaddr*>(&peer_addr), &peer_len);
参数 说明
sockfd UDP socket。作为接收端时,通常先通过 bind() 绑定端口。
buf / len 可写接收缓冲区及最大容量。若数据报大于 len,超出部分会被丢弃,因此缓冲区要满足协议最大包长。
flags 通常传 0;MSG_PEEK 可查看但不取走数据。
src_addr 输出参数,接收发送方 IP 和端口;不关心时可传 nullptr。
addrlen src_addr 的长度指针,是输入输出参数;调用前填缓冲区大小,调用后是实际地址长度。若 src_addr 为 nullptr,此参数也传 nullptr。

成功返回该数据报实际长度,失败返回 -1。返回 0 表示收到了一个合法的空 UDP 数据报,不表示“对端关闭”。

inet_pton():将文本 IP 转为二进制地址

1
int inet_pton(int af, const char* src, void* dst);
参数 说明
af 地址族:IPv4 传 AF_INET,IPv6 传 AF_INET6。
src 形如 "127.0.0.1" 或 "::1" 的 IP 文本,不能是域名。
dst 保存二进制结果的地址;IPv4 传 &addr.sin_addr,IPv6 传 &addr6.sin6_addr。

返回 1 表示成功,返回 0 表示 src 不是该地址族的合法 IP,返回 -1 表示地址族不支持等系统错误。解析 example.com 这类域名应使用 getaddrinfo()。

inet_ntop():将二进制 IP 转为文本

1
const char* inet_ntop(int af, const void* src, char* dst, socklen_t size);
参数 说明
af AF_INET 或 AF_INET6。
src 二进制 IP 的地址,例如 &client_addr.sin_addr。
dst 存放结果的字符数组。IPv4 使用 char ip[INET_ADDRSTRLEN]{};,IPv6 使用 INET6_ADDRSTRLEN。
size dst 的容量,通常传 sizeof(ip)。

成功返回 dst,失败返回 nullptr 并设置 errno。

getaddrinfo():解析域名或生成地址结构

1
2
int getaddrinfo(const char* node, const char* service,
const addrinfo* hints, addrinfo** res);

推荐使用 getaddrinfo() 解析地址:它支持域名、IPv4、IPv6 和服务端绑定。它失败返回 EAI 错误码,应该使用 gai_strerror(),而不是 perror():

1
2
3
4
5
6
7
8
9
10
11
12
addrinfo hints{};
hints.ai_family = AF_UNSPEC; // 同时尝试 IPv4 和 IPv6
hints.ai_socktype = SOCK_STREAM;

addrinfo* result = nullptr;
const int rc = getaddrinfo("example.com", "443", &hints, &result);
if (rc != 0) {
std::cerr << gai_strerror(rc) << '\n';
} else {
// 遍历 result,逐个 socket/connect;结束后必须 freeaddrinfo(result)
freeaddrinfo(result);
}
参数 说明
node 主机名、域名或 IP 文本,例如 "example.com"、"127.0.0.1";服务端配合 AI_PASSIVE 时传 nullptr。
service 端口或服务名的字符串,例如 "8080"、"https",不能直接传整数 8080。
hints 期望结果的筛选条件。常设 ai_family = AF_UNSPEC(同时接受 IPv4/IPv6)和 ai_socktype = SOCK_STREAM(TCP)。服务端再设置 ai_flags = AI_PASSIVE。
res 输出参数。成功后得到 addrinfo 链表;逐个尝试 socket()、connect() 或 bind(),最后必须调用 freeaddrinfo(*res)。

服务端将 hints.ai_flags 设置为 AI_PASSIVE 并传 node == nullptr,可以获得适合 bind() 的本地地址。

getsockname() 与 getpeername():查询本地和对端地址

1
2
int getsockname(int sockfd, sockaddr* addr, socklen_t* addrlen);
int getpeername(int sockfd, sockaddr* addr, socklen_t* addrlen);

两者的参数形式相同:

参数 说明
sockfd 要查询的 socket。getpeername() 需要已连接的 TCP socket,或已 connect() 的 UDP socket。
addr 输出地址缓冲区;IPv4 常用 sockaddr_in 并转换为 sockaddr*。
addrlen 输入输出参数。调用前填地址缓冲区大小,调用后得到实际地址长度。

getsockname() 查询本地 IP 和端口,常用于 bind(..., 端口 0) 后查询系统分配的实际端口;getpeername() 查询已连接对端的 IP 和端口。端口字段 sin_port 仍是网络字节序,打印前要用 ntohs() 转换。

shutdown():关闭一个或两个通信方向

1
int shutdown(int sockfd, int how);
操作 可发送 可接收 释放文件描述符
shutdown(fd, SHUT_RD) 是 否 否
shutdown(fd, SHUT_WR) 否 是 否
shutdown(fd, SHUT_RDWR) 否 否 否
close(fd) 否 否 是
参数 说明
sockfd 已连接的通信 socket。
how SHUT_RD 停止读取、SHUT_WR 停止发送、SHUT_RDWR 同时停止收发。

shutdown(fd, SHUT_WR) 会发送 FIN,适用于“请求已全部发送,但仍要读取完整响应”的协议。成功返回 0,失败返回 -1。它不释放文件描述符,因此最终仍应 close(fd)。

close():释放 Socket 文件描述符

1
int close(int fd);

fd 是需要关闭的 socket 描述符,可以是 socket() 返回的监听或客户端 socket,也可以是 accept() 返回的 client_fd。成功返回 0,失败返回 -1。每个成功创建或接受的 socket 最终都应关闭一次。close() 报错时不要盲目重试,因为该描述符编号可能已经被系统复用。

fcntl():设置非阻塞模式

1
int fcntl(int fd, int cmd, ...);

使用 fcntl() 设置非阻塞:

1
2
3
4
bool set_nonblocking(int fd) {
const int flags = fcntl(fd, F_GETFL, 0);
return flags != -1 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) != -1;
}
参数 说明
fd 要设置的 socket 描述符。
cmd F_GETFL 读取当前文件状态标志,F_SETFL 设置文件状态标志。
第三个参数 F_GETFL 时通常传 0;F_SETFL 时传入新的标志组合,例如 `flags

非阻塞模式不会等待:暂时不能读写时常返回 -1,并设置 errno 为 EAGAIN 或 EWOULDBLOCK。生产程序通常配合 poll()、select()、epoll(Linux)或 kqueue(macOS/BSD)等 I/O 多路复用机制。

最小可运行 TCP 回显示例

下面代码仅用于学习:服务端一次处理一个连接,协议是“读到什么就回写什么”。它不适用于高并发生产环境,但展示了资源释放、错误检查和部分发送处理。

服务端:tcp_echo_server.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
#include <arpa/inet.h>
#include <cerrno>
#include <cstddef>
#include <cstdio>
#include <cstring>
#include <iostream>
#include <sys/socket.h>
#include <unistd.h>

bool send_all(int fd, const char* data, std::size_t length) {
std::size_t sent = 0;
while (sent < length) {
const ssize_t n = send(fd, data + sent, length - sent, 0);
if (n > 0) {
sent += static_cast<std::size_t>(n);
} else if (n == -1 && errno == EINTR) {
continue;
} else {
return false;
}
}
return true;
}

int main() {
const int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd == -1) {
perror("socket");
return 1;
}

int enabled = 1;
if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR,
&enabled, sizeof(enabled)) == -1) {
perror("setsockopt");
close(listen_fd);
return 1;
}

sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // 仅本机访问
addr.sin_port = htons(8080);

if (bind(listen_fd, reinterpret_cast<const sockaddr*>(&addr), sizeof(addr)) == -1) {
perror("bind");
close(listen_fd);
return 1;
}
if (listen(listen_fd, SOMAXCONN) == -1) {
perror("listen");
close(listen_fd);
return 1;
}

std::cout << "listening on 127.0.0.1:8080\n";
for (;;) {
const int client_fd = accept(listen_fd, nullptr, nullptr);
if (client_fd == -1) {
if (errno == EINTR) continue;
perror("accept");
break;
}

char buffer[4096];
for (;;) {
const ssize_t n = recv(client_fd, buffer, sizeof(buffer), 0);
if (n > 0) {
if (!send_all(client_fd, buffer, static_cast<std::size_t>(n))) break;
} else if (n == 0) {
break;
} else if (errno == EINTR) {
continue;
} else {
perror("recv");
break;
}
}
close(client_fd);
}
close(listen_fd);
}

客户端:tcp_echo_client.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#include <arpa/inet.h>
#include <cerrno>
#include <cstddef>
#include <cstdio>
#include <cstring>
#include <iostream>
#include <string>
#include <sys/socket.h>
#include <unistd.h>

bool send_all(int fd, const char* data, std::size_t length) {
std::size_t sent = 0;
while (sent < length) {
const ssize_t n = send(fd, data + sent, length - sent, 0);
if (n > 0) {
sent += static_cast<std::size_t>(n);
} else if (n == -1 && errno == EINTR) {
continue;
} else {
return false;
}
}
return true;
}

int main() {
const int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd == -1) {
perror("socket");
return 1;
}

sockaddr_in server{};
server.sin_family = AF_INET;
server.sin_port = htons(8080);
if (inet_pton(AF_INET, "127.0.0.1", &server.sin_addr) != 1) {
std::cerr << "invalid server IP\n";
close(fd);
return 1;
}
if (connect(fd, reinterpret_cast<const sockaddr*>(&server), sizeof(server)) == -1) {
perror("connect");
close(fd);
return 1;
}

const char message[] = "hello socket";
if (!send_all(fd, message, sizeof(message) - 1)) {
perror("send");
close(fd);
return 1;
}
if (shutdown(fd, SHUT_WR) == -1) {
perror("shutdown");
close(fd);
return 1;
}

std::string reply;
char buffer[128];
for (;;) {
const ssize_t n = recv(fd, buffer, sizeof(buffer), 0);
if (n > 0) {
reply.append(buffer, static_cast<std::size_t>(n));
} else if (n == 0) {
break;
} else if (errno == EINTR) {
continue;
} else {
perror("recv");
close(fd);
return 1;
}
}
std::cout << "reply: " << reply << '\n';
close(fd);
}

在两个终端分别编译运行:

1
2
3
4
c++ -std=c++17 -Wall -Wextra -pedantic tcp_echo_server.cpp -o server
c++ -std=c++17 -Wall -Wextra -pedantic tcp_echo_client.cpp -o client
./server
./client

常见问题

  • TCP 是字节流:不能假定一次 send() 对应一次 recv();必须设计协议边界。
  • send() 可部分发送:发送完整消息需要循环;非阻塞场景还要在可写事件到来后继续。
  • recv() == 0 对 TCP 表示对端正常关闭;对 UDP 则可表示空数据报。
  • EINTR 表示调用被信号中断,通常可重试;EAGAIN / EWOULDBLOCK 在非阻塞或设置超时时表示“暂时无法继续”。
  • 地址、端口和长度字段的字节序不能混用;端口写入 sin_port 前使用 htons(),读取时使用 ntohs()。
  • 不要把 sockaddr_in 用于 IPv6;需要双栈或域名解析时优先使用 getaddrinfo()。
  • 每个 socket() 和每个成功的 accept() 都应恰好对应一次 close()。
  • 网络输入不可信:长度字段应设上限,解析前检查缓冲区边界,避免把接收数据直接当作可信字符串或结构体。