为什么bit endianness是bitfields中的一个问题?

问题描述 投票:52回答:7

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

据我所知,bitfields纯粹是编译器构造,用于促进位级操作。

例如,考虑以下位域:

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)

但是,无论架构如何,位操作都是明确定义的并且可以正常工作。那么,bitfields怎么不便携?

c cross-platform portability low-level bit-fields
7个回答
70
投票

根据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位整数的复杂性。然后添加一个事实,即允许编译器在位字段内的任何位置添加任意数量的填充字节,因为它被视为结构(它不能在结构的最开头添加填充,但在其他任何地方都添加填充)。

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

因此,为了回答这个问题,没有便携式位域代码这样的东西,因为C标准对于应该如何实现位字段非常模糊。可以信任的唯一比特字段是布尔值的块,其中程序员不关心存储器中位的位置。

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


14
投票

据我所知,bitfields纯粹是编译器构造

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

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

简而言之,位字段的指定不足以使它们对于它们似乎最常用的情况有用。


9
投票

Iso / No C 9899:6.7.2.2.1 / 10

实现可以分配足够大的任何可寻址存储单元以保持比特字段。如果剩余足够的空间,则紧跟在结构中另一个位字段之后的位字段将被打包到同一单元的相邻位中。如果剩余的空间不足,则是否将不适合的位域放入下一个单元或重叠相邻单元是实现定义的。在一个单元内(从高阶到低阶或低阶到高阶)的位字分配顺序是实现定义的。可寻址存储单元的对齐未指定。

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

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


6
投票

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

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

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

在big endian上,布局将是这样的(最重要的是第一位):

AAAABBBB BBBBCCCC

在小端,布局将是这样的:

BBBBAAAA CCCCBBBB

如果你想从小端访问大端布局,反之亦然,你将不得不做一些额外的工作。这种可移植性的增加具有性能损失,并且由于结构布局已经是不可移植的,因此语言实现者采用了更快的版本。

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


1
投票

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


0
投票

回答最重要的一点:如果您在单个编译器/ HW平台上将其用作纯软件构造,那么字节序将不会成为问题。如果您在多个平台上使用代码或数据或需要匹配硬件位布局,那么这是一个问题。而且很多专业软件都是跨平台的,因此它必须要关心。

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

具体例子:

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。在过去,我们将磁盘上的数据存储为大端,因为英特尔是一个奇怪的人,并提供高速指令来交换字节。如今,英特尔非常普遍,在大端系统上经常使Little 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);

请注意,这与您编写的用于执行endian交换的代码相同,但您不再需要检查字节顺序。你可以使用宏来减少痛苦。

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

这是一个经典测试:

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");

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


0
投票

只是要指出 - 我们一直在讨论字节字节序的问题,而不是位字节中的位字节式或字节序,这与另一个问题相反:

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

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

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

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

© www.soinside.com 2019 - 2024. All rights reserved.