高中就有写一个集控的想法,做一些只可意会不可言传的事情hhh,暑假借用科技实现了,上半部分围绕协议的基础设计和数据解析

协议设计

消息头

协议采用最经典也最稳妥的 “定长头 + 变长 payload” 结构。每条消息由 12 字节的固定头紧跟 payload 组成:

// #pragma pack(push, 1) 让编译器按 1 字节对齐,
// 即不在结构体成员之间插入任何填充字节。
// 这样 sizeof(MsgHeader) 在所有平台都严格等于 12,
// 可以安全地用 memcpy 直接读写、跨二进制交互。
#pragma pack(push, 1)
struct MsgHeader {
    uint32_t magic;   // 协议魔数,固定为 0x5243544C (ASCII "RCTL")
    uint32_t type;    // 消息类型,见 enum MsgType
    uint32_t length;  // 紧随其后的 payload 字节数
};
#pragma pack(pop)     // 恢复默认对齐

关于这里用到的类型与编译指令:

  • uint32_t:来自 <cstdint>,是“固定宽度 32 位无符号整数”。C++ 标准保证它恰好 4 字节,不像 unsigned int 在不同平台可能不一样。协议头必须用固定宽度类型,否则跨平台时 sizeof 会变化,协议就乱了。
  • #pragma pack(push, 1) / #pragma pack(pop):MSVC / GCC / Clang 都支持的编译指令。push 把当前对齐设置压栈并设为 1 字节对齐,pop 恢复。如果不加,编译器默认会按 4 或 8 字节对齐(这是我让ai评估代码,给出的改进意见,说的确实有道理,不过暂时没有插入异端uint8/16的想法)

设计要点:

  • 长度前缀length 字段直接给出 payload 长度,接收端“先读 12 字节头 → 再读 length 字节 payload”即可成帧,无需扫描分隔符,性能和稳定性都优于分隔符方案。
  • 魔数 magic:取 0x5243544C,即 ASCII 串 "RCTL"。它有两个作用:
    1. 协议识别:在连接建立初期、或被误连的客户端发送垃圾数据时,魔数可以快速判定“这不是我们的协议”。

    2. 流错位恢复:当解析出错、缓冲区与消息边界错位时,魔数不匹配会触发“丢弃 1 字节后重试”的策略,给流重新对齐的机会。

      举一个具体例子(以小端机器为例,魔数 0x5243544C 在内存中按字节看是 4C 54 43 52)。假设某次接收后,缓冲区因为前面混入了一个垃圾字节 AA 而与消息边界错位了 1 字节:

      正常对齐:    [4C 54 43 52] [type 4B] [length 4B] [payload...]
                    ↑ 魔数起始
      
      错位 1 字节:  [AA] [4C 54 43 52] [type 4B] [length 4B] [payload...]
                    ↑ 首字节是垃圾
      • 第 1 次 tryParse:读到前 4 字节 AA 4C 54 43memcpyuint32_t 得到 0x43544CAA0x5243544C,魔数不匹配 → consumed = 1,调用方删掉首字节 AA
      • 第 2 次 tryParse:缓冲区开头变为 4C 54 43 52 ...memcpy0x5243544C = MSG_MAGIC,匹配成功,流重新对齐,继续正常解析后续的 type / length / payload

      如果没有这个“丢 1 字节重试”的机制,一旦错位,后续所有字节都会被永久误解,整条连接只能作废。这个策略给了流一个自我修复的机会。

消息类型与上限

type 是一个 uint32_t 枚举,覆盖了心跳、认证、命令、文件传输、进程控制、输入模拟等所有业务。设计上遵循 “请求-响应成对” 的约定:每个 *_REQ 通常对应一个 *_*_RSP,便于上层做配对追踪。

两个全局上限用于防护:

// constexpr: 编译期常量,比 #macro 更类型安全
constexpr uint32_t MSG_MAGIC       = 0x5243544C;            // "RCTL"
constexpr uint32_t MAX_PAYLOAD     = 32 * 1024 * 1024;      // 单条消息上限 32MB
constexpr uint32_t FILE_CHUNK_SIZE = 4  * 1024 * 1024;      // 文件分片 4MB

MAX_PAYLOAD 是一道安全阀:解析时若 length 超过它,直接抛异常断开连接,避免恶意端用一个虚假的 4GB 长度诱导接收端 vector::resize 出 OOM(Out Of Memory)。

消息解析

每条消息在代码里表示为:

struct Message {
    uint32_t type = 0;                  // 消息类型,对应 MsgType
    std::vector<uint8_t> payload;       // 变长负载,vector<uint8_t> 是“可变长字节缓冲区”的标准选择

    // 把 type + payload 序列化成连续字节流(含消息头)
    std::vector<uint8_t> serialize() const;

    // 尝试从 buf 中解析一条完整消息
    // 返回 true 表示成功,consumed 告知调用方消费了多少字节
    // 返回 false 表示数据不足或魔数错(consumed=0 半包,consumed=1 丢一字节重试)
    static bool tryParse(const std::vector<uint8_t>& buf,
                         Message& out, size_t& consumed);
};

关于 std::vector<uint8_t>

  • uint8_t 本质字节。vector<uint8_t> 等价于一个可动态扩容的字节数组,比 char* 更安全(自动管理内存)。
  • vectorsize() 是 O(1) 的,所以读取长度不需要额外存一个 length 字段。

serialize() 把头和 payload 拼成连续字节流:


std::vector<uint8_t> Message::serialize() const {
    if (payload.size() > MAX_PAYLOAD) {
        // 抛标准库异常,上层用 try/catch 捕获
        // runtime_error 来自 <stdexcept>,表示运行时才可发现的错误
        throw std::runtime_error("payload too large");
    }
    std::vector<uint8_t> buf;
    // reserve 预分配容量,避免后续 insert 多次扩容(vector 扩容是 O(n) 拷贝)
    buf.reserve(sizeof(MsgHeader) + payload.size());

    MsgHeader h;
    h.magic  = MSG_MAGIC;                          // 固定魔数
    h.type   = type;
    h.length = static_cast<uint32_t>(payload.size()); // size_t 转 uint32_t,前面已检查不超限

    // reinterpret_cast: 把结构体的二进制布局重新解释为字节指针
    // const uint8_t* p 指向 h 的首字节,长度 sizeof(h)=12
    const uint8_t* p = reinterpret_cast<const uint8_t*>(&h);
    buf.insert(buf.end(), p, p + sizeof(h));       // 追加 12 字节头
    buf.insert(buf.end(), payload.begin(), payload.end()); // 追加 payload
    return buf;
}

这里用到的 std::vector::insert 是 STL 容器的核心接口之一,作用是在指定迭代器位置之前插入元素。它的几个常用重载签名如下(详见参考链接):

// 文件: <vector>,类 std::vector<T>
// 诡异且神奇的函数重载增加了

// 1) 在 pos 之前插入单个元素 value
iterator insert(const_iterator pos, const T& value);

// 2) 在 pos 之前插入 count 个 value 的副本
iterator insert(const_iterator pos, size_type count, const T& value);

// 3) 在 pos 之前插入 [first, last) 范围内的元素(模板,适配任意输入迭代器)
template <class InputIt>
iterator insert(const_iterator pos, InputIt first, InputIt last);

// 4) 在 pos 之前插入初始化列表(C++11)
iterator insert(const_iterator pos, std::initializer_list<T> ilist);

本代码用的是第 3 种(迭代器范围版)buf.insert(buf.end(), p, p + sizeof(h))

  • pos = buf.end():插入位置是末尾迭代器,即在尾部追加(end() 指向“末尾之后”,所以在它之前插入 = 追加)。
  • first = plast = p + sizeof(h):要插入的范围。原始指针本身就是合法的输入迭代器——const uint8_t* 满足 InputIt 概念,所以 pp + sizeof(h) 构成一个 [p, p+12) 的半开区间,正好覆盖头部的 12 个字节。
  • insert 会把该范围内的元素逐个复制进容器,不会接管源内存的所有权(p 指向栈上局部变量 h,函数返回后失效,但内容此刻已被拷走)。

与逐个 push_back 相比,insert(pos, first, last) 一次调用插入一整段范围,配合前面的 reserve 通常只需一次扩容判定,效率更高。下一行 buf.insert(buf.end(), payload.begin(), payload.end()) 同理,把整个 payload 追加到 buf 末尾。

注意:pos 必须是 *this(即 buf 自己)的有效迭代器。跨容器混用迭代器属于未定义行为。

关于 reinterpret_cast<const uint8_t*>(&h)

  • 这是 C++ 四种 cast 中最“暴力”的一种,直接重新解释内存地址的类型。这里把 MsgHeader* 当成 uint8_t* 来逐字节拷贝,等价于 memcpy。因为 MsgHeader#pragma pack(1) 的 POD 类型,这种用法是安全的。

tryParse() 是协议的关键函数,负责流式解析

bool Message::tryParse(const std::vector<uint8_t>& buf, Message& out, size_t& consumed) {
    consumed = 0;

    // 情况 1: 连 12 字节头都没收全 → 半包,等更多数据
    // sizeof(MsgHeader) 在编译期确定,= 12
    if (buf.size() < sizeof(MsgHeader)) {
        return false;
    }

    MsgHeader h;
    // memcpy: 从 buf.data() 拷贝 12 字节到 h
    // 来自 <cstring>,等价于按字节复制,比逐字段赋值更简洁
    std::memcpy(&h, buf.data(), sizeof(h));

    // 情况 2: 魔数不匹配 → 流错位,丢 1 字节让上层重试对齐
    if (h.magic != MSG_MAGIC) {
        consumed = 1;   // 只消费 1 字节,调用方会 erase 首 1 字节后继续 tryParse
        return false;
    }
    // 情况 3: 长度超限 → 恶意/损坏数据,直接抛异常断开
    if (h.length > MAX_PAYLOAD) {
        throw std::runtime_error("message payload exceeds limit");
    }

    // 计算这条消息的总字节数: 头 + payload
    size_t total = sizeof(MsgHeader) + h.length;
    // 情况 4: payload 没收全 → 半包,等更多数据
    if (buf.size() < total) {
        return false;
    }

    // 情况 5: 完整消息到手
    out.type = h.type;
    // assign(first, last): 把迭代器范围内的元素复制到 payload
    // begin()+sizeof(MsgHeader) 跳过头部,begin()+total 取到 payload 末尾
    out.payload.assign(buf.begin() + sizeof(MsgHeader), buf.begin() + total);
    consumed = total;   // 告知调用方消费了 total 字节
    return true;
}

这里用到的 std::vector::assign 同样是 STL 容器的核心接口,作用是用新内容整体替换容器现有内容(先销毁旧元素、可能重分配,再填入新元素)。它的几个常用重载签名(详见参考链接):

// 文件: <vector>,类 std::vector<T>

// 1) 用 count 个 value 的副本替换内容
void assign(size_type count, const T& value);

// 2) 用 [first, last) 范围内的元素替换内容(模板)
template <class InputIt>
void assign(InputIt first, InputIt last);

// 3) 用初始化列表替换内容(C++11)
void assign(std::initializer_list<T> ilist);

本代码用的是第 2 种(迭代器范围版)out.payload.assign(buf.begin() + sizeof(MsgHeader), buf.begin() + total)

  • first = buf.begin() + sizeof(MsgHeader):跳过 12 字节头部,指向 payload 的起始字节。
  • last = buf.begin() + total:指向这条消息的结尾(total = 头 + length)。
  • 半开区间 [first, last) 正好覆盖 length 字节的 payload。

assigninsert 的关键区别

接口行为是否保留原内容
assign(first, last)清空容器,再把 [first, last) 拷入否,整体替换
insert(pos, first, last)pos 前拷入 [first, last),原有元素后移是,追加插入

这里用 assign 而非 insert,是因为每次解析出新消息时,out.payload 应被这条消息的负载整体替换,不应保留上一次的旧数据。若误用 insert,多条消息的 payload 会累积拼接,解析必然出错。

性能提示:assign 会先销毁现有元素并可能重分配容量。若新范围大于旧容量,触发一次堆分配;若小于等于旧容量,则复用已有内存。在频繁解析的场景下,复用同一个 out 对象(而非每条消息 new 一个)能显著减少分配开销——这也是 tryParse 设计成“填充 out 引用”而非“返回新对象”的原因之一。

consumed 是一个精巧的设计:它告诉调用方“这次解析吃掉了缓冲区前多少字节”,调用方据此 erase 已消费部分、保留剩余部分继续拼接下一条。返回值的三种语义分别是:

返回值 / consumed含义调用方动作
true / total成功解析一条处理消息,删除前 total 字节
false / 0数据不足(半包)退出循环,等下次 recv
false / 1魔数不匹配删除 1 字节,继续尝试重新对齐

Payload 的内部序列化

Payload 内部并不是无结构的字节,而是采用 “长度前缀 + 数据” 的 TLV 变体(Type-Length-Value,这里省略了 Type,只用 Length-Value)。每个业务结构都成对提供:

// 文件: src/common/protocol.h

struct AuthRequest {
    std::string token;                          // 认证令牌

    std::vector<uint8_t> toPayload() const;     // 序列化为字节流
    static AuthRequest fromPayload(const std::vector<uint8_t>& p); // 反序列化
};

辅助函数只做最朴素的内存拷贝:

// 文件: src/common/protocol.cpp (static 函数,仅本文件可见)

// write_u32: 把一个 32 位无符号整数按主机字节序追加到 out 末尾
// reinterpret_cast 把 &v 解释为字节指针,insert 追加 4 字节
static void write_u32(std::vector<uint8_t>& out, uint32_t v) {
    const uint8_t* p = reinterpret_cast<const uint8_t*>(&v);
    out.insert(out.end(), p, p + sizeof(v));    // sizeof(v) = 4
}

// read_u32: 从指针 p 处读取 4 字节为一个 uint32_t
// memcpy 把 4 字节原样复制到 v,不做字节序转换
static uint32_t read_u32(const uint8_t* p) {
    uint32_t v = 0;
    std::memcpy(&v, p, sizeof(v));
    return v;
}

// write_u64 / read_u64 / write_i32 / read_i32 同理,只是宽度不同

字符串的通用编码方式是 u32 长度 + UTF-8 字节,二进制数据同理。例如一个含“状态码 + 消息字符串 + 二进制数据”的响应会被编码为:

[ u32 status ][ u32 msg_len ][ msg bytes ][ u32 data_len ][ data bytes ]

这种“自描述长度前缀”的方式带来三个好处:

  • 解析时可以做边界校验:每读一个长度字段都检查 p.size() < off + n,不合法就抛异常,杜绝越界读。
  • 向前兼容性好:新增字段追加在末尾,旧解析器读不到就忽略;旧字段含义稳定时不破坏老客户端。
  • 无需引入第三方序列化库:协议很轻量,几行 memcpy 就能搞定,依赖少、可审计性强。

再提字节序

一个 uint32_t v = 0x01020304 在内存里的 4 个字节如何排列?有两种主流约定:

  • 小端序(Little-Endian):低位字节存放在低地址。04 03 02 01。x86 / ARM(默认)都是小端。
  • 大端序(Big-Endian):高位字节存放在低地址。01 02 03 04。网络协议、部分嵌入式、Java 虚拟机内部用大端。

TCP/IP 协议族规定多字节整数在网络上传输时使用网络字节序(大端)。POSIX 和 WINAPI(俩难得颗粒度对齐如此一致) 提供了四个转换函数:

// 来自 <arpa/inet.h> (Linux) 或 <winsock2.h> (Windows)

uint16_t htons(uint16_t hostshort);   // host to network short (16位)
uint32_t htonl(uint32_t hostlong);    // host to network long  (32位)
uint16_t ntohs(uint16_t netshort);    // network to host short
uint32_t ntohl(uint32_t netlong);     // network to host long

经查证,其中 h = host(主机序),n = network(网络序),to 表示转换方向。htons 即“主机序转网络序,16 位”。

本系统的协议层并没有magic / type / length 以及 payload 内的整数做字节序转换,而是直接使用主机字节序

// 头直接 memcpy 到结构体,不转换
std::memcpy(&h, buf.data(), sizeof(h));

// 整数直接按本机内存布局写入,不转换
const uint8_t* p = reinterpret_cast<const uint8_t*>(&v);
out.insert(out.end(), p, p + sizeof(v));

因为考虑到:

  1. 两端同构:服务端和被控端都运行在 x86 / ARM 这类小端机器上,主机序一致,转换是空操作。
  2. 简化代码:省掉大量 htonl/ntohl 调用,序列化代码更短、更不易出错。

(灰太狼·去,哪来这么多规矩.jpg)

至于Socket API,协议层用主机序,但 Socket API 层仍然遵守网络序。比如端口和 IP 地址:

// 端口必须用 htons 转换,因为 sin_port 字段按网络序存储
addr.sin_port = htons(port);

// IP 地址由 inet_pton 处理,它会自动按网络序写入 sin_addr
inet_pton(AF_INET, host.c_str(), &addr.sin_addr);

这两者并不矛盾——Socket API 是操作系统约定的标准接口,必须按规矩来;而协议体是应用层自定义的,可以自己说了算。


参考链接

C++ STD

字节序

综合参考