目录
- 网络编程基础概念
- OSI 七层模型与 TCP/IP 四层模型
- IP 地址与端口号
- 协议:TCP vs UDP
- 套接字:Socket
- 核心 Socket API
- 头文件
- 创建套接字:
socket() - 绑定地址:
bind() - 监听连接:
listen() - 接受连接:
accept() - 连接服务器:
connect() - 数据传输:
send()/recv()(TCP),sendto()/recvfrom()(UDP) - 关闭套接字:
close()
- 一个完整的 TCP 服务器/客户端示例
- TCP 服务器代码
- TCP 客户端代码
- 编译与运行
- 一个简单的 UDP 示例
- UDP 服务器/客户端代码
- 编译与运行
- 高级主题
- 多路复用:
select(),poll(),epoll() - 非阻塞 I/O
- 套接字选项
- 多路复用:
- 调试与常见问题
网络编程基础概念
OSI 七层模型与 TCP/IP 四层模型
网络通信遵循分层模型,每一层都建立在下一层之上。

| OSI 七层模型 | TCP/IP 四层模型 | 主要功能 | 协议示例 |
|---|---|---|---|
| 应用层 | 应用层 | 为应用程序提供网络服务 | HTTP, FTP, SMTP, DNS |
| 表示层 | 数据格式转换、加密解密 | SSL/TLS | |
| 会话层 | 建立、管理和终止会话 | NetBIOS, RPC | |
| 传输层 | 传输层 | 提供端到端的可靠或不可靠数据传输 | TCP, UDP |
| 网络层 | 网络层 | 负责数据包的路由和转发 | IP, ICMP, ARP |
| 数据链路层 | 网络接口层 | 在物理网络(如以太网)上传输数据 | Ethernet, Wi-Fi |
| 物理层 | 传输二进制比特流 | 网线、光纤、无线电波 |
核心思想:我们作为程序员,主要工作在 应用层,我们通过 套接字 这个 API,与底层的 传输层 进行交互,从而实现网络通信。
IP 地址与端口号
- IP 地址:网络中设备的唯一标识,就像你家的地址。
168.1.100。 - 端口号:同一台设备上,不同应用程序的标识,就像你家的房间号,取值范围是 0-65535。
- 0-1023:熟知端口,被系统或特定服务占用(如 HTTP 80, FTP 21)。
- 1024-49151:注册端口,用户程序可以使用。
- 49152-65535:动态/私有端口,客户端通常使用这个范围作为临时端口。
一个网络连接的唯一标识是 五元组:{源IP, 源端口, 目的IP, 目的端口, 协议}。
协议:TCP vs UDP
-
TCP (Transmission Control Protocol - 传输控制协议)
- 特点:面向连接、可靠、字节流。
- 过程:通信前需要通过“三次握手”建立连接,通信结束后需要“四次挥手”断开连接。
- 可靠性保证:通过序列号、确认应答、超时重传、流量控制和拥塞控制等机制确保数据无差错、不丢失、不重复且按序到达。
- 应用场景:对可靠性要求高的场景,如文件传输、网页浏览、邮件发送。
-
UDP (User Datagram Protocol - 用户数据报协议)
(图片来源网络,侵删)- 特点:无连接、不可靠、数据报。
- 过程:直接发送数据,无需建立连接。
- 特点:开销小、传输速度快,但不保证数据是否到达、到达的顺序或是否重复。
- 应用场景:对实时性要求高、能容忍少量丢包的场景,如视频会议、在线游戏、DNS查询。
套接字:Socket
套接字是操作系统提供给应用程序进行网络编程的 API,它像一个“文件描述符”,你可以对它进行读写操作,只不过读写的不是本地文件,而是网络数据。
在 Linux 中,一切皆文件,套接字在内核中也被视为一个文件,因此它也有一个文件描述符,是一个非负整数。
核心 Socket API
头文件
#include <sys/socket.h> // 核心套接字函数 #include <netinet/in.h> // IP 地址和端口号结构体 (AF_INET) #include <arpa/inet.h> // IP 地址转换函数 (inet_pton, inet_ntop) #include <unistd.h> // read, write, close #include <string.h> // memset, strerror #include <stdio.h> // perror, printf #include <stdlib.h> // exit
创建套接字:socket()
int socket(int domain, int type, int protocol);
- domain:地址族。
AF_INET:IPv4。AF_INET6:IPv6。AF_UNIX:本地进程间通信。
- type:套接字类型。
SOCK_STREAM:流式套接字,对应 TCP。SOCK_DGRAM:数据报套接字,对应 UDP。
- protocol:协议,通常设为
0,系统会自动根据domain和type选择。 - 返回值:成功返回套接字文件描述符,失败返回
-1。
绑定地址:bind()
服务器端需要调用此函数,将套接字与本机的 IP 地址和端口号绑定。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- sockfd:由
socket()返回的套接字描述符。 - addr:指向
sockaddr结构体的指针,包含了要绑定的 IP 和端口。 - addrlen:
addr结构体的长度。
sockaddr 是一个通用结构体,对于 IPv4,我们通常使用 sockaddr_in 结构体,并用 memset 和类型转换来初始化它。
struct sockaddr_in {
sa_family_t sin_family; // 地址族, AF_INET
in_port_t sin_port; // 16位端口号, 需要用htons()转换
struct in_addr sin_addr; // 32位IP地址
// ... 其他字段
};
struct in_addr {
in_addr_t s_addr; // 32位IPv4地址, 需要用inet_pton()转换
}
关键点:
- IP 地址:如果服务器监听所有网络接口,IP 地址可以设置为
INADDR_ANY,其值为0.0.0。 - 端口号:端口号是网络字节序的,而我们的主机可能是小端序,需要用
htons()(host to network short) 将端口号转换为网络字节序。
监听连接:listen()
对于 TCP 服务器,在 bind() 之后,调用 listen() 将套接字从主动连接模式变为被动监听模式,等待客户端连接。
int listen(int sockfd, int backlog);
- sockfd:已绑定地址的套接字描述符。
- backlog:等待连接队列的最大长度,当有多个客户端同时连接时,超出此数量的连接可能会被拒绝。
接受连接:accept()
accept() 是一个阻塞函数,它会从已完成连接的队列中取出一个连接,并为这个新连接创建一个新的套接字。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- sockfd:由
listen()的套接字,称为“监听套接字”。 - addr:一个
sockaddr结构体指针,用于存放客户端的 IP 地址和端口信息,如果不需要,可以设为NULL。 - addrlen:一个指针,指向
addr结构体的长度,函数返回时,它会更新为实际写入的长度。 - 返回值:成功返回一个新的套接字文件描述符(连接套接字),用于与这个特定的客户端通信,失败返回
-1。
重要:服务器会一直保留这个“监听套接字”,用于接受后续的客户端连接,而 accept() 返回的“连接套接字”则专门用于与当前已连接的客户端进行数据传输。
连接服务器:connect()
TCP 客户端调用此函数来主动连接服务器。
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- sockfd:由
socket()创建的套接字描述符。 - addr:指向服务器
sockaddr结构体的指针,包含服务器的 IP 和端口。 - addrlen:
addr结构体的长度。 - 返回值:成功返回
0,失败返回-1。connect()是一个阻塞函数,直到连接成功或失败才返回。
数据传输
-
TCP (流式)
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);send()和recv()与write()和read()功能非常相似,flags通常设为0。
-
UDP (数据报)
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);sendto()需要指定目标地址。recvfrom()在接收数据的同时,可以获取到数据发送方的地址。
关闭套接字:close()
int close(int fd);
关闭套接字,释放相关资源,对于 TCP,它会发送 FIN 包来终止连接。
一个完整的 TCP 服务器/客户端示例
TCP 服务器代码 (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);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY; // 监听所有网络接口
address.sin_port = htons(PORT); // 将端口号转换为网络字节序
// 3. 绑定套接字到指定端口
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
printf("Server bound to port %d\n", PORT);
// 4. 开始监听连接
if (listen(server_fd, 3) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
printf("Server is listening for connections...\n");
// 5. 接受客户端连接
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
perror("accept");
exit(EXIT_FAILURE);
}
printf("Connection accepted from %s:%d\n", inet_ntoa(address.sin_addr), ntohs(address.sin_port));
// 6. 与客户端通信
int valread;
while ((valread = read(new_socket, buffer, BUFFER_SIZE)) > 0) {
printf("Client: %s", buffer);
send(new_socket, buffer, valread, 0); // 将收到的消息回显给客户端
}
// 7. 关闭套接字
close(new_socket);
close(server_fd);
return 0;
}
TCP 客户端代码 (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 SERVER_IP "127.0.0.1" // 本地回环地址
#define BUFFER_SIZE 1024
int main() {
int sock = 0;
struct sockaddr_in serv_addr;
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);
// 2. 将IP地址从文本转换为网络字节序
if (inet_pton(AF_INET, SERVER_IP, &serv_addr.sin_addr) <= 0) {
printf("\nInvalid address/ Address not supported \n");
return -1;
}
// 3. 连接服务器
if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
printf("\nConnection Failed \n");
return -1;
}
printf("Connected to server at %s:%d\n", SERVER_IP, PORT);
// 4. 与服务器通信
while(1) {
printf("Enter message to send (or 'quit' to exit): ");
fgets(buffer, BUFFER_SIZE, stdin);
if (strncmp(buffer, "quit", 4) == 0) {
break;
}
send(sock, buffer, strlen(buffer), 0);
printf("Message sent.\n");
int valread = read(sock, buffer, BUFFER_SIZE);
if (valread > 0) {
printf("Server echo: %s", buffer);
}
}
// 5. 关闭套接字
close(sock);
return 0;
}
编译与运行
-
编译:
# 分别编译服务器和客户端 gcc server.c -o server gcc client.c -o client
-
运行:
- 打开一个终端,运行服务器:
./server # 输出: # Server bound to port 8080 # Server is listening for connections...
- 打开另一个终端,运行客户端:
./client # 输出: # Connected to server at 127.0.0.1:8080 # Enter message to send (or 'quit' to exit):
- 在客户端输入消息,服务器会收到并回显。
- 客户端输入:
Hello, Server! - 服务器输出:
Client: Hello, Server! - 客户端输出:
Server echo: Hello, Server!
- 客户端输入:
- 打开一个终端,运行服务器:
一个简单的 UDP 示例
UDP 无需连接,代码更简单,服务器和客户端的代码结构很相似,都可以直接发送和接收数据。
UDP 代码 (udp_server_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 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 is listening on port %d\n", PORT);
// 3. 循环接收和发送数据
int len, n;
len = sizeof(cliaddr);
while(1) {
// 4. 接收客户端数据
n = recvfrom(sockfd, (char *)buffer, BUFFER_SIZE, 0, (struct sockaddr *)&cliaddr, &len);
buffer[n] = '\0';
printf("Client : %s\n", buffer);
// 5. 回显数据给客户端
sendto(sockfd, (const char *)buffer, strlen(buffer), 0, (const struct sockaddr *)&cliaddr, len);
printf("Echo message sent.\n");
}
close(sockfd);
return 0;
}
编译与运行:
- 编译:
gcc udp_server_client.c -o udp_server - 运行:
./udp_server - 你可以使用另一个终端,用
netcat工具来测试:nc -u 127.0.0.1 8081,然后输入任意文本,服务器会回显。
高级主题
多路复用:select(), poll(), epoll()
当服务器需要同时处理多个客户端连接时,不能为每个连接都开一个线程(会消耗大量资源),这时就需要 I/O 多路复用 技术。
-
select():- 原理:创建一个文件描述符集合(
fd_set),通过select()函数阻塞,直到集合中任何一个描述符准备好 I/O(可读、可写、异常)。 - 缺点:单个进程能监视的文件描述符数量有限(通常为 1024);每次调用都需要将
fd_set从用户空间拷贝到内核空间,并且需要遍历所有描述符来找出哪些就绪了,效率低。
- 原理:创建一个文件描述符集合(
-
poll():- 原理:用
struct pollfd数组代替fd_set,解决了文件描述符数量限制的问题。 - 缺点:和
select()一样,每次都需要将整个数组拷贝到内核,并且需要遍历。
- 原理:用
-
epoll()(Linux 特有,推荐使用):- 原理:在内核中维护一个事件表。
epoll_ctl()用于向内核注册/修改/删除要监控的文件描述符。epoll_wait()只返回那些已经就绪的文件描述符。 - 优点:
- 没有数量限制:取决于系统内存。
- 效率高:
epoll_wait只返回就绪的描述符,无需遍历所有描述符。 - 支持边缘触发:
ET (Edge-Triggered)模式下,只有状态发生变化(如从不可读变为可读)时才会通知,效率更高,但编程也更复杂。
- 原理:在内核中维护一个事件表。
非阻塞 I/O
将套接字设置为非阻塞模式后,如果请求的操作不能立即完成(如 accept 没有连接,read 没有数据),函数会立即返回一个错误码(EAGAIN 或 EWOULDBLOCK),而不是阻塞等待。
这通常与 I/O 多路复用(如 epoll)结合使用,通过轮询或事件通知来处理 I/O 操作。
套接字选项
可以使用 setsockopt() 和 getsockopt() 来获取或设置套接字的属性。
SO_REUSEADDR:避免TIME_WAIT状态导致的地址占用问题,服务器重启时常用。SO_RCVBUF/SO_SNDBUF:设置接收/发送缓冲区大小。SO_KEEPALIVE:启用 TCP 保活机制,检测长时间无数据传输的连接是否还活着。
调试与常见问题
-
Address already in use:- 原因:服务器程序异常退出后,套接字处于
TIME_WAIT状态,端口被暂时占用。 - 解决:在
bind()之前,使用setsockopt()设置SO_REUSEADDR选项。
- 原因:服务器程序异常退出后,套接字处于
-
Permission denied:- 原因:尝试绑定小于 1024 的端口号,但没有 root 权限。
- 解决:使用大于 1024 的端口号,或使用
sudo运行程序。
-
Connection refused:- 原因:客户端连接时,目标 IP 和端口上没有服务器在监听。
- 解决:检查服务器是否已启动,IP 地址和端口号是否正确。
-
Broken pipe:- 原因:客户端在服务器关闭连接前关闭了连接,服务器尝试向一个已关闭的连接写入数据。
- 解决:在
send()或write()调用后检查返回值,处理SIGPIPE信号(或忽略它)。
-
数据粘包/拆包问题 (TCP):
- 原因:TCP 是面向字节流的,没有消息边界,发送方多次
send()的数据可能会被合并成一个数据包到达;一次send()的大数据也可能被拆分成多个数据包到达。 - 解决:
- 应用层定义协议:在数据包前加上固定长度的头部,声明数据包的长度。
- 使用特殊分隔符:在消息末尾加上特殊字符(如
\n),但消息内容中不能包含该字符。 - 使用成熟框架:如 Protobuf, gRPC 等,它们已经处理了这些问题。
- 原因:TCP 是面向字节流的,没有消息边界,发送方多次
希望这份详细的指南能帮助你入门 Linux C 语言网络编程!从 TCP 开始,理解了客户端/服务器的基本模型后,再学习 UDP 和高级主题会更容易。
