最佳实践:二进制数据处理与封装
作者:哲思
时间: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);
}
}
re