草庐IT

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

哲思 2023-04-17 原文

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

作者:哲思

时间: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动态灵活易复用。

有关最佳实践:二进制数据处理与封装的更多相关文章

  1. ruby-on-rails - 使用 Ruby on Rails 进行自动化测试 - 最佳实践 - 2

    很好奇,就使用ruby​​onrails自动化单元测试而言,你们正在做什么?您是否创建了一个脚本来在cron中运行rake作业并将结果邮寄给您?git中的预提交Hook?只是手动调用?我完全理解测试,但想知道在错误发生之前捕获错误的最佳实践是什么。让我们理所当然地认为测试本身是完美无缺的,并且可以正常工作。下一步是什么以确保他们在正确的时间将可能有害的结果传达给您? 最佳答案 不确定您到底想听什么,但是有几个级别的自动代码库控制:在处理某项功能时,您可以使用类似autotest的内容获得关于哪些有效,哪些无效的即时反馈。要确保您的提

  2. ruby - 解析 RDFa、微数据等的最佳方式是什么,使用统一的模式/词汇(例如 schema.org)存储和显示信息 - 2

    我主要使用Ruby来执行此操作,但到目前为止我的攻击计划如下:使用gemsrdf、rdf-rdfa和rdf-microdata或mida来解析给定任何URI的数据。我认为最好映射到像schema.org这样的统一模式,例如使用这个yaml文件,它试图描述数据词汇表和opengraph到schema.org之间的转换:#SchemaXtoschema.orgconversion#data-vocabularyDV:name:namestreet-address:streetAddressregion:addressRegionlocality:addressLocalityphoto:i

  3. ruby - 如何指定 Rack 处理程序 - 2

    Rackup通过Rack的默认处理程序成功运行任何Rack应用程序。例如:classRackAppdefcall(environment)['200',{'Content-Type'=>'text/html'},["Helloworld"]]endendrunRackApp.new但是当最后一行更改为使用Rack的内置CGI处理程序时,rackup给出“NoMethodErrorat/undefinedmethod`call'fornil:NilClass”:Rack::Handler::CGI.runRackApp.newRack的其他内置处理程序也提出了同样的反对意见。例如Rack

  4. ruby - Ruby 有 `Pair` 数据类型吗? - 2

    有时我需要处理键/值数据。我不喜欢使用数组,因为它们在大小上没有限制(很容易不小心添加超过2个项目,而且您最终需要稍后验证大小)。此外,0和1的索引变成了魔数(MagicNumber),并且在传达含义方面做得很差(“当我说0时,我的意思是head...”)。散列也不合适,因为可能会不小心添加额外的条目。我写了下面的类来解决这个问题:classPairattr_accessor:head,:taildefinitialize(h,t)@head,@tail=h,tendend它工作得很好并且解决了问题,但我很想知道:Ruby标准库是否已经带有这样一个类? 最佳

  5. ruby - 我如何添加二进制数据来遏制 POST - 2

    我正在尝试使用Curbgem执行以下POST以解析云curl-XPOST\-H"X-Parse-Application-Id:PARSE_APP_ID"\-H"X-Parse-REST-API-Key:PARSE_API_KEY"\-H"Content-Type:image/jpeg"\--data-binary'@myPicture.jpg'\https://api.parse.com/1/files/pic.jpg用这个:curl=Curl::Easy.new("https://api.parse.com/1/files/lion.jpg")curl.multipart_form_

  6. 世界前沿3D开发引擎HOOPS全面讲解——集3D数据读取、3D图形渲染、3D数据发布于一体的全新3D应用开发工具 - 2

    无论您是想搭建桌面端、WEB端或者移动端APP应用,HOOPSPlatform组件都可以为您提供弹性的3D集成架构,同时,由工业领域3D技术专家组成的HOOPS技术团队也能为您提供技术支持服务。如果您的客户期望有一种在多个平台(桌面/WEB/APP,而且某些客户端是“瘦”客户端)快速、方便地将数据接入到3D应用系统的解决方案,并且当访问数据时,在各个平台上的性能和用户体验保持一致,HOOPSPlatform将帮助您完成。利用HOOPSPlatform,您可以开发在任何环境下的3D基础应用架构。HOOPSPlatform可以帮您打造3D创新型产品,HOOPSSDK包含的技术有:快速且准确的CAD

  7. 叮咚买菜基于 Apache Doris 统一 OLAP 引擎的应用实践 - 2

    导读:随着叮咚买菜业务的发展,不同的业务场景对数据分析提出了不同的需求,他们希望引入一款实时OLAP数据库,构建一个灵活的多维实时查询和分析的平台,统一数据的接入和查询方案,解决各业务线对数据高效实时查询和精细化运营的需求。经过调研选型,最终引入ApacheDoris作为最终的OLAP分析引擎,Doris作为核心的OLAP引擎支持复杂地分析操作、提供多维的数据视图,在叮咚买菜数十个业务场景中广泛应用。作者|叮咚买菜资深数据工程师韩青叮咚买菜创立于2017年5月,是一家专注美好食物的创业公司。叮咚买菜专注吃的事业,为满足更多人“想吃什么”而努力,通过美好食材的供应、美好滋味的开发以及美食品牌的孵

  8. Ruby - 如何将消息长度表示为 2 个二进制字节 - 2

    我正在使用Ruby,我正在与一个网络端点通信,该端点在发送消息本身之前需要格式化“header”。header中的第一个字段必须是消息长度,它被定义为网络字节顺序中的2二进制字节消息长度。比如我的消息长度是1024。如何将1024表示为二进制双字节? 最佳答案 Ruby(以及Perl和Python等)中字节整理的标准工具是pack和unpack。ruby的packisinArray.您的长度应该是两个字节长,并且按网络字节顺序排列,这听起来像是n格式说明符的工作:n|Integer|16-bitunsigned,network(bi

  9. FOHEART H1数据手套驱动Optitrack光学动捕双手运动(Unity3D) - 2

    本教程将在Unity3D中混合Optitrack与数据手套的数据流,在人体运动的基础上,添加双手手指部分的运动。双手手背的角度仍由Optitrack提供,数据手套提供双手手指的角度。 01  客户端软件分别安装MotiveBody与MotionVenus并校准人体与数据手套。MotiveBodyMotionVenus数据手套使用、校准流程参照:https://gitee.com/foheart_1/foheart-h1-data-summary.git02  数据转发打开MotiveBody软件的Streaming,开始向Unity3D广播数据;MotionVenus中设置->选项选择Unit

  10. 使用canal同步MySQL数据到ES - 2

    文章目录一、概述简介原理模块二、配置Mysql使用版本环境要求1.操作系统2.mysql要求三、配置canal-server离线下载在线下载上传解压修改配置单机配置集群配置分库分表配置1.修改全局配置2.实例配置垂直分库水平分库3.修改group-instance.xml4.启动监听四、配置canal-adapter1修改启动配置2配置映射文件3启动ES数据同步查询所有订阅同步数据同步开关启动4.验证五、配置canal-admin一、概述简介canal是Alibaba旗下的一款开源项目,Java开发。基于数据库增量日志解析,提供增量数据订阅&消费。Git地址:https://github.co

随机推荐