
🔥小叶-duck:个人主页
❄️个人专栏:《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》
《Linux系统从入门到实践》《Linux网络从入门到实践》
✨未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游
目录
[1.1 再谈协议的本质](#1.1 再谈协议的本质)
[1.2 TCP 面向字节流特性带来的核心问题](#1.2 TCP 面向字节流特性带来的核心问题)
[1.3 结构化数据的跨平台 / 跨语言传输问题](#1.3 结构化数据的跨平台 / 跨语言传输问题)
二、重新理解read、write、recv、send和tcp为什么支持全双工
[2.1 重谈read、write、recv、send](#2.1 重谈read、write、recv、send)
[2.2 IO 系统调用的本质:内存拷贝](#2.2 IO 系统调用的本质:内存拷贝)
[2.3 tcp为什么支持全双工?](#2.3 tcp为什么支持全双工?)
[2.4 内核视角:TCP 报文的生命周期:sk_buff](#2.4 内核视角:TCP 报文的生命周期:sk_buff)
[2.4.1 内核管理思想:先描述,再组织](#2.4.1 内核管理思想:先描述,再组织)
[2.4.2 struct sk_buff 核心结构体](#2.4.2 struct sk_buff 核心结构体)
[2.4.3 封装与解包(核心优化:移动指针,不拷贝数据)](#2.4.3 封装与解包(核心优化:移动指针,不拷贝数据))
[2.4.4 socket 与报文队列](#2.4.4 socket 与报文队列)
[3.1 核心定义](#3.1 核心定义)
[3.2 主流序列化方案对比](#3.2 主流序列化方案对比)
[4.1 Jsoncpp](#4.1 Jsoncpp)
[4.1.1 环境准备](#4.1.1 环境准备)
[4.1.2 Jsoncpp测试代码](#4.1.2 Jsoncpp测试代码)
[4.2 协议设计:请求与应答报文](#4.2 协议设计:请求与应答报文)
[4.3 基于 Jsoncpp 的序列化与反序列化实现](#4.3 基于 Jsoncpp 的序列化与反序列化实现)
[4.4 报文编解码:长度 + 分隔符解决 TCP 粘包半包问题](#4.4 报文编解码:长度 + 分隔符解决 TCP 粘包半包问题)
前言
在学习 TCP 网络编程之后,很多人会产生一个误区:既然 TCP 已经做到可靠传输,有序、无差错交付数据,是不是就可以直接把业务数据交给 Socket 发送就万事大吉。但实际开发中我们会不断遇到粘包半包、跨平台数据解析错乱等一系列棘手问题,这正是 TCP 面向字节流的本质特性带来的现实难题。TCP 只负责字节流的搬运,它完全不理解上层业务报文的边界,也不关心我们传输的是怎样的结构化数据。想要真正实现稳定可用的网络通信,就必须在应用层搭建一套完整的自定义协议体系。
本文会从协议的本质出发,先厘清
read/write/recv/send系统调用的真实工作逻辑,深入内核视角了解sk_buff报文缓冲区的运作机制,搞懂 TCP 全双工的底层原理。接着讲解序列化与反序列化的核心价值,对比主流序列化方案,再基于 JsonCpp 完成整套自定义协议的工程实战,包含报文设计、序列化封装、以及使用「长度 + 分隔符」方案解决 TCP 粘包半包问题。
一、为什么我们需要应用层自定义协议?
1.1 再谈协议的本质
- 协议的核心本质,就是通信交互双方共同遵守的一套数据交互约定与行为规范,没有这套约定,收发双方就无法读懂彼此的数据。
- Socket 提供的读写接口 ,底层全部是原始字节流来完成数据收发。如果仅仅做简单回显字符串这类极简场景,直接调用原生 Socket 接口就可以满足需求。但真实线上业务场景,绝大多数要传递的都是带有业务含义的结构化数据;举几个典型场景:网络计算器要传递操作数、运算符;聊天程序需要携带发送者昵称、消息正文、发送时间、用户头像等多组不同字段信息。
- 怎样才能保证发送端产出的结构化数据,接收端可以精准、没有歧义地完成解析?这就要求通信双方提前对齐数据格式、每个字段代表的意义、数据边界切割规则,这一整套预先达成的约定 ,就是我们所说的应用层协议。(TCP 只能保证字节可靠到达,不会帮我们做业务数据拆分,这也是粘包问题的根源所在,自定义协议正是用来解决该问题)
举一个极简案例:实现网络版本计算器,客户端给服务端传递两个数字,服务端运算完毕把计算结果回传。这里存在两种最基础的协议设计方案思路:
- 方案一:简单字符串约定
- 客户端固定发送类似
"1+2"格式字符串,双方约定字符串内部包含两个整数操作数,中间存在且只能存在一个运算符,数字和运算符之间不能带有空格。服务端拿到字节之后,按照这套规则切割解析字符串,提取出参与运算的数字与运算符,执行计算逻辑。
- 客户端固定发送类似
方案一虽然表面看起来实现简单,但扩展性非常差,业务字段一多,字符串切割逻辑会变得十分繁琐易错。
- 方案二:结构化数据约定
- 专门定义结构体来承载交互信息,发送端把结构体按照统一规则转化为字节流,也就是序列化;接收端收到字节之后,依照完全相同的规则,还原回原始结构体,即反序列化。
protobuf、json 本质就是成熟的序列化方案,我们手写自定义协议,就是自己实现一套简易的序列化与反序列化逻辑。方案二正是工业级开发的标准做法,也是接下来我们设计自定义协议的核心讲解内容。

1.2 TCP 面向字节流特性带来的核心问题
面向字节流的核心含义是:TCP 并不关心应用层传输的数据是什么格式、有没有业务边界,它只负责把发送方写入的字节流,可靠、有序地全部传递给接收方,但不保证接收方的 read 调用次数和发送方的 write 调用次数一一匹配 。
TCP 内核维护着发送缓冲区和接收缓冲区(后面重谈read、write会进行详解),数据在缓冲区中是连续的字节序列,应用层每次 read 能读到多少字节,取决于缓冲区当前的实际状态,和发送端调用了几次 write 没有直接对应关系
举个具体例子:
- 客户端分两次调用 write,分别写入
"1+2"和"3*4"两个请求; - 服务端调用 read 时,可能一次性读到
"1+23*4",两个请求粘在了一起,这就是粘包问题; - 也可能第一次读到
"1+",第二次读到"23*4",一个请求被拆分成了两次读取,这就是半包问题(拆包)。
粘包和半包 本质是同一个问题的两种表现 ,根源都在于TCP 没有应用层消息边界,读取时机不确定导致数据合并或拆分。
TCP 本身不会帮我们处理业务报文的边界,所以这个问题必须由应用层协议来解决。
1.3 结构化数据的跨平台 / 跨语言传输问题


直接把结构体内存原样发送,只有在条件十分苛刻的场景才可以正常工作:客户端与服务端运行在相同平台、使用同一门编程语言、相同编译器以及完全一致的编译选项。
放到工业级项目开发中,这种方式并不被推荐,主要存在两大致命缺陷:
- 平台内存对齐差异:不同硬件平台、不同编译器,处理结构体内存对齐的策略并不统一。完全相同的结构体定义,分别运行在 32 位系统与 64 位系统上,最终占用的内存大小、成员偏移位置都可能不一样,接收端拿到字节之后,解析出来的数据全部错乱。
- 跨语言兼容性极差:结构体属于 C/C++ 语言独有的语法概念。假设后端服务使用 C++ 编写,而客户端采用 Java、Python、Go 等其他语言开发,其他语言完全无法读懂 C++ 结构体的内存排布,双方根本无法完成数据交互。
序列化就是用来化解上述难题的关键手段:不管运行在哪种平台、使用哪一门编程语言,都可以把业务结构化数据转换成一套标准统一的字节流或者字符串 ;接收端读取数据后,依照预先约定好的统一规则,反向还原成本地语言对应的业务数据结构,以此完成跨平台、跨语言之间的网络数据交互。

二、重新理解read、write、recv、send和tcp为什么支持全双工
2.1 重谈read、write、recv、send
在正式进入协议设计之前,我们必须彻底搞懂 read /write/recv/send 这些 IO 系统调用的底层本质,这是理解网络通信的核心,也是面试的必考题。

2.2 IO 系统调用的本质:内存拷贝
不管是 write/send 发送接口,还是 read/recv 接收接口,所有网络 IO 系统调用,底层核心行为就是内存拷贝。
发送端:write/send 完整执行流程
- 应用层执行
write(sockfd, buffer, len),传入位于用户空间的缓冲区地址与数据长度。 - 内核把用户空间 buffer 内的数据,拷贝到当前 socket 对应的内核发送缓冲区。
- 拷贝动作完成,write 调用就直接返回。后续 TCP 协议栈结合滑动窗口、拥塞控制算法,把发送缓冲区的数据封装成 TCP 报文,投递到网络当中。
如果发送缓冲区剩余空间不足,阻塞模式下 write 会挂起等待 缓冲区腾出位置。这其实也就是用户到内核的生产消费者模型 ,为什么 write 会被阻塞,原因就在于生产条件不满足了。
接收端:read/recv 完整执行流程
- 网卡硬件收到远端发来的 TCP 报文,触发硬件中断交给内核;内核校验报文合法之后,把数据存入 socket 对应的内核接收缓冲区。
- 应用程序调用
read(sockfd, buffer, len),内核再把接收缓冲区中的字节拷贝到用户态提供的 buffer 内存。 - 拷贝结束,read 返回本次实际读到的字节数量,上层业务代码就可以处理这份数据。
如果接收缓冲区没有数据,阻塞模式下 read 也会挂起等待 内核向缓冲区发送数据。这其实也就是内核到用户的生产消费者模型 ,为什么 read 会被阻塞,原因就在于消费条件不满足了。
面试高频考点:write 调用返回成功,是否代表对方已经收到数据? 答案是否定。write 成功仅仅代表数据顺利拷贝到内核发送缓冲区。倘若此时本机直接宕机,缓冲区还没来得及发出的数据就直接丢失。TCP 内核协议栈会尽力保障数据可靠送达,但上层应用无法依靠 write 返回值判断对端接收状态。
2.3 tcp为什么支持全双工?
TCP 能够实现全双工通信,也就是同一个 socket 文件描述符可以同时做发送、接收两类操作,底层关键在于 Linux 内核会为每一个 TCP socket,分配两块互相独立的内存缓冲区:发送缓冲区(Send Buffer)与接收缓冲区(Receive Buffer)。
两块缓冲区都处于内核空间,不属于应用程序的用户内存。
基于这套内核缓冲区模型,可以提炼出三条关键结论:
- 全双工的本质:发送缓冲区、接收缓冲区二者完全隔离互不干扰。内核一边向对端递交数据,一边也可以接收对方发来的数据;应用程序在同一个 socket 上同时执行 read 读取、write 写入,不会发生资源冲突。
- IO 的异步特性 :应用程序调用 write 返回成功,只代表数据已经拷贝进内核发送缓冲区,并不代表数据已经发送到网络,更不能代表对端已经收到消息。数据什么时候发出、单次发送多少字节、网络出错之后如何重传,全部交给内核 TCP 协议栈自主调度,这也是 TCP 叫做传输控制协议的由来。(应用层只管把数据交给内核,真正网络传输是内核异步完成,应用代码不会卡在网络发送环节)
- 网络传输的本质 :一次完整 TCP 网络通信,本质就是发送端内核发送缓冲区的数据,经过网络链路,拷贝到接收端内核接收缓冲区的过程。(数据要先后经历用户空间→发送缓冲区→网络→接收缓冲区→用户空间多次内存拷贝)
2.4 内核视角:TCP 报文的生命周期:sk_buff
从内核的视角来看,所有的网络报文都会被封装成一个核心结构体:struct sk_buff(socket buffer),这是 Linux 内核网络协议栈的核心数据结构。
2.4.1 内核管理思想:先描述,再组织
Linux 内核不直接操作原始报文内存块,先用sk_buff结构体描述一块报文数据,再通过结构体指针组织、管理报文 。 报文真实数据缓冲区和描述结构体sk_buff是分离的,大量操作只移动指针,不拷贝原始报文,实现高性能。
2.4.2 struct sk_buff 核心结构体
sk_buff是 Linux 内核网络子系统的核心,每一个网络报文,在内核中都对应一个sk_buff对象。 主要成员:
- 链表指针:把多个 sk_buff 串成队列(发送队列、接收队列)
- 各层协议头指针:指向链路层、IP 层、TCP/UDP 头部位置
- 数据指针、长度字段:标记 payload、协议头的起止位置
- 网络设备信息:记录报文从哪个网卡接收 / 发出
重点:sk_buff 是描述符,报文真实数据存放在另外分配的缓冲区,不是存在结构体内部。

2.4.3 封装与解包(核心优化:移动指针,不拷贝数据)
封装(发送报文,自上而下逐层加协议头)
data指针向上移动,在现有数据前面预留头部空间。
- 应用数据 → TCP 头(挪指针预留空间)→ IP 头 → 链路层头。
- 只是修改指针位置,不会把内存数据向后拷贝腾出头部空间,效率极高。
解包(接收报文,自下而上剥协议头)
data指针向下移动,跳过当前层协议头部。
- 网卡收到完整帧,data 指向链路层头;向上交付网络层,data 下移跳过以太网头;交付传输层,继续下移跳过 IP 头,最终指向 TCP payload。
- 全程移动指针,原始报文数据不发生内存拷贝。
这是 Linux 网络栈高性能关键:协议分层处理靠指针偏移,而不是内存复制。

2.4.4 socket 与报文队列
一条链路:fd → struct file → struct socket → struct sock
fd:用户态文件描述符;struct file:文件系统抽象,一切皆文件,socket 也被包装成 file;struct socket:BSD socket 层结构体;struct sock:内核网络协议层结构体(TCP 真正内核对象)。
struct sock内部维护两个 sk_buff 链表队列:
sk_write_queue:发送队列 ,保存待发送的 sk_buff 报文;write/send拷贝数据到内核,最终生成 sk_buff 挂到此队列;TCP 协议栈从此队列取报文发包。sk_receive_queue:接收队列 ,网卡收到报文生成 sk_buff 挂入接收队列;read/recv就是从这个队列取出 sk_buff,把 payload 拷贝给到用户空间。
- 用户调用
write(),数据拷贝进入内核,内核构造sk_buff,挂入sock->sk_write_queue发送队列。TCP 内核异步处理队列中的 sk_buff,执行封装、交给网卡发出。- 网卡收到报文,内核生成
sk_buff,挂入sock->sk_receive_queue接收队列;用户调用read(),从接收队列取出 sk_buff,拷贝数据到用户缓冲区。- 内核 TCP 发送缓冲区、接收缓冲区本质就是由大量
sk_buff组成的链表队列。- 协议封装解包全程依靠移动 sk_buff 内部指针完成,避免大量内存拷贝,是内核网络栈高性能来源。

三、序列化与反序列化:网络传输的核心基石
3.1 核心定义
- 序列化:把内存里具备结构的数据,例如结构体、类实例对象,依照一套约定规则,转换为一段连续字节流或者字符串的过程。它主要解决结构化数据无法直接在网络上传输、落盘保存的问题。
- 反序列化:作为序列化的逆向操作,把网络接收得到的字节流、字符串,遵照完全一致的解析规则,还原回内存中可直接操作的结构化业务数据,方便上层业务代码读取各个字段。
通俗来讲:
序列化完成的是多变一: 将分散的多个业务字段打包成一整块可传输的数据;
反序列化则是**一变多:**把收到的整块数据重新拆解成一个个独立可用的字段。
3.2 主流序列化方案对比
工业项目开发一般不会从零手写序列化逻辑,大多直接选用成熟开源组件,不同方案的特性各有取舍,适配不一样的业务场景。
| 序列化方案 | 核心特点 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Json | 文本格式,键值对组织 | 可读性好,多语言互通,上手简单,不用预编译 | 生成数据体积偏大,解析性能一般 | Web 接口、配置文件、轻量级网络交互 |
| Protobuf | 二进制格式谷歌开源 | 序列化后数据紧凑,性能高,支持版本兼容 | 肉眼不可读,需要提前编译 proto 描述文件 | 微服务 RPC、高性能后端通信、移动端网络交互 |
| XML | 文本格式,标签嵌套 | 格式规范,支持深度嵌套复杂结构 | 报文臃肿,解析开销大 | 老式配置文件、遗留系统对外接口 |
| 自定义二进制 | 手动设计二进制报文 | 体积、性能可以做到最优 | 开发维护成本高,跨语言兼容性差 | 嵌入式设备、追求极致性能的底层通信 |
后续我们实现网络计算器在协议中实现序列化和反序列化我们就采用Jsoncpp库,它是 C++ 中最常用的 Json 处理库,API 简单易用,完全满足绝大多数后端业务场景的需求。
四、实战落地:自定义协议完整实现
我们以网络版计算器为业务场景,完整实现一套工业级的应用层协议,包含:协议设计、序列化 / 反序列化、粘包问题解决三大核心模块。
后续我们完整实现网络版计算器的全部模块,这里为了展示序列化和反序列化的效果,我们先对协议层Protocol.hpp头文件中的请求报文和应答报文进行设计。
4.1 Jsoncpp
Jsoncpp 是 C++ 平台下专门用来操作 JSON 格式的开源库,核心能力包含两大块:将内存 JSON 对象序列化为字符串,以及把 JSON 字符串反序列化还原成 C++ 可操作的数据结构,在大量 C++ 后端项目中被广泛使用。
Jsoncpp 核心特性:
- 简单易用:对外暴露的 API 设计直观,上手门槛低,能够快速完成 JSON 数据读写。
- 高性能:内部做了性能优化,面对大批量 JSON 数据时依旧可以保持不错的解析效率。
- 完整标准支持:完整兼容 JSON 规范,支持对象、数组、字符串、数值、布尔、null 全部数据类型。
- 完善错误处理:解析异常时会输出详细报错内容与出错位置,便于定位解析 BUG,方便调试排错。
在 Jsoncpp 中完成序列化、反序列化存在多套接口与工具类可供选用,下面会针对库中序列化、反序列化的实操用法展开详细说明。
4.1.1 环境准备
Jsoncpp 库的安装非常简单,在 Ubuntu/Debian 系统下执行:
bash
sudo apt-get install -y libjsoncpp-dev
CentOS/RHEL 系统下执行:
bash
sudo yum install -y jsoncpp-devel
编译时需要链接 jsoncpp 库,编译指令需加上
-ljsoncpp参数。
4.1.2 Jsoncpp测试代码
序列化
cpp
#include <iostream>
#include <string>
#include <jsoncpp/json/json.h>
int main()
{
//序列化
Json::Value root;
root["name"] = "张三";
root["sex"] = "男";
root["age"] = 18;
root["isStudent"] = true;
Json::StreamWriterBuilder builder;
builder["emitUTF8"] = true; // 关键:直接输出UTF‑8中文,不转\u
// std::string s = Json::writeString(builder, root);
// std::cout << s << std::endl;
// builder["indentation"] = ""; // FastWriter是紧凑无换行,就填"";StyledWriter如果想要美化换行写" "
builder["indentation"] = " "; // FastWriter是紧凑无换行,就填"";StyledWriter如果想要美化换行写" "
std::string s = Json::writeString(builder, root);
std::cout << s << std::endl;
//旧版 API,它们没有任何成员开关可以关闭 unicode 转义,只要中文就输出\uXXXX,改不了
// std::string s = root.toStyledString();
// std::cout << s << std::endl;
// Json::FastWriter writer;
// // Json::StyledWriter writer;
// std::string s = writer.write(root);
// std::cout << s << std::endl;
return 0;
}
代码结果展示:

反序列化
cpp
#include <iostream>
#include <string>
#include <jsoncpp/json/json.h>
int main()
{
//反序列化
std::string json_string = "{\"name\":\"张三\",\"age\":18,\"city\":\"北京\"}";
//反序列化,起手式:Json::Value
Json::Value root;
Json::Reader reader;
bool ok = reader.parse(json_string, root);
(void)ok;
//把序列化字符串,反序列化到Json::Value里面
std::string name = root["name"].asString();
int age = root["age"].asInt();
std::string city = root["city"].asString();
std::cout << name << std::endl;
std::cout << age << std::endl;
std::cout << city << std::endl;
return 0;
}


4.2 协议设计:请求与应答报文
协议设计的第一步,是定义通信双方交互使用的结构化报文。我们把报文划分为两类:客户端发往服务端的请求报文 ,以及服务端回传客户端的应答报文 。
明确区分请求与应答,是绝大多数 B/S 架构应用层协议的通用设计思路。
请求报文(Client → Server)
客户端向服务端发送的计算请求,一共包含三个核心字段:
| 字段名 | 类型 | 含义 |
|---|---|---|
| datax | int | 第一个操作数 |
| datay | int | 第二个操作数 |
| oper | char | 运算符,支持 + - * / % |
应答报文(Server → Client)
服务端向客户端返回的计算结果,包含两个核心字段:
| 字段名 | 类型 | 含义 |
|---|---|---|
| result | int | 计算结果,仅当 code为 0 时有效 |
| code | int | 状态码,0 表示计算成功,非 0 表示错误码(如除零错误、非法运算符) |
这套报文结构,就是我们自定义协议的核心。客户端和服务端都必须严格遵循这套规范,完成数据的序列化与反序列化,双方才能正确解析彼此的数据。
4.3 基于 Jsoncpp 的序列化与反序列化实现
我们将请求报文和应答报文的核心实现封装在Protocol.hpp头文件中,客户端和服务端只需引入该头文件,即可使用统一的协议规范,这也是协议开发的最佳实践。
cpp
#ifndef PROTOCOL_HPP
#define PROTOCOL_HPP
#include "Common.hpp"
#include "Socket.hpp"
#include <jsoncpp/json/json.h>
using namespace SocketModule;
// 实现一个自定义的网络版本计算器
// 约定好各个字段的含义,本质就是约定好协议!
// ===================== 请求报文:client -> server =====================
// client->server
class Request
{
public:
Request()
{
}
Request(int x, int y, char oper) : _x(x), _y(y), _oper(oper)
{
}
// 序列化:将结构化的请求对象,转换成Json字符串
std::string Serialize()
{
Json::Value root;
root["x"] = _x;
root["y"] = _y;
root["oper"] = _oper;
Json::FastWriter writer;
std::string s = writer.write(root);
return s;
}
// 反序列化
bool Deserialize(std::string &in)
{
Json::Value root;
Json::Reader reader;
bool ok = reader.parse(in, root);
if (ok)
{
_x = root["x"].asInt();
_y = root["y"].asInt();
_oper = root["oper"].asInt();
}
return ok;
}
int X() const { return _x; }
int Y() const { return _y; }
char Oper() const { return _oper; }
~Request()
{
}
private:
int _x;
int _y;
char _oper; // + - * / % ------> 要求:_x _oper _y -> 10 + 20
};
// ===================== 应答报文:server -> client =====================
// server->client
class Responce
{
public:
Responce()
{
}
Responce(int result, int code) : _result(result), _code(code)
{
}
// 序列化
std::string Serialize()
{
Json::Value root;
root["result"] = _result;
root["code"] = _code;
Json::FastWriter writer;
std::string s = writer.write(root);
return s;
}
// 反序列化
bool Deserialize(std::string &in)
{
Json::Value root;
Json::Reader reader;
bool ok = reader.parse(in, root);
if (ok)
{
_result = root["result"].asInt();
_code = root["code"].asInt();
}
return ok;
}
void SetResult(int res)
{
_result = res;
}
void SetCode(int code)
{
_code = code;
}
void ShowResult()
{
std::cout << "计算结果是: " << _result << "[" << _code << "]" << std::endl;
}
~Responce()
{
}
private:
int _result; // 运算结果,无法区分清楚应答的结果是正常计算结果,还是异常值
int _code; // 规定 0:success; 1/2/3/4.. : 不同的运算异常情况
};
源码核心解读
- Json::Value 万能对象 :Jsoncpp 库的核心类,用来存放 JSON 键值对结构,支持 int、string、数组、嵌套对象等全部 JSON 数据类型;代码中使用
root["key"] = value的语法完成各个报文字段的赋值填充。 - 序列化核心逻辑 :借助
Json::FastWriter把Json::Value对象输出为紧凑字符串。对比StyledWriter,它不会生成多余空格、换行,缩减报文体积,更适合网络传输场景。 - 反序列化核心逻辑 :依靠
Json::Reader解析接收的 JSON 字符串,还原出Json::Value对象;之后调用asInt()这类类型接口取出对应字段,恢复成业务结构化数据。 - 接口封装设计 :类内部成员变量全部设置为
private,对外只暴露public成员函数。实现数据封装隔离,防止上层业务代码直接修改内部字段,避免协议报文格式错乱。
4.4 报文编解码:长度 + 分隔符解决 TCP 粘包半包问题
序列化解决了结构化数据的传输问题,但还没有解决 TCP 面向字节流带来的粘包 / 半包问题。我们采用 "报文长度 + 分隔符" 的经典方案,实现报文的编解码,彻底解决粘包 / 半包问题。
我们约定最终的网络传输报文格式为:
bash
[报文长度]\r\n[序列化后的业务报文]\r\n
例如:序列化后的业务报文长度为 30,那么最终发送的报文为
"30\r\n{"datax":10,"datay":20,"oper":43}\r\n" 。
这就是我们约定出来双方都需要遵守的协议!
编解码核心实现(附详细注释)
cpp
//Protocol.hpp
// 首先我们要分析一下对于面向字节流而言读取的时候会存在哪些情况:
//{"x": 10, "y": 20, "oper": '+'} //理想情况(一个完整报文)
//{"x": 10, "y //读取不到一个完整报文(少读)
//{"x": 10, "y": 20, "oper": '+'}{"x": 10, "y": //读取多于一个完整报文(多读)
// 对于第一种情况就是理想情况,我们就可以直接进行获取,不做其他处理了
// 对于第二种情况(少读),我们就不应该读上来,而是要让调用方继续读取数据直到是至少一个完整报文的情况
// 对于第三章情况(多读),我们就应该读上来,但是不应该全部都读而是只读取一个完整报文,而多余的就应该保留下来,怎么保留后面再讲
// 对于上面的三种情况都需要做不同的处理,但是归根结底存在一个问题:我们怎么知道读取的字段长度就是一个完整报文的长度??
// 所以我们就存在一个解决方法:我们序列化成字符串结果最开头加上这个字符串的大小,将两者拼接起来,如下:
// 假设:50{"x": 10, "y": 20, "oper": '+'}
// 但为了方便后续展示代码结果,我们在50和字符串中间加上\r\n换行符作为分隔符(正常写代码直接用空格作为分隔符即可)
std::string EnCode(const std::string &jsonstr)
{
// 50\r\n{"x": 10, "y": 20, "oper": '+'}\r\n
// 1. 将报文长度转为字符串
std::string jsonstr_len = std::to_string(jsonstr.size());
// 2. 按照约定格式封装报文
return jsonstr_len + sep + jsonstr + sep; // 其实就可以理解为是在应用层封装报头
}
// 当我们将序列化完的结果进行上面的封装报头的操作,读取的时候(面向字节流)就会出现如下的情况:
// 5
// 50
// 50\r\n
// 50\r\n{"x": 10, "y"
// 50\r\n{"x": 10, "y": 20, "oper": '+'}\r\n
// 50\r\n{"x": 10, "y": 20, "oper": '+'}\r\n50\r\n{"x": 10,
//......
// 所以我们就需要对上面这些全部情况都分别进行判断处理
// 1、判断报文完整性
// 2、如果包含了至少一个完整的报文------>进行提取,并且将这个完整报文进行溢出保留多余部分,方便处理下一个
// 3、如果包含没有一个完整的报文------>让调用方继续读取数据直到是至少一个完整报文的情况
bool DeCode(std::string &buffer, std::string *package)
{
// 1. 查找第一个分隔符,提取报文长度
ssize_t pos = buffer.find(sep);
if (pos == std::string::npos)
{
// 说明根本没有找到分隔符\r\n,说明是上面的前两种情况,也就说明是没有读取到完整报文(其实都没有报文...)
return false; // 让调用方继续从内核中读取数据直到是至少一个完整报文的情况
}
// 2. 提取报文长度字符串,转为整型
std::string package_len_str = buffer.substr(0, pos); // 获取理想一个完整报头长度的字符串
int package_len = std::stoi(package_len_str); // 获取理想一个完整报头的长度
// 3. 计算完整报文的总长度
// 总长度 = 长度字符串长度 + 2个分隔符长度 + 业务报文长度
// 到这一定存在报文的内容,但是一定是一个完整的报文吗??
int target_len = package_len_str.size() + package_len + 2 * sep.size(); // 理想一个封装报头后完整报文的长度
// 4. 判断缓冲区中是否有完整的报文
if (buffer.size() < target_len)
{
// 这种情况就对应于上面的第四种情况,也就是存在报文但不是一个完整的报文
// 也就和上面没有找到分隔符一样,需要让调用方继续从内核中读取数据直到是至少一个完整报文的情况
return false;
}
// 5. 提取完整的业务报文
// 到这里就说明了一定是存在至少一个完整的报文内容了(buffer.size() >= target_len)
// 所以接下来我们就需要将一个完整的报文取出来并且在buffer中进行剔除,用于后续再次使用
*package = buffer.substr(pos + sep.size(), package_len);
// 所以这就是为什么函数第二个参数是指针,因为是输出型参数,我们需要将获取到的完整报文传出去
// 我们只需要报文的内容而不需要封装的报头,所以截取位置并不是从0开始,这其实也就可以理解为是在应用层进行解包
// 6. 从缓冲区中移除已经处理过的报文,保留剩余数据
// 获取到了完整报文,接下来就需要将完整报文从buffer剔除,保留多余部分进行后续使用
buffer.erase(0, target_len); // 我们要剔除的不只是一个完整报文,而是包含报头的完整报文
// 并且这也就是为什么我们函数第一个参数传的是引用,目的就是为了这个剔除操作能直接影响外部传入的数据
// 这样下次外部同样的字符串buffer_queue获取到新的报文,就能继续做判断
return true;
}
编解码逻辑核心解读
- 编码逻辑 :给业务报文前置长度字段,搭配
\r\n作为分隔符,接收端由此能够获取业务报文真实长度,以此判断报文是否接收完整。 - 解码逻辑的核心设计 :
- 半包处理:缓冲区找不到分隔符,或是现有数据不足以构成一整份报文,直接返回 false,暂不解析,等待后续读到更多数据再处理。
- 粘包处理:解析出一份完整报文之后,仅擦除缓冲区已经消费的数据,剩余字节继续留在缓冲区,留给下一轮解码,不会丢失粘连在一起的其他报文。
- 原子性处理:只有判定缓冲区存在完整报文,才执行提取与裁剪操作;报文不完整时不会改动缓冲区,保障解析逻辑安全可靠。
这套编解码属于工业网络开发解决粘包半包的经典实现,HTTP、各类 RPC 协议底层也大量借鉴 "长度 + 分隔符" 这一类设计思路。
五.、面试高频核心考点总结
- 什么是 TCP 粘包/半包问题?产生原因以及对应的解决思路?
- 粘包/半包问题本质来源于 TCP 面向字节流的特性:多条独立业务报文在传输时发生粘连合并(粘包),或是单条业务报文被拆分成多段(半包),接收方读取数据后,无法自动区分每一条业务报文的边界,不能直接解析出完整业务数据。
- 产生根源:TCP 协议只负责字节流的可靠交付,完全不感知上层应用的报文边界。应用层多次调用 write 发送的数据,内核有可能合并为一个 TCP 段发出;内核收到的多条 TCP 段,也可能被应用层一次 read 全部读到缓冲区,由此产生粘包、半包现象。
- 解决方案:必须在应用层自定义协议 来划定报文边 界,常见实现包含**「报文长度 + 分隔符」、固定长度报文、特殊结束标记等** 方式;其中报文长度 + 分隔符是工业网络编程里的标准实现方案。
- write /send 函数调用返回成功,是否代表数据已经送达对端?
- 并不代表数据已经发送到对端主机。send/write 返回成功,仅代表数据已经从进程的用户空间拷贝进入 socket 内核发送缓冲区,数据此时还驻留在本机内核,并没有真正推向网络,更不能代表对端已经完成接收。真正的数据发包由内核 TCP 协议栈异步完成;只有收到远端返回的 ACK 确认应答,才能确认数据已经被对端内核接收。
- TCP 为什么能够实现全双工通信,底层依靠什么机制?
- TCP 实现全双工的底层支撑:内核为每一个 TCP socket 维护两套相互隔离的缓冲区,独立的发送缓冲区与接收缓冲区。发送和接收两条路径互不阻塞、互不干扰,内核可以同时完成收、发处理。应用程序可以在同一个 socket 描述符上同时执行读、写操作,由此实现全双工通信,双方可以同时收发业务数据。
- 序列化与反序列化承担什么作用?为什么网络编程不建议直接传输内存结构体?
- 核心作用:将程序内存中的结构化对象,转换成可以在网络上传输的字节流;接收端再通过反序列化还原出原始业务对象,实现跨平台、跨语言的数据交互,保证两端对数据的理解保持一致。
- 不建议直接发送结构体的原因:(1)不同 CPU 架构、编译器的内存对齐策略存在差异,两端解析出来的数据会错乱;(2)结构体属于语言绑定的数据结构,很难做到跨语言交互;(3)结构体中若包含指针成员,网络只会传递指针地址值,对端进程地址空间完全不同,无法访问对应内存,会引发严重错误。
- TCP 本身已经提供可靠传输,为什么还需要我们自己设计应用层协议?
- TCP 提供的可靠,仅仅保障字节流能够有序、无差错、无重复地从一端交付到另一端。但 TCP 只是字节流搬运工具,它不会识别业务报文边界,看不懂字节对应的业务含义,更不会保证应用层业务报文的完整性。
- 应用层协议的意义:约定业务数据格式、报文边界、交互状态与错误处理规则,让通信两端可以从连续的字节流中拆分出一条条具备业务含义的报文,支撑上层业务逻辑正常运行。
结束语
本篇完整走完了应用层自定义协议从原理剖析到代码落地的全过程。我们跳出单纯调用 Socket API 的表层使用,理解了 TCP 字节流带来的粘包半包根源,厘清 read、write 等 IO 调用的内核本质,也透过
sk_buff看清了内核中 TCP 报文的流转与优化逻辑。序列化与反序列化是网络通信的基石,它解决了结构化数据跨平台传输的难题;而报文编解码,则负责在连续的字节流中切割出一条条完整业务报文。结合 JsonCpp 实现的整套协议,正是把理论转化为工程代码的实践体现,其中 "长度 + 分隔符" 的处理思路,也大量存在于 HTTP、各类 RPC 等成熟工业协议当中。很多人会误以为 TCP 的可靠就等同于业务通信的可靠,读完本文应当明白:TCP 只保证字节层面的传输质量,报文边界、业务格式、数据跨平台兼容,都需要应用层协议自己去定义和保障。