? ? ? ??驱动开发人员或者经常与协议规范打交道的工程师对位域肯定不陌生。当我们需要用C语言数据类型来表示软硬件平台指定的描述符结构,以及某些网络协议的包格式时;或者描述为了节省内存而自定义的紧凑数据结构时;为了可读性,编码的方便性,我们会使用使用位域(Bit-Field)。本文将探讨位域的基本概念,使用细节和一些注意项。
位域——基本概念
带有预定义宽度的变量被称为位域,形式如下:
struct?位域结构名 {
类型说明符 位域名:位域长度
....
};
类型说明:定义了位域的类型,位域只能是整数类型,在C语言规范中规定的类型包括int,uint32_t和signed int,至于其它整数类型,如(unsigned) char, (unsigned) short, (unsigned) long, (unsigned) long long等,是否可用作位域的存储单元,都取决于编译器的实现。(大多数主流编译器都允许:在目标体系架构上所支持的所有的整数类型,均可用作位域的类型说明。)
位域名:顾名思义是位域的名称
位域长度:代表位域占用的bit位的数量,宽度必须小于或等于指定类型的位宽度。
举个栗子:
struct foo {
? ? ? ? uint32_t a:8;
? ? ? ? uint32_t b:2;
? ? ? ? uint32_t c:6;
};
由位域的定义我们可以知道,位域的分配是基于比特的。由于绝大多数计算机(如果不是全部的话)都以字节为单位进行编址,这就意味着,你不能对位域进行以下操作:
a. 对一个位域元素进行取地址操作
b. 对一个位域元素进行指针操作
c. 定义位域元素数组
d. 进行sizeof运算
位域——排布规则
C99规定:“在一个存储单元内,如果之前的位域分配之后,还剩下足够的比特位以分配紧随其后定义的下一个位域,则应该在这个存储单元内,从第一个空闲比特位开始为这个位域进行分配”,除此之外,C标准没有再定义任何与位域分配相关的标准,这就给了编译器相当大的自由度。我们分析以下三种场景
a. 场景一:如果相邻位域元素的类型相同,且其位宽之和小于类型的sizeof大小,则后面的元素将紧邻前一个元素存储,直到不能容纳为止
这一场景是C标准规定的内容,很好理解,所以在上面的栗子中,位域元素a,b,c会在一个uint32_t存储单元内分配。这是由于a,b,c类型相同,且位宽之和(8+6+2)小于32。
b. 场景二:如果相邻位域字段的类型相同,但其位宽之和大于类型的sizeof大小,这种跨越边界的场景,C规范并没有定义,编译器自行实现分配方式。例如:
struct foo {
? ? ? ? uint32_t a:8;
? ? ? ? uint32_t b:28;
? ? ? ? uint32_t c:28;
};
在自然对齐的情况下,GCC会将b,c分配在新的存储单元,不会让b,c横跨uint32_t的存储单元边界。整个数据结构占用12个字节。
但如果使用#pragma pack(n)来指定成员对齐,无论n为何值,b,c都将横跨存储单元的边界,如下
#pragma pack(n)
struct foo {
? ? ? ? uint32_t a:8;
? ? ? ? uint32_t b:28;
? ? ? ? uint32_t c:28;
};
#pragma pack()
上面的foo整个结构占用8个字节。有兴趣的同学不妨找GCC环境测试一下。
而MSVC则无论何种方式,都会在新的存储单元里面分配b,c,也就是整个结构占用12字节。
实际上#pragma pack指定除了影响位域元素的存储单元边界问题,还会影响存储单元的大小。例如下面的栗子:
struct foo {
? ? ? ? uint32_t a:4;
? ? ? ? uint32_t b:4;
};
在自然对齐的方式下,GCC对sizeof(foo)的求值都是4。但如果用#pragma pack(1)来指定这个位域的对齐方式,结果则为1,也就是说GCC自动选择了合适的存储单元(1byte)来存放a,b共8bit的数据,忽略了用户指定的存储单元类型(uint32_t)。
c. 场景三:如果相邻位域字段的类型不同,各编译器的实现又有差异,VC6采取不压缩的方式(不同位域字段存放在不同的位域类型字节中),GCC则采取压缩的方式。例如
struct foo {
? ? ? ? uint32_t a:15;
? ? ? ? uint32_t b:14;
? ? ????uint32_t c:14;
? ? ? ? uint16_t d;
};
上述结构的大小与编译器相关。GCC下如果位域没有放满,且后续元素可以放下,则会将两者合并,所以上述结构大小是8字节。
为了避免不同的编译器在实现这些未定义行为时的互相不兼容,我们在定义位域时,应当尽量占满整个存储单元,必要的时候可以使用空域。
位域——空域(无名位域)
空域,也称为无名位域(unnamed bit-field),指的是没有名字的位域定义。无名位域分为两种:
1. 宽度非零的无名位域;
2. 宽度为零的无名领域,宽度为0的未命名域会强制下一个位域元素对齐到下一个存储单元边界。
举个栗子:
struct foo {
? ? ? ? uint32_t a:15;
? ? ? ? uint32_t b:14;
? ? ? ? uint32_t :3; ????????//用于占满32bit
? ? ? ? uint32_t c:12;
? ? ? ? uint32_t :20;? ? ? ?//用于占满32bit
? ? ? ? uint32_t d;
};
struct foo {
? ? ? ? uint32_t a:15;
? ? ? ? uint32_t b:14;
? ? ? ? uint32_t :0;? ? ? ?
? ? ? ? uint32 c:12;? ? ? ? //从下一个32bit开始
? ? ? ? uint32 :0;
? ? ? ? uint32_t d;? ? ? ? //从下一个32bit开始
};?
这两种定义方式下,整个结构的大小都是12字节。
需要特别注意的是,位域类型不同的场景。例如:
struct foo {
? ? ? ? uint32_t a:8;
? ? ? ? uint32_t b:12;
? ? ? ? uint8_t :0;? ? ? ? //宽度为0的无名位域
? ? ? ? uint8_t c:4;
};
上述位域结构中,c是从32位对齐还是自身的8位对齐的位置分配呢?不同的编译器处理也不一致。GCC会将分配位置放在以uint8_t对齐的位置,即从foo起始地址第3个字节(从0开始计数)开始为c分配位域。
位域——符号位
当存储单元是有符号整数时,位域是否有符号,不同的编译器有不同的实现。如果一个位域的定义为int a:1;如下面的定义:
struct foo {
? ? ? ? int a:1;
? ? ? ? int b:2;
? ? ? ? int c:2;
};
在某些编译器的实现里,a的取值范围为[-1,0],而另外一些编译器的取值范围则是[0,1]。另一个位域的定义为int b:2,则其取值范围可能是[-2,1],或者[0,3],这取决于编译器是否允许有符号的位域值。基于上面的位域结构定义,我们使用下面的代码对test进行赋值。
struct foo test;
test.a = 1;test.b=3;test.c=1;
对于GCC来说,得到的结果是,a=-1; b=-1, c=1。
位域——原子性
操作同一个存储单元内不同的位域元素,需要注意互斥,并发访问时需要保护。例如下面的栗子:
struct foo {
? ? ? ? uint32_t a:15;
? ? ? ? uint32_t b:14;
? ? ? ? uint32_t :3;
? ? ? ? uint32_t c:12;
? ? ? ? uint32_t :20;
? ? ? ? uint16_t d:
? ? ? ? uint16_t e;
};
struct foo g_bit_test;
线程0:g_bit_test.a++;
线程1:g_bit_test.c++;
线程2:g_bit_test.d++;
线程3:g_bit_test.e++;
以上线程0~3都不需要互斥保护,可以并发访问,因为他们位于不同的存储单元。再比如下面的情况:
线程0:g_bit_test.a++;
线程1:g_bit_test.b++;
a,b位于同一个存储单元,线程0和1需要互斥去保护,避免并发。
位域——大小端
位域在存储单元内的布局,取决于位域在存储单元内的分配顺序。对struct中的成员进行内存分配的时候,C语言遵循统一的规则——按struct代码中的元素排列顺序分配内存,先出现的元素排列在内存的低地址。该规则对于不同基本数据元素或位域元素都一致。
大端(big endian):大端低地址对应msb,因此从msb向lsb分配位域
小端(little endian):小端低地址对应lsb,因此从lsb向msb分配位域
需要注意,比特序在一个字节内,大端把低位放在最后一个bit,小端把低位放在第一个bit。
举个栗子更为直观,如下:
struct foo {
? ? ? ? uint32_t a:1;
? ? ? ? uint32_t b:2;
? ? ? ? uint32_t c:3;
? ? ? ? uint32_t d:4;
? ? ? ? uint32_t e:5;
? ? ? ? uint32_t f:6;
? ? ? ? uint32_t g:11;
};
上述结构在不同平台下内存排列方式:
大端:
地址:[00000000][00000001][00000002][00000003]
数据:[abbcccdd][ddeeeeef][fffffggg][gggggggg]
小端:
地址:[00000000][00000001][00000002][00000003]
数据:[ddcccbba][feeeeedd][gggfffff][gggggggg]
需要留意bit位的分配是以一个Byte(8bit)进行的,并不是完全的首位倒置的顺序。
再举一个栗子:
struct {
? ? ? ? uint32_t a:8;
? ? ? ? uint32_t b:8;
? ? ? ? uint32_t c:8;
? ? ? ? uint32_t d:8;
} struct_bit;
如果从地址&struct_bit处读第一个字节,无论大小端,肯定是a
但是如果从地址&struct_bit处读四个字节,则a在低地址还是高地址就取决于大小端,在大端的情况下读出0xabcd,在小端的情况下读出0xdcba。
为了解决位域在不同大小端平台上的差异,需要定义不同的位域结构,例如:
typedef??struct
{
#ifdef __LITTLE_ENDIAN__
????uint16_t?A:4;
????uint16_t?B:7;
????uint16_t?C:5;
#else
????uint16_t?C:5;
????uint16_t?B:7;
????uint16_t?A:4;
#endif
}Test_S;
通过以上逆序定义位域的方式,解决了大小端位域的排列问题。两个CPU相互发送这样的结构时,还需要做htons与ntohs操作,解决字节的大小端问题。具体可以参考上一篇的内容。
位域定义位置上的倒换,都是对一个类型内部来说,如果有3个uint32_t类型的变量都是按位域定义的A B C,大小端情况下,这三个word整体位置并没有改变,不会变成C B A?,只是各自类型内部各个位域定义的位置翻转。
typedef??struct
{
#ifdef __LITTLE_ENDIAN__
//word A
位域定义
Uint32_t?xxx:5;
…
//word B
Uint32_t?xxx:5;
??????…
//word C
????Uint32_t?xxx:4;
????…
#else
???//word A
位域定义翻转
//word B
位域定义翻转
//word C
????位域定义翻转
#endif
}Test_S;
位域——总结
综上,所以在使用位域时,应该注意
1 .?仅仅使用无符号类型,
2 .?使用uint8_t,uint16_t,uint32_t等能够明确指定存储单元宽度的自定义类型。
3 .?不要定义可能会进行跨越分配单元的位域;
4 .?不要使用宽度为0的无名位域;
5 .?明确对一个存储单元内的所有无用的比特进行填充,避免跨界;
6 .?处理大小端问题,定义大小端两套数据结构分别针对不同的主机序场景
文章如有错误或者纰漏,欢迎在评论区指正,thx。
上一篇: