[Linux]从手写报头到内核套路:序列化、反序列化与自定义协议全链路

LinuxTCP 套接字编程:从文件描述符抽象到并发服务模型的内核视角-CSDN博客

👆上一篇文章里,一条 socket() → bind() → listen() → accept() 的TCP连接全程。连接建好了,两个进程可以互相发字节了

然后呢?

然后你会撞上一个尴尬的事实:你能把字节送到对面,却送不了"意思" 。你手里是一个 C++ 对象,里面有三个字段;网线上跑的是一串没有类型、没有边界、没有结构的字节。这中间隔着的东西,就是本文要讲的序列化、反序列化与自定义协议。

  本文不堆知识点,而是沿着一条推理链往前走:先看全貌 → 再看内核给的到底是什么 → 字节流的边界从哪里来 → 帧里的内容怎么摆 → 用什么格式摆 → 代码怎么分层装起来 → 怎么跑起来亲眼看见它 → 最后下沉到内核,看同一套思想在那里长什么样。

卯榫结构下,知识点环环相扣,如沐春风。


目录


一、站到一万米高空:一次「10 20 +」的完整旅行

1.1 我们要造的东西

 先把目标说清楚:一个网络计算器 。客户端输入 10 20 +,服务端算出 30,把结果送回来。就这么一句话。

这一节我们先把地图摊开,看清全貌,后面再一层一层往下钻。这张图就是全文的目录,建议先花两分钟看明白它。

1.2 全景图:从键盘敲下回车,到结果打印出来

  把这张图从头看到尾,你会发现一件很有意思的事:每一层都在做同样两件事------往下走的时候"加东西"(加结构、加报头),往上走的时候"减东西"(拆结构、去报头)。

 这件事在网络里有正式的名字,叫封装与解封装(Encapsulation / Decapsulation) 。你其实早就见过它了:应用层加 len\r\n 报头,传输层加 TCP 报头(端口、序号、校验和),网络层加 IP 报头(源 IP、目的 IP、总长度),链路层加以太网帧头帧尾。每一层都认为自己送的是"数据",每一层都要为下一层补上自己那一段说明。

1.3 图里藏着三次"变形",也就是本文的三条主线

 再仔细看这张图,真正容易出错、也最值得讲的是中间那三次"变形",它们对应三个完全不同的问题:

 第一次变形(①→③):对象怎么变成字节? 内存里的 Request 有三个字段,要变成一串能上网的字节。这一步叫序列化,它解决的是"内容怎么摆"的问题。

 第二次变形(③→⑧):一串字节流,怎么知道哪一段是一条消息? 对面的 TCP 给你的是一条连续不断的字节河,它不告诉你"这一条消息到这里结束了"。这一步叫定界 ,它解决的是"边界在哪里"的问题,由我们的应用层协议(Packet / Unpack)负责。

 第三次变形(⑧→⑩):字节怎么变回对象? 对面收到一串字节,要还原成和自己一模一样的 Request。这一步叫反序列化 ,它比序列化多了一份麻烦------输入是不可信的。

 这里必须强调一个极易被混淆的点:定界和序列化是两件互相正交的事,只是经常被一起提起。

  • 定界回答的是"这一帧从哪里开始、到哪里结束"------像是"船多大";
  • 序列化回答的是"这帧里面的字节怎么摆,对面才能还原出同样的对象"------像是"船上装什么"。

 不信你看:Packet() 里那行 std::to_string(json_string.size()) + gsep + json_string + gsep,它对 json_string 的内容一无所知 ------里面装 JSON 也好,装图片也好,装纯文本也好,加头逻辑一个字都不用改。反过来,Serialize() 也完全不管自己产出的字符串最后会被谁加头、被切成几段发出去。两个模块各自只关心自己的那一半,这才叫"正交"。

1.4 本文的下行路线

 全景看完了,接下来我们由大到小、由外到内,一层层往下钻:

 内核给的到底是什么(第二章)

→ 边界怎么画出来(第三章)

→ 格子里的内容怎么摆(第四章)

→ 用什么格式摆最划算(第五、六章)

→ 我们自己的报文长什么样(第七章)

→ 代码怎么分层装起来(第八章)

→ 跑一遍看效果(第九章)

→ 同一套思想在内核里的四个实例(第十章)

→ 还没填上的坑(第十一章)

→ 速查表(第十二章)。

 现在出发。第一站不是代码,而是内核------因为在动手写协议之前,我们得先搞清楚:我们的数据,在这条路上到底要经过哪些"关卡"。


二、把镜头拉近:内核为什么只肯给你一串字节

2.1 承上启下:先承认一个反直觉的事实

 前面我们画了全景图,看到数据从用户程序往下走,穿过内核,穿过网线,再进到对面的内核和用户程序。

 接下来我们要看的第一件事,可能会颠覆你的一个直觉:当你调用 write() 或 send() 成功返回时,你的数据根本还没有发到网络上。

 write() 返回成功,只意味着"我把数据交给了操作系统",不意味着"对端收到了"。那数据去哪了?它进了内核里的一个缓冲区 。这一节我们就把这个缓冲区讲清楚------因为它是后面所有问题的源头:粘包、半包、字节流没有边界,全都从这里长出来。

2.2 传输层的两对缓冲区:write 和 read 的本质是拷贝

 先记住两条关于 TCP 套接字的基本事实。

 第一条:TCP 套接字是全双工的。 一个文件描述符,既能读也能写,互不干扰。原因很简单------通信的双方,各自都有一对缓冲区:

复制代码
【客户端内核】                        【服务端内核】
   发送缓冲区     ──────────────→      接收缓冲区
   接收缓冲区     ←──────────────      发送缓冲区
       ↑                                   ↑
用户 write 往里放                    用户 read 从里拿
用户 read 从里拿                     用户 write 往里放

 第二条:write 和 read 的本质,都是"拷贝",不是"发送"和"接收"。

  • 你调用 write(fd, buf, n),实际发生的是:把用户态 buf 里的 n 个字节,拷贝进内核的发送缓冲区。
  • 你调用 read(fd, buf, n),实际发生的是:把内核接收缓冲区里的数据,拷贝到用户态 buf 里。

 注意这个措辞的差别:真正的"发送"动作发生在内核里,由操作系统自主决定------什么时候发、一次发多少、出错了怎么办(重传、拥塞控制),这些全是内核的事,你的程序插不上手。

 更准确地说:内核里管这件事的,是网络层 。所以"两个进程在通信"这个说法,从系统层面看并不准确------准确的说法是:两端的操作系统在替两个进程通信。你的进程只负责把数据交给自己的操作系统,以及从自己的操作系统那里取走数据。

2.3 换一个视角:write/read 其实是一个生产者-消费者模型

 把上面的机制抽象一下,你会发现这是一个极其熟悉的模型:

  • 生产者 :调用 write 的用户进程。它往缓冲区里"放"数据。缓冲区满了怎么办?睡下等待 ------这就是 write 阻塞的原因。
  • 消费者 :内核里的协议栈。它从缓冲区里"取"数据,交给网卡发出去。缓冲区空了怎么办?睡下等待。
  • 数据从空变非空时,唤醒消费者;从满变不满时,唤醒生产者。

 所以 write 和 read 为什么会阻塞?本质是用户进程和操作系统之间的一次同步。 你写下的每一行 send(),背后都是一次跨特权级的、带条件变量的握手。

 这个模型后面还会反复出现:等一下你会看到,我们在用户态也会造一个缓冲区 (inbuffer),用完全一样的"生产者-消费者"模式来对付粘包和半包。内核在宏观上做的事,我们在微观上又做了一遍。

2.4 报文在内核里长什么样:sk_buff 与两条队列

 现在往缓冲区里再看深一层。

 缓冲区里躺着的是什么?是一个一个的报文 (从网卡收上来的,或者准备往网卡发出去的)。但内核不可能把"一堆字节"随便堆在那儿------它必须能管理这些报文:谁先谁后、属于哪个连接、该交给哪个协议处理、该往哪块网卡发。

 于是内核设计了一个结构体来描述"一个正在内核里流动的网络包",它叫 sk_buff(socket buffer)。而所有的报文,被组织成两条队列,挂在 socket 上:

复制代码
struct sk_buff_head sk_receive_queue;   // 接收队列:从网卡收上来、等着被用户 read 走的报文
struct sk_buff_head sk_write_queue;     // 发送队列:用户 write 进来、等着被协议栈发出去的报文

 这里先只建立三个印象,第十节我们再把这个结构体彻底拆开:

  1. 内核里没有"字节流",只有"包"。 所谓字节流,是内核把一个个收到的报文按顺序拼起来、再交给你的结果;
  2. 每个包都是一个 sk_buff 对象,它不复制数据,而是用几个指针"圈"出数据在内存里的范围;
  3. 排队是内核管理报文的基本手段,收有收的队,发有发的队。

2.5 三条结论,以及本章真正的"问题"

 把这一章的东西收束成三条结论:

 结论一:TCP 支持全双工,通信双方各有一对缓冲区。

 结论二:从系统级角度看,用户 write 是把数据拷进内核发送缓冲区,用户 read 是把数据从内核接收缓冲区拷出来------这本质是一个生产者-消费者模型;write/read 的阻塞,就是用户与操作系统的同步。

 结论三:用户调用 write 成功返回之后,数据还没有出现在网络上。什么时候发、发多少、出错怎么办,由发送方操作系统自主决定。

 现在,把结论三再往下推一步,本章真正的"问题"就浮出来了:

 既然"什么时候发、发多少"完全由操作系统决定,那么应用程序写下去的"消息边界",内核是不知道、也不关心的。

 你连续 send() 了三次、每次 10 字节。在内核眼里,那只是"我给缓冲区里塞了 30 个字节"。它完全可能把这 30 字节合成一个包发出去,也可能因为 MSS 限制切成 8+22 两个包,甚至可能因为你前面恰好还有别的数据,一起打包发走。

 到了对面,用户进程调用 read() 拿数据时,拿到的就是内核交给它的一段字节 ------这段字节可能正好是一条消息,可能是三条消息粘在一起(粘包 ),也可能只有半条甚至半条的半条(半包)。

 这就是网络编程里那个著名的"粘包问题"。

2.6 给"粘包"正个名

 必须说清楚:"粘包"这个词其实并不严谨。 TCP 根本没有"包"的概念,它眼里的数据就是一条没有缝隙的字节流;既然没有"包",也就无所谓"粘不粘"。

 准确的说法是:字节流不保留消息边界,而应用层需要边界。

 顺带回答一个高频疑问:UDP 有粘包问题吗?没有。 因为 UDP 是面向数据报的(SOCK_DGRAM),它保留消息边界 ------你一次 sendto 发出去的是一个完整的数据报,对面一次 recvfrom 就收到一个完整的、边界清晰的报。代价是:UDP 会丢包、会乱序、超长会被截断,它只是不给你"拼不齐"的烦恼,却给了你别的烦恼。

 这就是为什么"用 TCP 就必须自己写应用层协议,用 UDP 就不用"------因为边界这件事,TCP 拒绝替你管,UDP 替你管了。

2.7 承上启下

 问题摆明了:TCP 只给字节流,边界必须由应用层自己定义。

 那么下一个问题立刻来了:边界该怎么定义? 这是下一章的主题。


三、给字节流画格子:应用层协议登场

3.1 三种定界方案,以及为什么最终选"长度 + 内容"

 要让接收方能判断"这一帧到哪里结束",工程上其实只有三条路可走。我们一条条看,顺便看看每条路的代价。

 方案一:定长。 双方约定每条消息固定 100 字节,发送方不够就补空格,接收方每次都读 100 字节。这个方案简单到不需要任何解析代码,但极其浪费------发一个 1+1 也要占 100 字节;而且一旦哪天需要发的消息超过 100 字节,整个协议就得推翻重来。它只适合"消息长度天然固定"的场景。

 方案二:特殊分隔符。 比如约定用 \n 结尾,接收方一直读到 \n 为止,就算一帧。很多文本协议(HTTP 的头部、SMTP)用这种思路。但它天生带着一个麻烦:万一消息内容里本身就包含这个分隔符呢? 那你必须引入"转义"机制(把内容里的 \n 写成 \\n),发送方要转、接收方要还原,转义规则一旦复杂起来协议就变得很脆;而且解析时得逐字节扫描,效率也不高。

 方案三:长度 + 内容(自描述长度)。 在每条消息前面先说清楚"我这条正文有多少字节",接收方先读出长度,再按长度精确取走正文。

 三个方案对比下来,只有第三种既支持任意长度、又不需要转义、还能一次算出边界在哪个位置 ------它不需要扫描内容,读一个长度就能把边界"算"出来。所以它成了工业界的绝对主流:HTTP 的 Content-Length、UDP 报头的长度字段、netlink 的 nlmsg_len,全是这个思路。

 我们的项目选的就是方案三。

3.2 一帧长什么样:Packet() 加头

 先看这个方案在源码里的落地。

复制代码
const std::string gsep = "\r\n";   // 分隔符:回车+换行,用来隔开"长度"和"正文"

 然后是加头函数。注意它的名字叫 Packet(打包),不叫 Serialize(序列化) ------这个命名很讲究,前面说过,它俩是两件不同的事:Packet 只负责"画格子",不关心格子里面装的是什么。

复制代码
std::string Packet(const std::string &json_string)
{
    // 把正文的长度转成十进制字符串,前面拼上,再前后各加一个 \r\n
    // 例如正文 "{\"left\":10}" 有 10 字节,这里就产出 "10\r\n{\"left\":10}\r\n"
    return std::to_string(json_string.size()) + gsep + json_string + gsep;
}

 于是一条完整的帧,由四段拼成:

复制代码
 32  \r\n  {"left":10,"right":20,"oper":43}  \r\n
 ↑      ↑                 ↑                    ↑
长度串  分隔符           JSON 正文          结束分隔符
(2字节) (2字节)          (32字节)             (2字节)

整帧总长 = 2 + 2 + 32 + 2 = 38 字节

 这里有三个细节值得掰开讲,每个都是真实的设计取舍。

 细节一:为什么长度用十进制字符串("32"),而不是 4 字节二进制?

 用二进制当然更紧凑(4 字节 vs 2~3 字节)、解析也更快(一条指令读出来)。但它有代价:必须约定字节序 (谁来 htonl?大端还是小端?),而且抓包时看到的是一串乱码,调试时很痛苦。用十进制字符串,好处是人能直接看懂 、跨平台没有字节序问题,代价只是多几个字节、解析时要 stoi。

 对于一个教学项目、乃至很多真实的文本协议(HTTP 的 Content-Length 就是十进制字符串),这个取舍完全合理。记住这个对比,第十节你会看到内核在 UDP 报头里做的正好是相反的选择,而且那个选择同样合理。

 细节二:为什么分隔符用 \r\n 而不是 \n?

 \r\n(回车+换行)是 HTTP、SMTP 等经典文本协议的传统,好处是 telnet 上去手工调试时视觉上更清晰。但要老实说一句:在"长度前缀"方案里,分隔符不是必需的 。长度字段已经能算出一帧到哪里结束了,解析器非要去找 \r\n,只是为了给"长度"和"正文"之间留一个显眼的视觉分界罢了。

 细节三:正文后面那个 \r\n 是冗余的。

 同理,长度已经决定正文到哪结束,尾部再补一个 \r\n 在信息论上是多余的。保留它纯粹是为了抓包时帧的尾部肉眼可见。但这份"美观"是有代价的------解析时算总长度必须把它算进去:

复制代码
int total = lenstr.size() + len + 2 * gsep.size();
//                                      ↑ 注意是 2,不是 1
// 一个 \r\n 在长度和正文之间,一个 \r\n 在正文末尾,两个都要算进整帧长度

 一旦这里少算一个 \r\n,后面所有的解析都会错位------这是这类协议最典型的翻车方式。

 再加一个小注:jsoncpp 的 FastWriter 生成的 JSON 文本末尾会自带一个 \n 。这个 \n 同样算在 json_string.size() 里,同样计入长度字段。所以如果你发现长度比肉眼数的多 1 字节,不用慌,那是 FastWriter 的"历史包袱"。

3.3 去头比加头难:Unpack 为什么必须返回三个状态

 加头很容易,去头才是难点 ------因为接收缓冲区里的数据随时可能是不完整的。

 在动手写代码之前,先想清楚一个问题:如果 Unpack 只返回 true/false,够不够用?

 不够。因为接收方会遇到三种截然不同的情况:

  1. 缓冲区里确实有一条完整报文 → 成功取走,并且要继续看还有没有下一条;
  2. 缓冲区里的数据还不够拼出一条完整报文 → 什么都不能做,等下一次 recv 追加数据;
  3. 数据本身是坏的(比如长度字段是乱码)→ 这是真出错了。

 如果只有 true/false,"数据还不够"和"真出错了"就分不开:

  • 把"还不够"当成"出错"处理 → 收到半包时误判,可能直接关闭连接、丢弃缓冲区里已经辛苦攒好的数据;
  • 把"出错"当成"还不够"处理 → 程序死死卡在那里,等一个永远不会到来的完整报文。

 所以这里必须用三态返回值。 源码里的约定是这样的:

复制代码
// 返回值语义(非常关键,务必记住):
//   ret > 0 :成功解出一条报文,*json_string 里是正文
//   ret == 0:数据还不够,这不是错误!什么都不做,等下次 recv
//   ret < 0 :真出错了
int Unpack(std::string &packet, std::string *json_string);

 有了这个约定,我们来看完整实现,每一步都配上人话:

复制代码
int Unpack(std::string &packet, std::string *json_string)
{
    // 缓冲区是空的 → 没数据可解,等同于"还不够"
    if (packet.empty())
        return 0;

    // 调用方连输出参数都没给 → 这是编程错误,不是数据问题
    if (json_string == nullptr)
        return -1;

    // 第一步:找第一个 \r\n,它的位置就是"长度串"的结尾
    auto pos = packet.find(gsep);
    if (pos == std::string::npos)
        return 0;   // 连分隔符都没找到 → 报头都没收全,等下次

    // 第二步:把分隔符前面那截取出来,就是长度串,比如 "32"
    std::string lenstr = packet.substr(0, pos);
    // ⚠️ 源码在这里留了一行 TODO:
    //    "lenstr 合法性判断,lenstr -> 123 345"
    //    也就是说,作者自己知道这里缺了校验。这个坑第十一章专门讲

    // 第三步:字符串长度 → 整数长度
    int len = std::stoi(lenstr);

    // 第四步:算出一整帧的总字节数
    //   lenstr.size()   长度串本身占的字节("32" 是 2 字节)
    // + len             正文占的字节
    // + 2*gsep.size()   两个 \r\n 各占 2 字节,共 4 字节
    int total = lenstr.size() + len + 2 * gsep.size();

    // 第五步:缓冲区里的字节够不够一整帧?
    if (packet.size() < total)
        return 0;   // 不够 → 半包,等下次 recv,一个字节都别动它

    // 第六步:从第一个 \r\n 之后开始,精确取 len 个字节,这就是正文
    *json_string = packet.substr(pos + gsep.size(), len);

    // 第七步:把这整帧从缓冲区里删掉
    //   erase(0, total) 的意思是"从下标 0 开始,删掉 total 个字节",
    //   剩下的内容(可能是下一条报文的开头)会自动顺移上来,留给下一轮
    packet.erase(0, total);

    return 1;   // 成功解出一条
}

 这段代码里有两个设计精华,值得单独拎出来记住。

 精华一:return 0 的时候,缓冲区一个字节都不动。

 不管是"报头没收全"还是"正文没收全",函数都原样返回,packet 纹丝不动。这个"不动"是刻意的------半包数据必须原地保留,等下次 recv 追加新数据后接着拼。 如果你在这里"好心"把已读的部分删掉,半包就永远拼不起来了。

 精华二:erase 只删已经消费掉的那一整帧。

 处理完一帧就精确删掉一帧的长度,剩下的部分(下一个报文的开头)留在缓冲区里继续等。加上前面"不够就返回"的策略,这两条合起来,实际上构成了一个生产者-消费者模型 :Recv 往缓冲区尾部追加 字节(生产),Unpack 从缓冲区头部精确摘走完整帧(消费)。缓冲区就像一条传送带。

 你看,2.3 节里内核干的事,我们在用户态又干了一遍------只是粒度从"报文"变成了"帧"。

3.4 半包的代码解法(一):Recv 里的那个 +=

 现在把镜头切到网络层,看看字节是怎么进到这条"传送带"上的。

复制代码
int Recv(std::string *out) override
{
    char inbuffer[1024];
    // 一次最多读 1023 字节(留一个字节给结尾的 '\0',后面要当 C 字符串用)
    ssize_t n = recv(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0);
    if (n > 0)
    {
        inbuffer[n] = 0;    // 手工补字符串结束符
        *out += inbuffer;   // ★★★ 注意这里是 +=(追加),不是 =(覆盖)
    }
    return n;
}

 这一个 +=,就是半包问题在代码层面的一半答案。

 假如把它写成 *out = inbuffer(每次覆盖),会怎么样?上一次 recv 收到的半条报文,会被这一次的新数据直接覆盖掉,半包永远拼不起来,粘包/半包问题永久无解。

 用 += 之后,缓冲区变成了一个会增长的累积体:

复制代码
第 1 次 recv(18字节):拿到 "32\r\n{\"left\":10,\"ri"        ← 半包
第 2 次 recv(20字节):拿到 "ght\":20,\"oper\":43}\r\n"      ← 补全了
累积后:               "32\r\n{\"left\":10,\"right\":20,\"oper\":43}\r\n"

 然后 Unpack 一算:total = 2 + 32 + 4 = 38,缓冲区正好 38 字节,够,切走。

 顺带看一个容易被忽视的细节:char inbuffer[1024] 意味着单次 recv 最多只能拿回 1023 字节 。所以哪怕你不考虑任何极端情况,只要一条 JSON 报文超过 1023 字节,它就必然被切成多次 recv 才能收完。

 这句话值得再说一遍:半包不是"可能发生的意外",而是"迟早会发生的事实"。

3.5 粘包的代码解法:while(true) 循环解到底

 半包的问题解决了(+= 攒着 + 不够就不动),接下来是它的孪生兄弟:粘包 ------一次 recv 可能带来 N 条报文(N 可以是 0、1、3、10......)。

 怎么办?循环解包,一条都不能漏。

 这就是服务端 ParseRequest 里那个 while(true) 的全部意义。我们在第八章会看它的完整实现,这里先记住它的骨架:"解包 → 反序列化 → 处理 → 打包应答"这四步,要在一个死循环里反复做,直到 Unpack 明确告诉你"没有完整帧了"(返回 0)才退出。

 这也顺带解释了一个设计细节:为什么 Unpack 的返回值里,"成功解出一条"必须是一个正数 而不是 true?因为调用方需要在一个循环里反复调用它,只有"成功/不够/出错"三态齐全,循环才能写出正确的退出条件。

3.6 局部小结与承上启下

 到这里,第一个大问题(怎么定界)彻底解决了。我们用到的全部工具就是三样:

  • Packet:加头,把正文变成 len\r\n正文\r\n 的一帧;
  • Unpack:去头,三态返回,"不够"时一个字节都不动;
  • Recv 的 += 与 while(true) 循环:分别对付半包和粘包。

 现在,缓冲区里已经能精确切出一条完整的文本了:

复制代码
{"left":10,"right":20,"oper":43}

 下一个问题顺势而来:为什么是这个样子? 为什么不是 "10 20 +" 这种更省事的写法?为什么不是干脆把内存里的 Request 结构体原封不动发过去?

 这就要请出本章标题里的另一半------序列化了。


四、格子里的内容怎么摆:序列化登场

4.1 一个天真但致命的方案

 前面我们辛苦地把边界画出来了,现在格子里躺着一串字节。问题是:这串字节应该长什么样?

 先别急着谈 JSON。假设我们什么库都不用,最直觉的做法会是什么?

 我们有一个这样的结构体(先看个大概,第七章会详细设计它):

复制代码
class Request {
public:
    int  _data_x;   // 左操作数
    int  _data_y;   // 右操作数
    char _oper;     // 运算符
};

 那么"把请求发出去"最直觉的写法就是:把这个结构体在内存里的样子,原样倒到网线上。

复制代码
// ⚠️ 危险示范,千万不要这么写
Request req(10, 20, '+');
send(sockfd, &req, sizeof(req), 0);   // 把这一块内存的字节原样倒出去

 对面收到之后,同样暴力地强转回来:

复制代码
// ⚠️ 同样危险
Request *req = (Request *)buffer;
int result = req->_data_x + req->_data_y;   // 看起来能跑?

 在同一台机器、同一个编译器、同一套编译选项下,这段代码甚至真的能跑通。可一旦环境变了,它就会以各种匪夷所思的方式崩掉。为什么?下面五条理由,每一条都足以判它死刑。

4.2 不能直接发结构体的五条理由

 理由一:内存对齐与填充字节,让"内存布局"变成一个不确定的东西。

 不用背复杂的对齐规则,看一个例子就够了:

复制代码
struct A {
    int  x;     // 4 字节
    char c;     // 1 字节
    int  y;     // 4 字节
};
std::cout << sizeof(A);   // 你猜是多少?

 很多人的第一反应是 9。但实际输出通常是 12 。因为编译器为了让 y 落在 4 字节边界上(对齐后 CPU 读取更快),会在 c 后面塞进 3 个填充字节:

复制代码
偏移:  0    1    2    3    4    5    6    7    8    9   10   11
内容: [x    x    x    x ][c ][pad pad pad][y    y    y    y ]
                            ↑ 这 3 个字节是垃圾数据,内容取决于当时内存里的残留值

 于是就出问题了:填充字节里的内容是不确定的 ,你把整个结构体发过去,等于把栈上的垃圾数据也一起发了过去。更糟的是,不同的编译器、不同的编译选项(比如 #pragma pack)、32 位与 64 位平台,填充规则都可能不一样------A 机器上 sizeof 是 12,B 机器上可能是 9,两端对不齐,解析必然错位。

 理由二:字节序(大小端)。

 int x = 10; 在内存里占 4 个字节。到底是 00 00 00 0A(大端)还是 0A 00 00 00(小端)?这取决于 CPU 架构。x86 是小端,很多 ARM 可以配置,而网络协议统一采用大端。

 你直接发内存,就等于把自己机器的字节序也一并发过去了。 如果对面是另一种字节序,它读出来的 10 会变成 167772160。

 理由三:指针。

 这一条最要命。如果结构体里有指针:

复制代码
struct BadMsg {
    char *name;   // 指针
    int   len;
};

 你发过去的 8 个字节,是指针的值 ------也就是"某个地址"。但这个地址是你进程虚拟地址空间里的地址,在对方进程里毫无意义,甚至对方进程的那个地址上压根没有映射物理内存。

 指针是不能被序列化的,它没有跨进程、跨主机的意义。 能被传输的只有"数据本身",不能是"数据在哪"。

 理由四:类型宽度不统一。

 long 在 32 位系统上是 4 字节,在 64 位 Linux 上是 8 字节,size_t 同理。直接把内存倒过去,两端对"这个字段多长"的理解就不一致。

 理由五:演进与跨语言。

 今天 Request 有三个字段,明天产品说"再加一个优先级字段"。你在结构体末尾加一行,所有老客户端立刻全废------它们发来的字节少 4 个,新服务端按新布局解析就会越界。

 更别提跨语言:服务端是 C++,客户端是 Python 或 Java(这太常见了)。Python 根本不认识 C++ 结构体的内存布局,你怎么把 Request 的对象内存交给它?

4.3 序列化的定义:为数据造一门"共同语言"

 把上面的教训总结成一句话:

 序列化(Serialization):把内存中的对象,转换成一种"与平台无关、与语言无关"的字节序列。  反序列化(Deserialization):把这个字节序列,还原成内存中的对象。

 关键词是**"与平台无关"**。序列化要做的,正是把 4.2 里的五个问题一个个消灭掉:

  • 用明确的文本格式 (或明确的字节序约定)消灭对齐与字节序问题;
  • 用不带动地址的纯数据 消灭指针问题;
  • 用自描述的键值结构 ("left": 10,键名就写在数据里)消灭"两端结构体布局必须完全一致"的问题------对面多认一个字段、少认一个字段,都不影响它读到 left;
  • 用跨语言的通用格式 消灭语言壁垒。

 所以序列化的本质,是为数据定义一门"共同语言":数据在两台机器之间传递时,不再说各自的方言(本机内存布局),而是统一说普通话(约定的序列化格式)。

4.4 反序列化:逆运算,但多了一个"信任问题"

 反序列化看起来只是"反着来一遍",但它比序列化多了一层麻烦:输入是不可信的。

 序列化的时候,数据是你自己的对象,你有完全的控制权,怎么写都是对的。反序列化的时候,数据是从网络上来的,它可能是:

  • 残缺的 (半包)------这个我们已经在 Unpack 那里拦住了;
  • 格式错的(对面程序有 bug,或者根本不是我们的客户端);
  • 恶意的(有人手工构造了一个畸形报文)。

 所以反序列化的第一件事不是"解析",而是"先判断能不能解析"。这也是为什么源码里 Deserialize 的返回值是 bool:

复制代码
bool Deserialize(std::string &in);   // 返回 false = 解析失败,别信这条数据

 这个 bool 不只是个返回值,它是一条纪律:凡是来自网络的输入,在使用之前都必须校验。

4.5 插叙:内核其实一直在做序列化------从 sockaddr_in 说起

 道理讲完了,但在挑序列化格式之前,我想先插一段题外话。因为它能给你一个最朴素的参照系:序列化不是"网络编程里的新东西",而是"数据在不同表示形式之间转换"这件事的通用名字------内核自己就一直在做。

 回忆上一篇文章讲过的 bind() / connect(),它们都需要一个地址参数。而"地址"这个东西,在我们的程序里同时以三种形态存在:

  • 形态一,给人看的 :字符串 "192.168.1.1" 和端口号 8080;
  • 形态二,给内核看的 :struct sockaddr_in,一块固定布局的二进制内存;
  • 形态三,给网线看的:一串大端字节序的字节。

 这三种形态之间的每一次转换,本质上都是一次手工的序列化或反序列化。我们一个个看。

 先说 sockaddr_in 这个结构体。它为什么长这样?它要解决的是这样一个问题:

 socket API 必须支持多种协议族,它们的地址格式各不相同。但 bind()、connect() 这些函数的参数类型必须是统一的 ------否则每支持一种协议,就要新增一套 API。怎么办?答案是定义一个"通用地址"struct sockaddr,让每种具体协议的地址结构体都跟它一样大,用的时候强转。

 这个需求直接决定了它的字段:

复制代码
struct sockaddr {              // 通用地址(相当于"基类",本身几乎不用)
    sa_family_t sa_family;     // 2 字节:地址族
    char        sa_data[14];   // 14 字节:地址内容,具体含义由协议族决定
};                             // 总共 16 字节

struct sockaddr_in {           // IPv4 专用地址
    sa_family_t    sin_family; // 2 字节:地址族,填 AF_INET
    in_port_t      sin_port;   // 2 字节:端口号 ------ 必须是【网络字节序】
    struct in_addr sin_addr;   // 4 字节:IPv4 地址 ------ 必须是【网络字节序】
    char           sin_zero[8];// 8 字节:纯填充!没有任何数据含义
};                             // 2+2+4+8 = 16 字节,和 sockaddr 正好一样大

 重点看最后那个 sin_zero[8]:它是纯粹为了"凑大小"而存在的。 前三个字段加起来才 8 字节,离 16 还差 8,所以补 8 个字节。补到 16 之后,sockaddr_in* 才能安全地强转成 sockaddr* 传给系统调用。

 你看,这个字段的存在理由不是"存了什么数据",而是"为了让布局对齐、让类型转换安全"------设计结构体的时候,先想清楚"要解决什么问题",字段自然就长出来了。 这正是我们在 sockaddr_in 里看到的,也是后面设计 Request / Response 时要用的方法。

 有了它,源码里那个宏就好理解了:

复制代码
// 把具体协议地址强转成通用地址类型,喂给 socket 系统调用
#define CONV(address) ((struct sockaddr *)address)

 现在再看那三次转换,你会发现它们全是"序列化"。

 转换一:主机字节序 → 网络字节序。

复制代码
_address.sin_port = htons(_port);   // h->n:host to network,short(16位)
// 在小端机器上,htons 实际做的事就是"把两个字节翻个面"
// 例如端口 8080 = 0x1F90,内存里小端是 [90 1F],翻成 [1F 90] 才是网络序

 为什么要有这一步?因为网络协议统一规定用大端 ,而 x86 是小端。htons 做的正是"把我这台机器内部的表示,转成网络上的通用表示"------这不是序列化是什么?数据没变(还是 8080),只是表示形式变了。

 转换二:字符串 IP → 4 字节二进制。

复制代码
inet_pton(AF_INET, ip.c_str(), &(_address.sin_addr));
// p = presentation(表示形式,人看的字符串)
// n = numeric(数值形式,网线上跑的 4 字节)
// "192.168.1.1" → [C0 A8 01 01]

 转换三:4 字节二进制 → 字符串 IP(反序列化)。

复制代码
char ipstr[32];
inet_ntop(AF_INET, &(_address.sin_addr), ipstr, sizeof(ipstr));
_ip = ipstr;
_port = ntohs(_address.sin_port);   // 网络序 → 主机序,反向再翻一次面

 注意源码里把 inet_ntoa 注释掉了,改用 inet_ntop。原因上一篇文章讲过:inet_ntoa 用的是静态缓冲区,多线程下会互相覆盖,是个线程安全的坑 ;inet_ntop 由调用方提供缓冲区,安全。这个改动本身,就是"反序列化要注意线程安全"的一个真实实例。

 插叙小结一下:序列化不是网络编程的专利,它是"数据在不同表示形式之间转换"的通用名字。你写 htons、inet_pton 的时候,其实已经在做序列化了。区别只在于:sockaddr_in 的格式是固定的、位精确的、二进制 的,而我们接下来要选的 JSON 格式是灵活的、文本的、自描述的。

 顺便留一个思考题:如果把 sockaddr_in 改用 JSON 来传,它会变成 {"family":"AF_INET","port":8080,"ip":"192.168.1.1"}------可读性暴涨,体积也暴涨,而且内核根本不可能去解析文本。这就是"位精确二进制"与"自描述文本"的分界线:越靠近硬件、越追求效率的地方,越用前者;越靠近人、越追求灵活的地方,越用后者。

4.6 承上启下

 道理讲完了。现在的问题是:用什么格式来做序列化? 自己拼字符串行不行?JSON、XML、protobuf 又该怎么选?这是下一章的内容。


五、挑一门"共同语言":手搓、JSON、XML 与 protobuf

5.1 先看"自己做"为什么不可以

复制代码
// 1. 自己做 - 不建议的!
// 2. 用别人的 - json protobuf xml

 "自己做"长什么样?大概就是把三个字段拼成一个字符串:

复制代码
// 手工拼接:用空格分隔,运算符放中间
// 10 20 '-'  →  "10 - 20"
std::string s = std::to_string(_data_x) + " " + _oper + " " + std::to_string(_data_y);

 对面再用 sscanf 或者 stringstream 把它拆开。这在小项目里能跑,但它有一堆隐患:

  • 分隔符冲突:万一哪个字段里出现了空格呢?我们现在三个字段分别是整数和单个字符,恰好安全;但业务一变(比如要传一个字符串参数),立刻爆炸。
  • 解析歧义 :"10 - 20" 和 "10 -20" 是一个意思吗?负数怎么处理?
  • 没有字段名:位置就是含义。想加字段?只能加在末尾,中间插一个就全乱套。
  • 没有类型 :"10" 到底是数字还是字符串?不知道。
  • 解析代码很脆 :sscanf 的返回值、stringstream 的失败状态,全都得小心处理。

 更麻烦的是:每加一个协议,你就要手写一套这样的解析代码。 这是典型的"重复造轮子,并且造不好"。

5.2 我们为什么选 JSON

用 JSON 纯粹是图方便。日志里直接能读明文,不用像 protobuf 那样看着乱码还得折腾解码工具;不用维护 .proto 和代码生成,加字段直接改。体积在这场景根本没影响,一次请求就几十字节,多几个键名还没 TCP/IP 头大,完全无所谓。

 选择序列化方案的判断标准,从来不是"哪个更先进",而是"我的场景最在意什么"。 这就是为什么今天 JSON 和 protobuf 都活得好好的------它们服务的是完全不同的场景。

5.3 承上启下

 选定了 JSON,接下来的问题就非常具体了:在 C++ 里怎么用 JSON? 这是下一章的内容。


六、把库用起来:jsoncpp 三件套

 C++ 标准库没有 JSON(这是 C++ 程序员长期的痛),我们用第三方库 jsoncpp 。Ubuntu 下 sudo apt install libjsoncpp-dev 安装,编译时加 -ljsoncpp。

复制代码
#include <jsoncpp/json/json.h>
// 部分系统上路径是 <json/json.h>,取决于安装方式

 这个头文件路径是"我代码明明没问题却编译不过"的头号原因。 如果报 No such file or directory,先用 find /usr/include -name "json.h" 找一下实际位置。

 jsoncpp 的接口非常精简,核心就三样东西:一个容器(Json::Value)、一个读工(Json::Reader)、一个写工(Json::Writer)。

6.1 Json::Value:为什么需要一个"万能类型"

 这是 jsoncpp 里最重要的类型。理解它的关键,是先理解它要解决的矛盾:

 C++ 是静态类型 语言------变量声明的时候类型就定死了,int 就是 int,std::string 就是 std::string。  但 JSON 是动态类型 的------同一个 {} 里面,"age" 是数字,"name" 是字符串,"scores" 是数组,"info" 又是另一个对象。类型是跟着数据走的,不是跟着声明走的。

 这个矛盾怎么解?答案是:造一个"什么都能装"的容器类型,自己记录"我现在到底是什么"。 Json::Value 扮演的就是这个角色------它内部存了一个类型标记,你往里塞什么,它就变成什么类型。

 用法极其直观:

复制代码
Json::Value root;                 // 创建一个空的 Value
root["name"] = "zhangsan";        // 塞字符串 → 它变成 object,并多了一个键 "name"
root["age"]  = 18;                // 塞整数
root["sex"]  = "man";
// 现在它就等价于:{"name":"zhangsan","age":18,"sex":"man"}

 它可以嵌套,因为 Json::Value 本身又能作为值塞进另一个 Json::Value:

复制代码
Json::Value stu;                  // 学生:一个对象
stu["name"] = "zhangsan";
stu["age"]  = 18;

Json::Value score;                // 成绩:另一个对象
score["math"]    = 88;
score["chinese"] = 98;

Json::Value one;                  // 把两个对象组装成一个大对象
one["who"]   = stu;
one["score"] = score;
// 结果:{"who":{"name":"zhangsan","age":18},"score":{"math":88,"chinese":98}}

 还能当数组用------append 就是往数组末尾追加元素:

复制代码
Json::Value root;
for (int i = 0; i < 10; i++) {
    root.append(one);      // 追加 10 次,root 变成一个 JSON 数组
}

 取值时用 asXxx() 系列方法做转换:

复制代码
std::string name = root["name"].asString();   // 当字符串读
int         age  = root["age"].asInt();       // 当整数读

 这里有一个必须知道的特性:asInt() 做的是"尽力转换",不是"严格校验"。 如果这个键根本不存在,root["missing"] 会返回一个"空 Value",asInt() 给你 0,asString() 给你 ""------不报错,也不抛异常。

 这既是方便(不用写一堆空值检查),也是陷阱:字段名拼错一个字母,你不会得到任何报错,只会得到一个 0。 所以反序列化之后做一次字段存在性检查(root.isMember("left"))是个好习惯。

6.2 反序列化工具:Json::Reader

 从字符串解析出 Json::Value,用 Json::Reader:

复制代码
Json::Reader reader;              // 造一个解析器
Json::Value  root;                // 准备接结果
bool parseok = reader.parse(json_string, root);   // 字符串 → Value
if (!parseok) {
    // ★ 关键:一定要检查返回值!
    // 网络上传来的数据是不可信的,解析失败是完全正常的事情
    return false;
}
// 解析成功,现在可以取值了
std::string name = root["name"].asString();

 reader.parse() 还有一个重载可以拿到具体的错误信息:

复制代码
Json::Value root;
Json::Reader reader;
JSONCPP_STRING err;               // 老版本是 std::string
reader.parse(json_string, root, err);
// err 里会有形如 "* Line 1, Column 12  Missing ',' or '}'" 的定位信息

 这在调试序列化问题的时候极其有用------比"解析失败了"这四个字有用一万倍。

6.3 序列化工具:三种 Writer,以及怎么选

 反方向,把 Json::Value 变成字符串,jsoncpp 提供了三种 Writer,区别只在"输出格式好不好看"。

 第一种,Json::FastWriter------紧凑输出

复制代码
Json::FastWriter writer;
std::string s = writer.write(root);
// 输出:{"name":"zhangsan","age":18}
// ★ 没有多余的空白和换行,一行搞定,最省流量
// ★ 注意:结尾会带一个 '\n'(历史包袱,接收方要么容忍,要么 trim 掉)

 第二种,Json::StyledWriter------格式化输出(给人看的)。

复制代码
Json::StyledWriter writer;
std::string s = writer.write(root);
// 输出:
// {
//    "name" : "zhangsan",
//    "age" : 18
// }
// 适合打日志,不适合发网络(多出来的空格换行全是流量)

 第三种,Json::StreamWriterBuilder------新版推荐方式。

复制代码
Json::StreamWriterBuilder wbuilder;
std::unique_ptr<Json::StreamWriter> swriter(wbuilder.newStreamWriter());
std::stringstream ss;
swriter->write(root, &ss);
std::cout << ss.str() << std::endl;
// 优点:可以通过 wbuilder 的 settings 精细控制缩进、精度等参数

 怎么选?一句话:要发到网络上的用 FastWriter(省流量),要打日志给人看的用 StyledWriter。

 这也是 testjson/test.cpp 这个文件存在的意义------它是一个专门用来试各种写法的练习场,把注释里的代码一块块放开试,比看文档快得多。

 还有一个"野路子":root.toStyledString() 一行搞定。但源码注释里明确写着 "不推荐"------因为它内部实现依赖全局状态,线程安全性存疑。

6.4 承上启下

 到这里,JSON 库的用法已经齐了:Value 装数据,Reader 解析,Writer 生成。

 但库只是工具。真正要设计的是我们自己的报文结构体------里面放哪些字段、每个字段用什么类型、遇到错误怎么表达。这是下一章的内容。


七、设计我们自己的报文:Request 与 Response

7.1 先问需求,再定字段

 设计任何结构体之前,都应该先问一句:这个东西要解决什么问题? 字段不是拍脑袋想出来的,是被需求逼出来的。

 一次远程计算请求,要能被对面的服务端独立、完整地理解并执行 ,它至少需要携带三样信息:左操作数、右操作数、运算符。这三个东西合在一起,才能唯一确定一次二元运算------少一个都算不了。

 而服务端的应答需要携带两样信息:计算结果 ,以及状态码。

 第二样是设计的关键点,我们等一下展开。先看请求。

7.2 Request:三个字段,以及为什么要自己会序列化

复制代码
class Request
{
public:
    Request() : _data_x(0), _data_y(0), _oper(0) {}   // 默认构造:全 0
    Request(int x, int y, char oper) : _data_x(x), _data_y(y), _oper(oper) {}

    bool Serialize(std::string *out);      // 对象 → JSON 字符串(把自己摊开)
    bool Deserialize(std::string &in);     // JSON 字符串 → 对象(把自己装回来)

public:
    // 10 20 '-' → 表示算 10 - 20 = ?
    // 约定:_data_x、_oper、_data_y 这个三元组唯一确定一次运算
    int  _data_x;   // 左操作数
    int  _data_y;   // 右操作数
    char _oper;     // 运算符:'+' '-' '*' '/' '%'
};

 为什么把 Serialize / Deserialize 做成这个类自己的成员函数? 因为" 怎么把自己变成字节 "是这一类自己的知识。协议一旦升级(比如加个字段),只需要改这一个类,所有用到它的地方自动跟上。这就是"数据 + 行为绑定",避免了"序列化逻辑散落在各个业务文件里"的混乱。

 看 Serialize 的实现:

复制代码
bool Serialize(std::string *out)
{
    // 1. 造一个 JSON 对象(Json::Value 就是那个"万能容器")
    Json::Value root;
    root["left"]  = _data_x;    // 左操作数 → 键名 "left"
    root["right"] = _data_y;    // 右操作数 → 键名 "right"
    root["oper"]  = _oper;      // 运算符   → 键名 "oper"  ← 这一行有故事,见 7.3

    // 2. 用 FastWriter 序列化成紧凑的一行字符串
    Json::FastWriter writer;
    *out = writer.write(root);  // 输出用指针参数:把结果写回调用方给的字符串

    return true;                // 目前恒为 true,保留返回值是给未来留的余地
}

 再看对应的 Deserialize:

复制代码
bool Deserialize(std::string &in)
{
    Json::Value  root;
    Json::Reader reader;

    // 1. 解析。★ 一定要检查返回值,因为 in 来自网络,完全可能不是合法 JSON
    bool parsesuccess = reader.parse(in, root);
    if (!parsesuccess)
        return false;           // 解析失败 → 明确告诉调用方"别信这条数据"

    // 2. 从 Value 里按键名把字段取回来
    //    这里的 asInt() 是"尽力转换":键不存在会静默返回 0,不会报错
    _data_x = root["left"].asInt();
    _data_y = root["right"].asInt();
    _oper   = root["oper"].asInt();

    return true;
}

  注意 Serialize 用指针 做输出参数(std::string *out),而 Deserialize 用引用 做输入参数(std::string &in)。这个风格上的不对称,第十一章会作为一个小改进点来谈。

7.3 一个真实的坑:char _oper 进了 JSON 会变成什么?

 上面 Serialize 里有一行 root["oper"] = _oper;,看起来人畜无害,但它的实际行为和你的直觉不一样。

 直觉上,我们希望 JSON 里是:

复制代码
{"left":10, "right":20, "oper":"+"}

 但实际的输出是:

复制代码
{"left":10, "right":20, "oper":43}

 43 是哪来的? 是字符 '+' 的 ASCII 码。

 原因在于 Json::Value 的重载集合。它提供了针对 int、unsigned、int64、double、bool、const char*、std::string 的赋值版本,但偏偏没有针对 char 的版本。而 C++ 的重载决议规则里,char → int 属于整型提升 (Promotion,优先级高),char → bool 属于布尔转换 (Conversion,优先级低)。规则规定提升优于转换,于是 char 被提升成 int,以整数的身份写进了 JSON。

 那么问题来了:这算 bug 吗? 严格说,不算,因为它是可逆的:

  • 序列化时:'+'(值 43)→ 写成 JSON 数字 43;
  • 反序列化时:root["oper"].asInt() 得到 43 → 赋给 char _oper → 隐式转回字符 '+'。

 一来一回,值是对的,程序能正常跑。但它留下两个隐患:

 隐患一,可读性损失。 抓包或看日志时,你看到的是 "oper":43,得在脑子里查 ASCII 表才知道是加号。JSON 作为文本协议最大的优势(人可读),在这里被丢掉了。

 隐患二,契约模糊。 这个字段到底应该被理解成"数字 43"还是"字符 '+'"?将来换一门语言实现客户端(比如 Python),对面拿到 43 大概率会当成整数处理,然后 43 == '+' 在 Python 里是 False------跨语言实现时就翻车了。

 正确的做法是明确转成单字符串:

复制代码
root["oper"] = std::string(1, _oper);   // 用单个字符构造一个 std::string
// 输出:{"left":10,"right":20,"oper":"+"}   ← 清晰、无歧义、跨语言安全
// 对应地,反序列化时要改成:
// _oper = root["oper"].asString()[0];

 这个坑的普遍意义是:序列化时,每个字段的"线上类型"应该是被明确设计过的,而不是"碰巧被编译器推导出来的"。 C++ 的隐式类型转换在这里悄悄替你做了决定,而你应该把它显式地写出来。

 验证方法:把 Request req(10, 20, '+') 序列化后的结果打印出来,看看 oper 后面是 "+" 还是 43。凡是涉及隐式转换的地方,都值得亲自跑一遍验证。

7.4 Response 为什么要两个字段:因为不是每次计算都有结果

 现在看应答。

复制代码
class Response
{
public:
    Response() : _result(0), _code(0) {}
    Response(int result, int code) : _result(result), _code(code) {}

    bool Serialize(std::string *out)
    {
        Json::Value root;
        root["result"] = _result;   // 计算结果
        root["code"]   = _code;     // 状态码
        Json::FastWriter writer;
        *out = writer.write(root);
        return true;
    }

    bool Deserialize(std::string &in)
    {
        Json::Value  root;
        Json::Reader reader;
        if (!reader.parse(in, root))    // 解析失败要返回 false
            return false;
        _result = root["result"].asInt();
        _code   = root["code"].asInt();
        return true;
    }

public:
    int _result;   // 结果
    int _code;     // 状态码
};

 为什么"结果"和"状态码"必须分开? 因为不是每次计算都有结果。

 想想 10 / 0 这种情况:算不出来。但这又不是一个"错误"------它是完全合法的输入,服务端应该礼貌地说"你这个运算我做不了,原因是除零",而不是默默返回一个 0(0 也可能是一个合法的计算结果!),更不该干脆断开连接。

 所以应答必须能表达两种状态:

  • 成功 :{"result":30, "code":0} ------ 结果有效,看 _result;
  • 失败 :{"result":0, "code":1} ------ 结果无效,看 _code 解释原因。

 用 _code 的值来编码失败原因 ,这是分布式系统里极其常见的做法(例如:HTTP 状态码)。一个能表达失败细节的协议,才是一个能被诊断的协议。

7.5 Calculator::Execute:状态码是从业务分支里长出来的

 状态码不是凭空定的,它由业务逻辑里所有可能失败的分支决定。

复制代码
Response Execute(const Request &req)
{
    Response resp;              // 默认构造:_result=0, _code=0(0 表示成功)
    switch (req._oper)
    {
    case '+':
        resp._result = req._data_x + req._data_y;
        break;                  // _code 保持 0 → 成功
    case '-':
        resp._result = req._data_x - req._data_y;
        break;
    case '*':
        resp._result = req._data_x * req._data_y;
        break;
    case '/':
    {
        if (req._data_y == 0)
            resp._code = 1;     // 错误码 1:除数为 0
        else
            resp._result = req._data_x / req._data_y;
    }
    break;
    case '%':
    {
        if (req._data_y == 0)
            resp._code = 2;     // 错误码 2:取模的除数为 0
        else
            resp._result = req._data_x % req._data_y;
    }
    break;
    default:
        resp._code = 3;         // 错误码 3:不认识的运算符
        break;
    }
    return resp;
}

 把状态码整理一下:

_code 含义 触发条件
0 成功 默认值。所有正常计算路径都不会改它
1 除零错误 '/' 且右操作数为 0
2 取模除零 '%' 且右操作数为 0
3 非法运算符 switch 走到了 default

 注意 '/' 和 '%' 的除零被分成了两个不同的错误码(1 和 2)。 虽然都是除零,但对定位问题的人来说,信息量不一样。这是"错误码的粒度,要按使用者的诊断需要来定"的一个小示范------太粗(全都叫"出错了")没法定位,太细(每个分支一个码)又没人记得住。

 还有一点值得特别注意:Calculator 完全不知道 JSON、不知道 socket、不知道协议的存在。 它只接收一个 Request 对象,返回一个 Response 对象。这叫业务逻辑与通信细节解耦------它是下一章"分层"的基础,也是整个项目能保持清晰的原因。

7.6 承上启下

 到这一步,零件都齐了:

  • Request / Response:会自己序列化和反序列化;
  • Calculator:会算数,但不懂网络;
  • Protocol:会切帧、会打包,但不懂业务;
  • TcpSocket / TcpServer:会收发字节,但不懂"报文"。

 下一章的问题就是:怎么把它们拼起来,而且不要让它们互相知道对方的细节?


八、把零件装起来:分层、回调与两条流水线

8.1 先想清楚"不能怎么做"

 上一章我们把零件凑齐了:序列化的、算数的、切帧的、收发字节的。

 现在到了组装环节。在动手之前,先想清楚一个"绝对不能这么做"的方案:让 TcpServer 直接去调用 Calculator。

 为什么不行?因为这样一来,网络模块从此就依赖了业务模块 。将来你想用同一个 TcpServer 去写个聊天室服务、写个文件传输服务,就必须去动网络模块的代码------一个本来跟业务毫无关系的模块,被业务"污染"了。这就是耦合。

 正确的思路是:每一层只干自己的事,彼此之间通过一层薄薄的"接口"说话。具体怎么做?分两步。

8.2 第一步:分层------三层各管一段

 先把职责切开:

复制代码
┌─────────────────────────────────────────────────────┐
│  业务层  Calculator                                  
│  会算数;只知道 Request / Response,完全不知道网络的存在 
├─────────────────────────────────────────────────────┤
│  协议层  Protocol(+ Request / Response)            
│  会切帧、打包、序列化;只知道"字符串",不知道算的是加法 
├─────────────────────────────────────────────────────┤
│  网络层  TcpServer / TcpSocket / InetAddr            
│  会收发字节、管连接;只知道"字节流",不知道里面装的是什么
└─────────────────────────────────────────────────────┘

 每一层"知道什么、不知道什么",是这张图里最重要的信息:

  • 业务层 知道 Request 里每个字段的含义、Response::_code 该怎么定;它不知道 socket、IP、端口、JSON 这些词是什么意思。
  • 协议层 知道报文格式、帧长什么样、序列化规则;它不知道业务是什么------算数也好、聊天也好、传文件也好,它一视同仁。
  • 网络层 知道 fd、地址、收发循环;它不知道收到的字节是 JSON 还是别的什么东西。

 一句话总结:每一层只认识自己这一层的概念,对上下层的具体实现一无所知。 这就是分层的价值。

8.3 第二步:既然下层不认识上层,那"收到数据后该干什么"由谁决定?------回调

 分层之后有个显而易见的问题:网络层不许认识业务层,那"收到数据之后该干什么"这件事,谁来决定?

 答案是:上层把自己的处理函数,通过构造函数塞进下层。

复制代码
// 定义一个"函数类型":
//   输入一个 Request&,输出一个 Response ------ 这就是"业务处理"这个动作的抽象
using HandlerRequest_t  = std::function<Response(Request &)>;

//   输入一个 Response&,没有输出 ------ 这是"应答处理"这个动作的抽象
using HandlerResponse_t = std::function<void (Response &)>;

class Protocol
{
public:
    // 服务端的构造方式:把"怎么处理请求"注入进来
    Protocol(HandlerRequest_t handler) : _version("1.0"), _handler_request(handler) {}

    // 客户端的构造方式:把"怎么处理应答"注入进来
    Protocol(HandlerResponse_t handler_response)
        : _version("1.0"), _handler_response(handler_response) {}

private:
    std::string        _version;          // ← 预留的版本号,目前从未被使用(见 11.5)
    HandlerRequest_t   _handler_request;  // 业务处理回调(服务端用)
    HandlerResponse_t  _handler_response; // 应答处理回调(客户端用)
};

 std::function 是什么? 一句话:它是一个"可以像变量一样被传递、被存储的函数"。普通函数、lambda 表达式、绑定了对象的成员函数、仿函数对象------任何能像函数一样被调用的东西,都能装进它。

 有了它,Protocol 层就获得了一种能力:"我不知道这条请求该怎么处理,但我手里有个东西,调用它就能拿到答案。"

 这个模式在架构上叫依赖注入(Dependency Injection) ,在系统编程里叫回调 。它的收益是复用 :换成聊天室、游戏服务器,Protocol 一行都不用改,换一个 HandlerRequest_t 传进去就完事。

 注意两个构造函数被写得"很灵性"------服务端和客户端各自只需要注入自己关心的那个回调。服务端只关心"怎么处理请求",客户端只关心"应答回来后怎么处理",另一个成员就留空。这样同一个类,两端都能用。

8.4 服务端的五步流水线:ParseRequest

 现在看服务端收到一段字节后,具体发生了什么。

复制代码
// 如果读到半个报文,什么都不做;
// 如果读到一个或多个完整报文,循环处理,把每一条都处理掉
std::string ParseRequest(std::string &inbuffer)
{
    std::string result;              // 攒起来的所有应答(可能不止一条!)
    while (true)
    {
        std::string json_string;

        // ── 步骤 1:解包(去报头)──────────────────────
        int n = Unpack(inbuffer, &json_string);
        if (n < 0) {
            LOG(LogLevel::DEBUG) << "no way !!";    // 真出错了
            return std::string();
        }
        if (n == 0) {
            // 数据不够,缓冲区里已经没有完整报文了
            // ★ 注意:返回的是 result 而不是空串!
            //   因为前面几轮循环可能已经生成了应答,不能因为"末尾剩半个"就丢掉它们
            LOG(LogLevel::INFO) << inbuffer << " parse done";
            return result;
        }

        // ── 步骤 2:反序列化(拆内容)──────────────────
        Request req;
        if (!req.Deserialize(json_string))
            return std::string();   // ⚠️ 这里有个坑,见 11.3

        // ── 步骤 3:调业务(通过回调,Protocol 根本不知道"算数"这回事)──
        Response resp;
        if (_handler_request)
            resp = _handler_request(req);

        // ── 步骤 4:应答序列化 ─────────────────────────
        std::string resp_json_string;
        resp.Serialize(&resp_json_string);

        // ── 步骤 5:加报头,攒进结果里 ──────────────────
        result += Packet(resp_json_string);

        // 回到循环开头,看缓冲区里还有没有下一条报文
    }
}

 这个 while(true) 循环是全文的技术核心之一。 它的意义在第 3.5 节已经讲过:一次 recv 可能带来 N 条请求,所以必须循环解包,一条都不能漏。

 这里有一个极容易写错、后果又很严重的细节,值得单独拎出来说:n == 0 那一行,return 的必须是 result,不能是空串。 此时缓冲区里确实没有完整帧了,但循环前面几轮可能已经算出了应答 ------如果直接 return std::string(),这些已经算好的结果会被整批丢掉。这是一个"看起来没问题、跑起来偶发丢包"的典型 bug。

 还有一处设计值得注意:请求和应答用的是同一套报文格式 (都走 Packet)。这叫对称协议------实现起来最省事,代价是报文自己不带"我是请求还是应答"的标识,只能靠"谁连谁"来区分方向(客户端发出去的一定是请求,服务端发出来的一定是应答)。

 HTTP/1.1 就不这么做------它用请求行 GET / HTTP/1.1 和状态行 HTTP/1.1 200 OK 明确区分两者的身份,代价是解析逻辑更复杂。两种方案没有高下之分,只有"你愿不愿意为通用性付出解析复杂度"的取舍。

8.5 客户端的对称流水线:ParseResponse

 客户端方向是完全对称的,只是最后一步从"业务处理 + 攒应答"变成了"调用回调 + 什么都不发"。

复制代码
std::string ParseResponse(std::string &inbuffer)
{
    while (true)
    {
        std::string json_string;

        // 1. 解包
        int n = Unpack(inbuffer, &json_string);
        if (n < 0) {
            LOG(LogLevel::DEBUG) << "no way !!";
            return std::string();
        }
        if (n == 0) {
            LOG(LogLevel::INFO) << inbuffer << " parse done";
            return std::string();   // 客户端不需要返回内容,调用方也不看
        }

        // 2. 反序列化成 Response 对象
        Response resp;
        if (!resp.Deserialize(json_string))
            return std::string();

        // 3. 调回调,把结果交给上层(客户端就是打印一下)
        if (_handler_response)
            _handler_response(resp);
        // 注意:客户端收到应答后通常不再回话,所以没有第 4、5 步
    }
}

 对比一下两条流水线,你会发现服务端是五步 (多出"业务 + 攒应答"),客户端是三步 ;服务端的返回值是"攒好的应答字节"(要发回去),客户端的返回值则没有意义(调用方不看)。理解了这种对称性,你就理解了整个协议层的骨架。

8.6 服务端 main:四步组装

 现在看整个服务端是怎么把零件装起来的。这二十来行代码是全文的"集成点"。

复制代码
int main(int argc, char *argv[])
{
    if (argc != 2) { Usage(argv[0]); exit(1); }
    uint16_t port = std::stoi(argv[1]);

    // 【第 1 步】定义计算器 ------ 业务对象,不知道网络的存在
    std::unique_ptr<Calculator> cal = std::make_unique<Calculator>();

    // 【第 2 步】定义协议对象,并把"业务"通过 lambda 注入进去
    //   这个 lambda 就是 Protocol 要用的 HandlerRequest_t:
    //   你给我一个 Request,我还你一个 Response
    std::unique_ptr<Protocol> protocol = std::make_unique<Protocol>(
        [&cal](Request &req) -> Response {
            return cal->Execute(req);
        }
    );

    // 【第 3 步】定义网络对象,并把"协议"通过 lambda 注入进去
    //   这个 lambda 是 TcpServer 要用的 Handler_t:
    //   你给我一串字节,我还你一串应答字节
    std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(
        [&protocol](std::string &inbuffer) -> std::string {
            return protocol->ParseRequest(inbuffer);
        }, port
    );

    // 【第 4 步】启动事件循环
    tsvr->Loop();
    return 0;
}

 这个组装方式有个共同点:每一层都是"通过构造函数接收下一层的处理函数",而不是去 new 一个对象。 于是形成了一条完整的调用链:

复制代码
TcpServer 收到字节
   → 调 _handler → Protocol::ParseRequest
        → 调 _handler_request → Calculator::Execute
   ← 拿到应答字节
TcpServer 发送字节

 而每一层只认识一个 std::function ,完全不认识下层具体类型。这就是依赖倒置 在几十行代码里的落地:高层模块(业务)和低层模块(网络)都依赖抽象(std::function),不互相依赖。

 顺带看一眼依赖方向:精简版里画过它的依赖图,关键点是------依赖箭头单向向下,没有一条箭头从网络层指回业务层。这是整个架构能成立的前提。

8.7 网络层的内部结构:模板方法模式

 前面说网络层"只管收发字节"。但网络层自己内部也有设计问题要解决:它要同时支持 TCP 和 UDP(甚至将来更多协议),而这几种协议的"连接建立流程"是类似的,具体步骤却各不相同。

 怎么让"流程"复用、"步骤"可替换?源码用的办法叫模板方法模式:父类把流程顺序定死,子类只实现每一步的具体动作。

复制代码
class Socket                                  // 抽象基类:定义"骨架"
{
protected:
    // 三个纯虚函数 = 三个必须由子类实现的"步骤"
    virtual void CreateSocketOrDie() = 0;            // 步骤 1:创建套接字
    virtual void BindSocketOrDie(uint16_t port) = 0; // 步骤 2:绑定端口
    virtual void ListenSocketOrDie() = 0;            // 步骤 3:开始监听

public:
    virtual std::shared_ptr<Socket> Accepter(InetAddr &addr) = 0;  // 接受连接
    virtual int  Recv(std::string *out) = 0;                       // 收字节
    virtual int  Send(const std::string &in) = 0;                  // 发字节
    virtual bool Connect(InetAddr &addr) = 0;                      // 发起连接
    virtual void Close() = 0;                                      // 关闭

    // 服务端:固定的"三步走"流程 ------ 谁也不能改顺序,只能改每步怎么做
    void BuildTcpSocketMethod(uint16_t port)
    {
        CreateSocketOrDie();
        BindSocketOrDie(port);
        ListenSocketOrDie();
    }

    // 客户端:只需要创建套接字这一步
    void BuildTcpClientSockMethod()
    {
        CreateSocketOrDie();
    }
};

class TcpSocket : public Socket { /* 真正去调 socket/bind/listen/accept 的实现 */ };
// class UdpSocket : public Socket { ... };   // 留好的扩展位(源码里注释着)

 设计意图一句话说清 :父类 Socket 定死"流程顺序 "(建 → 绑 → 听),子类 TcpSocket 只负责"每一步怎么做"。将来要加 UDP,只要再写一个子类实现那三个纯虚函数,流程代码一行都不用重写。

 这就是模板方法模式 :"实现的方式可以不一致,但操作的接口必须一致" ------ 而强制接口一致的手段,就是纯虚函数(子类不实现就创建不出对象)。

8.8 服务端的并发模型与两个缓冲区的生命周期

 最后一个关键细节,藏在服务端的收发循环里。先看并发模型:

复制代码
void Loop()
{
    signal(SIGCHLD, SIG_IGN);   // 子进程退出时由内核自动回收,避免产生僵尸进程
    while (true)
    {
        InetAddr clientaddr;
        auto sockfd = _listensock->Accepter(clientaddr);   // 主线程不断 accept
        if (!sockfd)
            continue;

        if (fork() == 0)
        {
            // 子进程:专门服务这一个客户端,处理完就退出
            service(sockfd, clientaddr);
            sockfd->Close();
            exit(0);
        }
        // 父进程:什么都不做,回到循环开头继续 accept
    }
}

 这是经典的"一个连接一个进程"模型:父进程只管接受连接,每来一个客户端就 fork 一个子进程专门伺候它。signal(SIGCHLD, SIG_IGN) 让内核自动回收子进程,避免僵尸进程堆积。(这个模型的扩展性问题,第十一章会作为改进点来谈。)

 然后是最能体现功力的地方------子进程里的收发循环:

复制代码
void service(std::shared_ptr<Socket> sockfd, InetAddr &clientaddr)
{
    // ★ 注意这两个变量的【位置】------它们定义在 while 外面!
    std::string inbuffer, outbuffer;

    while (true)
    {
        outbuffer.clear();   // 每轮开头清空【发送】缓冲区

        // 接收:inbuffer 是跨轮累积的(Recv 内部用的是 +=)
        int n = sockfd->Recv(&inbuffer);
        if (n <= 0) {
            // n == 0 表示对端正常关闭(客户端退出了)
            // n <  0 表示接收出错
            LOG(LogLevel::WARNING) << "recv: client quit, " << clientaddr.ToString();
            break;
        }

        // 处理:一次可能解出并生成多条应答,所以用 += 攒着
        if (_handler)
            outbuffer += _handler(inbuffer);

        // 一个字都没解出来(说明只收到了半包)→ 这一轮不发,直接回去继续 recv
        if (outbuffer.empty())
            continue;

        n = sockfd->Send(outbuffer);
        if (n < 0) {
            LOG(LogLevel::WARNING) << "send: client quit, " << clientaddr.ToString();
            break;
        }
    }
}

 这段代码里最值得琢磨的,是两个缓冲区截然不同的生命周期。

 inbuffer 和 outbuffer 都定义在 while 外面,但它们的用法完全不同:

  • inbuffer:跨轮累积,永远不清空。 因为里面可能存着没收全的半个请求,必须留着,等下一次 recv 把剩下的字节追加进来(靠的正是 3.4 节那个 +=)。半包的"半"字,就藏在这个变量的生命周期里。
  • outbuffer:每轮开头 clear()。 因为应答一旦发出去就完成使命了,不需要跨轮保留。不清空的话,上一轮的应答会被重复发送。

 如果把 inbuffer 也放进 while 里面(或者每轮清空),半包就永远拼不起来------这是半包问题最经典的一种翻车方式,值得你在自己的代码里反复检查。

 另外,outbuffer += _handler(inbuffer) 里的这个 += 也有讲究:一次 recv 可能解出多条 请求(粘包),于是产生多条 应答。用 += 把它们拼在一起,一次 send 全部发出去 ,比循环调用 N 次 send 少 N-1 次系统调用------这是一个实打实的小优化。

8.9 承上启下

 到这里,整个项目已经能从代码上"闭合"了:字节进来、被切帧、被还原成对象、被算完、被重新序列化、被发回去。

 但纸面上的正确不等于真的正确。下一章,我们要把粘包现象亲手复现出来------因为只有亲眼见过它,你才会真正相信前面那些"绕来绕去"的设计不是多余的。


九、跑起来看见它:一次发三条,复现粘包

9.1 实验代码:客户端一口气发三条

 理论讲完了,现在用代码把粘包现象复现出来。

复制代码
while (true)
{
    // ★ 关键:这一轮要拼 3 条请求
    int cnt = 3;
    std::string outbuffer;
    while (cnt--)
    {
        // 0. 采集用户输入
        int x, y; char oper;
        std::cout << "Enter Your x: ";    std::cin >> x;
        std::cout << "Enter Your y: ";    std::cin >> y;
        std::cout << "Enter Your oper: "; std::cin >> oper;

        // 1. 构造请求对象
        Request req(x, y, oper);

        // 2. 序列化:对象 → JSON 字符串
        std::string req_json;
        req.Serialize(&req_json);

        // 3. 加报头:JSON → 一帧完整报文
        std::string send_req_string = procotol.Packet(req_json);

        // 4. ★ 攒进 outbuffer(注意是 +=),攒满 3 条一起发
        outbuffer += send_req_string;
    }

    // 5. 一次 send 把 3 条报文全发出去
    socket->Send(outbuffer);

    // 6. 接收:可能收到半条,也可能一次收到全部 3 条应答
    socket->Recv(&inbuffer);

    // 7. 解析:循环解包,把这一轮收到的所有应答都处理掉
    procotol.ParseResponse(inbuffer);
}

 这段代码的意图很明确:它故意制造"多条消息挤在一起"的场景,来验证我们的协议层能不能扛住。

9.2 一次 recv 收到三条的真相

 跑起来会发生什么?假设你输入了 10 + 20、100 - 1、6 * 7 三组数据。客户端这一轮拼出来的 outbuffer 是这样的:

复制代码
32\r\n{"left":10,"right":20,"oper":43}\r\n32\r\n{"left":100,"right":1,"oper":45}\r\n30\r\n{"left":6,"right":7,"oper":42}\r\n

 (每条帧的结构都是 长度\r\n正文\r\n:前两条正文 32 字节、第三条 30 字节,所以三条帧分别是 38、38、36 字节,加起来 112 字节。)

 一次 send 出去之后,服务端打印出来的 inbuffer 会是什么?

 大概率是完整的三条,粘在一起。

 为什么?三个原因:

  1. 三条加起来才 112 字节,远小于 MSS(通常 1460 字节),TCP 没有理由主动拆分它;
  2. 服务端 recv 用的是 1023 字节的缓冲区,一次装得下;
  3. 服务端真正去读数据的时刻,这三条已经全都躺在内核接收缓冲区里了。

 于是 ParseRequest 拿到这一大坨,开始它的 while(true) 循环:第一次 Unpack 切走第一条,第二次切走第二条,第三次切走第三条,第四次发现数据不够了、返回 0,循环退出。 三条请求,三次计算,result 里攒下三条应答,一次 send 全部发回。

 客户端这边完全对称:一次 recv 拿到三条应答粘在一起,ParseResponse 循环三次,打印出:

复制代码
result: 30[0]
result: 99[0]
result: 42[0]

 粘包现象,就这么在两端同时被复现出来了。 而我们的代码处理得干干净净------因为它从一开始就没假设过"一次 recv 等于一条消息"。

9.3 反过来看:这正好证明了循环解析的必要性

 现在做个思想实验:如果 ParseRequest 里没有 那个 while(true) 循环,只解一条会怎样?

 你的三条请求,只有第一条会被处理,后两条会被静默丢弃------客户端会一直等下一条应答,卡死在那里。

 而且这个 bug 极难复现:有时候三条恰好分三次 recv 到达,程序一切正常;有时候(网络稍有拥塞,或者你手速快一点)它们粘在一起了,程序就丢消息。那种"偶尔出错、重试一下就好"的玄学 bug,根源往往就是这里的半包/粘包处理不完整。

 这就是为什么"用 TCP 就必须处理粘包"------它不是一种需要规避的意外,而是 TCP 的设计本质。

9.4 承上启下

 到这里,用户态的完整故事已经讲完了:从对象到字节,从字节到帧,从帧回到对象,中间还亲手验证了一遍。

 但作为一个啃 Linux 内核的学习者,我们不该停在这里。应该往下再问一句:这一整套思路,内核是怎么做的?

 下一章你会看到:内核在完全相同的处境下,做出了几乎相同的选择,只是实现得更极致。


十、下沉到内核:同样的四个套路

 这一章我们把前面所有设计,拿到内核里找它们的"同款"。你会看到四个熟悉的套路:长度前缀定界、加头去头不拷贝、自描述报头、公共头部 + 强转。

10.1 套路一:长度前缀定界------看 UDP 报头

 我们的报文格式是 len\r\n{正文}\r\n,核心思想是"先说长度,再放内容"。现在看 Linux 的 UDP 报头长什么样:

复制代码
struct udphdr {
    __be16 source;   // 源端口
    __be16 dest;     // 目的端口
    __be16 len;      // ★ 整个 UDP 报文的长度 = 8 字节报头 + 数据
    __sum16 check;   // 校验和
};

 看到第三行了么?len------内核也在用"长度前缀"来标记一条消息的边界 ,和我们 Packet() 里的 std::to_string(json_string.size()) 是同一个思想。

 但内核做了两个和我们不同的选择,值得对比。

 选择一:用二进制 16 位整数,而不是十进制字符串。

 因为内核要的是极致的效率和固定偏移 :解析 len 就是"从第 4 个字节开始读 2 字节",一条 movzwl 指令搞定,不用扫描字符串、不用 stoi、也不可能抛异常。而我们用十进制字符串,是因为我们更在意可读性和调试便利。

 这正是 3.2 节"细节一"里那个取舍的另一面------同一个问题,在不同的约束下,会有不同的最优解。

 选择二:用 __be16 明确标注大端。

 代码里写 __be16 而不是 uint16_t,是在用类型系统表达"这个字段是网络字节序的,取出来用之前要 ntohs" 。这是一个非常值得学习的内核编程习惯:把隐式约定变成显式类型。

 回到我们 7.3 节那个 char _oper 的坑 ------如果我们也能用类型明确表达"这个字段的线上类型是字符",就不会有 43 这种意外了。内核在这件事上比我们做得严谨。

 顺便回答一个前面留下的问题:为什么 UDP 报头里有 len,TCP 报头里却没有?

复制代码
struct tcphdr {
    __be16 source;     // 源端口
    __be16 dest;       // 目的端口
    __be32 seq;        // 序号    ← TCP 靠序号来定位字节,而不是长度
    __be32 ack_seq;    // 确认号
    // ... 标志位、窗口、校验和、紧急指针 ...
    // ★ 注意:这里没有 total_length 这样的字段!
};

 TCP 为什么不需要长度字段? 因为 TCP 的职责就是提供无边界的字节流 ------它的"边界"概念是字节序号(seq),而不是"消息"。所以 TCP 可以自由地合并、切分段,完全不需要(也不应该)记录"应用层写了几次"。这就是第二章那个"字节河"在报文格式上的体现。

 那整条链路上,边界到底由谁负责? 答案是:

复制代码
应用层:TCP 不给边界 → 我们自己加 len\r\n      (本文的工作)
传输层:UDP 自带 len 字段;TCP 刻意不加        (把定界责任推给应用层)
网络层:IP 报头里的 tot_len 保证"每个 IP 包"的边界清楚
链路层:以太网帧有自己的帧定界符

 "谁能提供边界,谁就必须提供边界"------这是一条贯穿整个协议栈的设计原则。 你看,我们手写的那行 Packet,其实是在代替 TCP 完成它拒绝做的那件事。

10.2 套路二:加头去头不拷贝------sk_buff 的四个指针

 还记得第二章提到的 sk_buff 吗?我们在那里只说了"内核用 sk_buff 描述报文,用两条队列管理收发"。现在把镜头拉近,看看这个结构体为什么长成这样。

 先问需求:一个网络包在内核里从网卡走到应用层,要穿过链路层 → 网络层 → 传输层 → socket 层,每一层都要剥掉一个头;反过来发送时,每一层都要加上一个头。

 如果每加一层头就重新分配一块内存、把数据整体拷贝过去------一个包就要拷贝四五次,性能无法接受。

 所以内核设计的 sk_buff,核心就是用指针 而不是数据搬移来表示"头在哪里":

源码位置 :include/linux/skbuff.h(节选)

复制代码
struct sk_buff {
    /* ── 四个指针,圈出缓冲区里的三块区域 ── */
    unsigned char *head;    // 缓冲区起点(刚分配出来的内存开头)
    unsigned char *data;    // 当前有效数据的起点
    unsigned char *tail;    // 当前有效数据的终点
    unsigned char *end;     // 缓冲区终点

    unsigned int len;       // 当前有效数据的长度(= tail - data)

    struct sock       *sk;    // 这个包属于哪个 socket
    struct net_device *dev;   // 从哪块网卡收的 / 要往哪块网卡发
    __u16              protocol; // 三层协议类型(ETH_P_IP / ETH_P_ARP ...)

    unsigned char cb[48] __aligned(8);  // 各层协议各用一块的"私有小黑板"
};

 先看字段设计:四个指针圈出三块区域,sk 回答"这是我的哪个连接",dev 回答"我该从哪进哪出",protocol 回答"我该交给哪个三层协议处理",cb[48] 是留给各层协议临时存状态的"小黑板"。每一个字段都在回答一个具体的管理问题------这就是"先有需求、后有字段"的典型。

 关键在四个指针的排布:

复制代码
      head        data              tail        end
       ↓           ↓                 ↓           ↓
       ┌───────────┬─────────────────┬───────────┐
       │ headroom  │    有效数据      │  tailroom │
       └───────────┴─────────────────┴───────────┘
        ← 预留空间 →                   ← 预留空间 →
        (给各层加头用)               (给各层加尾用)

 headroom 和 tailroom 是刻意预留出来的空间,专门给各层"加头加尾"用。于是:

  • 加一个头 :skb_push(len) ------ 只是把 data 指针往前挪 len 个字节,然后往那块空间里填头部。数据一个字节都没动。
  • 去掉一个头 :skb_pull(len) ------ 把 data 指针往后挪 len 个字节。同样没有拷贝。
  • 追加尾部数据 :skb_put(len) ------ 把 tail 指针往后挪。

 这就是内核版的 Packet() 和 Unpack()------只不过它用"移动指针"代替了"拼接字符串"。

 回头看我们的实现,差距就出来了:Packet 做的是 std::to_string(...) + gsep + json + gsep,会分配一块新内存并拷贝两次 ;Unpack 里的 packet.erase(0, total),会把后面的字节整体往前搬(一次 O(n) 的 memmove)。

 在小报文、低并发下这无所谓。但在每秒几十万个包的高性能服务器上,这些拷贝就是瓶颈。这也解释了为什么几乎所有高性能 C++ 网络库都要自己实现一个 Buffer 类 ------它们都在模仿 sk_buff 的思路:用两个下标(读下标 / 写下标)代替 erase,只有下标在移动,数据不动,等空闲的时候再做一次整体压缩。

复制代码
muduo 的 Buffer:  [ 已读 |   可读   |  可写  |   空闲   ]
                      ↑          ↑         ↑
                  readIndex  readIndex_  writeIndex_

 "用边界指针表示数据范围,而不是用数据搬移表示"------这是我们从 sk_buff 里能学到的最有价值的一课。

10.3 套路四:公共头部 + 强转------回到 sockaddr

 最后回到 4.5 节那个 sockaddr_in,把它和内核的设计思想接上。

 那个 sin_zero[8] 填充字段的存在意义,现在应该很清楚了:它是在用空间换取"类型转换的安全性"。

复制代码
struct sockaddr_in addr;
bind(fd, (struct sockaddr *)&addr, sizeof(addr));
//       ↑ 这个强转就是 CONV 宏做的事

 强转之所以安全,是因为两者大小都是 16 字节,而且前 2 字节(地址族)的语义是相同的 。接收方拿到一个 sockaddr* 之后,先读前 2 字节的 sa_family,知道是 AF_INET,再把指针强转回 sockaddr_in*,去访问端口和 IP。

 "公共头部 + 强转回具体类型"------这个模式你在内核里会反复遇到 :回想一下 IPC 那三个结构体(shmid_ds、msqid_ds、semid_ds)共享的公共首成员 kern_ipc_perm,是完全一样的手法。

 为什么要有这个模式? 因为接口要统一,数据要分化 :bind() 只能有一个版本,但地址有几十种格式。解决矛盾的办法,就是"让所有格式共享一个公共头部,用类型转换在统一接口和具体数据之间来回切换"。

 有意思的是,这套思路在 JSON 里也有个对应物 :Json::Value。它内部记录"我现在是 object 还是 array 还是 int",你调用 asInt() 时它会检查类型------这就是动态类型版本的"公共头部 + 强转" 。区别只是:C 语言靠手工强转(快,但转错了直接读到垃圾内存),Json::Value 靠内部类型标记(安全,但有运行时开销)。

 同一个设计思想,一个用在了地址结构上,一个用在了数据容器上------这就是"套路"的力量。

10.4 本章小结

 把四个套路排一下,你会发现它们其实回答的是同一批问题在四个不同层面的解法:

  • "边界在哪里" → UDP 报头的 len、IP 的 tot_len、netlink 的 nlmsg_len,和我们 Packet 里的长度前缀一模一样;
  • "加头去头怎么不拷贝" → sk_buff 的 head/data/tail/end 四指针和 skb_push/pull;
  • "统一接口怎么适配多种数据" → sockaddr 的公共头部 + 强转,以及它的动态类型版本 Json::Value。

 用户态和内核态,用的是同一本设计手册,只是内核写得更较真。


十一、这条路还没走完:源码里真实的坑

 代码跑通了,功能也验证了。但**"能跑"和"健壮"之间还有很长的距离**。这一章把项目里真实存在的问题一个一个摆出来------因为看懂这些坑,比多写一百行代码更有价值。

11.1 std::stoi 会抛异常,而调用链上没人接

 回到 Unpack 里的这一行:

复制代码
std::string lenstr = packet.substr(0, pos);
// lenstr 合法性判断,lenstr -> 123 345     ← 源码里作者自己留的 TODO
int len = std::stoi(lenstr);

 std::stoi 在两种情况下会抛异常:

  • lenstr 不是合法数字(比如对面发来 abc\r\n...)→ 抛 std::invalid_argument;
  • 数字超出 int 范围(比如 99999999999999999999)→ 抛 std::out_of_range。

 而调用链上没有任何 try/catch 。异常会一路向上穿透 ParseRequest、_handler、service、Loop,最后冲出 main------进程直接终止。

 这意味着什么?任何人用 nc 手工给服务器发一句 abc\r\n,就能把服务器打挂。 这是一个真实的拒绝服务(DoS)漏洞。

 源码里那行注释说明作者知道这里有问题,只是没来得及补。修法有三种:

复制代码
// 方案一:先校验字符,再转换
if (lenstr.empty() || lenstr.find_first_not_of("0123456789") != std::string::npos)
    return -1;   // 长度串里出现了非数字字符 → 直接判定为协议错误

// 方案二:用不抛异常的 std::from_chars(C++17)
int len = 0;
auto [ptr, ec] = std::from_chars(lenstr.data(), lenstr.data() + lenstr.size(), len);
if (ec != std::errc()) return -1;

// 方案三:包一层 try/catch
try { len = std::stoi(lenstr); }
catch (...) { return -1; }

 三种都能修,但思路不同:方案一是"不信任何非法输入 ",方案二是"用不抛异常的工具 ",方案三是"兜底接住"。生产代码里,通常是方案一/二做主、方案三做保险。

11.2 长度字段是个攻击面:负数长度会直接搞崩进程

 即使 stoi 成功了,还有一个更阴险的输入:负数。

 如果对面发来 -100\r\n,那么:

复制代码
int len = std::stoi("-100");                          // len = -100(stoi 认为这是合法的!)
int total = 3 + (-100) + 4;                           // total = -93(负数!)
if (packet.size() < total) return 0;                  // size() 是无符号,和负数比较......
                                                      // 这里会发生危险的隐式类型转换
*json_string = packet.substr(pos + 2, len);            // len = -100 → 被转成巨大的 size_t
                                                      // 结果把缓冲区剩下的内容全当成"正文"
packet.erase(0, total);                               // ★ total 是负数 → 转成 size_t 变成天文数字
                                                      // → 超出字符串长度 → 抛 std::out_of_range → 崩溃

 根本原因在于:packet.size() 返回的是 size_t(无符号),而 total 是 int(有符号)。两者比较时,int 会被隐式转换成 size_t,负数瞬间变成一个天文数字。

 这是 C/C++ 里最经典的坑之一,编译器的 -Wsign-compare 警告就是专门抓它的。编译时请务必加上 -Wall -Wextra------它们能提前把这类问题喊出来。

 正确的做法是:凡是"来自网络的长度 / 下标 / 偏移",在参与任何运算之前,都必须先做范围校验。

复制代码
// 既防负数,也防"声称有 10GB 大包"的内存炸弹
if (len < 0 || len > MAX_MSG_SIZE)
    return -1;

 这里的上限 MAX_MSG_SIZE 同样重要------没有上限的长度字段就是一个内存炸弹 :攻击者声称"我这条消息有 2GB",服务端就会傻傻地等着收满 2GB 才处理,或者提前分配 2GB 内存。校验范围,是协议实现的第一条纪律。

11.3 一帧解析失败,前面算好的应答全丢

 再看 ParseRequest 里的这一步:

复制代码
Request req;
if (!req.Deserialize(json_string))
    return std::string();   // ⚠️ 返回空串,把已经攒下的 result 一起丢了

 设想一次 recv 收到了 3 条请求:第 1 条正常,第 2 条 JSON 损坏(比如被中间设备篡改,或者对面客户端有 bug),第 3 条正常。

 会发生什么?第 1 条已经算完、应答已经在 result 里了;处理到第 2 条时 Deserialize 失败,函数直接 return std::string()------第 1 条的应答被一起丢掉,第 3 条也不会被处理。

 正确的做法是"跳过坏的那一条,继续处理后面的",并且把已经攒好的 result 返回给调用方:

复制代码
if (!req.Deserialize(json_string)) {
    LOG(LogLevel::WARNING) << "bad json, skip: " << json_string;
    continue;   // ← 跳过这一条,继续循环,而不是整体 return
}

 这里的原则是:一个坏帧不应该毁掉一批好帧。 在协议实现里,这叫错误隔离(Fault Isolation)------错误应该被限制在尽可能小的范围内,而不是让一处的失败扩散成整体的失败。这个原则不只适用于网络协议,在分布式系统、微服务、批处理里,到处都能看到它的身影。

11.4 send 不保证把数据全部发出去

 看这一行:

复制代码
int Send(const std::string &in) override
{
    return send(_sockfd, in.c_str(), in.size(), 0);   // 返回值直接被丢掉了
}

 send 返回的是实际写入内核缓冲区的字节数 ,它可能小于你要求的字节数。什么情况会发生?

  • 发送缓冲区快满了(对端读得太慢);
  • 被信号中断(EINTR);
  • 对于非阻塞 socket,这几乎是常态。

 目前的代码只检查了 n < 0(出错),默认每次 send 都能一次发完 。在小报文、本地测试的环境下这几乎总是成立,所以问题看不出来;但一旦报文变大、或者网络拥塞,就会出现"只发了一半"的情况------而对端会把半个报文当垃圾,你会看到一个极难复现的诡异 bug。

 正确的写法是循环发送:

复制代码
int Send(const std::string &in) override
{
    size_t total_sent = 0;
    while (total_sent < in.size()) {
        ssize_t n = send(_sockfd, in.c_str() + total_sent, in.size() - total_sent, 0);
        if (n < 0) {
            if (errno == EINTR) continue;   // 被信号打断,重试即可
            return -1;                       // 真错误
        }
        total_sent += n;                     // 累加已发送的字节数
    }
    return total_sent;
}

 注意这和 recv 的对称性 :recv 可能只读到半个包,send 也可能只写出半个包。两端都要处理"部分完成",才是完整的答案------这正是第十一章开头说的"能跑和健壮之间的距离"。

11.5 其它零碎的改进点

 Deserialize 的入参应该用 const 引用。 它并不修改 in,写成 const std::string & 才是正确地表达意图(而且能接受临时对象)。顺带一提,项目里 Serialize 用指针做出参、Deserialize 用引用做入参,两种风格混用------统一成"输入用 const&、输出用 *"会更清晰。

 _oper 的类型契约要写进协议。 就是 7.3 节那个坑。要么明确用 std::string(1, _oper) 存成字符,要么在文档里写清楚"这里传的是 ASCII 码"------但不能像现在这样,靠"碰巧能转回来"来工作。

 JSON 的体积和性能。 每条报文都要重复传输 "left"、"right"、"oper" 这些键名,在报文里占了将近一半的字节。如果 QPS 很高,换成 protobuf 能省下大量带宽------这也是为什么内核协议栈全都是二进制的 (回想 10.1 节的 __be16)。

 日志里打印整个 inbuffer 有风险。 LOG(LogLevel::DEBUG) << inbuffer 会把收到的全部内容打进日志。如果客户端发来超长数据,日志会被撑爆------这本身也是一种 DoS。生产环境必须限制日志长度。

 fork 模型的扩展性。 TcpServer::Loop 用的是"一个连接一个进程"。连接一多,进程数和内存开销、上下文切换开销都会上来。换成线程池(或 epoll 事件循环)会好得多------这是上一篇文章讲过的内容。

11.7 编译与依赖

 最后补一句工程上的事。Makefile 里链接了 jsoncpp:

复制代码
netcal_server:NetCalServer.cc
	g++ -o $@ $^ -std=c++17 -ljsoncpp
#                      ↑ 指定 C++17 标准
#                              ↑ 链接 jsoncpp 库

 两个最常见的报错,正好对应编译链接的两个阶段:

  • fatal error: jsoncpp/json/json.h: No such file or directory → 头文件没找到。要么是没装开发包(apt install libjsoncpp-dev),要么是路径不同(试试 <json/json.h>)。
  • undefined reference to Json::Value::... → 头文件找到了,但没链接库 。检查 -ljsoncpp 有没有加上。链接错误永远发生在编译成功之后,这是编译链接两阶段的基本特征。

十二、速查表

12.1 先把主线串一遍

复制代码
TCP 是字节流,不保留"消息"边界
    ↓
应用层必须自己定界 → 三种方案:定长 / 分隔符 / 长度+内容
    ↓
选"长度+内容":Packet() 加头(len\r\n + 正文 + \r\n)
               Unpack() 去头,必须三态返回(1成功 / 0不够 / -1出错)
    ↓
半包的代码解法:Recv 用 += 累积 + Unpack 精确 erase(不够就一字节不动)
粘包的代码解法:while(true) 循环解到底,一条都不能漏
    ↓
边界解决后,帧里的内容怎么摆?→ 序列化
    ↓
不能直接发结构体!对齐填充 / 字节序 / 指针 / 类型宽度 / 版本演进 / 跨语言
    ↓
选方案:手搓(脆) vs JSON(可读灵活) vs protobuf(小快但需 schema)
    ↓
jsoncpp 三件套:Json::Value(万能容器)+ Reader(解析)+ Writer(生成)
    ↓
设计报文结构体:Request(三个字段)+ Response(结果 + 状态码)
    ↓
组装:分层(网络 / 协议 / 业务)+ 回调(std::function 依赖注入)
    ↓
下沉到内核:UDP 的 len 字段、sk_buff 的 head/data/tail/end、
            nlmsghdr 的 nlmsg_len、sockaddr_in 的公共头部
    ↓
同一个思想,在用户态和内核态各实现了一遍

12.2 概念速查

问题 答案
什么是序列化? 把内存中的对象,转成"与平台无关、与语言无关"的字节序列
什么是反序列化? 把字节序列还原成内存中的对象;输入不可信,必须先校验再使用
定界和序列化是一回事吗? 不是。定界解决"帧从哪到哪"(Packet/Unpack),序列化解决"帧里的字节怎么摆"(Serialize/Deserialize)
为什么不能直接 send(&struct, sizeof(struct))? 对齐填充字节不确定、字节序不同、指针无意义、类型宽度不统一、无法演进、无法跨语言
TCP 为什么有粘包? TCP 是字节流,不保留写入边界;内核按效率合并/切分段,从不记录"应用层写了几次"
UDP 有粘包问题吗? 没有。UDP 保留消息边界(一次 sendto 对应一次 recvfrom),代价是丢包/乱序/截断
write 返回成功,数据到对面了吗? 没有。write 只是把数据拷贝进内核发送缓冲区,什么时候发由内核决定
内核怎么管理收发缓冲区里的报文? 用 sk_buff 描述每个报文,用 sk_receive_queue / sk_write_queue 两条队列排队
最常见的字节序坑在哪? 多字节整数跨主机传输。网络统一用大端,x86 用小端,所以要 htons / htonl

12.3 自定义协议速查

问题 答案
为什么用"长度+内容"定界? 只有它既支持任意长度、又不需要转义、还能一次算出边界位置
报文格式是什么? len\r\n{json正文}\r\n(长度串 + 分隔符 + 正文 + 结束分隔符)
长度为什么用十进制字符串? 人可读、抓包直观、无字节序问题;代价是解析要用 stoi 且可能失败
Unpack 为什么必须三态返回? 要区分"解出一条(1)"/"数据不够(0)"/"真出错(-1)";只有 true/false 会把半包误判成错误
收到半包时 Unpack 做什么? 什么都不做(return 0),缓冲区一个字节都不动
半包在代码上靠什么解决? Recv 里用 += 累积不覆盖;Unpack 成功后 erase 只删已消费的部分
ParseRequest 为什么用 while(true)? 一次 recv 可能带来 N 条报文(粘包),必须循环解完,一条都不能漏
n == 0 时为什么返回 result 而不是空串? 前面几轮循环可能已经算出了应答,返回空串会把它们整批丢掉
inbuffer / outbuffer 的生命周期? inbuffer 跨轮累积不能清 (存着半个包);outbuffer 每轮 clear()(发完即完成)
请求和应答的格式一样吗? 本项目是对称协议,都用 Packet,靠"谁连谁"区分方向;HTTP 用请求行/状态行区分

12.4 jsoncpp 速查

问题 答案
Json::Value 是什么? 一个"什么都能装"的动态类型容器,内部记录自己的实际类型
怎么加字段? root["key"] = value;;嵌套对象直接赋值另一个 Value
怎么当数组用? root.append(value);
怎么解析字符串? Json::Reader reader; reader.parse(s, root); 必须检查返回值
解析失败怎么看原因? reader.parse(s, root, err_string) 的第三个参数带着错误位置
三种 Writer 怎么选? FastWriter 紧凑(发网络用);StyledWriter 美化(打日志用);StreamWriterBuilder 新版推荐、可精细控制
asInt() 键不存在会怎样? 静默返回 0,不报错 。字段名拼错一个字母不会有任何提示(可用 isMember() 检查)
头文件路径? 一般是 <jsoncpp/json/json.h>,部分系统是 <json/json.h>

12.5 结构体设计速查

问题 答案
Request 需要哪些字段? 左操作数、右操作数、运算符------三元组唯一确定一次运算
Response 为什么要有 _code? 不是每次计算都有结果(如除零)。0=成功,1=除零,2=取模除零,3=非法运算符
状态码粒度怎么定? 按使用者的诊断需要定。/ 除零(1) 和 % 除零(2) 分开,因为信息量不同
char _oper 写进 JSON 会变成什么? 数字 43(ASCII 码) ,不是字符串 "+"。因为重载决议把 char 整型提升成了 int
这个坑怎么避免? root["oper"] = std::string(1, _oper);,并对应改成 asString()[0]
为什么 Serialize/Deserialize 是成员函数? "怎么把自己变成字节"是这类数据自己的知识,协议升级只改这一个类

12.6 架构速查

问题 答案
三层职责怎么分? 网络层只管字节;协议层只管切帧/序列化;业务层只管算数
层与层之间怎么连接? std::function 回调 + 构造函数注入(依赖注入)
为什么不让 TcpServer 直接调 Calculator? 会造成耦合,网络模块从此不能被别的业务复用
Protocol 怎么做到业务无关? 它只持有 HandlerRequest_t(Request→Response 的函数),不持有具体业务对象
网络层的类结构用了什么模式? 模板方法模式:Socket 定死流程(建→绑→听),TcpSocket 实现每一步
服务端的并发模型? fork 一连接一进程;SIGCHLD 设为 SIG_IGN 防止僵尸进程

12.7 内核对照速查

问题 答案
UDP 报头里有长度字段吗? 有,len(含 8 字节报头)。这就是内核版的长度前缀
TCP 报头里有长度字段吗? 没有 。TCP 靠 seq 序号定位字节,把定界责任推给应用层
IP 层怎么保证边界? 报头里的 tot_len 保证每个 IP 数据报的边界清楚
sk_buff 的四个指针是什么? head / data / tail / end,圈出 headroom + 有效数据 + tailroom
内核加头为什么快? skb_push 只是移动 data 指针,不拷贝数据 (对比我们的字符串拼接和 erase)
nlmsghdr 和我们的报头什么关系? 同一个思想:nlmsg_len 就是我们的 len;它多了 type / seq / flags
我们的协议缺了什么元信息? 消息类型、序号(配对请求应答)、版本号(_version 成员从未被使用)
sockaddr_in 的 sin_zero[8] 干嘛的? 纯填充,让 sockaddr_in 和 sockaddr 都是 16 字节,强转才安全
htons 算序列化吗? 算。"把机器内部表示转成网络通用表示"就是序列化,只是位精确、二进制的

12.8 踩坑速查

症状 原因 修法
服务器被一句 abc\r\n 打挂 std::stoi 抛异常且无人接(真实 DoS) 校验字符集 / std::from_chars / try-catch
发 -100\r\n 就崩溃 负数长度 + int/size_t 隐式转换 + erase 抛 out_of_range 校验 0 <= len <= MAX_MSG_SIZE,编译加 -Wall -Wextra
偶发丢消息,重试就好 半包没处理完整 / Unpack 循环被提前 return 打断 保证 while(true) 循环处理完整,错误隔离用 continue
一条坏 JSON 导致整批应答丢失 Deserialize 失败时 return std::string() 丢弃了已攒的 result 改成 continue 跳过坏帧
大报文偶发对端解析失败 send 没有循环,可能只发了一半 循环 send,处理 EINTR 和部分发送
日志里 oper 是数字不是字符 char 被整型提升成 int 写进 JSON std::string(1, _oper)
编译报 No such file <jsoncpp/json/json.h> 没装 libjsoncpp-dev 或路径不同 装包 / 改用 <json/json.h>
undefined reference to Json::Value 编译成功但没链接库 Makefile 加 -ljsoncpp

12.9 一句话带走

 TCP 给了你一条字节河,序列化是你自己搭的船。

 定界(len\r\n)决定船有多大,序列化(JSON)决定船上装什么,反序列化决定对面能不能把货卸下来点清楚。而 Unpack 的三态返回值、Recv 的 +=、while(true) 的循环,是保证这条船在半包和粘包的浪里不会翻的三块压舱石。

 至于内核------它早就在用同一套办法了,只是它管的是 sk_buff,我们管的是 std::string。

相关推荐
Nil2081 小时前
leetcode 139单词拆分
linux·运维·服务器
Starry-sky(jing)1 小时前
BUG: unable to handle kernel paging request 完整排查:dmesg 四要素与三路定罪
linux·运维·服务器·内核·排障
mounter6251 小时前
从硬件互连到操作系统变革:CXL 技术演进与 Linux 内核工程挑战
linux·运维·服务器
zhengqweasd1 小时前
机房托管服务器怎么选硬盘,机械盘和 SSD 托管场景差异
运维·服务器·github
xh didida3 小时前
Linux -- 基础IO
linux·服务器·开发语言·c++
心易行者3 小时前
Agent应用+API端点商业化进阶实战:从单体智能体到可付费调用的API全流程
运维·服务器·人工智能·python·apache
科技研学社3 小时前
精度决胜品质:羽绒服缝制工艺标准与自动化精度对标解析
运维·自动化
夜之眷属3 小时前
Core dump 崩溃排查:JVM 宕机后,那份 core 文件怎么用 gdb 还原现场
java·运维·服务器·jvm