一、起点:此前写的所有代码,都是应用层代码
网络协议栈是分层的,最上面那一层叫应用层。这一层有一个别的层没有的特点:它不由标准规定,而由写代码的人自己定。
到目前为止写过的所有程序------翻译、远程命令执行、聊天------都在应用层。它们调用的 socket / bind / listen / accept / connect / read / write 全部是操作系统提供的系统调用,属于传输层及以下的能力。
| 层 | 归谁管 | 典型内容 |
|---|---|---|
| 应用层 | ★ 写代码的人 | HTTP、自定义协议、序列化格式 |
| 传输层 | 内核 | TCP / UDP |
| 网络层 | 内核 | IP |
| 数据链路层 | 驱动 | 以太网 / Wi-Fi |
| 物理层 | 硬件 | 双绞线 / 光纤 |
由此可以定位一个常见困惑:「报文长什么样」这件事,内核既不知道、也无权决定。它只负责把字节搬到对面,搬到之后这串字节是什么意思,全靠两端自己约定。
二、协议的第一层含义:一份约定
在写具体协议之前,先看一个每天都在用的例子。
2.1 一条聊天消息其实不是一个字符串
在 QQ 或微信里发一句话,屏幕上看到的是「一句话」,但发出去的从来不止这一个人写下的内容。一条消息至少包含三部分:
| 部分 | 实际类型 | 说明 |
|---|---|---|
| 头像 | 图片的路径 | 一个字符串,不是图片本身 |
| 昵称 | 字符串 | 发消息的人叫什么 |
| 消息内容 | 字符串 | 正文 |
三个字符串凑在一起,才构成一条完整的报文。
2.2 分三次发会出什么事
如果偷懒,把三个字符串分成三次 write 发出去,接收端会收到三个字符串------但它不知道哪个配哪个。
关键在于:接收端在接收消息的同时,可能还在收别人的消息。于是完全可能出现「A 的头像 + B 的昵称 + C 的内容」这种拼接,界面直接乱掉。
2.3 所以要打包成一个
解决办法很自然:把三个字符串用特殊字符串起来,拼成一个大字符串。
头像路径 | 昵称 | 消息内容
分隔符可以是空格、换行、\3 这类不会出现在正文里的字符------用哪个不重要,两端用同一个才重要。
打包带来三个好处:
| 好处 | 原因 |
|---|---|
| 保证报文完整性 | 三个字段要么一起到,要么一起不到,不会被别人插进来 |
| 方便网络传送 | 一次 write 就够了,不用管三次调用的对应关系 |
| 方便上层处理 | 收到之后按同一个分隔符拆开,字段各归各位 |
2.4 这就是结构化数据
回到打包之前的状态:三个字符串一起放在内存里,它们其实是一个结构体的三个字段。
cpp
struct message
{
std::string path; // 头像
std::string nick_name; // 昵称
std::string msg; // 正文
};
所以完整链条是:
struct message ──序列化──▶ 一串字节 ──网络──▶ 一串字节 ──反序列化──▶ struct message

💡 一个足够清晰的表述:所谓协议定制,本质就是在定制双方都能认识的、符合通信与业务需要的结构化数据;而所谓结构化数据,落到代码里就是 struct 或者 class。
三、★★ 序列化与反序列化
3.1 两个动作
| 动作 | 方向 | 目的 |
|---|---|---|
| 序列化 | 结构化数据 → 一串字节 | 把信息由多变一,方便网络发送 |
| 反序列化 | 一串字节 → 结构化数据 | 把信息由一变多,方便上层处理 |
「由多变一」的「多」指的是多个字段,「一」指的是一个可在网络上投递的整体。
3.2 为什么要「由多变一」
网络只认字节,不认结构体和对象。write 吐出去的是一串连续的字节,它没有能力表达「这里是一个结构体、那里是另一个结构体」。
所以字段必须先被拍平成一维的字节序列:字段按约定顺序排好,中间用约定好的方式划出边界。
3.3 为什么要「由一变多」
反过来的理由同样直接:上层要拿昵称就取昵称、要拿内容就取内容。如果对面递上来的是一整块字节,业务代码就得自己数偏移量,这显然不可接受。
拆成结构体之后,业务代码面对的是字节流 ,而不是一串需要心算的位置。
字符串只是字节流的一种特例(可打印、有编码)。真正的协议里完全可能出现二进制字节(比如长度前缀是一个 4 字节整数),那时候它已经不叫字符串了,但仍然是字节流。

四、★★ 能不能直接把结构体发过去
既然两端认识同一个结构体,把它的内存原样 write 出去,对面原样 read 进来,不就行了?
答案:可以,但严重不建议。
4.1 先说「可以」
这个做法在特定场景下确实能跑。一个熟悉的例子:写通讯录程序时,把 struct person 用 fwrite 一个个直接写进文件,读的时候再用 fread 读回来------这样是能工作的。
同样的道理用在网络上:客户端把 struct Request { int x; int y; char op; } 的内存原样发出去,服务端收到之后按同样的结构体解释,也能拿到 x、y 和运算符。
操作系统内部其实就是这么干的:内核里传递的协议数据,全部都是结构体对象,直接传二进制。 原因是它的字节数更少、效率更高------而且内核是同一份 C 代码,两边不会有任何不一致。
4.2 再说「不建议」:两道坎
第一道坎:内存对齐。
结构体为了对齐会插入填充字节,而填充规则由编译器、目标平台、编译选项共同决定:
| 差异来源 | 后果 |
|---|---|
| 32 位 与 64 位 | 指针宽度不同,布局随之后移 |
| gcc 与 msvc | 默认对齐策略不同 |
| Windows 与 Linux | 同一份 struct 的 sizeof 可能不同 |
客户端在 Windows 上、服务端在 Linux 上,同一个结构体可能一个 12 字节、一个 16 字节。字节流一错位,读出来的 y 就是垃圾值。字段越多,错位越严重。
第二道坎:跨语言。
「两端都能认识这个结构体」这句话成立的前提是两端用同一种语言。
| 组合 | 能不能对上 |
|---|---|
| C 对 C | 勉强可以 |
| C++ 对 C++ | 勉强可以 |
| C++ 对 Python | ✖ 对不上 |
| C++ 对 Java | ✖ 对不上 |
Python 的对象布局与 C 的结构体完全不同;Java 的字符串不是 C 的字节数组(它有对象头、有长度字段、编码方式也不一样)。再加上字节序问题(大端 / 小端),只要换一门语言,这份「约定」立刻作废。
4.3 还有一个更根本的理由
即使两端同语言、同平台、同编译器,把结构化数据直接拷进发送缓冲区仍然有问题:类里可能有指针。
cpp
struct Bad {
char *name; // 指向 new / malloc 出来的空间
std::vector<int> v; // 内部还有一层指针
};
把这样的对象按字节拷过去,拷走的只是指针的值(一个地址),而那个地址在对方进程里毫无意义。这就是典型的浅拷贝陷阱:真正该传的数据在指针指向的地方,却没有被传过去。
序列化天生避开了这件事------它按字段逐个编码,字符串就写字符串的内容,容器就写元素个数加元素,永远不会把地址写进去。
五、★ 序列化的真正价值:对软件解耦
「直接发结构体不行」并不能反过来证明「序列化一定好」。序列化的价值需要独立论证,而它的核心只有一条:解耦。
5.1 字段可以随便改
序列化之后,网络上跑的东西统一是字节流。这意味着:
| 变更 | 对网络层的影响 |
|---|---|
| 给结构体加一个字段 | ★ 无。加完重新序列化即可 |
| 改一个字段的类型 | ★ 无。序列化函数内部处理 |
| 调整字段顺序 | ★ 无。只要两端同步改序列化逻辑 |
如果直接发二进制,任何一条变更都会让两端的字节布局错位,必须同时重新编译、同时上线,中间不能有任何版本差。
产品需求变更是常态,而协议的字节布局一旦散落在各处,每次变更都是一次高风险操作。序列化把这份风险收进了一个函数里。
5.2 语言可以随便换
所有语言都支持字符串和字节数组这件事,是跨语言互通的基石。
C++ 的 struct ──序列化──▶ 字节流 ──反序列化──▶ Python 的 class
客户端用 C++ 写、服务端用 Python 写,只要两边的序列化与反序列化遵循同一份格式约定,就能通信。这是直接发结构体永远做不到的。
5.3 ★ 结论
从今往后,凡是要做网络协议式的通信,应用层强烈建议使用「序列化 + 反序列化」方案。
直接把结构体对象发过去,除非场景特殊,否则不建议。
💡 一句话记住两条边界:内核内部可以传结构体(同一份 C 代码);应用层不要传结构体(两端什么都可能不一样)。
六、★★ 重新理解 write 与 read
上面讲的是「上层怎么组织数据」。接下来要往下走一层,看清 write 和 read 到底做了什么。这一节的结论会推翻一个很常见的直觉。

6.1 先看 TCP 的内部结构
每一个 TCP 套接字,在内核里对应一对缓冲区:
| 缓冲区 | 作用 |
|---|---|
| 发送缓冲区 | 存放本端准备发出去、但还没真正发走的字节 |
| 接收缓冲区 | 存放已经从网络收到、但还没被应用读走的字节 |
这两个缓冲区都在 OS kernel 里,不在用户空间。应用层能拿到的只有一个 sockfd------一个指向这份内核资源的文件描述符。
6.2 write 没有把数据发到网络上
调用 write(sockfd, buffer, strlen(buffer)) 时发生的事情是:
用户空间的一块内存 ──拷贝──▶ TCP 发送缓冲区
就这些。数据没有出网卡。
用户态程序没有权限直接操作网卡,也没有权限决定什么时候把数据推上线。它能做的只有一件事:把数据交给内核,然后返回。
所以 write 的返回值是「成功拷贝了多少字节」,而不是「对方收到了多少」。这两个数字之间没有任何保证关系。
💡 一个准确的类比:寄快递。把包裹交给楼下的驿站、填单子、拿到回执,然后就回家了。包裹什么时候发车、走哪条路线、路上出不出问题,全由快递公司决定------寄件人既不知道也管不着。
6.3 那什么时候发
write 返回之后,这串字节就在发送缓冲区里等。什么时候发、发多少、出错了怎么办,由 TCP 协议自主决定。
这就是 TCP 全名的由来------传输控制协议(Transmission Control Protocol)。「控制」控制的就是这件事:
| 问题 | 谁决定 |
|---|---|
| 什么时候发 | TCP |
| 一次发多少 | TCP(受对端接收缓冲区剩余空间、拥塞窗口、MSS 等影响) |
| 出错了怎么办 | TCP(超时重传、快重传、拥塞控制) |
应用层插不上手,也不需要插手。
6.4 内核把数据送到网络,也是拷贝
继续往下看:TCP 把数据交给网络层,网络层交给链路层,最后交给网卡------每一步都是把数据从一个地方搬到另一个地方。
内存 ──拷贝──▶ 网卡设备 ──▶ 网络
对端则反过来:网卡先收到,交给内核,内核放进接收缓冲区。
6.5 read 也不是从网络读
read(sockfd, buffer, n) 做的事情是:
| 接收缓冲区状态 | read 的行为 |
|---|---|
| 空 | 阻塞,进程被挂起等待 |
| 有数据 | 把数据拷贝到用户空间,返回拷贝的字节数 |
所以「read 从网络读数据」这个说法在实现层面是不成立的。它读的是本机内核里的接收缓冲区------一个纯粹的内存区域。
6.6 ★ 三条结论
结论一:write / read 的本质不是收发,而是拷贝。
write:用户空间 → 内核发送缓冲区read:内核接收缓冲区 → 用户空间
这个认识可以推广:往显示器打印(printf)也是拷贝,往文件里写也是拷贝。整个计算机体系里,所谓「IO」几乎都是「把数据从一个地方拷到另一个地方」。
结论二:主机间通信的本质,是把发送方的发送缓冲区里的数据,拷贝到对端的接收缓冲区。
A 的发送缓冲区 ──网络──▶ B 的接收缓冲区
不考虑上层用户的那两层拷贝,网络通信本身就是一个「缓冲区到缓冲区」的搬运过程。
结论三:发送和接收是分离的,所以一个 fd 既能读又能写。
发送缓冲区和接收缓冲区是两个独立的队列,互不干扰。同一个 sockfd 背后同时挂着这两个队列,因此读和写可以同时进行------这就是 TCP 全双工的由来。
七、★ 四组生产者消费者模型
把上面那条「内核往缓冲区写、用户从缓冲区读」的关系单独拎出来看,会发现它和进程间通信里讲过的一个经典结构完全同构:
| 角色 | 在缓冲区上的动作 |
|---|---|
| 操作系统(内核) | 把从网络收到的数据写入接收缓冲区 |
| 上层用户(应用) | 通过 read 从接收缓冲区读出数据 |
一个写、一个读,中间隔着一块固定大小的内存------这正是生产者消费者模型。
7.1 阻塞的真正原因
一旦认清了这是个生产者消费者模型,read 的阻塞就不再是「网络慢」这种模糊说法,而是精确的同步行为:
| 情形 | 行为 | 原因 |
|---|---|---|
| 接收缓冲区为空 | read 阻塞 |
消费者没有东西可消费 |
| 发送缓冲区写满 | write 阻塞 |
生产者没有空位可放 |
这与管道的行为完全一致:管道写满时不允许再写,管道为空时读端阻塞。两者都是同一种同步机制在不同对象上的表现。
7.2 为什么是「四组」
一次完整的双向通信里,缓冲区一共有四个:
| 编号 | 缓冲区 | 生产者 | 消费者 |
|---|---|---|---|
| ① | A 的发送缓冲区 | A 的应用(write) |
A 的内核(TCP 取走发送) |
| ② | B 的接收缓冲区 | B 的内核(TCP 收到写入) | B 的应用(read) |
| ③ | B 的发送缓冲区 | B 的应用(write) |
B 的内核(TCP 取走发送) |
| ④ | A 的接收缓冲区 | A 的内核(TCP 收到写入) | A 的应用(read) |
所以一个 TCP 连接在结构上就是四组生产者消费者模型,两组负责一个方向,互不干扰。

八、★★ TCP 面向字节流:一次 read 不等于一条消息
8.1 先看 UDP 为什么简单
UDP 是面向数据报 的:sendto 一次,内核就把这一整块做成一个 UDP 报文发出去;对端 recvfrom 一次,就拿到这一个完整的报文。
发一个报文 ──▶ 收一个报文 (严格 1 : 1)
所以 UDP 里「一次读到一个完整消息」这个假设是成立的,代码写起来简单得多。
8.2 TCP 就不是了
TCP 是面向字节流 的。发送方 write 把字节放进发送缓冲区,此后:
- 内核可能只把其中一部分发出去(对端接收缓冲区剩余空间不够、拥塞窗口限制);
- 也可能攒了好几条一起发(Nagle 算法、发送窗口足够大)。
到了接收端,接收缓冲区里就是一条连续的字节流,里面的边界信息已经彻底丢失。
于是接收方的 read 会出现三种情况:
| 情况 | 现象 |
|---|---|
| 一次读到半条 | 消息还在路上,只到了一半 |
| 一次读到一条半 | 两条消息黏在一起(即所谓粘包) |
| 一次读到三条 | 对端发了三次,缓冲区里攒着 |
发送次数与接收次数之间,没有任何 1:1 的对应关系。
💡 三个熟悉的对照,能立刻感受到这件事:
- 管道 :写端一直写、读端一直不读,等读端终于
read一次,可能把积攒的历史数据全读上来了。 - 文件:往文件里写十个字符串很爽,读的时候很难受------因为没有分隔符,不知道该在哪里断开。
- 文件里混写类型 :写进去一个
int、一个double、一个字符串,读的时候必须精确知道每一段多长,否则全乱。
这三种场景的共同点是:它们都是面向字节流的,边界都得自己维护。 当时写管道和文件代码「读起来难受」,根源就是缺一份协议约定。
8.3 半个报文不能反序列化
这是整条链条上最要命的一环:
读到半个报文 ──直接反序列化──▶ ✖ 必然出错
反序列化函数按约定的格式去解析字段,如果字节还没到齐,它会把后半段的垃圾数据当成字段内容,解析结果完全错误却不报错。
正确做法只有一个:
读到半个 → 先不处理,继续 read → 攒够一条完整报文 → 再反序列化
「攒够一条」这件事,网络层不会做,内核不会做,只能由应用层自己做。
九、那行 read 的 bug,现在可以正式判定
回到课堂开头那个被标上「其实是有 bug 的」的写法:
c
char buffer[1024];
ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
buffer[n] = '\0'; // 当作一个完整的字符串处理
假设这个服务器要处理的是翻译请求,那么它隐含了三个假设,每一个都不成立:
| 假设 | 为什么错 |
|---|---|
① 一次 read 读到一整条消息 |
可能只读到半条,也可能读到一条半 |
| ② 一行/一句 = 一条消息 | TCP 没有「行」的概念,换行符只是一个普通字节 |
| ③ 超长消息被截断 | 1024 装不下的部分留在缓冲区,下一轮被当成新消息读走 |
buffer 里读到的不一定是一个完整的单词------可能是半个单词,可能是一个半,甚至可能是五个半。
顺带说明:同样的写法在 UDP 版本里是正确的。因为 UDP 面向数据报,recvfrom 一次就一定拿到一个完整报文。这个 bug 是 TCP 特有的。
十、接下来要自己定的协议:两件事
把前面所有结论收拢,未来的自定义协议必须同时提供两个功能:
| 功能 | 要求 | 为什么 |
|---|---|---|
| ① 结构化字段 + 序列化方案 | 约定好有哪些字段、什么类型、什么顺序,并提供序列化与反序列化的实现 | 让消息能过网络,且两端语义一致 |
| ② 解决读取不完整 | 读取时保证拿到的是完整的一条报文 | TCP 面向字节流,边界只能自己找 |
关于第 ② 点有一个明确的取舍:只处理读取侧。
发送侧不需要操心「一条消息该发几次」,因为 write 只是往发送缓冲区拷字节,怎么发由 TCP 决定------应用层控制不了,也不必控制。而读取侧必须自己保证完整性,否则反序列化必然出错。

十一、总结速查表
11.1 概念对照
| 概念 | 定义 | 一句话记忆 |
|---|---|---|
| 应用层协议 | 双方约定的报文格式 | 报文长什么样,由写代码的人自己定 |
| 结构化数据 | struct / class |
多个字段打包在一起 |
| 序列化 | 结构化数据 → 字节流 | 由多变一,为了发送 |
| 反序列化 | 字节流 → 结构化数据 | 由一变多,为了处理 |
| 面向数据报 | 一次发一个报文,一次收一个报文 | UDP,边界天然存在 |
| 面向字节流 | 只有连续字节,没有边界 | TCP,边界必须自己找 |
11.2 read / write 的真实语义
| 接口 | 常被误解为 | 实际是 |
|---|---|---|
write |
把数据发到网络上 | ★ 用户空间 → 内核发送缓冲区 |
read |
从网络读数据 | ★ 内核接收缓冲区 → 用户空间 |
| 返回值 | 对方收到了多少 | 本次拷贝了多少字节 |
| 何时发、发多少 | 由 write 的次数决定 |
★ 由 TCP 自主决定 |
11.3 直接发结构体 vs 序列化
| 维度 | 直接发结构体 | 序列化 |
|---|---|---|
| 内存对齐 | ✖ 两端必须完全一致 | ✔ 不受影响 |
| 跨语言 | ✖ 只能同语言 | ✔ 任意语言 |
| 字段变更 | ✖ 两端必须同时重编 | ✔ 改完重新序列化即可 |
| 含指针 / 容器 | ✖ 只能浅拷贝,丢数据 | ✔ 按字段逐个编码 |
| 效率 | ✔ 字节数最少 | 略差 |
| 适用场景 | 内核内部(同一份 C 代码) | ★ 应用层 |