目录
- 预备知识
- 什么是 Socket?
- 网络基础回顾 (IP, Port, TCP/UDP)
- 开发环境准备
- 核心 Socket 函数 (TCP 示例)
socket(): 创建套接字bind(): 绑定地址和端口listen(): 开始监听 (服务端)accept(): 接受连接 (服务端)connect(): 发起连接 (客户端)send()/recv(): 发送和接收数据close(): 关闭套接字
- 实战:一个简单的 Echo 服务器/客户端
- 服务端代码 (
server.c) - 客户端代码 (
client.c) - 如何编译和运行
- 服务端代码 (
- 错误处理
- 为什么
perror()和strerror(errno)是你的好朋友
- 为什么
- 从 TCP 到 UDP
- UDP 的特点
- UDP 核心函数 (
sendto,recvfrom) - 一个简单的 UDP 示例
- 高级主题
- 多路 I/O 复用:
select(),poll(),epoll()(Linux) - 非阻塞 I/O
- 套接字选项 (
setsockopt)
- 多路 I/O 复用:
- 总结与最佳实践
预备知识
什么是 Socket?
你可以把 Socket (套接字) 想象成一个通信的“端点”,它是一个由操作系统提供的编程接口,允许程序通过网络发送和接收数据,两个程序通过网络进行通信,就像是两座建筑通过一个标准的邮局系统(Socket API)互相邮寄信件。

网络基础回顾
- IP 地址: 网络中设备的唯一地址,
168.1.100(IPv4) 或2001:db8::1(IPv6),它标识了网络上的“哪一台机器”。 - 端口号: 在一台机器上,可能有多个程序在进行网络通信,端口号用于区分这些程序,它标识了“这台机器上的哪个程序”,Web 服务通常使用 80 端口,HTTPS 使用 443 端口,范围是 0-65535,0-1023 是系统保留端口。
- TCP (Transmission Control Protocol):
- 面向连接: 在传输数据前,必须先建立一个连接(三次握手)。
- 可靠传输: 通过确认、重传、排序等机制确保数据无差错、不丢失、不重复且按序到达。
- 全双工通信: 连接建立后,双方可以同时进行数据的发送和接收。
- 面向字节流: 应用层看到的数据是一个无结构的字节流。
- UDP (User Datagram Protocol):
- 无连接: 不需要建立连接,直接发送数据包(称为数据报)。
- 不可靠传输: 不保证数据包的顺序、不保证不丢失、不保证不重复。
- 高效: 协议开销小,传输速度快。
- 面向数据报: 应用层以数据报的形式处理数据。
开发环境准备
- 操作系统: Linux 是学习 Socket 编程的最佳平台,因为其 API 最标准,本教程的示例代码和命令均基于 Linux。
- 编译器: GCC (GNU Compiler Collection)。
- 网络工具:
netstat或ss用于查看网络连接状态。
核心 Socket 函数 (TCP 示例)
我们将以最常用的 TCP 流套接字 (SOCK_STREAM) 为例来介绍核心函数。
socket(): 创建套接字
这是使用 Socket API 的第一步,告诉操作系统你想要一个通信端点。
#include <sys/socket.h> int socket(int domain, int type, int protocol);
- domain (地址域): 指定使用的协议族。
AF_INET: IPv4 地址。AF_INET6: IPv6 地址。AF_UNIX: 用于同一台机器上的进程间通信 (IPC)。
- type (套接字类型):
SOCK_STREAM: 提供面向连接的、可靠的字节流服务 (TCP)。SOCK_DGRAM: 提供无连接的、不可靠的数据报服务 (UDP)。
- protocol (协议): 通常设为 0,让系统自动根据
domain和type选择。
返回值: 成功返回一个套接字文件描述符(一个非负整数),失败返回 -1。
bind(): 绑定地址和端口
这个函数将一个 IP 地址和端口号与套接字关联起来,对于服务端来说,这是必须的,它告诉客户端:“请连接到这个地址和这个端口上”。

#include <sys/socket.h> int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- sockfd:
socket()返回的套接字描述符。 - addr: 一个指向
sockaddr结构体的指针,这是一个通用的地址结构,根据domain不同,需要使用不同的结构体,并通过强制转换传入。- 对于
AF_INET,使用struct sockaddr_in。
- 对于
- addrlen:
addr结构体的长度。
struct sockaddr_in 结构体:
#include <netinet/in.h>
#include <arpa/inet.h> // for inet_addr
struct sockaddr_in {
short sin_family; // 地址族, AF_INET
unsigned short sin_port; // 端口号, 需要用 htons() 转换
struct in_addr sin_addr; // IP 地址, in_addr 是一个结构体
unsigned char sin_zero[8]; // 填充字段,保持与 sockaddr 结构体大小一致
};
// in_addr 结构体
struct in_addr {
in_addr_t s_addr; // 32位 IPv4 地址
};
重要: 端口号和 IP 地址在赋值给 sin_port 和 sin_addr.s_addr 时,必须从主机字节序转换到网络字节序,使用 htons() (host to network short) 和 inet_addr() (将点分十进制字符串转为网络字节序的 IP)。
listen(): 开始监听
bind() 之后,服务端调用 listen() 来准备接受客户端的连接请求,它会让套接字进入“被动”模式,等待客户端的连接。
#include <sys/socket.h> int listen(int sockfd, int backlog);
- sockfd: 已绑定的套接字描述符。
- backlog: 指定在调用
accept()之前,内核可以排队的最大连接请求数。
返回值: 成功返回 0,失败返回 -1。

accept(): 接受连接
当客户端发起连接后,服务端的 listen() 会感知到。accept() 会从连接队列中取出一个已完成的连接,并返回一个新的套接字描述符,专门用于与这个客户端通信,原来的监听套接字继续用于接受新的连接。
#include <sys/socket.h> int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- sockfd: 处于监听状态的套接字描述符。
- addr: 用于保存客户端的地址信息,如果不需要,可以设为
NULL。 - addrlen: 一个指向
addr长度的指针。addr是NULL,这里也设为NULL。
返回值: 成功返回一个新的套接字描述符(用于通信),失败返回 -1。
connect(): 发起连接
客户端调用 connect() 来主动向服务端发起连接请求。
#include <sys/socket.h> int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- sockfd: 客户端创建的套接字描述符。
- addr: 指向服务端
sockaddr_in结构体的指针,包含了服务端的 IP 和端口。 - addrlen:
addr结构体的长度。
返回值: 成功返回 0,失败返回 -1。
send() / recv(): 发送和接收数据
连接建立后,客户端和服务端都使用这两个函数在连接上传输数据。
#include <sys/socket.h> // 发送数据 ssize_t send(int sockfd, const void *buf, size_t len, int flags); // 接收数据 ssize_t recv(int sockfd, void *buf, size_t len, int flags);
- sockfd: 用于通信的套接字描述符(服务端是
accept()返回的,客户端是connect()使用的)。 - buf: 发送/接收数据的缓冲区。
- len: 缓冲区的长度。
- flags: 通常设为 0。
返回值:
- 成功时,
send()返回实际发送的字节数,recv()返回实际接收到的字节数。 - 如果返回值是 0,表示对方已经关闭了连接。
- 如果返回值是 -1,表示发生了错误。
close(): 关闭套接字
当通信结束时,调用 close() 来关闭套接字,释放相关资源。
#include <unistd.h> int close(int fd);
实战:一个简单的 Echo 服务器/客户端
这个例子中,客户端发送一条消息,服务器收到后,将原消息发送回客户端。
服务端代码 (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. 创建套接字
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 2. 设置套接字选项,允许地址重用
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
perror("setsockopt");
exit(EXIT_FAILURE);
}
// 3. 绑定地址和端口
address.sin_family = AF_INET;
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: %s:%d\n", inet_ntoa(address.sin_addr), ntohs(address.sin_port));
// 6. 读取客户端消息
int valread = read(new_socket, buffer, BUFFER_SIZE);
printf("Client message: %s\n", buffer);
// 7. 将消息回写给客户端
send(new_socket, buffer, valread, 0);
printf("Echo message sent back to client.\n");
// 8. 关闭套接字
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. 创建套接字
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("Server echo: %s\n", buffer);
// 5. 关闭套接字
close(sock);
return 0;
}
如何编译和运行
-
保存代码: 将上面的代码分别保存为
server.c和client.c。 -
编译:
# 编译服务端 gcc server.c -o server # 编译客户端 gcc client.c -o client
-
运行:
- 首先启动服务端 (它会一直等待连接):
./server
你会看到输出:
Server listening on port 8080... - 然后启动客户端 (在另一个终端中):
./client
客户端会连接到服务端,并输出:
Connected to server. Hello message sent Server echo: Hello from client - 服务端终端 也会输出:
Client connected: 127.0.0.1:xxxx Client message: Hello from client Echo message sent back to client.
- 首先启动服务端 (它会一直等待连接):
错误处理
在 Socket 编程中,几乎所有的函数都可能失败。检查每一个函数的返回值 是一个必须养成的良好习惯。
当函数失败时,全局变量 errno 会被设置成一个错误码,你可以使用 perror() 函数来打印一个描述性的错误信息。
#include <stdio.h>
#include <errno.h> // 需要 errno 头文件
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd == -1) {
perror("socket() failed"); // 会打印 "socket() failed: [具体的错误描述,如 Address already in use]"
exit(EXIT_FAILURE);
}
strerror(errno) 也可以将错误码转换为字符串。
从 TCP 到 UDP
UDP 编程比 TCP 简单,因为它没有连接的概念。
主要区别
- 创建套接字: 类型为
SOCK_DGRAM。 - 服务端:
- 不需要
listen()和accept()。 - 使用
bind()来绑定端口,然后直接进入循环,用recvfrom()接收数据。
- 不需要
- 客户端:
- 不需要
connect()。 - 每次发送数据时,都需要在
sendto()中指定目标地址和端口。 - 接收数据时使用
recvfrom(),它会告诉你数据来自哪里。
- 不需要
核心函数
// 发送数据到指定地址
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
// 从任意地址接收数据
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
一个简单的 UDP 示例 (服务端)
// udp_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 8081
#define BUFFER_SIZE 1024
int main() {
int sockfd;
char buffer[BUFFER_SIZE];
struct sockaddr_in servaddr, cliaddr;
// 1. 创建 UDP 套接字
if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) < 0) {
perror("socket creation failed");
exit(EXIT_FAILURE);
}
memset(&servaddr, 0, sizeof(servaddr));
memset(&cliaddr, 0, sizeof(cliaddr));
// 2. 绑定地址和端口
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = INADDR_ANY;
servaddr.sin_port = htons(PORT);
if (bind(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
printf("UDP Server listening on port %d...\n", PORT);
int len, n;
len = sizeof(cliaddr);
// 3. 循环接收数据
while (1) {
n = recvfrom(sockfd, (char *)buffer, BUFFER_SIZE, MSG_WAITALL, (struct sockaddr *)&cliaddr, &len);
buffer[n] = '\0';
printf("Client : %s\n", buffer);
sendto(sockfd, (const char *)buffer, n, MSG_CONFIRM, (const struct sockaddr *)&cliaddr, len);
printf("Echo message sent.\n");
}
close(sockfd);
return 0;
}
高级主题
多路 I/O 复用 (select())
当一个服务器需要同时处理多个客户端连接时,为每个连接都创建一个线程或进程是不现实的,I/O 复用技术允许你监视多个套接字,并知道其中任何一个是否“就绪”(即可读、可写或出现异常)。
select() 是最古老、最广泛支持的 I/O 复用函数。
#include <sys/select.h> int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
工作流程:
- 创建一个
fd_set集合(read_fds)。 - 使用
FD_ZERO()清空集合。 - 使用
FD_SET()将你关心的套接字(server_fd,client_fd1,client_fd2)添加到集合中。 - 调用
select(),它会阻塞,直到集合中的任何一个套接字就绪,或者超时。 select()返回后,你需要再次遍历所有套接字,使用FD_ISSET()检查哪些套接字在集合中(即哪些套接字就绪了)。- 处理就绪的套接字(从可读的套接字
recv()数据)。 - 在下一次循环前,重新设置
fd_set集合。
非阻塞 I/O
默认情况下,套接字是阻塞的,这意味着如果一个操作(如 read())不能立即完成,调用进程将被挂起,直到操作完成。
可以通过 fcntl() 将套接字设置为非阻塞模式,在非阻塞模式下,如果操作不能立即完成,函数会立即返回一个错误,并将 errno 设置为 EAGAIN 或 EWOULDBLOCK。
这通常与 I/O 复用技术结合使用,例如在一个循环中不断尝试 read(),直到 select() 告诉你这个套接字“可读”。
套接字选项 (setsockopt)
setsockopt 用于设置套接字的属性或选项。
SO_REUSEADDR: 允许在close()后立即重用相同的地址和端口,避免了TIME_WAIT状态带来的问题。SO_RCVBUF/SO_SNDBUF: 设置接收和发送缓冲区的大小。TCP_NODELAY: 禁用 Nagle 算法,对于需要低延迟的应用(如实时游戏)很有用。
总结与最佳实践
- 从 TCP 开始: TCP 更可靠,更容易理解,适合大多数应用场景。
- 检查返回值: 永远不要忽略 Socket 函数的返回值,并妥善处理错误。
- 使用
perror(): 它是调试 Socket 程序的利器。 - 字节序转换: 记住在使用 IP 和端口时,始终使用
htons(),ntohs(),inet_addr()等函数进行主机字节序和网络字节序的转换。 - 关闭套接字: 当通信结束时,记得调用
close()释放资源,防止资源泄露。 - 理解阻塞 vs. 非阻塞: 阻塞 I/O 简单但效率低,非阻塞 I/O 配合 I/O 复用是构建高性能服务器的关键。
这份教程为你提供了 C 语言 Socket 编程的坚实基础,你可以尝试扩展这些示例,
- 让服务器能同时处理多个客户端(使用
select()或多线程)。 - 实现一个简单的聊天室。
- 尝试使用更高级的 I/O 复用技术,如
poll()或epoll(Linux)。
