2. 第一个TCP Socket程序:从零搭建一次完整通信
说实话,很多初学者学Socket,上来就背API,结果写出来的程序要么连不上,要么一跑就崩。我个人习惯是——先动手写一个能跑通的例子,再回头理解每一行代码的意义。这一章我们就来干这件事。
我们会写两个程序:一个服务端,一个客户端。服务端等着别人来连,客户端主动去连。连上之后,客户端发一句"你好",服务端收到后回一句"收到",然后两边各自关闭。就这么简单,但背后涉及了Socket编程最核心的七个步骤。
本章核心流程:服务端创建Socket → 绑定地址与端口 → 监听 → 接受连接 → 客户端创建Socket → 连接服务器 → 发送/接收数据 → 关闭Socket
2.1 服务端:先搭好架子
服务端是"被动方"。它得先启动,然后一直等着。你想想看,如果服务端还没起来客户端就发请求,那肯定连不上。所以顺序很重要。
2.1.1 创建Socket
第一步,调用 socket() 函数。它返回一个文件描述符,后面所有操作都靠它。
#include <sys/socket.h>
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket");
exit(1);
}
参数解释:
- AF_INET:IPv4协议族。你要是用IPv6就写AF_INET6。
- SOCK_STREAM:流式套接字,对应TCP。SOCK_DGRAM才是UDP。
- 0:协议自动选择。TCP的话填0就行。
我的习惯:每次创建完socket,我都会检查返回值。曾经有一次我忘了检查,结果后面bind一直失败,排查了半天才发现是socket没创建成功。嗯,血的教训。
2.1.2 绑定地址与端口
Socket创建好了,但它还没跟任何地址关联。你得告诉操作系统:"我这个Socket要监听哪个IP和哪个端口"。这就是 bind() 干的事。
#include <netinet/in.h>
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); // 端口8888
if (bind(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
perror("bind");
exit(1);
}
这里有几个坑,我一个个说:
- htonl / htons:主机字节序转网络字节序。说白了,你的电脑可能是小端序,网络是大端序,得转一下。我刚开始学的时候老忘,结果bind上去的端口跟我想的不一样。
- INADDR_ANY:表示监听本机所有IP地址。如果你只想让某个特定IP连,可以写成
inet_addr("192.168.1.100")。 - 端口号:1024以下需要root权限。我建议你开发时用8000-9999之间的端口,省得每次都要sudo。
注意:如果程序崩溃退出后立刻重启,可能会报"Address already in use"。这是因为端口还没释放。你可以设置SO_REUSEADDR选项来解决,后面章节会讲。
2.1.3 监听连接
绑定完之后,Socket还不会主动接收连接。你得调用 listen() 告诉内核:"我准备好了,有人连我就通知我"。
if (listen(sockfd, 5) < 0) {
perror("listen");
exit(1);
}
printf("Server listening on port 8888...\n");
第二个参数是backlog,表示连接请求队列的最大长度。比如填5,就意味着最多有5个客户端在排队等待accept。超过的会被拒绝。我在项目中一般设128或256,具体看并发量。
2.1.4 接受客户端连接
这一步会阻塞。程序停在这里,直到有客户端连上来。
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");
exit(1);
}
printf("Client connected: %s:%d\n",
inet_ntoa(cli_addr.sin_addr),
ntohs(cli_addr.sin_port));
accept() 返回一个新的Socket描述符 connfd。原来的 sockfd 继续监听,新的 connfd 用来跟这个客户端通信。这是多并发的关键——每个客户端都有自己的专属通道。
2.2 客户端:主动出击
客户端就简单多了。它不需要bind,也不需要listen。直接创建Socket,然后connect过去就行。
2.2.1 创建Socket
跟服务端一模一样:
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket");
exit(1);
}
2.2.2 连接服务器
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 连接本机
serv_addr.sin_port = htons(8888);
if (connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
perror("connect");
exit(1);
}
printf("Connected to server.\n");
connect() 会触发TCP三次握手。如果服务器没启动,或者端口不对,connect会返回-1。我曾经在调试时把端口写成了8889,结果客户端一直卡在那里,超时后才报错。嗯,这种低级错误谁都犯过。
2.3 发送与接收数据
连接建立之后,两边就可以用 send() 和 recv() 通信了。这两个函数跟读写文件差不多,因为Socket在Linux里本质上就是一个文件描述符。
2.3.1 客户端发送
char *msg = "Hello, server!";
if (send(sockfd, msg, strlen(msg), 0) < 0) {
perror("send");
exit(1);
}
printf("Sent: %s\n", msg);
2.3.2 服务端接收并回复
char buffer[1024] = {0};
int n = recv(connfd, buffer, sizeof(buffer) - 1, 0);
if (n < 0) {
perror("recv");
exit(1);
}
printf("Received: %s\n", buffer);
// 回复客户端
char *reply = "Message received!";
send(connfd, reply, strlen(reply), 0);
注意:recv() 返回0表示对方已经关闭连接。这是判断连接是否断开的常用方法。我在写聊天程序时就靠这个来检测对方是否下线。
2.4 关闭Socket
通信结束,记得关闭Socket。不然文件描述符会泄漏,程序跑久了就会报"Too many open files"。
// 服务端关闭连接Socket和监听Socket
close(connfd);
close(sockfd);
// 客户端关闭
close(sockfd);
服务端有两个Socket要关:一个是 accept() 返回的连接Socket,一个是原来的监听Socket。客户端只需要关一个。
2.5 完整代码示例
我把服务端和客户端的完整代码贴出来。你可以直接复制编译运行。
服务端代码(server.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>
int main() {
// 1. 创建Socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) { perror("socket"); exit(1); }
// 2. 绑定地址和端口
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"); exit(1);
}
// 3. 监听
if (listen(sockfd, 5) < 0) { perror("listen"); exit(1); }
printf("Server listening on port 8888...\n");
// 4. 接受连接
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"); exit(1); }
printf("Client connected: %s:%d\n",
inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port));
// 5. 接收数据
char buffer[1024] = {0};
int n = recv(connfd, buffer, sizeof(buffer) - 1, 0);
if (n < 0) { perror("recv"); exit(1); }
printf("Received: %s\n", buffer);
// 6. 回复数据
char *reply = "Message received!";
send(connfd, reply, strlen(reply), 0);
// 7. 关闭
close(connfd);
close(sockfd);
return 0;
}
客户端代码(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>
int main() {
// 1. 创建Socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) { perror("socket"); exit(1); }
// 2. 连接服务器
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
serv_addr.sin_port = htons(8888);
if (connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
perror("connect"); exit(1);
}
printf("Connected to server.\n");
// 3. 发送数据
char *msg = "Hello, server!";
if (send(sockfd, msg, strlen(msg), 0) < 0) {
perror("send"); exit(1);
}
printf("Sent: %s\n", msg);
// 4. 接收回复
char buffer[1024] = {0};
int n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (n < 0) { perror("recv"); exit(1); }
printf("Received: %s\n", buffer);
// 5. 关闭
close(sockfd);
return 0;
}
2.6 编译与运行
打开两个终端,先启动服务端,再启动客户端:
# 终端1
gcc server.c -o server
./server
# 终端2
gcc client.c -o client
./client
你会看到服务端打印出"Received: Hello, server!",客户端打印出"Received: Message received!"。恭喜,你的第一个TCP Socket程序跑通了。
总结一下:这一章我们走完了TCP Socket通信的完整流程。服务端七步走:socket → bind → listen → accept → recv/send → close。客户端四步走:socket → connect → send/recv → close。别看步骤多,其实每一步都有它的道理。下一章我们会深入聊一聊这些函数背后的细节,以及常见的错误处理。