
📚 本文收录于「流浪」的系列专栏
| 🐧 Linux系统 | ⚙️ C++ |
| 📊 数据结构与算法 | 🐍 Python |
| 🔗 LangChain & LangGraph | 🗄️ MySQL 数据库 |
| 🌿 Git 工具 | 🌐 计算机网络 |
| 🤖 LLM | 💯 大厂面试、八股 |
| 📚 学习筑基专栏 |
🏠 博客主页:流浪 | 📝 原创首发于 CSDN
前言: 《计算机网络篇7》把 TCP 的接口按调用顺序过了一遍,服务端 socket、bind、listen、accept,客户端 socket、connect,连上之后统一换成 read/write。接口这一层算是通了,可接口只负责把字节搬来搬去,它不回答另一个问题:一条消息到底长什么样,边界又是谁划的 ?本篇往接口底下再挖一层,先讲清应用层协议为什么是一组约定、序列化和反序列化在其中干的是什么活,再把 read 和 write 重新理解一遍------它们从头到尾只在本机的缓冲区里做拷贝 ;最后落到 TCP 面向字节流这件事上,看看报文边界为什么不归 OS 管。
一、应用层协议约定数据怎么排,序列化和反序列化负责拼与拆
1.1 业务代码全部落在应用层,协议是给这些数据立的规矩
1. 平时代码里处理的数据都是结构化的
- 平时写业务代码,处理的对象都是一个一个有结构的整体:一条用户信息有名字、年龄、地址,一条聊天消息有发送者、时间、内容
- 这些代码全部落在应用层,跑在 TCP 或 UDP 已经铺好的通道之上,字段各归各位,偏移由编译器维护
2. socket 收发的是字节,结构在传输过程中会丢
- socket API 在读数据时,是按字符串的形式来收发的
- 换句话说,接口层拿到的只是一串字节,谁在哪个位置、各自多长,它一概不问
- 那结构体的数据怎么过网络?「把结构体传过去」这句话,在接口层面是落不了地的
3. 缺的这层约定就是协议
1. 双方得事先商量好:这条消息由哪几个字段组成、按什么顺序排、每个字段多长
-
商量好之后,发送方按约定拼、接收方按约定拆,两边对同一串字节的理解才能一致
-
商量出来的这组规则就是应用层协议
协议不是什么抽象口号,它就是双方约好的结构化数据怎么排------字段有哪几个、谁在前谁在后、各自占多少字节,都写死了才能互通。
1.2 序列化和反序列化:头像、消息、昵称拼成一条报文再拆回来
1. 三个字段共同构成一个报文
- 拿 QQ 群聊天来举例,一条消息发出去,对端要渲染出来的东西不止文字
- 头像一个字段,消息一个字段,昵称一个字段
- 三条凑在一起才是完整的一条聊天消息,缺任何一条,界面都拼不出正常的样子
2. 发送方把三个字段拼成字符串,接收方再把它拆回三个字段
- 发送前先按约定把三个字段拼成一条长字符串,头像放哪个位置、占多少字节,昵称放哪个位置、占多少字节,全由协议定死
- 拼好之后,这条长字符串对 socket 来说就是一串再普通不过的字节
- 对方收到后按同一份约定从固定位置切,三个字段重新变回头像、消息和昵称,界面才能正常渲染
- 发送方拼、接收方拆,这两个动作合起来就是序列化和反序列化,一条报文的两端各做一次
序列化是把结构化的数据按约定拼成一条长字符串,反序列化是把它拆回来------中间跑的都是没有差别的字节。
1.3 序列化和反序列化这一层的价值是解耦
1. 业务代码不用管字节怎么摆
- 有了序列化这层,上层只管按自己的结构组织数据
- 这些字段怎么拼、按什么顺序摆、每段多长,统统交给序列化去办
- 业务逻辑和底下的字节排列之间,从此隔了一道墙
2. 没有这层抽象,痛点全落在业务代码里
- 没有序列化,用户就得自己处理 string,把每个字段手工拼成字符串再 write
- 收数据时也一样,得自己从字节流里按位置切出来,再手工拼回结构体
- 收发逻辑和业务逻辑搅在一起:加一个字段,拼和拆两处都要改,改漏一处就是线上故障,换门语言还得重写

3. 问题落到 write 把字节写到哪儿去了
- 既然序列化之后交给 socket 的只是一串字节,那 write 把这串字节写出去,到底发生了什么
- 这串字节真的从本机「嗖」地一下飞到了对面吗
- 答案比想象的朴素,而且直接决定后面怎么看字节流
序列化把「数据长什么样」和「字节怎么摆」这两件事拆开了,业务代码因此可以只关心前者;没有这层拆解,收发逻辑会渗进每一处业务代码。
1.4 通信双方可以直接传二进制的结构体对象吗
1. 结构体可以原样传,但两端布局必须一致
- 把结构体在内存里的字节原样 write 出去,对端拿到之后按同一个结构体类型解读,语法上完全走得通
- 但这种可行性挂着一串前提:两端的字节序、类型宽度、字段布局都要严格一致
- 只要有一条对不上,解析出来的就是另一个东西
2. 大小端不一致会让同一串字节被反向解释
- 同一串字节,大端机器把高地址那端当高位、小端机器把低地址那端当高位,读出来的值天差地别
- 一个多字节整数传过去,可能变成完全不同的数
- 字段一长就不只是数值错了:解读位置整体错位,后面的字段跟着全乱
- 所以跨机器传数值要先转成网络字节序,这一层 《计算机网络篇4》 已经讲过
3. 不同语言对同一份结构的布局不一样
- C/S 两端万一用不同语言写,同一份数据的内存大小可能对不上
- 就算大小一样,字段的排列顺序和对齐填充规则也可能不同
- 结构体在内存里到底怎么摆,是编译器和语言规范决定的,不是协议规定的
直接传结构体能不能成,取决于两端的内存布局是否一致------这个条件在应用层基本不成立,所以协议必须自己定义一套与机器、与语言无关的排列规则。
1.5 OS 内部的协议反而全部传二进制结构体对象
1. 同一个问题在 OS 内部有相反的答案
- 应用层不建议传裸结构体,OS 内部的协议却几乎都是二进制结构体对象
- 协议栈内部的报文、控制块、头部全是结构体,直接按结构体解读
2. 区别在两端的布局是不是同一套
OS 内部两端是同一份源码(C语言)、同一个编译器、同一套对齐规则,内存布局严格一致,直接按结构体读不会错位- 应用层跨主机、跨语言,布局不保证一致,所以必须换成与机器无关的中立格式
裸结构体不是不能用,而是它的前提是两端布局一致------OS 内部满足这个前提所以敢直接传,应用层不满足,所以必须序列化。
二、重新理解 read 与 write:主机间通信的本质是拷贝
2.1 read 和 write 并不能直接跨网络收发数据
1. 两台主机通信时,两个接口都不跨网络对传
- 两台主机在通信时,read 和 write 并不能直接跨网络进行收发
- 它们从头到尾只跟本机的内存打交道,网络那一段由 OS 接手
2. write 只是把数据写进 TCP 底层的发送缓冲区
- write 的动作是把用户空间的数据拷进 TCP 底层的文件缓冲区,也就是发送缓冲区
- 这个缓冲区挂在本机的套接字上,数据到这里就算交出去,什么时候出门、以什么形式出门都不归 write 管
3. write 的本质是拷贝
write 不是「发送」,它只是一次从用户空间到内核发送缓冲区的拷贝,拷贝完成就返回。
2.2 什么时候发、怎么发由 TCP 协议自己决定
1. 数据出门的时机由协议栈掌握
- 缓冲区里的数据攒到多少才发、一次发多少字节、对端没收到要不要重发,全是 OS 和 TCP 协议的事
- 应用层能做的只是把字节交进去,剩下的时机和数量完全交出去
2. 传输控制协议控制的就是发送的时机与数量
- TCP 全称是传输控制协议,控制的正是传输这件事:发多少、什么时候发、出错怎么办
- 这个「控制」的立足点就是发送缓冲区,协议栈盯着它做决定
write 返回成功只说明数据进了缓冲区,说明不了对端收没收到。
2.3 read 也只是从接收缓冲区里把数据拷出来
1. read 面对的是本机的接收缓冲区
- 数据从对端过来,先落进本机的接收缓冲区,这是 OS 的地盘
- read 的动作是从这个缓冲区里把数据拷进用户空间,拷多少返回多少
2. read 和 write 是一对镜像动作
- 一个往缓冲区里拷进,一个从缓冲区里拷出,方向相反,本质相同
- 两个动作都只发生在自己这台机器上,都不跨网络
3. 收发两个接口都不负责网络传输
read 和 write 一样,本质都是本机内存之间的一次拷贝,网络那一段完全不在它们的职责范围内。

2.4 通信的本质是把自己的发送缓冲区拷到对端接收缓冲区
1. 一次通信拆开看是四步
- 发送方 write:数据从用户空间拷进本机发送缓冲区
- 内核按 TCP 的规则把数据从发送缓冲区送出去
- 数据到达对端,落进对端的接收缓冲区
- 对端 read:数据从接收缓冲区拷进它的用户空间
2. 中间两步应用代码碰不到
- 第二、三步由 OS 和协议栈完成,应用层只是触发者
- 从应用代码的角度看,数据是从一个缓冲区消失、又在另一个缓冲区出现
3. 所以计算机的世界里,通信即拷贝
主机间通信的本质,就是把发送方发送缓冲区里的数据拷贝到对端的接收缓冲区------中间的网络传输对应用层不可见,它能看到的自始至终只有两次拷贝。
2.5 每个套接字都有一对缓冲区,所以 TCP 通信是全双工的

1. 一个套接字对应一对缓冲区
- 发送缓冲区和接收缓冲区各有一个:一个管发,一个管收
- 两个方向的数据各走各的通道,互不抢占
2. 两个方向同时收发,这就是全双工
- 双方可以同时在各自的发送缓冲区里写、在各自的接收缓冲区里读,谁都不用等谁空出来
- 《计算机网络篇7》里 netstat 看到的同一条连接、两条方向相反的记录,就是这件事在表上的样子
全双工不是协议里写的一句口号,而是两对缓冲区各自独立带来的结果:收发互不抢道,才能同时进行。
三、TCP 面向字节流与 UDP 面向数据报:报文边界归谁保证
3.1 UDP 面向数据报:你发一个,我收一个
1. OS 把你 write 的整串原样打成一个包
- 在 UDP 里,OS 会把你写进去的整串数据打成一个包发往接收方
- 一次 write 就是一个用户数据报,边界按你的写次数被划出来
2. 对端一次读正好拿到一个完整报文
- 对端 read 一次,拿到的就是一个完整报文,不多不少
- 上一章那四步拷贝在 UDP 里同样成立,只不过缓冲区里排的是一个一个彼此独立的包
3. 这就叫面向数据报
UDP 替你保住了边界:写几次就是几个包,读一次就是一个包,所以它叫面向数据报的协议。
3.2 TCP 不是一发一收,OS 不关心字节串的内容和边界
1. TCP 里没有「发一个读一个」这回事
- TCP 是面向字节流的,这是它和 UDP 在数据形态上最大的分岔
- 什么时候发、怎么发、出错了怎么办,全由 OS 决定,应用层插不上手
2. OS 只看字节,不看内容也不认边界
- 送进发送缓冲区的那串字节,OS 并不关心它是什么内容
- 也不关心你在哪里断了句、哪几个字符合起来算一条消息
- 对它来说,这就是一段要按序送到的字节
3. 写和读的次数之间没有任何约定
写进缓冲区的次数和读出来的次数之间没有任何约定------TCP 保证的是字节按序到达,不是报文按次数对应。
3.3 一个完整报文可能只被发出去一部分
1. 上层以为发了一条消息,路上可能只走了一半
- 上层序列化之后写进去的是一个完整报文,OS 在发送时会因为对端接收缓冲区的情况,只把其中一部分先发过去
- 剩下那部分等下次再发,或者等对端读走一些、腾出空间之后接着发
- 从对端应用层的视角看,手里拿到的不是一条完整报文
2. 拿到半条就反序列化,解析必然出错
- 按约定从固定位置切字段,切出来的东西自然不对
- 长度字段读出来是半个数、内容被拦腰截断,各种错位都可能出现
3. 凑齐完整报文是应用层自己的责任
- 在 TCP 中,要读到完整报文由接收方应用层自己决定
- 读到的不是完整报文,就等后续字节到了,和前面那段拼成一条完整报文,再去反序列化
反序列化的前提是手上有一条完整报文;在 TCP 里凑齐这条报文不归 OS 管,得应用层自己等、自己攒。
3.4 接收缓冲区满的时候,发送方还在拷,接收方一次全拿走
1. 缓冲区装不下的报文只能等,攒下的会挨着排好
- 对端的接收缓冲区被前面没读走的数据占满,新报文进不去
- 发送方这边可能已经拷了不止一份报文进去,它们在缓冲区里挨着排好,中间没有任何分隔标记
2. 接收方read 时一次就把缓冲区里的数据全取走
- read 的语义是「有多少取多少,最多取你给的那个上限」
- 攒了三条报文,一次 read 就能全部拿走
收发速度不匹配时,缓冲区先攒后放------攒进去的和放出来的都不保持报文边界,这就是字节流在缓冲区层面的样子。
3.5 面向字节流的两个后果:边界自己切、收发次数不对等
1. 不存在「一次 read 一条报文」的保证
- 连续发的两条报文可能被一次读到,这叫作粘包
- 一条报文也可能被分几次读到,这叫作半包
- 两个现象说的其实是同一件事:字节流里没有天生的报文边界
- 它不是 TCP 的缺陷,而是面向字节流的必然后果
2. 收发次数不对等是常态
- 一端 write 三次,对端可能 read 一次就全拿到
- 一端 write 一次,对端也可能 read 三次才拿完
- 次数对不上是常态,对上才是碰巧
3. 边界和次数都只能在应用层解决
粘包和半包不是两个要单独修的 bug,而是「字节流没有边界」这一个事实的两种表现------边界在应用层划定,两个现象一起消失。
3.6 报文边界要靠应用层自己约定
1. 报文边界有三种常见的划法
- 定长:每个报文长度固定,收满这个长度就取出去解析,不够就先存着
- 好处是实现最简单,凑够字节数就必然是一条完整报文
- 代价是内容长度一变就废,不足的部分还得填充补位
- 分隔符:约定一个特殊符号作为报文结束标志,遇到它就切一刀
- 好处是文本类协议读起来最直观,HTTP 头用的就是这一套
- 代价是内容里出现这个符号时必须转义,否则会被误认成报文结尾
- 长度字段:在报文头部固定几个字节写清后面的内容有多少字节,先读头、再按长度读体
- 好处是二进制协议里最通用,内容里出现什么字节都不影响切分
- 代价是多一道工序:得先把报文头读全,才知道后面该收多少字节


2. 三种划法解决的是同一件事
- 都是用一个双方都认的规则,把「无边界的字节流」重新切回「一条一条的报文」
- 选哪种取决于内容特点:纯文本可以用分隔符;二进制、内容里什么字节都可能出现的,基本都走长度字段
3. 封包拆包属于工程实现
定长、分隔符、长度字段这三条路,本质都是给字节流补一个应用层自己认得的边界------协议设计的一大半工作就是在做这件事。
四、一段代码串起三件事:序列化和反序列化、write 和接收缓冲区
4.1 序列化把结构体拼成字符串,反序列化再把它拆回来
1. 协议就是那个分隔符
- 结构体里放两个字段:昵称和消息正文,各占固定长度
- 协议约定用
|把两个字段拼起来,接收方见到|就知道昵称到哪儿结束,这个约定在两端写死
2. 序列化拼、反序列化拆,两个函数各管一头
c
// 协议约定:昵称和消息用 '|' 拼成一条字符串
struct chat_msg {
char nick[NICK_LEN]; // 昵称
char text[TEXT_LEN]; // 消息正文
};
// 序列化:结构体 → 一条长字符串,返回拼出来的长度
int serialize(const struct chat_msg *m, char *buf, int size)
{
return snprintf(buf, size, "%s|%s", m->nick, m->text);
}
// 反序列化:长字符串 → 结构体
// 找不到分隔符说明这条报文还没收全,返回 -1 让上层等下一次
int deserialize(const char *buf, struct chat_msg *m)
{
const char *sep = strchr(buf, '|');
if (sep == NULL)
return -1;
memcpy(m->nick, buf, sep - buf); // 分隔符之前是昵称
m->nick[sep - buf] = '\0';
strcpy(m->text, sep + 1); // 分隔符之后是消息正文
return 0;
}
4.2 交给 write 之后,数据只进了发送缓冲区
1. 发送端调用 write,拿到的只是拷贝成功的字节数
c
char buf[128];
int n = serialize(&m, buf, sizeof(buf)); // 结构体变成一条字符串
write(sockfd, buf, n); // 拷进发送缓冲区就返回,什么时候发不归它管
2. 接收端 read 从接收缓冲区拷出,拿到的可能只有半条
c
char rbuf[128];
int r = read(sockfd, rbuf, sizeof(rbuf)); // 从接收缓冲区拷出来,r 可能小于 n
if (deserialize(rbuf, &m2) < 0) {
// 半包:这段先留在自己的缓冲区里,等下一次 read 拼齐再拆
}
3. 三个动作在同一条链上,缺一环消息就到不了
序列化按约定拼、write 只负责拷贝、read 可能只拿到半条------三段代码串起来,正好是前面三章。
五、全篇总结
1. 序列化按约定拼,反序列化按约定拆
- 业务代码处理的数据都是结构化的,socket 收发的只是一串字节
- 协议定死字段有哪几个、谁在前谁在后、各占多少字节
- 序列化按约定拼、反序列化按约定拆,把业务逻辑和字节排列隔开
- 跨机器不能直接传裸结构体:两端大小端可能相反,不同语言的字段布局也对不上
2. read 和 write 的本质是拷贝
- 两个接口都不跨网络收发数据,自始至终只跟本机缓冲区打交道
- write 把数据拷进发送缓冲区,返回的字节数是拷进去多少
- 什么时候发、怎么发由 TCP 协议决定,read 再从接收缓冲区把数据拷出来
- 一次通信就是发送缓冲区到对端接收缓冲区,两对缓冲区撑起全双工
3. TCP 面向字节流,报文边界归应用层自己切
- UDP 面向数据报:写几次就是几个包,读一次就是一个包
- TCP 既不认内容也不认边界,一份报文可能只被发出去一部分
- 收发次数不对等是常态,粘包和半包是同一件事的两种表现
- 边界靠应用层自己约定:定长、分隔符、长度字段
六、文末面试题
6.1 推导题(本篇浅讲,答案带过)
1. 为什么跨主机通信不建议直接传结构体的二进制?
答(推导):结构体在内存里的字节排布是编译器和语言规范决定的,两台机器之间不保证一致。大小端不同,多字节字段会被反向解释;用不同语言写,同一份结构的类型宽度、字段顺序、对齐填充规则都可能不同。只要有一条对不上,对端解析出来的就是另一个东西。所以跨主机传数据要先序列化成一套与机器、语言无关的约定格式。
2. write 返回成功,是不是代表对端已经收到了?
答(推导):不是。write 的动作只是把用户空间的数据拷进本机的发送缓冲区,返回的字节数是「拷进去多少」,不是「对端收到多少」。数据什么时候真正发出去、分几次发、发丢了怎么办,都由 TCP 协议栈自己决定。所以 write 成功只能说明字节交给了内核,要确认对端收到得靠应用层自己的应答机制。
3. TCP 为什么不保证一次 write 对应一次 read?粘包是 TCP 的缺陷吗?
答(推导):因为 TCP 面向字节流,它保证的只是字节按序、可靠地到达,不保留报文边界。发送端受发送缓冲区和报文段长度的限制会把数据切开,接收端 read 也只是从接收缓冲区里舀,能舀出多少取决于缓冲区里当前有多少。所以两端次数对不上是常态,粘包和半包是同一件事的两种表现,属于字节流的固有性质,不是缺陷------边界要由应用层用自己的协议划定。
6.2 真题(带过)
1. 什么是 TCP 粘包和半包?怎么解决?
答(推导 · 已对照解析,转述):粘包是发送方连续发的多个数据包被接收方一次收到,半包是一个数据包被分成多次收到,两者根源都是 TCP 面向字节流、不保留消息边界。常见解决方式是应用层自己定边界:定长包、以特殊字符作结束标志(如 \r\n),以及最常用的包头加包体------包头里放一个长度字段表明包体多大,先读包头再按长度读包体。
【真题·转述自 TCP 协议如何解决粘包、半包问题】
2. send/write 的返回值代表什么?
答(推导 · 已对照解析,转述):返回 n 表示内核接受了 n 个字节放进发送缓冲区,仅此而已,不代表对端收到了,更不代表对端应用处理了。而且 n 可能小于传入的长度,也就是短写,在非阻塞模式或发送缓冲区快满的时候尤其常见,所以正确的写法是记住偏移量循环发,非阻塞下遇到 EAGAIN 就把剩余数据存进应用层发送缓冲、注册可写事件,等可写时继续。
【真题·转述自 网络:面试速记】
3. 什么是网络字节序?为什么需要 htonl、htons?
答(推导 · 已对照解析,转述):网络字节序是 TCP/IP 统一规定的大端序,也就是多字节数值的高位字节先发。不同架构的机器内存里存多字节整数的顺序可能相反,x86 是小端,如果直接把本机字节序的端口号、IP 发出去,对端按大端解读就会得到完全不同的值。所以发送前要用 htonl、htons 从主机字节序转成网络字节序,收到后再用 ntohl、ntohs 转回来;在大端机器上这几个函数是空操作,但代码里照样要写,才具有可移植性。
【真题·转述自 Linux网络编程核心:Socket、字节序与TCP/UDP实战解析】
这一篇把 read 和 write 的真实动作摊开了:它们不负责网络传输,从头到尾只是本机缓冲区之间的拷贝,通信的样子就是把发送缓冲区里的数据挪到对端接收缓冲区。TCP 面向字节流,报文边界从来不在 OS 手里------粘包半包不是缺陷是常态,边界得靠你自己的协议去划定。评论区聊聊:你第一次写 TCP 收发时,是不是也把「read 一次就等于收到一条完整消息」当成了理所当然?