2、TCP Socket编程实战:服务端创建与回声服务器
好,咱们正式开始动手写代码。这一章我会带着你从零搭建一个完整的TCP回声服务器——说白了就是客户端发什么,服务端原样返回什么。别看它简单,这里面把Socket编程的核心流程全走了一遍。
本章核心脉络:服务端四步曲(socket→bind→listen→accept)→ 客户端两步走(socket→connect)→ 数据收发(send/recv)→ 完整回声服务器实现
2.1 服务端创建:socket → bind → listen → accept
服务端的创建,我习惯把它叫做「四步曲」。每一步都有它的意义,少一步都不行。
2.1.1 socket() —— 创建套接字
socket() 说白了就是向操作系统申请一个「网络文件描述符」。你想想看,Linux 下一切皆文件,网络通信也不例外。
#include <sys/socket.h>
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket");
exit(EXIT_FAILURE);
}
| 参数 | 取值 | 含义 |
|---|---|---|
| domain | AF_INET | IPv4 协议族 |
| type | SOCK_STREAM | 面向连接的流式套接字(TCP) |
| protocol | 0 | 自动选择对应协议(TCP) |
个人经验:我早期写代码时总喜欢把第三个参数写成 IPPROTO_TCP,其实传 0 就够了。内核会根据前两个参数自动匹配,多写反而容易出错。
2.1.2 bind() —— 绑定地址和端口
bind() 就是把 socket 和具体的 IP、端口绑在一起。没有这一步,客户端就找不到你的服务。
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡
serv_addr.sin_port = htons(8888); // 端口号
if (bind(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
perror("bind");
close(sockfd);
exit(EXIT_FAILURE);
}
注意字节序:网络字节序是大端(Big-Endian),而 x86 主机是小端。htonl() 和 htons() 就是做这个转换的。我曾经见过一个同事忘了用 htons,结果端口死活绑不上——嗯,排查了半天才发现是字节序的问题。
2.1.3 listen() —— 进入监听状态
listen() 告诉内核:这个 socket 可以接受连接请求了。第二个参数是 backlog,表示连接请求队列的最大长度。
if (listen(sockfd, 5) < 0) {
perror("listen");
close(sockfd);
exit(EXIT_FAILURE);
}
backlog 设多少合适?我个人习惯在开发环境设 5,生产环境至少 128。但要注意,这个值不是越大越好——太大了反而会掩盖连接风暴的问题。
2.1.4 accept() —— 接受客户端连接
accept() 是个阻塞函数。它会一直等,直到有客户端连上来,然后返回一个新的 socket 用于通信。
struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
int connfd = accept(sockfd, (struct sockaddr*)&cli_addr, &cli_len);
if (connfd < 0) {
perror("accept");
close(sockfd);
exit(EXIT_FAILURE);
}
关键点:accept() 返回的是一个新的文件描述符(connfd),原来的 sockfd 还在继续监听。也就是说,一个服务端可以同时处理多个连接——当然,这需要多线程或 IO 多路复用的配合,我们后面会讲。
2.2 客户端创建:socket → connect
客户端就简单多了,两步搞定。
2.2.1 socket() —— 创建套接字
和服务端一样,先申请一个 socket。
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
2.2.2 connect() —— 发起连接
connect() 会触发 TCP 的三次握手。如果服务端没在 listen,connect 就会失败。
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(8888);
inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);
if (connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
perror("connect");
close(sockfd);
exit(EXIT_FAILURE);
}
避坑指南:inet_pton() 比 inet_addr() 更安全,它支持 IPv4 和 IPv6,而且返回值能告诉你地址格式是否合法。我建议你养成用 inet_pton 的习惯。
2.3 数据收发:send / recv
连接建立之后,就可以用 send() 和 recv() 来传数据了。这两个函数本质上是对 read() 和 write() 的封装,但多了 flags 参数。
// 发送数据
char *msg = "Hello, Server!";
int n = send(connfd, msg, strlen(msg), 0);
if (n < 0) {
perror("send");
}
// 接收数据
char buf[1024] = {0};
int n = recv(connfd, buf, sizeof(buf) - 1, 0);
if (n > 0) {
printf("Received: %s\n", buf);
} else if (n == 0) {
printf("Connection closed\n");
} else {
perror("recv");
}
| 返回值 | 含义 | 处理方式 |
|---|---|---|
| > 0 | 成功收发 n 字节 | 正常处理数据 |
| 0 | 对方关闭连接 | 关闭 socket,退出循环 |
| -1 | 出错(或 EINTR 信号中断) | 检查 errno,决定重试或退出 |
重要提醒:send() 和 recv() 不保证一次就能发完或收完所有数据。TCP 是流式协议,数据可能被拆分或合并。实际项目中一定要用循环来确保完整收发——这个我们后面专门讲「粘包与拆包」时会深入。
2.4 实战:完整回声服务器
好了,把上面的知识点串起来,就是一个完整的回声服务器。客户端发什么,服务端就回什么。
2.4.1 服务端代码
#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 sockfd, connfd;
struct sockaddr_in serv_addr, cli_addr;
socklen_t cli_len = sizeof(cli_addr);
char buf[BUFFER_SIZE];
// 1. socket
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) { perror("socket"); exit(1); }
// 2. bind
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(PORT);
if (bind(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
perror("bind"); close(sockfd); exit(1);
}
// 3. listen
if (listen(sockfd, 5) < 0) {
perror("listen"); close(sockfd); exit(1);
}
printf("Echo server listening on port %d...\n", PORT);
// 4. accept
connfd = accept(sockfd, (struct sockaddr*)&cli_addr, &cli_len);
if (connfd < 0) { perror("accept"); close(sockfd); exit(1); }
printf("Client connected.\n");
// 5. echo loop
while (1) {
memset(buf, 0, BUFFER_SIZE);
int n = recv(connfd, buf, BUFFER_SIZE - 1, 0);
if (n <= 0) {
printf("Client disconnected.\n");
break;
}
printf("Received: %s", buf);
send(connfd, buf, n, 0);
}
close(connfd);
close(sockfd);
return 0;
}
2.4.2 客户端代码
#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
#define BUFFER_SIZE 1024
int main() {
int sockfd;
struct sockaddr_in serv_addr;
char buf[BUFFER_SIZE];
// 1. socket
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) { perror("socket"); exit(1); }
// 2. connect
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);
if (connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
perror("connect"); close(sockfd); exit(1);
}
printf("Connected to server.\n");
// 3. send & recv
while (1) {
printf("Enter message: ");
fgets(buf, BUFFER_SIZE, stdin);
send(sockfd, buf, strlen(buf), 0);
memset(buf, 0, BUFFER_SIZE);
int n = recv(sockfd, buf, BUFFER_SIZE - 1, 0);
if (n <= 0) break;
printf("Echo: %s", buf);
}
close(sockfd);
return 0;
}
运行效果:先启动服务端,再启动客户端。客户端输入 "Hello",服务端返回 "Hello"。输入 "exit" 也一样会原样返回——这就是回声服务器的全部逻辑。
2.5 常见问题与避坑
- Address already in use:服务端关闭后立即重启,bind 会失败。这是因为 TIME_WAIT 状态还没结束。解决方案是用 setsockopt() 设置 SO_REUSEADDR。
- Connection refused:客户端连不上,多半是服务端没启动或者端口不对。用 netstat -an | grep 8888 检查一下。
- recv 返回 -1 但 errno 是 EINTR:被信号中断了,重新调用 recv 即可。这不是真正的错误。
我的习惯:每次写 Socket 程序,我都会在关键步骤后加 perror() 或 printf() 打印状态。调试阶段多打一行日志,能省下几小时的排查时间。
这一章的内容就到这里。代码虽然简单,但它是所有复杂网络应用的基础。你把这个回声服务器跑通了,后面的多线程、IO 多路复用、epoll 就都有了落脚点。