为啥位字节序是位域中的一个问题?

Posted

技术标签:

【中文标题】为啥位字节序是位域中的一个问题?【英文标题】:Why bit endianness is an issue in bitfields?为什么位字节序是位域中的一个问题? 【发布时间】:2011-08-27 23:02:51 【问题描述】:

任何使用位域的可移植代码似乎都可以区分小端和大端平台。有关此类代码的示例,请参阅declaration of struct iphdr in linux kernel。我不明白为什么位字节序是一个问题。

据我了解,位域是纯粹的编译器构造,用于促进位级操作。

例如,考虑以下位域:

struct ParsedInt 
    unsigned int f1:1;
    unsigned int f2:3;
    unsigned int f3:4;
;
uint8_t i;
struct ParsedInt *d = &i;
在这里,写d->f2 只是一种简洁易读的表达(i>>1) & (1<<4 - 1) 的方式。

但是,位操作是定义明确的,并且无论架构如何都可以工作。那么,位域为什么不能移植呢?

【问题讨论】:

只要你读写位就没有问题。问题是另一台机器写入比特或它们的位置在诸如 IP 的标准中规定。 C 标准甚至不固定字节的大小。您实际遇到问题的几率并不高。 您认为 d->f2 与 (i>>1)&(1 字节序如何影响位域打包:mjfrazer.org/mjfrazer/bitfields 【参考方案1】:

ISO/IEC 9899: 6.7.2.1 / 10

一个实现可以分配任何 足够大的可寻址存储单元 保持一个位域。如果空间足够 仍然是一个位域,立即 跟随另一个位域 结构应装入 同一单元的相邻位。如果 剩余空间不足,无论是 不适合的位域被放入 下一个单元或重叠相邻 单元是实现定义的。 该 位域的分配顺序 在一个单元内(从高阶到低阶 或低阶到高阶)是 实现定义。对齐方式 可寻址存储单元的 未指定。

在尝试编写可移植代码时,使用位移操作而不是对位字段顺序或对齐做出任何假设会更安全,而不管系统字节序或位数如何。

另见EXP11-C. Do not apply operators expecting one type to data of an incompatible type。

【讨论】:

【参考方案2】:

只是指出 - 我们一直在讨论字节字节序问题,而不是位字节序或位域中的字节序,这涉及到另一个问题:

如果您正在编写跨平台代码,切勿将结构写成二进制对象。除了上述字节序问题外,编译器之间还可能存在各种打包和格式化问题。这些语言对编译器如何在实际内存中布局结构或位域没有任何限制,因此当保存到磁盘时,您必须一次写入一个结构的每个数据成员,最好以字节中立的方式。

这种打包会影响位域中的“位字节序”,因为不同的编译器可能会以不同的方向存储位域,而位字节序会影响提取它们的方式。

因此请记住问题的两个级别 - 字节字节序会影响计算机读取单个标量值(例如浮点数)的能力,而编译器(和构建参数)会影响程序读取聚合结构的能力.

我过去所做的是以中立的方式保存和加载文件,并存储有关数据在内存中布局方式的元数据。这让我可以在兼容的情况下使用“快速简单”的二进制加载路径。

【讨论】:

这看起来应该是对您现有答案的编辑以添加新部分。我认为这看起来不像是对这个问题的单独回答。【参考方案3】:

重申最重要的一点:如果您在单个编译器/硬件平台上将其用作仅软件结构,那么字节序将不是问题。如果您在多个平台上使用代码或数据,或者需要匹配硬件位布局,那么 IS 是个问题。而且很多的专业软件是跨平台的,所以需要注意。

这是最简单的示例:我有将二进制格式的数字存储到磁盘的代码。如果我自己不明确地逐字节地写入和读取这些数据到磁盘,那么如果从相反的字节序系统读取,它将不是相同的值。

具体例子:

int16_t s = 4096; // a signed 16-bit number...

假设我的程序在磁盘上附带了一些我想读入的数据。假设在这种情况下我想将其加载为 4096...

fread((void*)&s, 2, fp); // reading it from disk as binary...

在这里,我将其读取为 16 位值,而不是显式字节。 这意味着如果我的系统与存储在磁盘上的字节序匹配,我得到 4096,如果不匹配,我得到 16 !!!!!

所以字节序最常见的用法是批量加载二进制数,如果不匹配则进行 bswap。过去,我们将数据以大端序存储在磁盘上,因为英特尔是个奇怪的人,它提供高速指令来交换字节。如今,Intel 如此普遍,以至于经常将 Little Endian 设为默认值,并在 big endian 系统上进行交换。

一种较慢但字节序中立的方法是按字节执行所有 I/O,即:

uint_8 ubyte;
int_8 sbyte;
int16_t s; // read s in endian neutral way

// Let's choose little endian as our chosen byte order:

fread((void*)&ubyte, 1, fp); // Only read 1 byte at a time
fread((void*)&sbyte, 1, fp); // Only read 1 byte at a time

// Reconstruct s

s = ubyte | (sByte << 8);

请注意,这与您为进行字节序交换而编写的代码相同,但您不再需要检查字节序。您可以使用宏来减轻这种痛苦。

我使用了程序使用的存储数据的示例。 提到的另一个主要应用是编写硬件寄存器,这些寄存器具有绝对顺序。一个非常常见的地方是图形。弄错字节顺序,你的红色和蓝色通道就会反转!同样,问题在于可移植性 - 您可以简单地适应给定的硬件平台和显卡,但如果您希望相同的代码在不同的机器上工作,则必须进行测试。

这是一个经典的测试:

typedef union  uint_16 s; uint_8 b[2];  EndianTest_t;

EndianTest_t test = 4096;

if (test.b[0] == 12) printf("Big Endian Detected!\n");

请注意,位域问题也存在,但与字节顺序问题正交。

【讨论】:

【参考方案4】:

据我了解,位域是纯粹的编译器构造

这是问题的一部分。如果位域的使用仅限于编译器“拥有”的内容,那么编译器如何打包或排序它们几乎与任何人无关。

但是,位域可能更常用于对编译器域外部的结构进行建模 - 硬件寄存器、用于通信的“有线”协议或文件格式布局。这些东西对位的布局有严格的要求,并且使用位域对它们进行建模意味着您必须依赖实现定义的 - 更糟糕的是 - 编译器将如何布局位域的未指定行为.

简而言之,位域的指定不够好,无法使其在似乎最常用的情况下有用。

【讨论】:

【参考方案5】:

根据 C 标准,编译器可以随意以任何它想要的随机方式存储位字段。您可以永远对位的分配位置做出任何假设。这里只是一些 C 标准没有规定的位域相关的东西:

未指定的行为

分配用于保存位字段的可寻址存储单元的对齐方式 (6.7.2.1)。

实现定义的行为

位域是否可以跨越存储单元边界 (6.7.2.1)。 一个单元中位域的分配顺序 (6.7.2.1)。

大/小端当然也是实现定义的。这意味着您的结构可以通过以下方式分配(假设为 16 位整数):

PADDING : 8
f1 : 1
f2 : 3
f3 : 4

or

PADDING : 8
f3 : 4
f2 : 3
f1 : 1

or

f1 : 1
f2 : 3
f3 : 4
PADDING : 8

or

f3 : 4
f2 : 3
f1 : 1
PADDING : 8

哪一个适用?猜测一下,或阅读编译器的深入后端文档。将大端或小端的 32 位整数的复杂性添加到此。然后添加一个事实,即允许编译器在位字段内的任何位置添加任意数量的填充 bytes,因为它被视为结构(它不能在结构的最开头添加填充,但在其他任何地方)。

然后我什至没有提到如果你使用普通的“int”作为位字段类型 = 实现定义的行为,或者如果你使用除 (unsigned) int = 实现定义的行为之外的任何其他类型会发生什么。

所以要回答这个问题,不存在可移植位域代码之类的东西,因为 C 标准对于应如何实现位域非常模糊。唯一可以信任位域的是布尔值块,程序员不关心位在内存中的位置。

唯一可移植的解决方案是使用按位运算符而不是位域。生成的机器代码将完全相同,但具有确定性。位运算符在任何系统的任何 C 编译器上都是 100% 可移植的。

【讨论】:

同时,位域通常与 pragma 一起使用,告诉编译器不要使用填充(即使按照 CPU 所需的对齐方式这样做效率不高),编译器的行为并不愚蠢。上述两个原因的结果:只剩下 2 种情况,一种用于大端机器,一种用于小端机器。这就是为什么你在一个低级头文件中只得到 2 个版本的原因。 @xryl669 但是,当您可以拥有一个 100% 可移植文件的一个版本时,为什么还想要一个完全不可移植的文件的两个版本呢?任何一种情况都会产生相同的机器代码。 @Lundin,你是对的。这是一个专注的问题。比较 struct iphdr s; s.version = 2; s.ihl = 3;uint8_t s[]; s[0] = (uint8_t)((3&lt;&lt;3)|(2&lt;&lt;0));。前者对于代码编写者和代码消费者来说都是显而易见的,后者是完全不透明的,因为代码消费者必须知道内存布局(您发现错误了吗?)。当然,您可以编写一个函数来设置这些字段中的任何一个(或两者)。但是您必须编写 很多 代码,这些代码可能永远不会被使用并且容易出错,最终导致(无用的)代码膨胀和复杂(如果界面太大而无法记住) @xryl669 您的代码的问题不是按位运算符,而是“幻数”的使用。它应该写成s[0] = VERSION | IHL;。理论上位域是一个好主意,但 C 标准完全不支持它们。根据我的经验,使用位域的代码更容易出错,因为使用它们的程序员总是对位域做出很多隐含的假设,而在实践中根本无法保证。 @xryl669 相反,如果你每天都这样做,就像我做嵌入式编程一样,位操作就变得非常琐碎。您可以通过s[0] = VERSION | IHL_SET(val); 解决您的问题,其中 IHL_SET 是一个简单的宏:#define IHL_SET(x) ((x &lt;&lt; IHL_OFFSET) &amp; IHL_MASK)。 (面具是可选的)。花了我 10 秒的时间来写,不费吹灰之力。【参考方案6】:

位域访问是根据对底层类型的操作来实现的。在示例中,unsigned int。所以如果你有类似的东西:

struct x 
    unsigned int a : 4;
    unsigned int b : 8;
    unsigned int c : 4;
;

当您访问字段b 时,编译器会访问整个unsigned int,然后移位和屏蔽相应的位范围。 (嗯,它不是必须,但我们可以假装它确实如此。)

在大端,布局将是这样的(最重要的位在前):

AAAABBBB BBBBCCCC

在little endian上,布局是这样的:

BBBBAAAA CCCCBBBB

如果你想从小端访问大端布局,反之亦然,你必须做一些额外的工作。这种可移植性的提高会降低性能,并且由于结构布局已经是不可移植的,因此语言实现者选择了更快的版本。

这做了很多假设。另请注意,sizeof(struct x) == 4 在大多数平台上。

【讨论】:

正如我在上面的评论中所写,这正是我不明白的。如果我将这个内存位置读入unsigned int 类型的变量中,它的值将始终是 AAAABBBBBBBBBCCCC,不管字节序是什么,对吧?然后,如果我想从中删除字段c,我会做i &amp; 0xff,它仍然是可移植的。为什么位域不一样? 这是不正确的,C 标准既没有指定位域的字节序,也没有指定位域的位顺序。编译器可以随意分配这些位。 听起来您对可移植性的期望与unsigned int 和位字段不同。在两种情况中,内存中的结构都是高效的,但如果不进行一些字节交换操作,就无法将其复制到其他系统。 @Lundin:我不是在谈论 C 标准,而是在谈论 C 标准的实现。 您能否详细说明您是如何提出 BBBBAAAA CCCCBBB 的?【参考方案7】:

位字段将根据机器的字节序以不同的顺序存储,这在某些情况下可能无关紧要,但在其他情况下可能很重要。例如,假设您的 ParsedInt 结构表示通过网络发送的数据包中的标志,小端机器和大端机器以与传输字节不同的顺序读取这些标志,这显然是一个问题。

【讨论】:

这正是我无法理解的。考虑我提供链接的 IP 标头示例。从 lsb 算起的前 4 位是版本,而第 5-8 位是长度。在网卡解码帧并将其放入内存后,如果我读取整个字节,我总是会得到相同的结果,对吧?然后,如果我使用位移位和按位与将字节切割成半字节,无论平台是什么,我仍然会得到相同的结果。那么为什么位域不一样呢? @Leonid,简短的回答是:因为标准不保证它是相同的。

以上是关于为啥位字节序是位域中的一个问题?的主要内容,如果未能解决你的问题,请参考以下文章

C位域操作

字节序

字节序的问题,为啥GBK和UTF-8没有字节序问题,而UTF-16就有?

字节序转换与结构体位域(bit field)值的读取 Part 2 - 深入理解字节序和结构体位域存储方式

字节序转换与结构体位域(bit field)值的读取

Linux基础(字节序是什么鬼)