序列化与自定义协议-把结构体拆成字节流再拼回来

序列化与自定义协议:把结构体拆成字节流,再把字节流拼回来

我的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 字符串。 再怎么封装、继承、多态,都是这两个动作的组织形式。

九、工程结构:模板方法与分层

代码层面笔记还留了两个设计点:

  1. 模板方法模式 :父类有一个虚函数,内部调用了另外三个虚函数;子类只覆盖那三个,外层流程不动------"调用我子类覆盖后的三个函数"。配合虚表机制(编译器链接器生成完整虚表、运行时 call *基址+eax*4 实时计算函数地址),UDP 和 TCP 重合的功能用继承合并,差异部分留给子类;
  2. 线程池里不要用 unique_ptr (拷贝语义的坑);子进程拷贝栈是写时拷贝,虚拟地址不用变------前面章节的知识在这里顺手复用。

最后一句总结很到位:这个代码分了很多层------应用层序列化、协议层封包解包、传输层缓冲区、内核 sk_buff 队列,每一层只做一件事,靠约定衔接。网络编程写到这一步,才算真正摸到"协议"这两个字的分量。

相关推荐
咯哦哦哦哦1 小时前
配置VNC sever 6.11.0版本 linux(激活码)
linux·运维·服务器
꯭自꯭闭꯭1 小时前
DM主备集群以及读写分离集群搭建
linux·运维·数据库
倔强的石头1061 小时前
【Linux指南】动静态库系列(九):动态库如何进入进程地址空间:从磁盘 .so 到共享内存映射
java·linux·服务器
裕晟资质规划1 小时前
涉密场所物理隔离与技术防护体系:标准矩阵、审查校验点与常见缺陷分析
大数据·前端·网络·人工智能·经验分享
xianyuCcCcCCCcc1 小时前
Docker 容器与网络
网络·docker·容器
脚踏实地,坚持不懈!1 小时前
Linux 内核源码解析:从 secondary_startup_64 到 pick_eevdf 的完整调用栈分析
android·linux·arm开发
鬼手点金2 小时前
Claude Code示范案例-进阶学习路径
java·服务器·前端·学习·计算机视觉·前向传播
茉莉玫瑰花茶2 小时前
GO [ 文件 ]
服务器·golang
xbzb2 小时前
Linux iSCSI 存储部署与 CHAP 认证完全指南
linux·服务器·iscsi·chap·共享硬盘