本文是阅读再谈C语言位域一文的读书笔记,结合K&&R C语言程序设计与维基百科中相关内容进行总结。
位段(或称“位域”,Bit field)为一种数据结构,可以把数据以位的形式紧凑的储存,并允许程序员对此结构的位进行操作。这种数据结构的好处:
可以使数据单元节省储存空间,当程序需要成千上万个数据单元时,这种方法就显得尤为重要。
位域可以很方便的访问一个整数值的部分内容从而可以简化程序源代码。
而位域这种数据结构的缺点在于,其内存分配与内存对齐的实现方式依赖于具体的机器和系统,在不同的平台可能有不同的结果,这导致了位域在本质上是不可移植的。
在C语言中,位域的声明和结构(struct)类似,但它的成员是一个或多个位的字段,这些不同长度的字段实际储存在一个或多个整型变量中。在声明时,位域成员必须是整形或枚举类型(通常是无符号类型),且在成员名的后面是一个冒号和一个整数,整数规定了成员所占用的位数。位域不能是静态类型。不能使用&对位域做取地址运算,因此不存在位域的指针,编译器通常不支持位域的引用(reference)。以下程序则展示了一个位段的声明:
struct CHAR
{
unsigned int ch : 8; //8位
unsigned int font : 6; //6位
unsigned int size : 18; //18位
};
struct CHAR ch1;
以下程序展示了一个结构体的声明:
struct CHAR2
{
unsigned char ch; //8位
unsigned char font; //8位
unsigned int size; //32位
};
struct CHAR2 ch2;
在ch1这个字段对象中,一共占据了32位的空间。而第二个程序利用结构体进行声明,可以看出,处理相同的数据,CHAR2类型占用了48位空间,如果考虑边界对齐并把要求最严格的int类型最先声明进行优化,那么CHAR2类型则要占据64位的空间。
无名位域
如果位域的定义没有给出标识符名字,那么这是无名位域,无法被初始化。无名位域用于填充(padding)内存布局。只有无名位域的比特数可以为0。这种占0比特的无名位域,用于强迫下一个位域在内存分配边界对齐。
在C语言中,我们可以得到某个字节的内存地址,我们具备了操作任意内存字节的能力;在C语言中,尝试获得一个bit field的地址是非法操作:
struct flag_t {
int a : 1;
};
struct flag_t flg;
printf("%p\n", &flg.a);
执行结果:
bits.c: In function ‘main’:
bits.c:9:5: error: cannot take address of bit-field ‘a’
printf("%p\n", &flg.a);
C语言中bit field的形式如下:
struct CHAR
{
unsigned int ch : 8; //8位
unsigned int font : 6; //6位
unsigned int size : 18; //18位
};
struct CHAR ch1;
其中8,6,18为对应位域所占据的bit数。
应用场景
在K&&R中有一个应用场景:
#define KEYWORD 01
#define EXTERNAL 02
#define STATIC 04
如果我们要设置flag第二位第三位为1可以使用如下方法:
flag |= EXTERNAL | STATIC
如果采用位域的话:
struct
{
unsigned int is_keryword:1;
unsigned int is_extern:1;
unsigned int is_static:1;
}flags;
可以通过flags.is_extern = 1 flags.is_static =1设置第二位第三位为1,用于后续条件测试等。
C标准允许unsigned int/signed int/int类型的位域声明,C99中加入了_Bool类型的位域。但像Gcc这样的编译器自行加入了一些扩展,比如支持short、char等整型类 型的位域字段,使用其他类型声明位域将得到错误的结果,比如:
struct flag_t {
char* a : 1;
};
error: bit-field ‘a’ has invalid type
C编译器究竟是如何为bit field分配存储空间的呢?我们以Gcc编译器(Ubuntu 12.04.2 x86_64 Gcc 4.7.2 )为例一起来探究一下。
我们先来看几个基本的bit field类型的例子:
#include <stdio.h>
struct bool_flag_t {
_Bool a : 1;
_Bool b : 1;
};
struct char_flag_t {
unsigned char a : 2;
unsigned char b : 3;
};
struct short_flag_t {
unsigned short a : 2;
unsigned short b : 3;
};
struct int_flag_t {
int a : 2;
int b : 3;
};
int main()
{
printf("%ld\n", sizeof(struct bool_flag_t));
printf("%ld\n", sizeof(struct char_flag_t));
printf("%ld\n", sizeof(struct short_flag_t));
printf("%ld\n", sizeof(struct int_flag_t));
printf("sizeof _Bool is %ld\n", sizeof(_Bool));
printf("sizeof char is %ld\n", sizeof(char));
printf("sizeof short is %ld\n", sizeof(short));
printf("sizeof int is %ld\n", sizeof(int));
return 0;
}
执行结果:
1
1
2
4
sizeof _Bool is 1
sizeof char is 1
sizeof short is 2
sizeof int is 4
可以看出gcc为不同类型的bit field分配了不同大小的基本内存空间。_Bool和char类型的基本存储空间为1个字节;short类型的基本存储空间为2个字节,int型的为4 个字节。这些空间的分配是基于结构体内部的bit field的size没有超出基本空间的界限为前提的。以short_flag_t为例:
struct short_flag_t {
unsigned short a : 2;
unsigned short b : 3;
};
a、b两个bit field总共才使用了5个bit的空间,没有超过2个字节(16bit),所以Compiler只为short_flag_t分配一个基本存储空间就可以存储下这两个bit field。如果bit field的size变大,size总和超出基本存储空间的size时,编译器会如何做呢?我们还是看例子:
struct short_flag_t {
unsigned short a : 7;
unsigned short b : 10;
};
将short_flag_t中的两个bit字段的size增大后,我们得到的sizeof(struct short_flag_t)变成了4,显然Compiler发现一个基础存储空间已经无法存储下这两个bit field了,就又为short_flag_t多分配了一个基本存储空间。这里我们所说的基本存储空间就称为“存储单元(storage unit)”。它是Compiler在给bit field分配内存空间时的基本单位,并且这些分配给bit field的内存是以存储单元大小的整数倍递增的。但从上面来看,不同类型bit field的存储单元大小是不同的。
sizeof(struct short_flag_t)变成了4,那a和b有便会有至少两种内存布局方式:
- a、b紧邻
- b在下一个可存储下它的存储单元中分配内存
具体采用哪种方式,是Compiler相关的,这会影响到bit field的可移植性。我们来测试一下gcc到底采用哪种方式:
#include <stdio.h>
void dump_native_bits_storage_layout(unsigned char *p, int bytes_num)
{
union flag_t
{
unsigned char c;
struct base_flag_t
{
unsigned int p7:1;
unsigned int p6:1;
unsigned int p5:1;
unsigned int p4:1;
unsigned int p3:1;
unsigned int p2:1;
unsigned int p1:1;
unsigned int p0:1;
} base;
} f;
for (int i = 0; i < bytes_num; i++)
{
f.c = *(p + i);
printf("%d%d%d%d %d%d%d%d ",
f.base.p7,
f.base.p6,
f.base.p5,
f.base.p4,
f.base.p3,
f.base.p2,
f.base.p1,
f.base.p0);
}
printf("\n");
}
int main(int argc, const char * argv[])
{
struct short_flag_t
{
unsigned short a : 7;
unsigned short b : 10;
};
struct short_flag_t s;
memset(&s, 0, sizeof(s));
s.a = 115; /*二进制形式:0000 0000 0111 0011 */
s.b = 997; /*二进制形式:0000 0011 1110 0101*/
dump_native_bits_storage_layout((unsigned char*)&s, sizeof(s));
return 0;
}
编译执行后的输出结果为: 1000 1110 0000 0000 1010 0111 1100 0000。可以看出gcc采用了第二种方式,即在为a分配内存后,发现该存储单元剩余的空间(16-7 = 9 bits)已经无法存储下字段了,于是乎gcc又分配了一个存储单元(2个字节)用来为b分配空间,而a与b之间也因此存在了空隙。
我们还可以通过匿名0长度位域字段的语法强制位域在下一个存储单元开始分配,例如:
struct short_flag_t {
unsigned short a : 2,
unsigned short b : 3;
};
这个结构体本来是完全可以在一个存储单元(2字节)内为a、b两个位域分配空间的。如果我们非要让b放在与a不同的存储单元中,我们可以通过加入 匿名0长度位域的方法来实现:
struct short_flag_t {
unsigned short a : 2;
unsigned short : 0;
unsigned short b : 3;
};
这样声明后,sizeof(struct short_flag_t)变成了4。
struct short_flag_t s;
memset(&s, 0, sizeof(s));
s.a = 2; /* 10 */
s.b = 4; /* 100 */
dump_native_bits_storage_layout((unsigned char*)&s, sizeof(s));
执行后,输出的结果为:
0100 0000 0000 0000 0010 0000 0000 0000
可以看到位域b被强制放到了第二个存储单元中。如果没有那个匿名0长度的位域,那结果应该是这样的:
0100 1000 0000 0000
最后位域的长度是不允许超出其类型的最大长度的,比如:
struct short_flag_t
{
short a : 17;
};
error: width of ‘a’ exceeds its type
位域的位序
再回顾一下上一节的最后那个例子(不使用匿名0长度位域时):
struct short_flag_t s;
memset(&s, 0, sizeof(s));
s.a = 115; /*二进制形式:0000 0000 0111 0011 */
s.b = 997; /*二进制形式:0000 0011 1110 0101*/
dump bits的结果为1100 1110 0000 0000 1010 0111 1100 0000 。
与s.a和s.b对应的二进制形式对比,发现a和b的bit顺序恰好相反。bit也有order的概念,称为位序。位域字段的内存位排序就称为该位域的位序。
我们来回顾一下字节序的概念,字节序分大端(big-endian,典型体系Sun Sparc)和小端(little-endian,典型体系Intel x86):
大端指的是数值(比如0×12345678)的逻辑最高位(0×12)放在起始地址(低地址)上,简称高位低址,就是高位放在起始地址。
小端指的是数值(比如0×12345678)的逻辑最低位(0×78)放在起始地址(低地址)上,简称低位低址,就是低位放在起始地址。
看下面例子:
int main()
{
char c[4];
unsigned int i = 0×12345678;
memcpy(c, &i, sizeof(i));
printf("%p – 0x%x\n", &c[0], c[0]);
printf("%p – 0x%x\n", &c[1], c[1]);
printf("%p – 0x%x\n", &c[2], c[2]);
printf("%p – 0x%x\n", &c[3], c[3]);
}
在x86 (小端机器)上输出结果如下:
0x7fff1a6747c0 – 0×78
0x7fff1a6747c1 – 0×56
0x7fff1a6747c2 – 0×34
0x7fff1a6747c3 – 0×12
在sparc(大端机器)上输出结果如下:
ffbffbd0 – 0×12
ffbffbd1 – 0×34
ffbffbd2 – 0×56
ffbffbd3 – 0×78
通过以上输出结果可以看出,小端机器的数值低位0×78放在了低地址0x7fff1a6747c0上;而大端机器则是将数值高位0×12放在了低 地址0xffbffbd0上。
机器的最小寻址单位是字节,bit无法寻址,也就没有高低地址和起始地址的概念,我们需要定义一下bit的“地址”。以一个字节为例,我们把从左到右的8个bit的位置(position)命名按顺序命名如下:
p7 p6 p5 p4 p3 p2 p1 p0
其中最左端的p7为起始地址。这样以一字节大小的数值10110101(b)为例,其在不同平台下的内存位序如下:
大端的含义是数值的最高位1(最左边的1)放在了起始位置p7上,即数值10110101的大端内存布局为10110101。
小端的含义是数值的最低位1(最右边的1)放在了起始位置p7上,即数值10110101的小端内存布局为10101101。
前面的函数dump_native_bits_storage_layout也是符合这一定义的,即最左为起始位置。
同理,对于一个bit个数为3且存储的数值为110(b)的位域而言,将其3个bit的位置按顺序命名如下:
p2 p1 p0
其在大端机器上的bit内存布局,即位域位序为: 110;
其在小端机器上的bit内存布局,即位域位序为: 011。
在此基础上,理解上面例子中的疑惑就很简单了。
s.a = 178; 二进制形式为:0000 0000 0111 0011(b)
大端机器上位域位序为 0000 0000 0111 0011
小端机器上位域位序为1100 1110 0000 0000
s.b = 997; 二进制形式为: 0000 0011 1110 0101(b)
大端机器上位域位序为0000 0011 1110 0101
小端机器上位域位序为1010 0111 1100 0000
于是在x86(小端)上的dump bits结果为:
0000 0011 1110 0101
而在sparc(大端)上的dump bits结果为:
1010 0111 1100 0000
同时我们可以看出这里是根据位域进行单独赋值的,这样位域的位序是也是以位域为单位排列的,即每个位域内部独立排序, 而不是按照存储单元(这里的存储单元是16bit)或按字节内bit序排列的。