1、计算机网络组成
1.1 网络分层的目的: 把“通信问题”拆分成多个职责清晰、可以独立演进的模块
- 分层带来的 4 个工程价值:
- ① 职责隔离:TCP 只管可靠传输、IP 只管寻址 + 路由、应用只管:业务逻辑
- ② 可替换性(工程扩展能力):TCP 能被 QUIC 替换、IPv4 能升级 IPv6
- ③ 可调试性
- ④ 团队协作
1.2 OSI vs TCP/IP
- OSI:(Open System Interconnect)七层模型是学术界的标准,它将网络通信细分成了 7 个逻辑层。
物理层:传输比特流(0 和 1)。
数据链路层:把比特流封装成“帧”,负责相邻节点间的传输。
网络层:负责路由选择,决定数据包走哪条路。
传输层:负责端到端的可靠性(TCP)或效率(UDP)。
会话层:管理连接。
表示层:数据格式化、加密解密。
应用层:写的 C 代码直接交互的地方。
几乎不需要区分 5、6、7 层,通常被统一看作“自己应用程序”要处理的事
OSI 是“讲清楚道理的模型”,不是“写代码的模型”
- TCP/IP
网络接口层(Network Interface) :对应 OSI 的物理层和数据链路层。
网络层(Internet) :核心协议是 IP 协议。
传输层(Transport):核心是 TCP 和 UDP。
应用层(Application) :对应 OSI 的上三层(写的代码)
从“传输层”到“网络接口层”全部由 Linux 内核处理,开发者
- 负责应用层
- 通过 Socket 套接字与“传输层”打交道
- 通过 TCP 协议发这段话给 IP X.X.X.X 的端口号 Y
2、TCP / UDP 协议
TCP 是“字节流协议”,UDP 是“报文协议”
- TCP 服务端流程
socket():买一部手机。
bind() :办一张手机卡,绑定一个号码(IP+端口)。
listen() :等电话响,设置最大排队人数。
accept() :按接听键。注意:这会产生一个新的 Socket 专门用于通话。
read() / write() :说话与听话。
close():挂断。
- TCP 客户端流程
socket():买一部手机。
connect() :拨号给对方。三次握手
read() / write() :通话。
close():挂断。
| 特性 | TCP | UDP |
|---|---|---|
| 连接性 | 面向连接(必先握手) | 无连接(直接发) |
| 可靠性 | 极高(丢包自动重传) | 无(丢包不管) |
| 速度 | 较慢(为了可靠牺牲了速度) | 极快 |
| 数据边界 | 字节流(像自来水,没头没尾,没有消息边界一切靠设计协议) | 数据包(像包裹,一个是一个) |
| 应用 | 网页(HTTP)、文件传输(FTP)、邮件 | 视频流、语音、在线游戏 |
TCP 关心的是:
- 字节是否可靠到达
- 顺序是否正确
TCP 不关心:
- 你一次 send 了多少
- 应用想怎么切消息
2.1、 三次握手:建立信任的过程
三次握手的核心目的是:确认双方的接收和发送能力都是正常的,并交换初始序列号。
-
第一次握手:SYN (Synchronize)
- 动作:客户端向服务端发送一个 SYN 包,并随机初始化一个序列号 。
- 状态:客户端进入
SYN_SENT状态。 - 意义:服务端收到后,知道客户端的发送能力正常。
-
第二次握手:SYN + ACK (Acknowledgement)
- 动作:服务端收到 SYN 包,回一个自己的 SYN(序列号 ),同时回一个 ACK(值为 )作为确认。
- 状态:服务端进入
SYN_RCVD状态。 - 意义:客户端收到后,知道服务端的发送和接收能力都正常。
-
第三次握手:ACK
- 动作:客户端再发一个确认包 ACK(值为 )。
- 状态:双方进入
ESTABLISHED(连接建立)状态。 - 意义:服务端收到后,知道客户端的接收能力也正常。
三次握手(建立通话)
当服务器的网卡收到第三次 ACK后,内核 TCP 协议栈认为:
- 序列号、ACK 号、窗口都合法
- 连接进入 ESTABLISHED 状态
- 把这条连接放进 accept 队列(也叫全连接队列)
连接被放进 accept 队列 → 内核把 listen_fd 标记为“可读” →
epoll 返回 EPOLLIN → 你的 events[i].data.fd == listen_fd 触发
accept 只做“取件”
-
内核从 accept 队列弹出一个已 ESTABLISHED 的连接
-
为你生成一个新的 socket(conn_fd)
-
把对端地址填进
client_addr -
不再做任何握手包收发——握手阶段已结束
服务器侧)
-
用户态 listen(fd, backlog); // 只设铃声,不碰包
-
内核态(完全自动)
- 收到 SYN(第 1 次握手)
- 回 SYN+ACK(第 2 次握手)
- 收到 ACK(第 3 次握手)
- 此时连接 ESTABLISHED
- 把这条连接放进 accept 队列
-
用户态(再次) epoll_wait(...) 返回; // 因为队列非空,listen_fd 可读 new_fd = accept(...); // 从队列里拿出已握好的连接,生成新 socket
accept 内部做了什么? 关键:
为什么不是两次?如果只有两次,服务端发完 SYN+ACK 就认为连接建立了,但如果这个包在网络中丢了,客户端不知道连接已开,服务端却一直空等,会浪费大量系统资源。
2.2、 四次挥手:分手
TCP 是全双工的,这意味着两条线(A 到 B,B 到 A)必须分别关闭。
-
第一次挥手:FIN (Finish)
- 动作:一方(假设是客户端)主动调用
close(),向对方发送 FIN 包。 - 状态:客户端进入
FIN_WAIT_1。
- 动作:一方(假设是客户端)主动调用
-
第二次挥手:ACK
- 动作:服务端收到 FIN,先回一个 ACK 告诉对方:“我知道你想分了,但我可能还有数据没发完,你等我一下”。
- 状态:服务端进入
CLOSE_WAIT,客户端进入FIN_WAIT_2。
-
第三次挥手:FIN
- 动作:服务端处理完所有残留数据,也调用
close(),向客户端发 FIN。 - 状态:服务端进入
LAST_ACK。
- 动作:服务端处理完所有残留数据,也调用
-
第四次挥手:ACK
- 动作:客户端收到 FIN,回一个 ACK。
- 状态:客户端进入
TIME_WAIT状态,等待 2MSL 时间后彻底关闭。服务端收到 ACK 后直接进入CLOSED状态。
- 第四次挥手
客户端(主动关闭) 服务端(被动关闭)
│ │
│──── FIN ──────────────────> │
│<─── ACK ─────────────────── │
│ │
│<─── FIN ─────────────────── │
TIME_WAIT ◄───┐ LAST_ACK
│ │ │
│──── ACK ──────────────────> │
│ │ │
(等2MSL) │ CLOSED ✅
│ │
CLOSED ✅ ◄───┘
| 客户端 | 服务端 |
|---|---|
| 收到 FIN → 立即进 TIME_WAIT | 发 FIN → 进 LAST_ACK |
| 从 TIME_WAIT 发 ACK | 收到 ACK → 立即 CLOSED |
| 独自等 2MSL | 早已结束 |
| 最后 CLOSED |
四次挥手(挂断通话)
场景:客户端先说“我说完了”,服务器还有话要讲。
- 客户端说“我说完了” → close(conn_fd)
发送FIN(第一次挥手)
客户端状态:FIN_WAIT_1 - 服务器听见 → read() 返回 0
回ACK(第二次挥手)
服务器状态:CLOSE_WAIT;客户端状态:FIN_WAIT_2
客户端→服务器方向已关闭,但服务器仍可对客户端说话。 - 服务器也说完了 → close(conn_fd)
发送FIN(第三次挥手)
服务器状态:LAST_ACK;客户端状态:TIME_WAIT - 客户端回最终
ACK(第四次挥手)
服务器收到后进入CLOSED—— 通话专线销毁
客户端维持TIME_WAIT2MSL(约 60 秒)防止旧包干扰,之后也CLOSED
3、 Socket 套接字
Socket 编程就是把“复杂的协议栈操作”变成“简单的文件读写”的过程。
3.1 Socket 简介:网络编程的“把手”
在 Linux 里,“万物皆文件”。
- 写日志有个
fd(文件描述符),操作网络也有个fd,就是 Socket。 - Socket 到底是什么? 它其实是应用层与传输层之间的一个接口。你不需要知道 TCP 是怎么重传数据的,你只需要把数据塞进 Socket 这个“把手”里,内核就会帮你把它发到互联网的另一头。
3.2 最小服务器、客户端尝试
- 服务器
// tcp_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
int main() {
// 1. 创建 socket(网络 fd)
int listenfd = socket(AF_INET, SOCK_STREAM, 0);//AF_INET:用 IPv4 协议 SOCK_STREAM:要用面向连接的字节流(即 TCP)
if (listenfd < 0) {
perror("socket");
exit(1);
}
// 2. 准备服务器地址
struct sockaddr_in addr;
//struct sockaddr:这是“通用地址结构”,它是为了兼容各种协议(IPv4, IPv6, 甚至蓝牙)设计的“老古董”。
//struct sockaddr_in:这是专门给 IPv4 使用的地址结构
//在代码里填充数据时,永远用 struct sockaddr_in(因为它有具体的 sin_port 和 sin_addr 字段,好操作);但在调用系统函数(如 bind 或 connect)时,必须强转成 struct sockaddr*。
memset(&addr, 0, sizeof(addr)); //把结构体 addr 整块内存清零 等价struct sockaddr_in addr = {0}; // 整个结构体清零
addr.sin_family = AF_INET; // 协议族:IPv4
addr.sin_port = htons(8888); // 端口号:必须转成网络字节序,不同 CPU 存 16 位数的顺序不一样:x86/x64 是小端:低位字节在前(低地址)。网络协议规定“大端”:高位字节在前。
addr.sin_addr.s_addr = INADDR_ANY; // 这是一个通配地址,监听所有网卡
int opt = 1;
if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
perror("setsockopt");
exit(1);
}
// 3. 绑定地址和端口
if (bind(listenfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind");
exit(1);
}
// 4. 开始监听
if (listen(listenfd, 128) < 0) {//128 (Backlog):这代表“请求队列”的大小
perror("listen");
exit(1);
}
printf("server listening on port 8888...\n");
// 5. 等待客户端连接
int connfd = accept(listenfd, NULL, NULL);//阻塞等待:程序运行到这里会“卡住”,直到有一个客户端完成了 TCP 三次握手
//accept 成功后会返回一个全新的文件描述符
if (connfd < 0) {
perror("accept");
exit(1);
}
// 6. 接收数据
char buf[1024];
int n = recv(connfd, buf, sizeof(buf) - 1, 0);//recv / send:这是专门用于套接字的读写函数。成功返回实际读/写的字节数
if (n > 0) {
buf[n] = '\0';
printf("server received: %s\n", buf);
}
// 7. 回发数据
send(connfd, buf, n, 0);
// 8. 关闭连接
close(connfd);//触发 TCP 四次挥手
close(listenfd);
return 0;
}
客户端
// tcp_client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
int main() {
// 1. 创建 socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 2. 服务器地址
struct sockaddr_in serv;
memset(&serv, 0, sizeof(serv));
serv.sin_family = AF_INET;
serv.sin_port = htons(8888);
inet_pton(AF_INET, "127.0.0.1", &serv.sin_addr);//把人类可读的字符串 IP("127.0.0.1")转成网络字节序的 32 位二进制数
//客户端不需要固定端口。调用 connect 时,内核会自动从系统临时端口范围(通常是 32768-60999)里选一个没被占用的端口分配给这个 sockfd
// 3. 发起连接
connect(sockfd, (struct sockaddr*)&serv, sizeof(serv));//触发“三次握手”让本地套接字 sockfd 按照地址结构 serv 里给出的 IP+端口,去跟远端服务器建立连接。
//sizeof(serv):告诉内核“地址结构体到底有多长”,以免它越界访问。
// 4. 发送数据
char msg[] = "hello tcp";
send(sockfd, msg, strlen(msg), 0);//将数据拷贝到内核的 TCP 发送缓冲区。如果缓冲区满了,send 会阻塞
// 5. 接收回显
char buf[1024];
int n = recv(sockfd, buf, sizeof(buf) - 1, 0);//这是一个同步阻塞调用。 如果服务器没回消息,客户端会一直停在这里等
buf[n] = '\0';
printf("client received: %s\n", buf);
close(sockfd);//发起 FIN 包,开始四次挥手。
return 0;
}
- 若不打开浏览器在两个终端正常运行,若打开浏览器时运行出现问题及原因
songu@LZD-20241025LBM:~/linux_c$ ./server
server listening on port 8888...
server received: GET /?id=6180dc18-cb10-4bba-a359-141bc8c4d4fa&vscodeBrowserReqId=1768310746711 HTTP/1.1
Host: localhost:8888
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Code/1.108.0 Chrome/142.0.7444.235 Electron/39.2.7 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: iframe
Sec-Fetch-Storage-Access: active
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: zh-CN
原因:
server 运行在 8888 端口。现代开发环境(比如 VS Code 或浏览器)有时会自动扫描本地打开的端口,现这个端口有反应,立刻发起了一个 HTTP 请求,server 里的 accept 成功接听了浏览器的电话,而不是 client,于是,server 打印出了浏览器发来的 HTTP 报文
流程:
-
Server 启动:进入
accept阻塞等待。 -
浏览器连接:
accept返回了浏览器的connfd。 -
Server 执行逻辑:收到 HTTP 请求 -> 打印 -> 把 HTTP 请求原样回发给浏览器 -> 执行
close(connfd)和close(listenfd)。 -
Server 进程结束:程序跑完了最后一行
return 0,服务器关门了。 -
Client 启动:此时
client试图去连8888端口。- 此时服务器程序已经退出了,系统内核里没有在监听
8888。 client代码里没检查connect的返回值。connect其实失败了,但client假装成功,继续往下跑send和recv。- 因为没连上,
recv拿不到数据,所以client窗口什么都没打印就直接结束了。
- 此时服务器程序已经退出了,系统内核里没有在监听
- 改进
服务器
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <ctype.h>
#define SERVER_PORT 8888
#define BACKLOG 128 // 工业标准排队长度
int main() {
// 1. 创建监听套接字
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if (lfd < 0) { perror("socket"); exit(1); }
// 设置端口复用:解决 "Address already in use" 报错
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 2. 绑定地址
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(SERVER_PORT);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(lfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
perror("bind"); exit(1);
}
// 3. 监听
if (listen(lfd, BACKLOG) < 0) { perror("listen"); exit(1); }
printf("--- 服务器已启动,监听端口: %d ---\n", SERVER_PORT);
/* --- 核心业务 --- */
while (1) {
struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
// 【外层】accept 会阻塞,直到三次握手完成
int cfd = accept(lfd, (struct sockaddr *)&cli_addr, &cli_len);
if (cfd < 0) {
perror("accept error");
continue;
}
// 【身份提取】提取客户端信息,方便日志记录
char cli_ip[INET_ADDRSTRLEN];
printf(">>> 监听到新连接: %s:%d (分配通信 FD: %d)\n",
inet_ntop(AF_INET, &cli_addr.sin_addr, cli_ip, sizeof(cli_ip)),
ntohs(cli_addr.sin_port), cfd);
// 【内层:交谈】处理同一个客户端的多次请求
char buf[1024];
while (1) {
// recv 也是阻塞的,没数据传过来就会停在这里
int n = recv(cfd, buf, sizeof(buf) - 1, 0);
if (n > 0) {
buf[n] = '\0'; // 行业标准:手动补齐字符串结束符,防止打印乱码
printf("[%s:%d]: %s", cli_ip, ntohs(cli_addr.sin_port), buf);
// 简单的协议处理:转大写
for (int i = 0; i < n; i++) buf[i] = toupper(buf[i]);
// 将处理结果发回客户端
send(cfd, buf, n, 0);
}
else if (n == 0) {
// 收到 FIN 包,说明客户端调用了 close()
printf("<<< 客户端 %s:%d 已主动断开连接。\n", cli_ip, ntohs(cli_addr.sin_port));
break; // 退出当前交谈循环
}
else {
perror("recv error");
break;
}
}
// 【收尾】一次服务结束,必须释放此 FD,否则会造成文件描述符泄漏
close(cfd);
printf("--- 系统已重置,继续监听下一个请求 ---\n\n");
}
close(lfd); //
return 0;
}
客户端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#define SERVER_IP "127.0.0.1"
#define SERVER_PORT 8888
int main() {
// 1. 创建套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) { perror("socket"); exit(1); }
// 2. 构造服务器地址
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(SERVER_PORT);
inet_pton(AF_INET, SERVER_IP, &serv_addr.sin_addr); // 字符串转网络二进制
// 3. 连接服务器:触发三次握手
if (connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
perror("connect"); exit(1);
}
printf("已成功连接到服务器 %s:%d\n", SERVER_IP, SERVER_PORT);
// 4. 多轮交互逻辑
char buf[1024];
while (1) {
printf("请输入消息 (输入 quit 退出): ");
if (fgets(buf, sizeof(buf), stdin) == NULL) break;
if (strncmp(buf, "quit", 4) == 0) break;
// 发送数据
send(sockfd, buf, strlen(buf), 0);
// 接收服务端处理后的回显
int n = recv(sockfd, buf, sizeof(buf) - 1, 0);
if (n <= 0) {
printf("服务器连接已断开。\n");
break;
}
buf[n] = '\0';
printf("服务器回复: %s", buf);
}
// 5. 关闭连接
close(sockfd);
printf("通信结束,已关闭。");
return 0;
}
运行结果显示
4、总结
- 一个模型:TCP/IP 四层架构
- 两套协议:TCP vs UDP
- 六个核心函数:Socket 的生命周期
-
socket():向内核申请一个网络通信的句柄(文件描述符)。 -
bind():给套接字贴上标签(绑定具体的 IP 和端口)。 -
listen():让套接字进入“待机”状态,准备接收连接。 -
accept():从完成握手的队列中取出一个连接(产生用于通信的cfd)。 -
connect():客户端主动发起连接,触发三次握手。 -
recv() / send():在已建立的连接上进行数据读写。
-
- 三个其他点
-
字节序 :
- 网络传输必须是大端序。
- 定律:填充地址用
htons(),打印地址用ntohs()。
-
地址转换 :
- 通过
inet_pton和inet_ntop在字符串 IP("127.0.0.1")和内核二进制 IP 之间转换。
- 通过
-
四次挥手与 TIME_WAIT:
- 主动关闭连接的一方会进入
TIME_WAIT状态。 - 使用
setsockopt(SO_REUSEADDR)允许立即重启服务器,避免“端口被占用”报错。
- 主动关闭连接的一方会进入
-