Socket编程基础:实现一个简单的TCP回射服务器
Socket编程,说白了就是让两台机器通过网络聊天。我刚开始接触网络编程时,总觉得这玩意儿很神秘——数据怎么就从一台电脑跑到另一台电脑上了?后来亲手写了一个回射服务器,才真正理解了TCP通信的完整流程。
今天咱们就手把手实现一个TCP回射服务器。所谓回射,就是客户端发什么,服务器原封不动地返回什么。虽然功能简单,但五脏俱全,能让你把socket()、bind()、listen()、accept()、connect()这几个核心函数彻底搞明白。
TCP Socket通信的核心流程
先看一张流程图,把整体脉络理清楚:
这张图我画了好几次才满意。你注意看,服务器端和客户端的流程是不对称的——服务器先启动,等着别人来找它;客户端主动发起连接。这个区别很重要,很多新手搞混了。
五个核心函数详解
1. socket() — 创建通信端点
socket()函数创建一个套接字,相当于打开了一扇门。它的原型是:
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
参数说明:
| 参数 | 常用值 | 含义 |
|---|---|---|
| domain | AF_INET | IPv4协议 |
| type | SOCK_STREAM | 面向连接的TCP流 |
| protocol | 0 | 自动选择协议 |
返回值是一个文件描述符。在Linux里,套接字本质上就是文件,所以你可以用read()、write()来操作它。我记得刚学的时候,总把socket()返回的值当成什么神秘句柄,后来才明白——它就是个数,跟open()返回的文件描述符没区别。
2. bind() — 绑定地址和端口
bind()把套接字和一个具体的地址、端口绑在一起。服务器必须调用bind(),客户端通常不需要。
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
这里有个坑——struct sockaddr是个通用结构,实际使用时需要填充struct sockaddr_in(IPv4)。我见过不少新手直接传struct sockaddr_in的指针,忘了做类型转换,编译报警告也不管,结果运行时bind失败。
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡
servaddr.sin_port = htons(8888); // 端口号
bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
3. listen() — 监听连接
listen()告诉内核,这个套接字要进入监听状态,准备接受客户端的连接请求。
int listen(int sockfd, int backlog);
backlog参数指定连接队列的最大长度。什么意思呢?当多个客户端同时连接时,内核会维护一个队列,backlog就是这个队列的上限。我建议设成5或10,太大反而浪费资源。
调用listen()之后,套接字就变成了被动套接字——它不再主动发起连接,而是等着别人来找它。
4. accept() — 接受连接
accept()从连接队列里取出一个已完成三次握手的连接,返回一个新的套接字描述符。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
这里要注意:accept()返回的是新套接字,原来的监听套接字还在继续监听。我刚开始写服务器时,以为accept()返回的就是原来的sockfd,结果用那个fd去通信,怎么都收不到数据。后来才明白——监听套接字只负责接客,真正干活的是新套接字。
- 监听套接字:只调用accept(),不读写数据
- 已连接套接字:负责和客户端通信,每个客户端一个
5. connect() — 发起连接
connect()是客户端用的,主动向服务器发起TCP连接。
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
connect()会触发TCP三次握手。如果服务器没启动,connect()会返回-1,errno设置为ECONNREFUSED。我在调试时经常遇到这个错误,第一反应就是——服务器没跑起来。
完整代码实现:TCP回射服务器
好了,理论说完了,咱们直接上代码。这是一个完整的回射服务器,客户端发什么,它就回什么。
#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 listen_fd, conn_fd;
struct sockaddr_in servaddr, cliaddr;
socklen_t cliaddr_len;
char buffer[BUFFER_SIZE];
ssize_t n;
// 1. 创建套接字
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket");
exit(1);
}
// 2. 绑定地址
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(PORT);
if (bind(listen_fd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
perror("bind");
close(listen_fd);
exit(1);
}
// 3. 开始监听
if (listen(listen_fd, 5) < 0) {
perror("listen");
close(listen_fd);
exit(1);
}
printf("服务器已启动,监听端口 %d...\n", PORT);
// 4. 循环接受连接
while (1) {
cliaddr_len = sizeof(cliaddr);
conn_fd = accept(listen_fd, (struct sockaddr *)&cliaddr, &cliaddr_len);
if (conn_fd < 0) {
perror("accept");
continue;
}
printf("客户端已连接: %s:%d\n",
inet_ntoa(cliaddr.sin_addr),
ntohs(cliaddr.sin_port));
// 5. 回射数据
while ((n = read(conn_fd, buffer, BUFFER_SIZE)) > 0) {
write(conn_fd, buffer, n);
}
printf("客户端断开连接\n");
close(conn_fd);
}
close(listen_fd);
return 0;
}
对应的客户端代码:
#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 sock_fd;
struct sockaddr_in servaddr;
char buffer[BUFFER_SIZE];
ssize_t n;
// 1. 创建套接字
sock_fd = socket(AF_INET, SOCK_STREAM, 0);
if (sock_fd < 0) {
perror("socket");
exit(1);
}
// 2. 连接服务器
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(PORT);
inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
if (connect(sock_fd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
perror("connect");
close(sock_fd);
exit(1);
}
printf("已连接到服务器\n");
// 3. 发送数据并接收回射
while (1) {
printf("请输入消息: ");
if (fgets(buffer, BUFFER_SIZE, stdin) == NULL) {
break;
}
write(sock_fd, buffer, strlen(buffer));
n = read(sock_fd, buffer, BUFFER_SIZE);
if (n <= 0) {
break;
}
buffer[n] = '\0';
printf("服务器回射: %s", buffer);
}
close(sock_fd);
return 0;
}
编译和运行
把服务器代码保存为server.c,客户端代码保存为client.c,然后:
gcc -o server server.c
gcc -o client client.c
# 先启动服务器
./server
# 再开一个终端运行客户端
./client
运行效果:客户端输入"hello",服务器返回"hello"。就这么简单。
避坑指南
写Socket程序,有几个坑我踩过不止一次:
- 端口被占用:程序退出后端口不会立即释放,等几分钟或用setsockopt()设置SO_REUSEADDR
- 粘包问题:TCP是流协议,多次write()可能合并成一次read()。回射服务器简单,但实际项目要考虑消息边界
- 阻塞问题:默认情况下read()是阻塞的,如果客户端不发数据,服务器就卡在那。可以用非阻塞IO或多线程解决
- 信号处理:子进程退出时会发送SIGCHLD信号,如果不处理,僵尸进程会越来越多
总结
TCP回射服务器虽然简单,但它是所有网络服务的基础。你想想看,HTTP服务器、FTP服务器、WebSocket服务器,本质上都是这个流程——创建套接字、绑定、监听、接受连接、读写数据。区别只在于应用层协议不同。
我个人建议,把这个代码亲手敲一遍,然后试着改一改——比如改成并发处理多个客户端,或者加上超时机制。只有亲手踩过坑,才能真正理解Socket编程的精髓。
公众号:蓝海资料掘金营,微信deep3321