vlambda博客
学习文章列表

C语言位域解析及在嵌入式编程中的应用

笔者能力有限,如文中出现错误的地方,还请各位朋友能给我指出来,我将不胜感激,谢谢~

位域的概念

位域(或者也能称之为位段,英文表达是 Bit field)是一种数据结构,可以把数据以位元的形式紧凑的存储,并允许程序员对此结构的位元进行操作。这种数据结构的好处是:

  • 可以使数据单元节省存储空间,当程序需要成千上万个数据单元时,这种数据结构的优点也就很明显地突出出来了。

  • 位段可以很方便地访问一个整数值的部分内容从而简化程序源代码。

位域的定义

总体来说位域的定义可以分为两大类,一个是结构体位域,一个是共用体体位域,由于共用体和结构体两者在定义上的形式都是相同的,因此对于位域的定义从形式上看,两者也都是相同的。

结构体位域

结构体位域定义的一般形式如下所示:

 
   
   
 
  1. struct 位域结构体名

  2. {

  3. 类型说明符 位域名 长度;

  4. }结构体变量名;

举个简单的例子进行说明:

 
   
   
 
  1. struct example0

  2. {

  3. unsigned char x : 3;

  4. unsigned char y : 2;

  5. unsigned char z : 1;

  6. }ex0_t;

上述定义是什么意思呢,用一张图就能很清楚地明白,下图是所定义的结构体位域在内存中的存储位置:

从图中我们可以看出,虽然 x 的类型是 unsigned char ,但是并没有占 8 个位,而是占了 3 个位,其取值范围也变成了 0 ~ 2^3-1。通过上述图片我们也可以猜到这个结构体位域的大小,笔者通过 printf 函数输出结构体位域的大小为:

 
   
   
 
  1. The Value of sizeof(ex0_t) is : 1 byte

关于结构体位域的大小遵循这样一个原则:整个结构体位域的总大小为最宽基本类型成员大小的整数倍,这一原则与笔者在上一篇文章《结构体内存对齐解析》中写的结构体的总大小的原则是相同的。

共用体位域

共用体位域定义的一般形式跟结构体定义的一般形式是大致相同的,直接举一个简单的例子进行说明:

 
   
   
 
  1. union example1

  2. {

  3. unsigned char x : 3;

  4. unsigned char y : 2;

  5. unsigned char z : 1;

  6. }ex1_u;

同样的,笔者在这里给出共用体位域在内存中的存储位置:

这里笔者也给出共用体位域的大小:

 
   
   
 
  1. The Value of sizeof(ex1_u) is : 1 byte

由此也可以得出共用体位域大小遵循的原则是:共用体位域的总大小为最大基本类型成员的大小

结构体位域详解

位域的类型使用无符号型

正如标题所示,在位域的使用过程中使用无符号的数据类型,下面给出一个例子来说明这个例子:

 
   
   
 
  1. struct BitField_8

  2. {

  3. char a : 2;

  4. char b : 3;

  5. }BF8;


  6. BF8.a = 0x3;/* 11 */

  7. BF8.b = 0x5;/* 101 */

  8. printf("%d,%d\n",BF8.a,BF8.b);

上述的输出结果为:

 
   
   
 
  1. -1,-3

输出结果并不是我们想要的,究其原因,实际上是因为 BF.a ,BF.b 都是有符号的,那么自然也就有符号位的存在,而最高位为 1 代表负数,负数又是以补码的形式存储在计算机中的,所以也就有了上述的结果。因此为了避免上述这种问题的出现,应该将 BitField_8 中的 char 转换成 unsigned char ,那输出的结果就是 3,5

位域禁止的操作

由于位域的特殊,同时也有了一些跟普通变量不同的特性:

  • 结构体位域成员不能够使用取址操作

 
   
   
 
  1. struct BitField_8

  2. {

  3. unsigned char a : 2;

  4. }BF8;

  5. printf("%p\n",&BF8.a); /*错误*/

  • 结构体位域成员不能够用 static 修饰

 
   
   
 
  1. struct BitField_8

  2. {

  3. static unsigned char a : 2;/*错误*/

  4. }BF8;

  • 结构体位域成员不能够使用数组

 
   
   
 
  1. struct BitField_8

  2. {

  3. unsigned char a[5] : 5;/*错误*/

  4. }BF8;

不同处理器,不同编译器对位域的影响

位域虽然能够以位的形式操作数据,但是也被人们告知要慎重使用,原因就在于不同的处理器结构,不同的编译器对于位域的一些特性会产生不同的结果,这也就是位域移植性差的原因

处理器影响

处理器对位域造成的影响也很容易理解,大端模式和小端模式的处理器会对下面的结构体位域产生不一样的存储方式,这里比较简单,如果对这个问题不清楚的朋友可以看笔者的这篇文章《union 的概念及在嵌入式编程中的应用》。

编译器影响

结构体位域成员不同类型

不同的编译器对于位域会有不同的结果,比如下面这段代码:

 
   
   
 
  1. struct BitField_5

  2. {

  3. unsigned int a : 4;

  4. unsigned char b : 4;

  5. }BF_8;


  6. int main(void)

  7. {

  8. printf("The Value of sizeof(BF_8) is:%lu bytes\n",sizeof(BF_8));

  9. }

上述所定义的结构体位域中,对于结构体位域内成员不同数据类型,不同的编译器有不同的处理,对于 Visual Studio 来说,面对不同的数据类型时,对于上述这个例子,存储完第一个成员 a 后,会重新另起 4 byte 的空间进行存储,因此对于上述代码在 Visual Studio 的运行结果是:

 
   
   
 
  1. The Value of sizeof(BF_8) is 8 bytes

可见在 vs 环境下这样使用位域不但没有能够节省内存空间,反而相比于结构体还扩大了。上述是 VS 环境下的测试结果,下面是在 GCC 环境下的测试结果:

 
   
   
 
  1. The Value of sizeof(BF_8) is 4 bytes

可见在 GCC 环境下,就算结构体位域成员的数据类型不一致,它其实按照“压缩”数据的方式进行存储的,也就是说结构体位域里的成员都是挨着存放的。

成员大小之和超过一个基本存储空间

除了上述成员不同类型对于不同编译器有不同的处理方式,当成员大小之和超过一个基本存储空间时,不同的编译器也有不同的处理方式,比如下面这段代码:

 
   
   
 
  1. struct short_flag_t

  2. {

  3. unsigned short a : 7;

  4. unsigned short b : 10;

  5. };

对于上面这段代码,同类型成员除了这样定义之外,也可以这样定义:

 
   
   
 
  1. struct short_flag_t

  2. {

  3. unsigned short a : 7,/*注意此处是逗号*/

  4. b : 10;

  5. };

上面的代码因为 unsigned short 的大小是 2 个字节,而成员 a,b加起来的大小已经超过了 2 个字节,所以这种情况下也就有了以下两种存储方式:

  • a , b 紧邻

  • b 在下一个可存储它的存储单元内分配内存

不同编译器可能面对这种情况会采用不同的存储方式,对于 GCC 来说,采用的是第二种,如果编译器采用的是第一种方式,而程序要求又需要按照第二种方式来进行存储,又该如何办呢?这时就要利用匿名 0 长度位域字段的语法强制位域在下一个存储单元存储,示例代码如下:

 
   
   
 
  1. struct short_flag_t

  2. {

  3. unsigned short a : 2;

  4. unsigned short : 0;

  5. unsigned short b : 3;

  6. }

上述代码对于 a , b 来讲,b 便不会紧挨着 a 进行存储,而是强制使 b 在下一个存储单元进行存储。

位域的应用

上述便是位域涉及的基本概念,那知道了基本概念之后,又能使用位域做些什么呢?最容易另人想到的就是使用结构体位域定义标志位,由于我们在裸机开发的过程中,没有信号量,事件等机制,通常会定义一些范围只存在于 0~1 的开关量,而在没有使用位域之前,最小的变量类型都是 1 个字节,使用结构体位域将能够根据取值范围定义该变量的位数,从而起到节省内存的作用。

用于访问微控制器的寄存器

位域受到处理器和编译器的影响,在使用前我们必须清楚当前处理器是大端对齐还是小端对齐,必须清楚当前编译器对所定义的位域有何影响

如果我们现在要使用位域访问一个 8 位的寄存器,这个寄存器大致长这个样子:

那么我们就可以使用结构体位域构造这样一个数据结构:

 
   
   
 
  1. typedef union

  2. {

  3. unsigned char Byte;

  4. struct

  5. {

  6. unsigned char bit012 : 3;

  7. unsigned char bit34 : 2;

  8. unsigned char bit5 : 1;

  9. unsigned char bit6 : 1;

  10. unsigned char bit7 : 1;

  11. }bits;

  12. }registerType;

 
   
   
 
  1. registerType *pReg = (registerType *)0x0000 8000;

在进行了上述定义之后,我们就可以对寄存器进行操作了,首先,我们可以使用位域的方式操作寄存器的位,比如这样:

 
   
   
 
  1. pReg->bits.bit5 = 1;

  2. pReg->bits.bit012 = 7;

当然也可以利用 union 的特性直接操作整个寄存器,如下:

 
   
   
 
  1. pReg->Byte = 0x55;

使用位域完成对于寄存器的访问,对于上述例子来讲,我们必须要注意的一点是此例子是基于小端对齐模式的。

总结

位域的用法虽然看起来更加灵活了,但是在使用时也要对我们的处理器和编译器有所了解,如果为了写出移植性较高的程序,应该避免使用位域。

参考资料:

[1] https://aticleworld.com/access-the-port-and-register-using-bit-field-in-embedded-c/

[2] https://www.raviyp.com/bitfields-in-c-for-accessing-microcontroller-registers/

[3]https://aticleworld.com/bit-field-in-c/