为什么位序对于位域是一个问题?

77
任何使用位域的可移植代码似乎都区分大小端平台。以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)
然而,位运算是定义良好的,并且无论架构如何都可以工作。那么,为什么位域不可移植?

6
只要你读取和写入二进制位,就不会有问题。问题在于另一台机器以及预设的标准(比如IP)写入了这些二进制位或者规定了它们的位置。C语言标准甚至没有规定字节的大小。实际上遇到这种问题的可能性并不高。 - Hans Passant
3
你认为 d->f2 等同于 (i>>1)&(1<<4 - 1) 这个假设是错误的。这完全取决于编译器。请看下面的答案。 - Ian Goldby
1
字节序如何影响位域的打包:http://mjfrazer.org/mjfrazer/bitfields/ - firo
7个回答

96

根据C标准,编译器可以以任何随机方式存储位字段。您永远不能假设位的分配位置。以下是C标准未指定的一些与位字段相关的内容:

未指定的行为

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

实现定义的行为

  • 位字段是否可以跨越存储单元边界(6.7.2.1)。
  • 在单元内分配位字段的顺序(6.7.2.1)。

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

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”作为位字段类型,那么会发生什么=实现定义的行为,如果您使用任何其他类型而不是(无符号)int,则也是实现定义的行为。
所以要回答这个问题,不存在可移植的位字段代码,因为C标准极其模糊地说明了位字段应该如何实现。位字段唯一可以信赖的是作为布尔值块的部分,程序员不关心位在内存中的位置。
唯一可移植的解决方案是使用位运算符而不是位字段。生成的机器代码将完全相同,但是是确定性的。位运算符在任何C编译器和任何系统上都是100%可移植的。

4
同时,位域经常与编译器指示符一起使用,告诉编译器不要使用填充(即使相对于 CPU 所需的对齐方式来说效率不高),而编译器的行为并非愚蠢。由于以上两个原因,结果只有两种情况,一个适用于大端机器,一个适用于小端机器。这就是为什么低级头文件中只有两个版本的原因。 - xryl669
2
@xryl669 但是为什么你想要两个完全不可移植的文件版本,而不是一个100%可移植的文件版本呢?无论哪种情况都会产生相同的机器代码。 - Lundin
2
@xryl669,你代码的问题不在于位运算符,而是使用了“魔法数字”。应该写成s[0] = VERSION | IHL;。理论上,位域是个好主意,但C标准完全不支持它们。根据我的经验,使用位域的代码更容易出现错误,因为使用它们的程序员总是对位域做出很多隐含的假设,在实践中这些假设都不能保证。 - Lundin
4
相反地,如果你像我一样每天从事嵌入式编程工作,位操作就变得非常简单了。你可以通过s[0] = VERSION | IHL_SET(val);来解决你的问题,其中IHL_SET是一个简单的宏定义:#define IHL_SET(x) ((x << IHL_OFFSET) & IHL_MASK)。(掩码是可选项)。我只用了10秒钟写出来,没有什么困难。 - Lundin
1
@SamGinrich 直到你需要为相同目标切换到不同的编译器...或者编写通用的可移植代码。 - undefined
显示剩余6条评论

20
据我所知,位域(bitfields)是纯粹由编译器构建的。这就是问题所在。如果将位域的使用限制在编译器“拥有”的范围内,那么编译器如何打包或排序位就基本上无关紧要了。然而,位域可能更常用于模拟与编译器领域外相关的结构,例如硬件寄存器、通信时的“线”协议或文件格式布局等。这些事物对位的布局有着严格的要求,而使用位域来模拟它们意味着你必须依赖于实现定义和甚至更糟的编译器未规定的行为来排列位域。
简而言之,位域的规范不足以使它们在最常用的情况下有用。

11

ISO/IEC 9899: 6.7.2.1 / 10

实现可以分配任何可寻址存储单元,以容纳位域。如果还有足够的空间,立即跟随结构中另一个位域的位域应打包到同一单元的相邻位中。如果剩余空间不足,无论位域是否合适都放入下一个单元或与相邻单元重叠是由实现定义的。 单位内位域分配的顺序(从高位到低位或从低位到高位)由具体实现定义。可寻址存储单元的对齐方式未指定。

在编写可移植代码时,无论系统大小端或位数如何,都应使用位移操作而非做出任何关于位域顺序或对齐的假设。

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


8

位域访问是基于底层类型的操作实现的。在这个例子中,是使用了unsigned int类型。因此,如果你有以下代码:

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

当你访问字段 b 时,编译器会访问整个 unsigned int,然后移位和屏蔽适当的位范围。(好吧,它不必这样做,但我们可以假装它这样做。)
在大端模式下,布局将如下所示(最高有效位先):
AAAABBBB BBBBCCCC

在小端字节序下,布局将如下所示:
BBBBAAAA CCCCBBBB

如果您想从小端或大端访问布局,需要进行额外的工作。这种增加的可移植性会带来性能损失,而且由于结构体布局已经不可移植,语言实现者选择了更快的版本。

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


正如我在上面的评论中所写的,这正是我不理解的。如果我将该内存位置读入类型为“unsigned int”的变量中,无论字节序如何,它的值始终为AAAABBBBBBBBBCCCC,对吗?然后,如果我想从中截取字段“c”,我会执行“i&0xff”,它仍然是可移植的。为什么位域不一样呢? - Leonid99
5
C标准规定位域的字节序和位顺序都没有明确规定,因此该说法是不正确的。编译器可以自由地分配这些位到任何位置。 - Lundin
1
听起来你对unsigned int和位域的可移植性有不同的期望。在两种情况下,内存中的结构是有效的,但是如果要将其复制到其他系统,则需要进行一些字节交换操作。 - Dietrich Epp
4
@Lundin:我不是在谈论C标准,我在谈论对C标准的实现。 - Dietrich Epp
3
你能详细说明一下你是如何想出 BBBBAAAA CCCCBBB 的吗? - Slava
请注意,这是实现定义的行为,一些实现可能会执行 AAAAPPPP BBBBBBBB CCCCPPPP。 - Karol S

2

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


这正是我不理解的地方。考虑我提供链接的IP头示例。从最低有效位开始计数的前4位是版本,而第5-8位是长度。在NIC解码帧并将其放入内存后,如果我读取整个字节,我将始终得到相同的结果,对吗?然后,如果我使用位移和按位AND来将字节分为半字节,无论平台如何,我仍将获得相同的结果。那么为什么位字段不同呢? - Leonid99
@Leonid,简短的回答是:因为标准没有保证它是相同的。 - makes

1
要强调的是:如果您在单个编译器/硬件平台上使用此软件结构,则字节顺序将不是问题。但是,如果您需要跨多个平台使用代码或数据,或需要匹配硬件位布局,则字节顺序就是一个问题。许多专业软件都是跨平台的,因此必须关注这一点。
以下是最简单的示例:我有一个将数字以二进制格式存储到磁盘中的代码。如果我没有显式地按字节写入和读取此数据,则从相反的字节顺序系统中读取的值将不同。
具体示例:
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。 过去,我们会将数据存储在磁盘上作为大端序,因为英特尔是异类,并提供了高速指令来交换字节。 如今,英特尔如此普遍,通常将小端序作为默认设置,并在大端序系统上进行交换。

一种较慢但字节序中立的方法是通过字节进行所有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");

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

0

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

如果你正在编写跨平台代码,永远不要将结构体直接写成二进制对象。除了上述的字节序问题外,不同编译器之间可能存在各种打包和格式化问题。语言对编译器在实际内存中如何布局结构体或位域没有任何限制,因此在保存到磁盘时,必须逐个数据成员地写入结构体,最好以字节中立的方式进行。

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

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

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


这似乎应该是对您现有答案的编辑,以添加新部分。我认为这看起来不像是对问题的单独回答。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接