我将从基础概念开始,逐步深入,并提供一个完整的、可运行的客户端/服务器端示例代码。

目录
- 核心概念:什么是 Socket?
- TCP 通信流程概览
- 核心 API 函数详解
- 完整代码示例:回显服务器
- 服务器端代码
- 客户端代码
- 如何编译和运行
- 关键注意事项
- 字节序
- 地址复用
- 错误处理
- 阻塞与非阻塞 I/O
核心概念:什么是 Socket?
Socket(套接字)是操作系统提供的一组 API,它充当了应用程序和网络协议栈之间的接口,你可以把它想象成一个“网络上的文件描述符”,应用程序通过读写这个“文件”来发送和接收数据。
Socket 主要分为三种类型:
- 流式套接字:使用 TCP 协议,提供面向连接、可靠的数据传输服务,数据无差错、不丢失、不重复,且按序到达。
- 数据报套接字:使用 UDP 协议,提供无连接、尽最大努力的数据传输服务,可能丢失、重复或乱序。
- 原始套接字:允许直接访问底层协议(如 IP、ICMP),通常用于网络编程和诊断工具。
本教程专注于 TCP 流式套接字。
TCP 通信流程概览
TCP 是面向连接的协议,通信前必须先建立连接。

服务器端 流程:
- 创建 Socket:使用
socket()函数创建一个套接字描述符。 - 绑定地址和端口:使用
bind()函数将套接字与一个特定的 IP 地址和端口号绑定,这样客户端才能找到它。 - 监听连接:使用
listen()函数将套接字设置为“监听”状态,等待客户端的连接请求。 - 接受连接:使用
accept()函数阻塞等待,直到有客户端连接上来。accept()会返回一个新的套接字描述符,专门用于与这个客户端通信。 - 收发数据:使用
send()和recv()(或read()/write()) 函数通过新建立的套接字与客户端进行数据交换。 - 关闭连接:通信结束后,使用
close()关闭套接字。
客户端 流程:
- 创建 Socket:同样使用
socket()函数创建一个套接字描述符。 - 连接服务器:使用
connect()函数主动向服务器的 IP 地址和端口发起连接请求。 - 收发数据:连接成功后,使用
send()和recv()(或read()/write()) 函数与服务器交换数据。 - 关闭连接:通信结束后,使用
close()关闭套接字。
流程图解:
客户端 服务器
| |
1. socket() 1. socket()
| |
| | 2. bind()
| |
| | 3. listen()
| |
2. connect() ------------------------------> 4. accept()
| | (返回新socket)
|<-------------------------------------|
5. send() / recv() <---------------------> 5. send() / recv()
| |
6. close() -------------------------------> 6. close()
| |
核心 API 函数详解
所有 Socket 函数的头文件是 <sys/socket.h>,网络地址相关的头文件是 <netinet/in.h>,字符串转换函数的头文件是 <arpa/inet.h>。
服务器端 API
-
int socket(int domain, int type, int protocol);domain: 地址域。AF_INET用于 IPv4,AF_INET6用于 IPv6。type: 套接字类型。SOCK_STREAM用于 TCP,SOCK_DGRAM用于 UDP。protocol: 协议,通常设为 0,系统会自动选择type对应的协议。- 返回值:成功返回一个新的套接字文件描述符(一个非负整数),失败返回 -1。
-
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);sockfd:socket()返回的套接字描述符。addr: 指向sockaddr结构体的指针,包含了要绑定的 IP 地址和端口号。addrlen:addr结构体的大小。- 注意:我们通常使用
struct sockaddr_in(IPv4) 来设置地址,因为它更方便,但在bind()时,需要将其强制转换为struct sockaddr *类型。 - 返回值:成功返回 0,失败返回 -1。
-
int listen(int sockfd, int backlog);sockfd: 已绑定地址的套接字描述符。backlog: 请求队列的最大长度,表示在调用accept()之前,系统可以缓存多少个待处理的连接请求。- 返回值:成功返回 0,失败返回 -1。
-
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);sockfd: 处于监听状态的套接字描述符。addr: 一个struct sockaddr指针,用于保存客户端的地址信息,如果不需要,可以设为NULL。addrlen: 一个指向socklen_t类型的指针,用于传入addr的大小,并在返回时保存实际写入的地址大小。- 返回值:成功返回一个新的套接字描述符,专门用于与这个已连接的客户端通信,失败返回 -1。
客户端 API
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);sockfd: 客户端创建的套接字描述符。addr: 指向服务器sockaddr结构体的指针,包含了服务器的 IP 地址和端口号。addrlen:addr结构体的大小。- 返回值:成功返回 0,失败返回 -1。
通用 API
-
ssize_t send(int sockfd, const void *buf, size_t len, int flags);sockfd: 已连接的套接字描述符(服务器端是accept()返回的,客户端是connect()使用的)。buf: 要发送数据的缓冲区。len: 要发送数据的字节数。flags: 通常设为 0。- 返回值:成功返回实际发送的字节数,失败返回 -1。
-
ssize_t recv(int sockfd, void *buf, size_t len, int flags);sockfd: 已连接的套接字描述符。buf: 用于接收数据的缓冲区。len: 缓冲区的大小。flags: 通常设为 0。- 返回值:成功返回实际接收到的字节数,如果连接对端已关闭,返回 0,失败返回 -1。
-
int close(int sockfd);- 关闭套接字,释放相关资源。
- 返回值:成功返回 0,失败返回 -1。
完整代码示例:回显服务器
这个例子中,服务器会接收客户端发来的任何消息,然后原封不动地发送回去。
服务器端代码 (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>
#define PORT 8080
#define BUFFER_SIZE 1024
int main() {
int server_fd, new_socket;
struct sockaddr_in address;
int opt = 1;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE] = {0};
// 1. 创建 socket 文件描述符
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 2. 设置 socket 选项,允许地址重用
// 防止 "Address already in use" 错误
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
perror("setsockopt");
exit(EXIT_FAILURE);
}
// 3. 绑定地址和端口
address.sin_family = AF_INET; // IPv4
address.sin_addr.s_addr = INADDR_ANY; // 监听所有可用的网络接口
address.sin_port = htons(PORT); // 将端口号从主机字节序转换为网络字节序
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// 4. 开始监听
if (listen(server_fd, 3) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
printf("Server listening on port %d...\n", PORT);
// 5. 接受客户端连接
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
perror("accept");
exit(EXIT_FAILURE);
}
printf("Client connected from %s:%d\n", inet_ntoa(address.sin_addr), ntohs(address.sin_port));
// 6. 循环收发数据
while (1) {
// 接收客户端数据
int valread = recv(new_socket, buffer, BUFFER_SIZE, 0);
if (valread == 0) {
// recv 返回 0 表示客户端已关闭连接
printf("Client disconnected.\n");
break;
} else if (valread < 0) {
perror("recv");
break;
}
printf("Received: %s", buffer);
// 将接收到的数据回显给客户端
send(new_socket, buffer, valread, 0);
printf("Echoed back to client.\n");
// 清空缓冲区
memset(buffer, 0, BUFFER_SIZE);
}
// 7. 关闭套接字
close(new_socket);
close(server_fd);
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>
#define PORT 8080
#define BUFFER_SIZE 1024
int main() {
int sock = 0;
struct sockaddr_in serv_addr;
char *hello = "Hello from client";
char buffer[BUFFER_SIZE] = {0};
// 1. 创建 socket
if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
printf("\n Socket creation error \n");
return -1;
}
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
// 将 IPv4 地址从文本转换为二进制形式
if (inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr) <= 0) {
printf("\nInvalid address/ Address not supported \n");
return -1;
}
// 2. 连接服务器
if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
printf("\nConnection Failed \n");
return -1;
}
printf("Connected to server.\n");
// 3. 发送数据
send(sock, hello, strlen(hello), 0);
printf("Hello message sent\n");
// 4. 接收服务器回显
int valread = read(sock, buffer, BUFFER_SIZE);
printf("%s\n", buffer);
// 5. 关闭套接字
close(sock);
return 0;
}
如何编译和运行
-
保存代码:将上面的代码分别保存为
server.c和client.c。 -
编译:打开终端,使用
gcc进行编译,需要链接pthread库(虽然这个例子没用,但服务器编程常需要,加上也无妨)。# 编译服务器 gcc server.c -o server # 编译客户端 gcc client.c -o client
-
运行:
- 第一步:在一个终端窗口中运行服务器,它会启动并等待连接。
./server # 输出: Server listening on port 8080...
- 第二步:在另一个终端窗口中运行客户端,它会连接到服务器。
./client # 客户端输出: # Connected to server. # Hello message sent # Hello from client
- 第三步:观察服务器的终端窗口,它会显示接收和回显的消息。
# 服务器端输出: # Server listening on port 8080... # Client connected from 127.0.0.1:xxxx # Received: Hello from client # Echoed back to client.
- 第一步:在一个终端窗口中运行服务器,它会启动并等待连接。
关键注意事项
字节序
- 主机字节序:CPU 存储多字节数据的顺序,Intel x86 架构是小端序(低位字节在前)。
- 网络字节序:网络数据传输的标准顺序,是大端序(高位字节在前)。
在进行网络编程时,所有涉及到的端口号和 IP 地址都需要在发送前从主机字节序转换为网络字节序,在接收后再转换回来。
htons(): host to short (16位)htonl(): host to long (32位)ntohs(): network to shortntohl(): network to long
地址复用
服务器程序在重启时,常常会提示 "Address already in use",这是因为操作系统会保持一段时间(TIME_WAIT 状态)的端口占用,使用 setsockopt() 设置 SO_REUSEADDR 选项可以立即重用该地址,避免这个错误。
错误处理
始终检查所有 Socket 函数的返回值! 它们可能会失败,使用 perror() 函数可以打印出有意义的错误信息,方便调试。
阻塞与非阻塞 I/O
默认情况下,socket() 创建的套接字是阻塞的。
- 阻塞:
accept()会一直等待,直到有客户端连接;recv()会一直等待,直到有数据到达,这会使程序在等待时卡住。 - 非阻塞:调用会立即返回,如果没有数据或连接,会返回一个错误(如
EWOULDBLOCK或EAGAIN),非阻塞 I/O 通常与 I/O 多路复用技术(如select,poll,epoll)结合使用,以实现高性能的服务器,能够同时处理成千上万的连接。
对于初学者,从阻塞式 I/O 开始是最简单和最容易理解的。
使用 C 语言进行 TCP Socket 通信是网络编程的基础,记住服务器和客户端的核心流程,熟练掌握 socket, bind, listen, accept, connect, send, recv, close 这几个关键函数,并注意字节序和错误处理,你就可以编写出基本的网络应用程序了。
这个回显服务器是学习网络编程的 "Hello, World!",理解了它,你就可以在此基础上构建更复杂的应用,如文件传输、聊天室、HTTP 服务器等。
