Socket 编程入门:从概念到实战
Socket,中文叫套接字,说白了就是网络通信的「门」。你想想看,两台机器要聊天,总得有个入口吧?Socket 就是这个入口。我刚开始学的时候,总觉得这玩意儿很玄乎,后来写多了才发现,它就是个文件描述符——对,跟你在 Linux 下读写文件用的那个 fd 是一回事。
核心认知:Socket 是应用层与传输层之间的编程接口。你调 Socket API,底层帮你走 TCP/UDP,你只管收发数据就行。
2.1 什么是 Socket?
Socket 起源于 BSD Unix,最初就是为了让进程间能跨机器通信。它抽象了「通信端点」这个概念:
- IP 地址:定位哪台机器
- 端口号:定位机器上的哪个进程
- 协议类型:TCP 还是 UDP
这三样凑一起,就是一个唯一的 Socket。我记得有次排查线上问题,发现两个服务连不上,折腾半天,结果是端口写错了。嗯,这种低级错误,谁还没犯过呢?
2.2 TCP Socket API 详解
TCP Socket 的 API 其实就六个函数,我管它叫「六脉神剑」。你把这六个玩明白了,基本所有 TCP 服务端客户端都能写。
| 函数 | 作用 | 服务端/客户端 |
|---|---|---|
socket() |
创建 Socket 文件描述符 | 双方都用 |
bind() |
绑定 IP 和端口 | 服务端 |
listen() |
进入监听状态 | 服务端 |
accept() |
接受客户端连接 | 服务端 |
connect() |
发起连接请求 | 客户端 |
close() |
关闭连接 | 双方都用 |
2.2.1 socket() — 开门
第一步,先拿一把钥匙——创建一个 Socket 描述符。
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
AF_INET:IPv4 协议族SOCK_STREAM:TCP 流式套接字0:自动选择协议(TCP)
返回的 sockfd 就是个整数,跟文件描述符一样。如果返回 -1,说明创建失败了。我见过新手忘了检查返回值,结果后面各种段错误,排查半天。
2.2.2 bind() — 挂牌
服务端得告诉系统:「我在这扇门上等着,谁来都行。」这就是 bind 干的事。
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡
server_addr.sin_port = htons(8080); // 端口 8080
bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
注意:端口号要用 htons() 转换字节序。网络字节序是大端,而 x86 是小端。我曾经忘了调这个,结果端口绑到了完全不同的数字上,客户端死活连不上。
2.2.3 listen() — 排队
bind 之后,服务端得说:「我准备好了,你们来吧。」listen 就是干这个的。
listen(sockfd, 5); // 最大等待队列长度 5
第二个参数是 backlog,表示内核为这个 Socket 排队的最大连接数。如果客户端来得太快,队列满了,新的连接就会被拒绝。我建议生产环境至少设 128,甚至更大。
2.2.4 accept() — 接客
这是服务端最核心的一步——从队列里取出一个连接,创建一个新的 Socket 专门跟这个客户端通信。
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(sockfd, (struct sockaddr*)&client_addr, &client_len);
注意,accept 返回的是 新 Socket,原来的监听 Socket 还在继续监听。这就是 TCP 并发的基础——一个门迎,多个服务员。
2.2.5 connect() — 敲门
客户端这边就简单了,直接拿着服务端的地址去敲门。
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr("192.168.1.100");
server_addr.sin_port = htons(8080);
connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
connect 成功返回 0,失败返回 -1。常见的失败原因:服务端没启动、端口被防火墙挡了、IP 地址写错了。
2.3 客户端/服务器模型
把上面六个函数串起来,就是经典的 C/S 模型。我画了一张图,你看一眼就明白了。
这张图把整个流程串起来了。你注意看,服务端是「被动方」,先启动、先 bind、先 listen,然后卡在 accept 那里等着。客户端是「主动方」,创建 Socket 后直接 connect 过去。
我的经验:写 TCP 服务端时,一定要先启动服务端,再启动客户端。顺序反了,客户端 connect 会直接失败。我在测试环境经常犯这个错,后来写了个启动脚本自动检查端口是否在监听。
2.4 完整代码示例
光说不练假把式。我给你一个最简的 echo 服务端和客户端,你直接复制就能跑。
服务端代码(echo_server.c)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
int main() {
int server_fd, client_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
char buffer[1024] = {0};
// 1. 创建 Socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket failed");
exit(1);
}
// 2. 绑定
memset(&server_addr, 0, sizeof(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(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("bind failed");
close(server_fd);
exit(1);
}
// 3. 监听
if (listen(server_fd, 5) < 0) {
perror("listen failed");
close(server_fd);
exit(1);
}
printf("Server listening on port 8080...\n");
// 4. 接受连接
client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &client_len);
if (client_fd < 0) {
perror("accept failed");
close(server_fd);
exit(1);
}
printf("Client connected!\n");
// 5. 收发数据
int n = read(client_fd, buffer, sizeof(buffer));
printf("Received: %s\n", buffer);
send(client_fd, buffer, n, 0);
// 6. 关闭
close(client_fd);
close(server_fd);
return 0;
}
客户端代码(echo_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() {
int sockfd;
struct sockaddr_in server_addr;
char *message = "Hello, TCP!";
char buffer[1024] = {0};
// 1. 创建 Socket
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket failed");
exit(1);
}
// 2. 连接服务端
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);
if (connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("connect failed");
close(sockfd);
exit(1);
}
printf("Connected to server!\n");
// 3. 发送数据
send(sockfd, message, strlen(message), 0);
printf("Sent: %s\n", message);
// 4. 接收回显
read(sockfd, buffer, sizeof(buffer));
printf("Received echo: %s\n", buffer);
// 5. 关闭
close(sockfd);
return 0;
}
避坑指南:我曾经在生产环境遇到过一个问题——服务端 accept 之后,客户端发来的数据读不全。排查了半天,发现是 read 没有循环读。TCP 是流式协议,一次 read 不一定能读完所有数据。正确的做法是循环 read,直到返回 0 或 -1。
2.5 常见问题与排查思路
写 Socket 程序,90% 的问题都出在下面这几个地方:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| bind 返回 EADDRINUSE | 端口被占用 | netstat -tlnp | grep 端口 |
| connect 超时 | 服务端没启动或防火墙拦截 | telnet IP 端口 测试连通性 |
| connect 拒绝 | 端口没在 listen 状态 | 检查服务端是否调用了 listen |
| read 返回 0 | 对方已关闭连接 | 检查 close 时机 |
| 数据粘包 | TCP 是流,没有消息边界 | 应用层自己加长度头或分隔符 |
嗯,说到粘包,这是个经典问题。TCP 是流式协议,它不关心你发了几次数据,只关心字节流。如果你连续 send 两次,接收方可能一次 read 就把两段数据全读走了。解决办法?我一般会在每个消息前面加 4 字节的长度字段,接收方先读长度,再读指定字节的数据。
一句话总结:Socket 编程就是「创建→绑定→监听→接受→收发→关闭」这六个步骤。服务端被动等待,客户端主动连接。记住这个模型,TCP 编程你就入门了。