基于反序位域的大端协议处理方法

综述

本文主要描述如何在C/C++软件中以一种简便的方法处理通信协议中的大小端转换方法;由于资源限制,目前在仅在windows平台的部分编译器与cpu中进行验证。

大小端

数据在内存中存储的基础单元是字节(Byte),最小的存储单元是位(bit);在常用的ARM架构中,按照字节处理数据时,数据总是低位在后,高位在前,即位LSB(least significant bit,LSB);而单个Byte所能表达的数据量有限,C/C++中扩展了众多以多个Byte表达的基础类型,如常用的int、uint32_t等等。

CPU处理内存中存储多个Byte的方式有两种,分别是大端模式与小端模式;所谓的大端模式,就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。所谓的小端模式,就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。

举个例子,比如数字 0x12 34 56 78在内存中的表示形式为:

MSB:

Byte 0 1 2 3
Data 0x12 0x34 0x56 0x78

LSB:

Byte 0 1 2 3
Data 0x78 0x56 0x34 0x12

验证大小端的方法

验证CPU的大小端的方式,可以通过指针或者联合体,判断一个32位无符号数的第1个字节的具体数值进行判断;方法如下:

c 复制代码
#include "stdio.h"
#include "stdint.h"

union MsbVerify
{
    uint32_t m_word;
    uint8_t  m_bytes[sizeof(uint32_t)];
};

int main()
{
    union MsbVerify _verify_code;
    _verify_code.m_word = 0x12345678;

    if(_verify_code.m_bytes[0] == 0x12)
    {
        printf("MSB\n");
    }
    else if(_verify_code.m_bytes[0] == 0x78)
    {
        printf("LSB\n");
    }

    return 0;
}

大小端的优劣

以人类的视角来看,数据的大端存储上更加符合人类从左往右的阅读习惯;但从计算机的角度来说,小端在数据处理方面更具有优势。

  1. 方便拓展

    当计算机存储256以内的无符号数uint8_t时,只需要一个字节即可表示;而当超过这个范围时,小端模式下只需要在后一个地址增加一个Byte即可扩展成uint16_t。如下, 当数据从0x0034扩展成2Byte的0x1234时,小端模式只需要将地址1中的数据填充成0x12即可;

    地址 0 1 => 0 1
    数据 0x34 0x00 => 0x34 0x12

    而对于大端数据来说,则需要先将地址0中的数据搬到地址1中,再在地址0中填充数据:

    地址 0 1 => 0 1 => 0 1
    数据 0x34 0x00 => 0x00 0x34 => 0x12 0x34

    相比于小端模式,大端模式需要更多的内存操作步骤才能完成一个数据的转存,大端的操作会消耗更多的CPU指令周期,导致涉及此类操作的内存访问效率降低;

  2. 计算

    同条目1,当CPU对数据进行计算时,以同样的一个自加计算为例,小端模式只需要在0地址进行自加;而大端需要根据数据长度,移动对应Byte的地址,再进行自加;这样的寻址过程同样占用CPU指令周期,造成性能降低;

具体可以参考epcdiy大佬的视频,此处也不再继续展开。
为啥50年前的怪异设计,依然用在现代手机电脑上?

位域

位域的定义与用法

关于位域的定义以及使用方法可以参考百度百科

对齐

由于CPU总线的限制,以32位CPU为例,其内存总线是32bit,意味着CPU的一个指令周期可以读取32位的内存;对应到RAM中,如果同样以一个32bit的内存块进行访问时,效率总是最高的。

而在内存设计中,地址总是从0开始,意味着CPU每次都是从4的倍数字节访问内存;那么,当一个数据被拆分到两个32bit时,CPU就必须要访问两次内存才能获取这个完整的数据。因此,C编译器为了保障效率,通常会将结构体的被定义的变量放在CPU能一次读取内存即可访问到的位置;对应到内存地址的表现上即为4字节对齐。

参考如下结构体设计,其最终的内存排布应该是怎么样的?

c 复制代码
struct MemAlignTest
{
    uint16_t data0;
    uint8_t  data1;
    uint16_t data2;
    uint32_t data3;
    uint8_t  array[3];
    uint16_t data4;
    uint64_t data5;
};

按照其定义中的描述,这个结构体的大小应当为22字节;在开发者的主观意图中,是希望这些数据能按照地址从小到大依次排满整个内存空间;即按照如下排布:

基地址 \偏移 0x00 0x01 0x02 0x03
0x00000000 data0_B0 data0_B1 data1 data2_B0
0x00000004 data2_B1 data3_B0 data3_B1 data3_B2
0x00000008 data3_B3 array[0] array[1] array[2]
0x0000000C data4_B0 data4_B1 data5_B0 data5_B1
0x00000010 data5_B2 data5_B3 data5_B4 data5_B5
0x0000000C data5_B6 data5_B7 reserve reserve

通过如下代码进行查看:

c 复制代码
    struct MemAlignTest align_test ;
    memset(&align_test,0xAA,sizeof(align_test));
    align_test.data0 = 0x1234;
    align_test.data1 = 0x88;
    align_test.data2 = 0xCCCC;
    align_test.data3 = 0xCCDDEEFF;
    align_test.array[0] = 0x11;
    align_test.array[1] = 0x22;
    align_test.array[2] = 0x33;
    align_test.data4 = 0xA9A9;
    align_test.data5 = 0x1234567800ABCDEF;
    printf("MemAlignTest size :%d\n",sizeof(align_test));
    memdump(&align_test,sizeof(align_test),8,4);

其输出结果为:

可以看到实际的数据长度未32字节,与我们预想的22字节差了10个字节,这就意味着因为对齐机制导致某些字节中间被强制插入了一些用户未定义的对齐保留字节;通过预置的一些立即数进行分析,我们可以了解到,其实际内存排布如下:

基地址 \偏移 0x00 0x01 0x02 0x03
0x00000000 0x34 0x12 0x88 0xAA
0x00000004 0xCC 0xCC 0xAA 0xAA
0x00000008 0xFF 0xEE 0xDD 0xCC
0x0000000C 0x11 0x22 0x33 0xAA
0x00000010 0xA9 0xA9 0xAA 0xAA
0x00000014 0xAA 0xAA 0xAA 0xAA
0x00000018 0xEF 0xCD 0xAB 0x00
0x0000001C 0x78 0x56 0x34 0x12

在了解实际的内存排布之前,我们需要先了解对齐的一些基本规则:

  1. 编译器希望CPU希望通过一内存访问即可读取到一个变量的所有数据;
  2. 编译器处理数据对齐时,数据存储的基地址一定是数据长度的整数倍;

但是在实际上,按照开发者的预想排列,data2所存储的地方位域0x0000_0003地址与0x0000_0004地址,以32bitCPU的寻址能力,无法一次性读取到data2的完整数据,因此编译器在0x0000_0003的地址中插入了1个字节的对齐保留字节;同理,直到0x0000_0010,即data4的存储位置前都符合这一规律。

在处理data4时,编译器同样按照规则1,将数据放在0x0000_0010地址中,并且在0x0000_0012~0x0000_0013位置保留出2字节;但是由于data5是64bit数据,按照规则2,由于0x0000_0014并不是8的整数倍地址,所以编译器还要继续保留4个字节,以保证data5能填充到基地址位0x0000_0018中。

所以实际这个结构体在内存中的排布方式应该是:

基地址 \偏移 0x00 0x01 0x02 0x03
0x00000000 data0_B0 data0_B1 data1 reserve
0x00000004 data2_B0 data2_B1 reserve reserve
0x00000008 data3_B0 data3_B1 data3_B2 data3_B3
0x0000000C array[0] array[1] array[2] reserve
0x00000010 data4_B0 data4_B1 reserve reserve
0x00000014 reserve reserve reserve reserve
0x00000018 data5_B0 data5_B1 data5_B2 data5_B3
0x0000001C data5_B4 data5_B5 data5_B6 data5_B7

在编译器的合理排列下,CPU总能在一次内存访问时获取结构体中的成员数值。

紧密排列的方法

如果不希望编译器对数据进行如此编排,可以通过

c 复制代码
#pragma pack(1)
/*结构体定义*/
struct _sname
{
    /*......*/
};
#pragma pack()

对结构体进行定义;在两个pack的声明间隔之间,编译器会按照1字节对齐的方式编排结构体。

基于反序位域的大端协议传输方法

写在开头的一个防杠声明:
注意:以下规则仅在arm-none-eabi-gcc v5.3的ARM平台以及CygWin中进行实验,其他类似场景需要根据其适应性进行评估,并非绝对地可移植;可以理解为一个仅作为特例特用使用

使用场景说明

以下声明的方法主要依靠位域实现,而位域这一功能仅部分编程语言具备该功能,并且在不同平台、不同CPU、不同编译器中都有用差异,因此在实际使用该内容进行设计时,需要先验证平台的可用性,包括且不限于:

  1. 位域的对齐方法,是4Byte、8Byte对齐等;
  2. 编译器是否支持位域跨字节;
  3. CPU支持的大小端编码方法;
  4. 协议按照多少字节对齐进行设计;

例如,在MSVC下,由于位域的限制;跨越基础类型的位域定义还是会发生对齐现象,导致位域结构与设计结构并不相同,而使得以下转换方法受到限制。

原理

由于一些历史原因与主观原因,在嵌入式设备中,多数自定义协议以大端进行传输;大端的传输方式无疑在程序员对传输数据抓包解析时具备无可替代的优势;但是在以ARM为主流的小端CPU的协议程序设计中,将内存中以小端存储的数据转换为协议中的大端数据是极为不便的,多数情况需要开发者针对协议进行逐个bit移位以及模掩处理。

一般的数据传输总线设计中,比如UART、CAN、SPI以及I2C等;多数以MSB的比特顺序在物理层进行传输;即在这些类似的总线中,类似于0x1A传输的顺序是0b00011010b;我们以物理层为MSB传输、内存存储为小端的多数ARM处理器的情况进行讨论。

传输方向如下所示,以0x1A为例:

传输方向 ======>
数据 0 0 0 1 1 0 1 0
  • 32Bit数据的传输方法

在小端内存中,一个32bit的数据0x12345678,其实际的存储的形式为:

字节 Byte0 Byte1 Byte2 Byte3
HEX 0x78 0x56 0x34 0x12
Bin 0b01111000 0b01010110 0b00110100 0b00010010

如果协议中要求以大端传输,在MSB传输中则需要将Byte0与Byte3调转,Byte2与Byte1进行调转传输。所以在传输时,如果以大端传输的情况下,需要先传Byte3的MSB,那么,我们可以通过指针或者联合体,将32bit数据转换成8bit数组,并且从最后一个字节进行传输,此时我们观察Bit传输方向与数据传输方向如下:

Bit序 => => => =>
小端 0x78 0x56 0x34 0x12
字节序 <= <= <= <=

转换成通用大端的情况可以发现,传输路径如下:

Bit序 => => => =>
小端 0x12 0x34 0x56 0x78
字节序 => => => =>
  • 扩展位域

从前述的位域描述中可知,数据总是按照低字节向高字节堆叠,也就是数据同样可以通过反序进行排列,并从高Byte进行发送;但是问题在于,这个排列是否有限制?

以下的代码均在cygwin下进行运行

让我们针对一个Byte对齐的32bit数据结构展示,其结构如下:

c 复制代码
typedef struct
{
    uint32_t datafield0 : 4;
    uint32_t datafield1 : 2;
    uint32_t datafield2 : 2;
    uint32_t datafield3 : 8;
    uint32_t datafield4 : 16;
}TestStruct;

让我们给它指定一些初始值,并且以Byte视角来看看它在内存中的样子:

c 复制代码
int i = 0;
TestStruct temp;
uint8_t* ptr = (uint8_t*)&temp;
temp.datafield0 = 0xC;
temp.datafield1 = 0x1;
temp.datafield2 = 0x2;
temp.datafield3 = 0xA5;
temp.datafield4 = 0x1234;
for(i = 0; i < sizeof(temp); i ++)
{
    printf("0x%.2X ",ptr[i]);
}
printf("\n");

输出结果如下:

可以发现,datafield0被放在了Byte0的低4bit,而datafield1与datafield2被合并成一个0x9(0b1001)放在了Byte0的高4bit,当我们以字节高地址先发数据时,我们可以发现,所得的顺序刚好是整个结构体进行数据反转的大端格式,即是按大端传输的格式如下:

c 复制代码
typedef struct
{
    uint32_t datafield4 : 16;
    uint32_t datafield3 : 8;
    uint32_t datafield2 : 2;
    uint32_t datafield1 : 2;
}TestStruct;

依此类推,可以发现在具备实体定义的位域结构中,这个规则是通用的。即:在MSB物理传输线中,从位域结构的高地址开始依次传输数据时,数据在物理总线的传输格式即是位域各个字段反转序列后的大端格式。

局限性

根据上述描述,在C/C++使用位域进行协议处理时,按照协议翻转顺序的方式进行定义,并以从高字节地址向低字节地址逐Byte进行数据传输时,可以借由位域与物理总线的特性将数据转换成大端,而不需要在代码中编写许多可读性极差并且难以维护的移位操作。

但是这种方式仍然存在许多局限性,比如:

  • 结构中的数组不适合该特性

    当结构中出现数组时,如果只是简单进行反序传输处理,在数组区域时会发现数组内的数据虽然是正常的大端格式,但是整个数组的排列也同样变成了从大索引到小索引的变化。

    因此,当传输的结构中出现数组时,需要在传输之前先将数组的元素排序预先进行反转。

  • 变长数据不适合该特性

    变长的数据通常以数组或者指针的形式存储在其他位置,而非存储在结构中;因此,针对变长的数据内容,需要进行分段处理,并在每一段中分别适合该特征。

  • 未对齐位置发生了反转

    当数据结构没有按照字节对齐时,正序进行设计的协议默认保留位置在协议末尾;此时如果以 #pragma pack(1) 进行1字节对齐的声明后,原本按照顺序定义的协议将在结构末尾设置未对齐的保留字段;但是在此设计中,需要将这样的未对齐字段在反序的结构头中进行显示声明,否则数据传输时,最先传输的便是未被设计到协议内的一些保留字段。

结语

本博文仅对在嵌入式领域使用的二进制协议大小端转换提出一种简单有效的处理方法,但该处理方法具备非常强的局限性;包括对编译器、CPU、开发平台以及编程语言具备非常强的依赖性;在移植过程中需要嵌入式开发者对自身的使用场景进行充分评估后再进行设计;该博文提出的方案也更偏向于 "特例特用" ;移植与使用过程均需要开发者谨慎处理,特别是针对编译器特性以及开发平台特性等内容,对该方法影响极大,具体如何使用需要进行评估后再决定。