68

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

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

例如,考虑以下位域:

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)

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

4

7 回答 7

92

按照 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% 可移植的。

于 2011-05-18T11:51:27.410 回答
19

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

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

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

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

于 2011-05-18T14:37:46.227 回答
10

ISO/IEC 9899: 6.7.2.1 / 10

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

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

另见EXP11-C。不要将期望一种类型的运算符应用于不兼容类型的数据

于 2011-05-18T12:08:24.220 回答
8

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

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

当您访问 fieldb时,编译器访问一个整体unsigned int,然后移位和屏蔽适当的位范围。(嗯,它不必但我们可以假装它确实如此。)

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

AAAABBBB BBBBCCCC

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

BBBBAAAA CCCCBBBB

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

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

于 2011-05-18T10:56:59.590 回答
1

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

于 2011-05-18T11:00:46.403 回答
0

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

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

具体例子:

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

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

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

这是一个经典的测试:

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

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

于 2018-04-02T18:08:05.447 回答
0

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

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

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

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

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

于 2019-03-07T16:07:06.600 回答