最佳实践:二进制数据处理与封装

作者:哲思

时间:2022.8.4

邮箱:zhe__si@163.com

GitHub:zhe-si (哲思) (github.com)

前言

最近在研究所做网络终端测试的项目,包括一些嵌入式和底层数据帧的封装调用。之前很少接触对二进制原始数据的处理与封装,所以在此进行整理。

以下例子主要以 c++ 语言进行说明。

什么是二进制数据

在电脑上一切数据都是通过二进制(0或1)进行存储的,通过多位二进制数据可以进而表示整形、浮点型、字符、字符串等各种基础类型数据或者一些更复杂的数据格式。

针对日常中一般的需求进行编程,我们通常无需关注底层的二进制数据。但如果要处理二进制文件(音频、视频、图片等)、设计空间上更高效的数据结构(网络数据帧、字节码、protobuf)或者处理某些底层时,需要我们处理这些二进制数据。

计算机中,称每一个二进制位为比特(bit,也称:位),是计算机中的最小存储单位。

每 8 比特组成一个字节(byte),一般是计算机实际存储和处理的最小单位(可以是它的倍数),也就是说,计算机是以字节为最小单位分配空间或进行计算的,不能分配比字节更小的存储空间(如,最小的数据类型是char,长度 1 字节,不支持申请 6 比特存储空间)或者直接处理小于字节单位的数据(如,两个 4 比特的数据相加减)。

若干字节构成一个计算机字(简称:字,word),表示计算机一次性处理事务的固定长度二进制数据,字的位数为字长。计算机是以字为单位处理或运算的,两个常见的概念是CPU位数操作系统位数

CPU 的位数就是指 CPU 执行一次指令能处理的最大位数(一个字长),和 CPU 中的寄存器的位数对应。其中,地址寄存器 MAR 限制了计算机的寻址范围,数据寄存器 MDR 限制了一次处理的数据长度。更多的位数带来了更大的寻址空间和更强的运算能力。

说明:寻址范围不等于内存大小,寻址对象有内存条、显卡内存、声卡、网卡和其他设备。之所以常把寻址范围当作内存上限,是因为内存是CPU的主要寻址对象。

这里解释一下常见的指令架构:x86 是 intel 推出的一种指令集架构(复杂指令集 CISC 架构),一开始只有32位的,叫 x86_32;后来 AMD 公司推出了兼容 x86_32 的 64 位指令集 amd64,被业界接受,intel 将其改名为 x86_64,简称 x64,而 x86_32 和 x86_64 可统称为 x86。与 x86 相对的是基于精简指令集RISC架构的 ARM 指令集架构,多用于移动设备。

操作系统基于 CPU 指令集实现,所以操作系统位数也直接对应 CPU 位数。由于 CPU 指令集的向下兼容性,所以 32 位操作系统也可以运行在 64 位的 CPU 上,但反过来不行。操作系统对软件提供了向下兼容的能力,64 位的操作系统支持 64 和 32 位的程序,但 32 位的操作系统只支持 32 位的程序。

处理二进制数据

在大多语言中,最小的数据类型是 char,一个字节,二进制数据多用 unsigned char 表示,并写作 uint8。语言底层常把它当作 int 进行运算。

二进制常数以“0b”开头,如:0b001。二进制数据也常用8进制(以“0”开头)和 16 进制(以“0x”开头)表示,如:0257(175,八进制)、0x1f(31,16进制)。8 进制 1 个数字表示 3 位二进制数据,16 进制 1 个数字表示 4 位二进制数据,一个字节可以用 2 个 16 进制数表示。

若要处理小于一字节的数据,就要使用位运算符(&、|、^、~、>>、<<)。

位运算符 描述 运算规则 用途
& 两个位都为1时,结果才为1 二进制位清零或得到指定位数据
| 两个位都为0时,结果才为0 二进制位设置为1;与对应位为0的数据相加
^ 异或 两个位相同为0,相异为1 反转指定位
~ 取反 0变1,1变0 二进制位全部取反
<< 左移 各二进位全部左移若干位,高位丢弃,低位补0 \(x*2^n\);将数据移到高位
>> 右移 各二进位全部右移若干位,对无符号数,高位补0,有符号数,各编译器处理方法不一样,有的补符号位(算术右移),有的补0(逻辑右移) \(x/2^n\);将数据移到低位

举个例子,判断某个字节的第3位是否是1:

// 先清0其他位,再判断是否等于0b100
bool isOne = (byte & 0b100) == 0b100;

再举个例子,计算机网络 IP 协议中的 control flag 和 fragment offset 合起来存储在 IP 头部的第 7、8 字节,flag 占前三位,后 13 位为 fragment offset,可以通过以下运算获得 flag 和 offset:

// 获得flag要截取byte7前3位数据:先清空后5位,保留前3位数据,再右移5位将前3位数据移到起始
uint8_t flag = (byte7 & 0b11100000) >> 5;
// 此处以大端存储,获得offset要截取byte7的低5位作为高位,byte8作为低位,求和:先清空byte7前3位,保留后5位数据,把它移到高8位上,再通过全0的低8位与byte8按位求或来求二者之和
((byte7 & 0b00011111) << 8) | byte8;

补充说明,当需要多个字节表示一个数据类型时,需要定义数据的高位字节是存储在高位地址空间还是低位地址空间,这就是大小端的定义。大端指高位字节存在低位地址,这是人的手写习惯;小端指低位字节存高位地址。在处理用多个字节表示的数据时,首先要搞清楚数据是大端还是小端。

所以,我们可以基于上述知识写一个无符号整形与字节流相互转换的通用方法:

// true为大端,低位地址存高位字节
bool ENDIAN = true;

/**
 * 将data转换为无符号整形数字(无符号char,short,int,long,long long等)
 * @tparam T 目标类型,默认为 uint32_t
 * @param data 载荷数据 byte数组
 * @param valueSize 数据长度,单位:byte,-1表示根据T类型自动计算
 * @param default_value 默认值,默认为0
 * @return 根据data转换的无符号整形数据
 */
template<typename T = uint32_t>
T payloadToUnsignedInt(std::vector<uint8_t> data, int valueSize = -1, T default_value = uint32_t(0)) {
    if (valueSize == -1) valueSize = sizeof(T);
    if (valueSize > data.size()) return default_value;
    T value = 0;
    for (int i = 0; i < valueSize; i++) {
        if (ENDIAN) {
            value |= (data[i] & 0xff) << ((valueSize - 1 - i) << 3);
        } else {
            value |= (data[i] & 0xff) << (i << 3);
        }
    }
    return value;
}

/**
 * 无符号整形转换为载荷 byte数组
 * @param value 无符号整形数据
 * @param valueSize 数据长度,单位:byte,-1表示根据T类型自动计算
 * @return 载荷 byte数组
 */
template<typename T>
std::vector<uint8_t> uintToPayload(T value, int valueSize = -1) {
    if (valueSize == -1) valueSize = sizeof(T);
    std::vector<uint8_t> data(valueSize, 0);
    for (int i = 0; i < valueSize; i++) {
        if (ENDIAN) {
            data[i] = (value >> ((valueSize - 1 - i) << 3)) & 0xff;
        } else {
            data[i] = (value >> (i << 3)) & 0xff;
        }
    }
    return data;
}

封装二进制数据

掌握了二进制数据的处理方法,接下来就是对二进制数据的封装,将其封装为人可以理解的对象。

二进制数据通常以 uint8_t 数组表示,不同位有不同的含义,需要根据实际含义进行解析后得到有意义的目标信息。所以重点就是描述每一位的含义,并基于该描述解析二进制数据,提供二进制数据与有含义的对象的相互转换。

思路1:基于配置文件

此处以自定义的二进制指令封装为例进行说明(项目地址),但该配置项目适用于任意二进制数据封装场景。面对这个需求,首先想到的是通过配置文件描述二进制流每一位的含义,加载配置文件后根据一些过滤条件配置确定当前二进制流段实际对应的配置并解析为字典。

由于项目包括一些嵌入式的内容,需要把所有文件编译后烧入板子,不支持存储普通文件格式的配置文件,所以采用变量形式的配置,全局声明配置的类型信息和配置对象(cmd_manager),项目内任意位置定义该配置对象即可。在其他场景也可选择 Json、xml 等配置格式。

本文设计的配置对象定义方式如下:

/**
 * 载荷配置项
 */
const CmdManager cmd_manager = { 2, {  // 指令个数,下面是每一个指令的配置
        {"TCRQ", 3, {  // 配置项名,配置项对应的字段数
            {"TE_SEQ_NO", -1, &FT_SHORT, 0},  // 具体配置项内字段配置(字段名,字段偏移,字段类型,配置项该字段过滤条件
            {"CMD", -1, &FT_CHARS_4, "TCRQ"},  // 配置项要求该字段等于"TCRQ",数据不满足则不匹配该配置项
            {"REPEAT_COUNT", -1, &FT_SHORT, 0}}}
}};

项目会自动加载该配置对象,之后针对原始二进制数据通过 PayloadObjectMapFactory 工厂匹配对应配置并生成数据对象,可从数据对象获得该对象类型(配置项名)并读写其中的字段值。或者指定配置项创建空的数据对象,进行数据设置后获得其原始二进制数据载荷。

评价:

该思路通过配置文件可以自由且动态的调整解析方式,易于复用、拓展或调整。其难点在于配置格式的设计,同时字典类型数据无法如直接声明类型结构那样清晰易用。

思路2:基于数据底层存储方式

此处以计算机网络数据帧封装为例进行说明。c++ 底层对对象/结构体的成员字段采用类型对齐连续存储方式,使用该特性可以基于实际含义自然声明、使用字段,同时可以直接作为二进制数据流处理。实现示例如下:

/**
 * 数据抽象类,提供二进制流到对象的相互转化能力
 * 内部类,只复用代码,不用于多态
 * @tparam size 数据字节长度
 */
template<int size>
class DataType {
public:
    DataType() { resetData(); }
    // 初始化所有数据
    void resetData() const { memset((void *) (this), 0, size); }
    // 从二进制流加载数据
    bool loadData(const std::vector<uint8_t>& data, int startIndex=0) {
        auto * p = (uint8_t *) this;  // 将自身当作二进制数组处理
        for (int i = 0; i < size; i++) {
            *p = data[i + startIndex];
            p++;
        }
        return true;
    }
    // 基于自身生成新的二进制数据流
    [[nodiscard]] std::vector<uint8_t> createData() const {
        std::vector<uint8_t> result;
        auto p = (uint8_t const *) this;
        for (int i = 0; i < size; i++) {
            result.push_back(*p);
            p++;
        }
        return result;
    }
    [[nodiscard]] int getSize() const { return size; }
};

// 以顺序声明方式定义具体的二进制数据类型,支持嵌套声明
class MACHeader : public DataType<14> {
public:
    // 通过上述无符号整形与字节流相互转化的方法将netType的读写进行封装
    [[nodiscard]] uint16_t getNetType() const {
        return payloadToUnsignedInt(std::vector<uint8_t>(netType.begin(), netType.end()), 2, uint16_t(0));
    }
    void setNetType(uint16_t _netType) {
        auto data = uintToPayload(_netType, 2);
        std::copy(data.begin(), data.end(), netType.begin());
    }

    // 提供与json互转的能力,为了提供映射为python对象的能力
    bool loadJson(const Json::Value& json);
    [[nodiscard]] Json::Value createJson() const;

    std::array<uint8_t, 6> desMac;  // 占多个字节的数据采用std::array数组描述,可避免类型丢失,同时保证数据类型仍然一致对其
    std::array<uint8_t, 6> srcMac;
    std::array<uint8_t, 2> netType;
};

本项目还需要提供 c++ 的数据帧对象映射到 python 对象的能力,为了简化 CPython 的拓展方法接口,c++ 层提供从 json 加载或生成 json 的能力,在 python 层实现一个 json 缓存,通过缓存提交和更新实现数据管理。为了致敬git,项目实际提交和更新方法命名为 push 和 pull,(╯▔^▔)╯。

评价:

该思路通过一种类似顺序声明的方式(有点像配置)定义数据流每个位置的实际含义,使用时清晰直接,并巧妙的通过其底层原理便捷的在对象和二进制数据流之间提供转化操作。但由于其需要实际声明类型,不如思路1动态灵活易复用。