序列化与自定义协议:把结构体拆成字节流,再把字节流拼回来
我的github:(https://github.com/xcx55/ubuntu-linux-project)
感谢各位大佬参观我的github!!
TCP 编程跑通之后(8-31),紧接着 9 月 1 号到 3 号解决一个所有 TCP 程序员都绕不开的问题:网络上传的是字节流,可上层想传的是结构化数据,中间的鸿沟怎么填? 这篇从 read/write 的本质讲起,一路讲到 sk_buff、粘包问题和一套完整的自定义协议。
一、先纠正一个直觉:read/write 是拷贝函数
很多初学者以为 write 是"把数据发出去"、read 是"把数据收回来"。笔记里第一句话就把这个直觉掰正了:
read 和 write 是拷贝函数。 就算是文件系统,read/write 也是把数据写到内核缓冲区,后面由线程再刷入。write/read 的任务,是把数据交给操作系统,结束。
所以网络 IO 的真实图景是(那张手绘图的核心):
用户进程 内核(TCP) 对端内核(TCP) 对端用户进程
| write() 拷贝 → 发送缓冲区 ────网络────→ 接收缓冲区 ← 拷贝 read() |
写满发送缓冲区,write 阻塞;接收缓冲区没数据,read 阻塞------细节下一节解释。
二、为什么 TCP/UDP 是全双工?因为有两个缓冲区
协议各自拥有两个缓冲区(发送一个、接收一个),两个方向互不干扰地读写------这就是"全双工"的物理基础。
而每一个缓冲区,都是一个生产者消费者模型:只不过生产者/消费者从"两个线程"变成了"用户和内核":
- read 阻塞的本质:缓冲区没数据,OS 让这个线程 cond 等待;数据来了,中断唤醒 read 线程;
- write 阻塞的本质:缓冲区写满,cond 等待;对端读走腾出空间,唤醒 write 线程。
read/write 拷贝完数据后,再去 cond 等待下一轮。文件系统也是这套思路------管道的阻塞、文件的读写,和网络的阻塞是同一个模型:抢锁 + 同步。到这里,信号、线程、网络三章在"cond 等待队列"这个点上彻底合流。
三、内核怎么管理报文:sk_buff
缓冲区里面长什么样?答案是队列 + 管理结构:
- 左边是描述一个报文的数据结构 ,右边是真实的报文数据------两者分开;
- 结构里有个
data指针指向报文数据:网络层加包时 data 指针++,再上一层加包再++------报文的层层封装,就是管理结构里指针的移动; - 每个报文都有自己的管理结构,叫
sk_buff(成员里有 next/prev 双链指针、len/data_len、cb48 控制块等); - 发送缓冲区和接收缓冲区就是 sk_buff 管理队列 (
sk_buff_head queue),一个节点描述一个报文,读取报文就是从这个队列里拿管理结构。
又一个"先描述、再组织":缓冲区本身不存数据,它存的是 sk_buff 链表。这句笔记的感叹值得保留:"看来文件的缓冲区也是类似的办法,至少也是有管理结构的"------Linux 的每个角落都在重复同一个设计模式。
四、粘包问题:TCP 会"少量多次"
TCP 不像 UDP 一次一个完整报文,它可能一点一点地发送数据(对方接收缓冲区字节不够时也会分批收)。
于是就有了经典的粘包问题 :发送端分了三次发,接收端一次 read 以为收到了全部------但其实只到了半截。数据不会丢(TCP 保证可靠性,丢了也有补救),但边界没了。
UDP 为什么没这个问题?因为它面向数据报,每个报文有天然边界;TCP 面向字节流,边界是用户层的责任。
五、应用层不推荐直接传结构体
要传"结构化的数据"(struct 或 class),第一反应是二进制结构体直接发。笔记里明确否了这条路:
应用层不推荐传递结构体------可能有一些语言不支持二进制结构体。
那推荐什么?序列化:
- 发送时多变 1:把结构体转成统一的形态(字符串),方便传输;
- 接收时1 变多:反序列化还原成结构体,方便上层处理;
- 只要序列化和反序列化的方式一样即可,两端语言不一样无所谓。
这就是"协议是一种约定"的落地:所谓协议定制,本质就是定制双方都认识的、符合通信和业务需要的结构化数据。
六、自定义协议:len\r\n + value + \r\n
序列化解决了"结构体变字符串",但没解决粘包------TCP 还是可能把一个 JSON 拆成几片发过来。所以要再套一层报文定制:
真实 send 的序列:
JSON value 的长度 \r\n JSON value 本身 \r\n
解析规则:
- 报文最前面是一个长度报头(4 字节 int):假设前 4 字节表示 20,说明接下来要 read 满 20 个字节才算一个完整报文;
- 连 4 个字节都没读到 → 读取失败,继续攒;
- 第一个
\r\n用来切出"长度"和"载荷"的边界;第二个\r\n是因为 TCP 分片发送,作为报文结束的兜底分隔。
网络流上读取顺序是递归推导的:服务刚启动时,第一个读到的必然是某个 JSON 报文的长度字节;因为初始状态定了,缺失的部分会被继续等待,下一个报文一定又从 len 开始------所以逻辑只需要处理"读够 len → 读载荷 → 再读 len"这一个循环。
用到的字符串操作也很朴素:find 返回 \r\n 的下标,substr(起始下标, 个数) 截取------协议解析没有魔法,就是字符串切割。
七、JSON:不要手写序列化
笔记里的原话:"不推荐自己做序列化和反序列化,反序列化使用 JSON!"
Json::Value是万能对象 :obj["key"] = value生成 KV 结构,什么类型都能装;- 序列化 = 把 Value 转成 JSON 看得懂的字符串;反序列化 = 传入 JSON 字符串、取出字段;
- JSON 字符串才是 TCP/UDP 真正传输的内容------面向字节流,就是字符串的 IO;
- 还有个细节:网络传输的 JSON 字符串没有
\0,长度由报头负责,不靠 C 字符串的结束符。
一个应用层的额外收益:序列化和反序列化可以提高应用层的性能(结构体对齐、字节序、语言差异的坑一次全部绕开)。
八、完整链路:一次网络通信的全景
把所有层串起来(就是笔记里那张流程图的文字版):
客户端 服务端
结构体数据 {message, time, nickname}
→ 序列化成 JSON 字符串
→ 封包 len\r\n value \r\n
→ write/send 拷贝进发送缓冲区
──────── TCP 字节流 ────────→ 接收缓冲区
→ read 拷贝出来
→ 解包(拆 len 报头)
→ 反序列化得到数据
→ 业务计算
→ 结果再序列化 → 再封包 → send
← ──────────────────────────────
→ 解包 → 反序列化 → 客户端拿到结果
核心思想就两个:加包 + JSON 字符串。 再怎么封装、继承、多态,都是这两个动作的组织形式。
九、工程结构:模板方法与分层
代码层面笔记还留了两个设计点:
- 模板方法模式 :父类有一个虚函数,内部调用了另外三个虚函数;子类只覆盖那三个,外层流程不动------"调用我子类覆盖后的三个函数"。配合虚表机制(编译器链接器生成完整虚表、运行时
call *基址+eax*4实时计算函数地址),UDP 和 TCP 重合的功能用继承合并,差异部分留给子类; - 线程池里不要用 unique_ptr (拷贝语义的坑);子进程拷贝栈是写时拷贝,虚拟地址不用变------前面章节的知识在这里顺手复用。
最后一句总结很到位:这个代码分了很多层------应用层序列化、协议层封包解包、传输层缓冲区、内核 sk_buff 队列,每一层只做一件事,靠约定衔接。网络编程写到这一步,才算真正摸到"协议"这两个字的分量。