C 语言位域(bit filed)

C 语言位域(bit filed)

位域也叫位段(bit field),它的好处是能帮我们节省结构体占用的内存。这一节我们围绕下面几点来展开:

  1. 位域的作用
  2. 位域的语法
  3. 位域的存储

下面示例代码的运行环境是:win10 专业版 + vs2019 社区版。

1. 问题场景

位域本质上还是一个结构体,用 struct 关键字定义。我们先看一个普通结构体是怎么定义的:

#include <stdio.h>

struct Student
{
	unsigned int age;
	unsigned int score;
	unsigned int grade;
	unsigned int gender;
};

void test()
{
	printf("Student 大小: %llu\n", sizeof(struct Student));
}

int main()
{
    test();
    return 0;
}

程序运行后输出:

Student 大小: 16

运行完这段代码,我们会发现一个值得琢磨的问题:

我们定义了一个 Student 结构体,里面有 4 个成员:age(年龄)、score(分数)、grade(年级)、gender(性别)。

每个成员都是 unsigned int,占 4 个字节,于是整个 Student 一共占 16 个字节,也就是 64 个 bit。

可是我们仔细一算,会发现空间用得太奢侈了:

  1. age 字段:年龄最小是 0,最大到 100 左右就够了。7 个 bit 能表示的最大值是 127,装下年龄绰绰有余,根本用不着 4 个字节 32 位,白白浪费。
  2. score 字段:分数从 0 到 120,7 个 bit 就能表示到 120,同样用不着 32 位。
  3. grade 字段:班级最多也就 15 个左右,4 个 bit 足够表示,也不用占 32 位。
  4. gender 字段:性别只有男女两种取值,1 个 bit 就够了。

如果真按上面这样精打细算,Student 占用的内存能少一大截。

可问题来了:我们给变量分配内存,最小单位是字节,没法直接按 bit 分配。那能不能让结构体成员按 bit 来占位呢?可以,这就要用到结构体位域。

说白了,位域就是让结构体成员以 bit 为单位来分配内存。

2. 位域使用

位域的用法,我们分两部分讲:先看怎么定义,再看怎么使用。

2.1 定义语法

一个位域由三部分组成:位域类型说明符、位域名、位域长度。我们把 Student 的成员改写成位域的形式,看下面这段代码:

struct Student
{
	unsigned int age : 7;  
	unsigned int score : 7;
	unsigned int grade : 4;
	unsigned int gender : 1;
};

void test()
{
	cout << sizeof(Student) << endl;  // 输出:4
}

改成位域之后,Student 从 16 字节缩到了 4 字节,内存一下子省了不少。

我们拿第 3 行 age 那一句来说,拆解开看看每部分的规则和含义:

  1. unsigned int 叫做位域类型说明符。
    1. C 标准规定位域类型说明符可以是 signed int、unsigned int,很多编译器在这两个基础上还扩展支持了 char、unsigned char、short、unsigned short、_Bool。
    2. 其中 signed 表示这个位域可正可负,unsigned 表示它只能存正数。
    3. 位域长度不能超过该类型本身的位数。即:类型说明符是 char,位域长度就不能超过 8 位,类型说明符是 int,位域长度就不能超过 32 位,其余以此类推。
  2. age 叫做位域名,我们就是通过这个名字去访问这个位域字段的。位域名也可以省略,省略之后没法再访问这个字段,但它依然占着内存。
  3. 冒号后面的数字表示这个位域的长度,单位是 bit。它的最大值不能超过类型说明符的位数,最小值可以是 0,长度为 0 时这个位域必须是无名的。

2.2 使用语法

  1. 位域不能取地址。原因是地址是按字节编号的,而位域字段不一定正好占满一个字节。
  2. 位域不能做成数组的形式。
  3. 给位域赋的值不能超过它能表示的范围,否则结果是未定义的。

看下面这段示例:

struct Student
{
	unsigned int age : 7;
	unsigned int score : 7;
	unsigned int grade : 4;
	unsigned int gender : 1;
};

void test()
{
	Student student;
	
	// 1. 成员访问
	student.age = 100;  // 正确
	student.age = 200;  // 超出表示范围结果未定义
	cout << student.age << endl;

	// 2. 不能对位域成员取地址
	// cout << &(student.age) << endl;   // 错误
}

3. 位域存储

位域成员在内存里怎么摆放,有下面几条规则:

  1. 如果结构体里所有位域成员的类型说明符相同,而且能塞进同一个存储单元(存储单元的大小就是类型说明符的大小),它们就会被放进同一个存储单元里。
struct Demo
{
	int a : 5;
	int b : 7;
	int c : 9;
};

void test()
{
	cout << sizeof(Demo) << endl;  // 输出:4
}

Demo 的 a、b、c 类型说明符相同,加起来占 5 + 7 + 9 = 21 个 bit,21 位还装得进一个 int(32 位)的存储单元,于是它们挤在同一个单元里。

2. 如果位域成员的类型说明符都相同,但一个存储单元装不下全部,那么装不下的部分会放到一个新的存储单元里。

struct Demo
{
	int a : 5;
	int b : 25;
	int c : 9;
};

void test()
{
	cout << sizeof(Demo) << endl;  // 输出:8
}

Demo 里 a 和 b 类型相同,加起来 30 位,还没超过一个存储单元,所以它俩放进同一个单元。

c 占 9 个 bit,第一个单元已经放不下了,于是单独占用第二个存储单元。一个存储单元的大小取最大类型说明符的大小,这里是 4 字节,所以 Demo 一共占 8 个字节。

3. 结构体里一旦出现匿名位域,它后面的成员就会放到新的存储单元里。

struct Demo
{
	int a : 5;
	int : 0;  // 无名位域
	int b : 6;
	int : 0;  // 无名位域
	int c : 9;
};

void test()
{
	cout << sizeof(Demo) << endl;  // 输出:12
}

Demo 里 a 放在第一个存储单元,它后面紧跟着一个无名位域,于是 b 被挤到第二个存储单元。之后又遇到一个无名位域,c 只好放进第三个存储单元。

其实 a、b、c 加起来 5 + 6 + 9 = 20 位,本来一个存储单元就装得下,但因为两个无名位域把它们强行拆开,最后 a、b、c 占了 3 个存储单元,也就是 12 个字节。

4. 如果相邻位域的类型不同,类型一换,后面的成员就要放到新的存储单元里。

struct Demo
{
	int a : 5;
	int b : 6;
	char c : 7;
	char d : 7;
};

void test()
{
	cout << sizeof(Demo) << endl;  // 输出:8
}

a、b 类型相同,加起来的位数小于一个存储单元(取位域里最大的那个类型),所以占 4 个字节。

c 和前面两个成员类型不一样,于是另起一个存储单元,d 跟 c 同类型,就跟 c 挤在同一个单元里。

算下来,Demo 一共占 2 个存储单元,也就是 8 个字节。

最后提醒一点:位域的内存分配和内存对齐方式跟具体机器和系统有关,换个平台结果可能就不一样,所以位域本质上是不可移植的。

至此,位域这一节就讲到这里。