为什么位字段中的位序是一个问题?
为什么位字段中的位序是一个问题?
任何使用位域的可移植代码似乎都区分小端和大端平台。参见Linux内核中struct iphdr的声明,例如:声明的结构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)
。然而,位操作是明确定义且与体系结构无关的。那么,为什么位域不可移植呢?
位端序(bit endianness)是指在存储和传输数据时,字节中位的排列顺序。在处理位字段(bitfields)时,位端序成为一个问题,因为不同的实现可能以不同的方式分配和排列位字段。
根据ISO/IEC 9899标准的规定,实现可以为位字段分配足够大的可寻址存储单元。如果有足够的空间,位字段可以紧密地排列在同一个单元中的相邻位中。如果空间不足,不适合的位字段可以根据实现的定义放入下一个单元或重叠相邻的单元。而位字段在单元内的分配顺序(从高位到低位或从低位到高位)是由实现定义的。此外,可寻址存储单元的对齐方式是未指定的。
为了编写可移植的代码,无论系统的位端序或位数如何,最安全的方法是使用位移操作而不是做出任何关于位字段顺序或对齐的假设。
此外,根据软件工程研究所(Software Engineering Institute)CMU维基上的EXP11-C规范,不要对具有位字段的结构的布局做任何假设。
位端序在位字段处理中成为一个问题,因为不同实现可能以不同的方式分配和排列位字段。为了编写可移植的代码,最好使用位移操作而不是做出假设。在处理具有位字段的结构时,也应遵循相关的规范。
为什么位尾端性在位字段中是一个问题?
位字段是编译器构造的一部分,如果位字段的使用仅限于编译器所拥有的部分,那么编译器如何打包和排序位对任何人来说几乎无关紧要。然而,位字段很可能被频繁地用于模拟编译器领域之外的结构,如硬件寄存器、通信的“线”协议或文件格式布局。这些东西对位的布局有严格的要求,而使用位字段来模拟它们意味着你必须依赖于编译器如何布局位字段的实现定义和未指定的行为。
简而言之,位字段没有被规定得足够好,以使它们在它们似乎最常用的情况下变得有用。
解决方法:
尽可能避免使用位字段来模拟硬件寄存器、通信协议或文件格式布局等外部结构。相反,应该使用明确规定位布局的方法来处理这些情况,以确保与特定要求的兼容性。
例如,可以使用位操作和位掩码来直接操作位,而不是使用位字段。这样可以确保位的布局与所需的严格要求相匹配。
此外,编写明确的文档和规范,确切地描述所需的位布局和顺序,以便其他开发人员可以正确地实现和使用这些结构。
通过避免使用位字段来模拟外部结构,并采取明确的方法来处理位布局和顺序,可以解决位尾端性在位字段中的问题。
为什么位端序在位字段中是一个问题?
根据C标准,编译器可以以任何随机的方式存储位字段。你不能做任何关于位的位置分配的假设。以下是C标准中未指定的与位字段相关的一些事项:
未指定的行为
- 用于保存位字段的可寻址存储单元的对齐方式(6.7.2.1)。
实现定义的行为
- 位字段是否可以跨越存储单元边界(6.7.2.1)。
- 位字段在单元内的分配顺序(6.7.2.1)。
大/小端当然也是实现定义的。这意味着你的结构体可以以以下方式分配(假设为16位整数):
PADDING : 8
f1 : 1
f2 : 3
f3 : 4
或
PADDING : 8
f3 : 4
f2 : 3
f1 : 1
或
f1 : 1
f2 : 3
f3 : 4
PADDING : 8
或
f3 : 4
f2 : 3
f1 : 1
PADDING : 8
应该使用哪一个?猜一猜,或者详细阅读编译器的后端文档。再加上32位整数的大端或小端。然后加上编译器允许在位字段内的任何位置添加任意数量的填充字节,因为它被视为结构体(它不能在结构体的开头添加填充,但在其他任何地方都可以添加)。
而且我还没有提到如果你使用普通的"int"作为位字段类型会发生什么,这是实现定义的行为,或者如果你使用除了(无符号)int之外的任何其他类型会发生什么,也是实现定义的行为。
所以回答这个问题,没有所谓的可移植位字段代码,因为C标准在位字段的实现方式上非常模糊。位字段唯一可以被信任的是作为布尔值块的一部分,程序员不关心位在内存中的位置。
唯一的可移植解决方案是使用位运算符而不是位字段。生成的机器代码完全相同,但是确定性的。位运算符在任何系统上的任何C编译器上都是100%可移植的。
同时,位字段通常与编译器的pragma一起使用,告诉编译器不要使用填充(即使与CPU所需的对齐性不符合),而且编译器的行为并不愚蠢。由于上述两个原因,结果只剩下两种情况,一种是大端机器,一种是小端机器。这就是为什么在低级头文件中只有两个版本的原因。
但是为什么你要想要一个完全不可移植的文件的两个版本,而不是一个100%可移植的文件呢?无论哪种情况,生成的机器代码都是相同的。
你是对的。这是一个关于焦点的问题。比较struct iphdr s; s.version = 2; s.ihl = 3;
和uint8_t s[]; s[0] = (uint8_t)((3<<3)|(2<<0));
。前者对于代码编写者和代码使用者来说是显而易见的,后者对于代码使用者来说是完全不透明的,因为代码使用者必须知道内存布局(你发现了错误吗?)。当然你可以编写一个函数来设置这些字段。但是你将不得不编写大量的代码,很可能永远不会使用,并且容易出错,导致(无用的)代码臃肿和复杂性(如果接口太大而无法记住)。
你的代码的问题不是位运算符,而是使用了"魔术数字"。它应该被写成s[0] = VERSION | IHL;
。理论上,位字段是一个好主意,但是C标准完全无法支持它们。根据我的经验,使用位字段的代码更容易出错,因为使用它们的程序员总是对位字段做出很多隐含的假设,而这些假设在实践中并没有得到保证。
IHL可能不是一个固定的数字(可以是一个6位宽的值),那么你将不得不以某种方式记住"移位量"(是的,它可以是一个宏"IHL_SHIFT")。它"解决"了存储问题,但是读取将必须使用掩码和移位来完成,这是复杂的(在我看来,比直接访问s.ihl要复杂得多)。如果你一生中只需要做一次或两次,那么你可以接受这个工作量。如果你每天都要使用这个结构体,而且还有其他许多结构体,直接成员访问更容易,只需正确编写一次即可。
相反,如果你像我一样每天都在嵌入式编程中工作,位操作变得非常简单。你可以通过s[0] = VERSION | IHL_SET(val);
解决你的问题,其中IHL_SET是一个简单的宏:#define IHL_SET(x) ((x << IHL_OFFSET) & IHL_MASK)
。(掩码是可选的)。花了我10秒钟写,没有任何努力。
直到你需要为相同的目标在不同的编译器上进行移植...或者写出可移植的代码。
不,讨论变得太困难了,一旦实际的技术论据被提出来,你就开始坚持自己的错误陈述...