C语言的本质(三):结构体和联合体

结构体和联合体

我们再用反汇编的方法研究一下C语言的结构体:

例子:

      #include <stdio.h>
      int main(void)
      {
          struct {
                  char a;
                  short b;
                  int c;
                  char d;
          } s;
          s.a = 1;
          s.b = 2;
          s.c = 3;
          s.d = 4;
          printf("%u\n", sizeof(s));
          return 0;
      }

main函数中几条语句的反汇编结果如下:

          s.a = 1;
      80483ed:   c6 44 24 14 01          movb   $0x1,0x14(%esp)
          s.b = 2;
      80483f2:   66 c7 44 24 16 02 00   movw   $0x2,0x16(%esp)
          s.c = 3;
      80483f9:   c7 44 24 18 03 00 00   movl   $0x3,0x18(%esp)
      8048400:   00
          s.d = 4;
      8048401:   c6 44 24 1c 04          movb   $0x4,0x1c(%esp)

从访问结构体成员的指令可以看出,结构体的四个成员在栈上的排列如图18.5所示。

虽然栈是从高地址向低地址增长的,但结构体成员也是从低地址向高地址排列的,这一点和数组类似。

但有一点和数组不同,结构体的各成员并不是一个紧挨一个排列的,中间有空隙,称为填充(Padding),不仅如此,在这个结构体的末尾也有三个字节的填充,所以sizeof(s)的值是12。

为什么编译器要这样处理呢?有一个知识点我此前一直回避没讲,大多数计算机体系结构对于访问内存的指令是有限制的,在32位平台上,如果一条指令访问4个字节(比如上面的movl),起始内存地址应该是4的整数倍,如果一条指令访问两个字节(比如上面的movw),起始内存地址应该是2的整数倍,这称为对齐(Alignment),访问一个字节的指令(比如上面的movb)没有对齐要求。

如果指令所访问的内存地址没有正确对齐会怎么样呢?在有些平台上将不能访问内存,引发一个异常,在x86平台上倒是能访问内存,但是不对齐的指令比对齐的指令执行效率要低,所以编译器在安排各种变量的地址时都会考虑到对齐的问题。

对于本例中的结构体,编译器会把它的基地址对齐到4字节边界,也就是说,esp+0x14这个地址一定是4的整数倍。

  • s.a占一个字节,没有对齐的问题。

  • s.b占两个字节,如果s.b紧挨在s.a后面,它的地址就不能是2的整数倍了,所以编译器会在结构体中插入一个填充字节,使s.b的地址是2的整数倍。

  • s.c占4字节,紧挨在s.b的后面就可以了,因为esp+0x18这个地址也是4的整数倍。

为什么s.d的后面也要有填充位填充到4字节边界呢?这是为了便于安排这个结构体后面的变量地址,假如用这种结构体类型组成一个数组,那么由于前一个结构体的末尾已经有填充字节对齐到4字节边界了,后一个结构体只需和前一个结构体紧挨着排列就可以了。

事实上,C标准规定数组元素必须紧挨着排列,不能有空隙,这样才能保证每个元素的地址可以按“基地址+n×每个元素的字节数”简单计算出来。

合理设计结构体各成员的排列顺序可以节省存储空间,如果上例中的结构体改成这样就可以避免产生填充字节:

      struct {
              char a;
              char d;
              short b;
              int c;
      } s;

此外,gcc提供了一种扩展语法可以消除结构体中的填充字节:

      struct {
              char a;
              short b;
              int c;
              char d;
      } __attribute__((packed)) s;

但这样就不能保证结构体成员的对齐了,在访问b和c的时候可能会有效率问题,甚至无法访问,所以除非有特别的理由,一般不要使用这种语法。

以前我们讲过的数据类型最少也要占一个字节,而结构体中还可以使用Bit-field语法定义只占几个bit的成员。下面这个例子出自王聪的网站(http://www.wangcong.org/):

Bit-field

      #include <stdio.h>
      typedef struct {
              unsigned int one:1;
              unsigned int two:3;
              unsigned int three:10;
              unsigned int four:5;
              unsigned int :2;
              unsigned int five:8;
              unsigned int six:8;
      } demo_type;
      int main(void)
      {
              demo_type s = { 1, 5, 513, 17, 129, 0x81 };
              printf("sizeof demo_type = %u\n", sizeof(demo_type));
              printf("values: s=%u,%u,%u,%u,%u,%u\n",
                    s.one, s.two, s.three, s.four, s.five, s.six);
              return 0;
      }

s这个结构体的布局如图18.6所示:

Bit-field也属于整型,可以用int或unsigned int声明,表示有符号数或无符号数,但它不像普通的int型一样占4个字节,冒号后面的数字表示这个Bit-field占几个bit。上例中的unsigned int :2;定义一个未命名的Bit-field占两个bit。即使不写未命名的Bit-field,编译器也有可能在两个成员之间插入填充位,例如图18.6的five和six之间有填充位,这样six这个成员就刚好单独占一个字节了,访问效率会比较高,这个结构体的末尾还填充了3个字节,以便对齐到4字节边界。以前我们说过x86的Byte Order是小端的,从图18.6中one和two的排列顺序可以看出,如果对一个字节再细分,则字节中的Bit Order也是小端的,因为排在结构体前面的成员(靠近低地址一边的成员)取字节中的低位。关于如何排列Bit-field在C标准中没有明确规定,这跟Byte Order、Bit Order、对齐等问题都有关,不同的平台和编译器可能会排列得很不一样,要编写可移植的代码就不能假定Bit-field是按某种固定方式排列的。Bit-field在驱动程序中是很有用的,因为经常需要单独操作设备寄存器中的一个或几个bit,但一定要小心使用,首先弄清楚每个Bit-field和设备寄存器中每个bit的对应关系。

在上例中我没有给出反汇编结果,直接画了个图说这个结构体的布局是这样的,那我有什么证据这么说呢?上例的反汇编结果比较繁琐,我们可以用另一种手段得到这个结构体的内存布局:

联合体

      #include <stdio.h>
      typedef union {
          struct {
                  unsigned int one:1;
                  unsigned int two:3;
                  unsigned int three:10;
                  unsigned int four:5;
                  unsigned int :2;
                  unsigned int five:8;
                  unsigned int six:8;
              } bitfield;
              unsigned char byte[8];
      } demo_type;
      int main(void)
      {
              demo_type u = {
   
   { 1, 5, 513, 17, 129, 0x81 }};
              printf("sizeof demo_type = %u\n", sizeof(demo_type));
              printf("values: u=%u,%u,%u,%u,%u,%u\n",
                    u.bitfield.one, u.bitfield.two, u.bitfield.three,
                    u.bitfield.four, u.bitfield.five, u.bitfield.six);
              printf("hex dump of u: %x %x %x %x %x %x %x %x\n",
                    u.byte[0], u.byte[1], u.byte[2], u.byte[3],
                    u.byte[4], u.byte[5], u.byte[6], u.byte[7]);
              return 0;
      }

关键字union定义一种新的数据类型,称为联合体,其语法类似于结构体。一个联合体的各个成员占用相同的内存空间,联合体的长度等于其中最长成员的长度。比如u这个联合体占8个字节,如果访问成员u.bitfield,则把这8个字节看成一个由Bit-field组成的结构体,如果访问成员u.byte,则把这8个字节看成一个数组。

联合体如果用Initializer初始化,则只初始化它的第一个成员,例如demo_type u ={ { 1, 5, 513, 17, 129, 0x81 }};初始化的是u.bitfield,这样我们只知道u.bitfield结构体各成员的值是多少,却不知道它的内存布局是什么样的,然后我们换一个视角,同样是这8个字节,我们把它看成一个u.byte数组,就可以看出每个字节分别是多少,内存布局是什么样了。

如果用C99的Memberwise初始化语法,则可以初始化联合体的任意一个成员,例如:

      demo_type u = { .byte = {0x1b, 0x60, 0x24, 0x10, 0x81, 0, 0, 0} };

最后回顾一下我们讲过的这些概念:

  • 1.数据类型的长度(例如ILP32、LP64)
  • 2.Calling Convention
  • 3.访问内存地址的对齐要求
  • 4.结构体和Bit-field的填充方式
  • 5.字节序(大端、小端)
  • 6.用什么指令做系统调用,各种系统调用的参数
  • 7.可执行文件和库文件格式(例如ELF格式)

这些统称为应用程序二进制接口规范(ABI,Application Binary Interface),如果两个平台具有相同的体系结构,并且遵循相同的ABI,就可以保证一个平台上的二进制程序直接拷贝到另一个平台就能运行,不用重新编译。比如有两台x86计算机,一台是PC,另一台是上网本,分别装了不同的Linux发行版,那么从一台机器拷贝一个二进制程序到另一台机器同样也能运行,因为这两台机器具有相同的体系结构,并且操作系统遵循相同的ABI。

如果在同一台计算机上装了Linux和Windows两个操作系统,在Windows系统中运行一个Linux的二进制程序是不行的,因为这两个操作系统的ABI不同。

C内联汇编

用C写程序比直接用汇编写程序更简洁,可读性更好,但效率可能不如汇编程序,因为C程序毕竟要经由编译器生成汇编代码,尽管现代编译器的优化已经做得很好了,但还是不如手写的汇编代码。

另外,有些平台相关的指令必须手写,在C语言中没有等价的语法,因为C语言中的概念是对各种平台的抽象,每种平台特有的一些东西就不会在C语言中出现了,例如x86是端口I/O,而C语言就没有这个概念,所以in/out指令必须用汇编来写。

C语言简洁易读,容易组织规模较大的代码,汇编效率高,而且写一些特殊指令必须用汇编,为了把这两方面的好处都占全了,gcc提供了一种扩展语法可以在C代码中使用内联汇编(Inline Assembly)。

最简单的格式是__asm__(“assembly code”);,

例如__asm__(“nop”);,nop这条指令什么都不做,只是让CPU空转一个指令执行周期。

如果需要执行多条汇编指令,则应该用\n\t将各条指令分隔开,例如:

      __asm__("movl $1, %eax\n\t"
              "movl $4, %ebx\n\t"
              "int $0x80");

通常内联汇编需要和C代码中的变量建立关联,要用到完整的内联汇编格式:

      __asm__(assembler template
              : output operands                /* optional */
              : input operands                 /* optional */
              : list of clobbered registers   /* optional */
              );

这种格式由四部分组成,

  • 第一部分是汇编指令,和上面的例子一样,
  • 第二部分和第三部分是约束条件,第二部分告诉编译器汇编指令的运算结果要输出到哪些C语言操作数中,这些操作数应该是左值表达式,
  • 第三部分告诉编译器汇编指令需要从哪些C语言操作数获得输入,
  • 第四部分是在汇编指令中被修改的寄存器列表(称为Clobber List),告诉编译器哪些寄存器的值在执行这条__asm__语句时会改变。

后三个部分是可选的,如果有就填写,没有就空着只写个冒号,例如:

      #include <stdio.h>
      int main(void)
      {
            int a = 10, b;
              __asm__("movl %1, %%eax\n\t"
                    "movl %%eax, %0\n\t"
                    :"=r"(b)       /* output */
                    :"r"(a)        /* input */
                    :"%eax"        /* clobbered register */
                    );
              printf("Result: %d, %d\n", a, b);
              return 0;
      }

这个程序将变量a的值赋给b。“r”(a)告诉编译器分配一个寄存器保存变量a的值,作为汇编指令的输入,也就是指令中的%1(按照约束条件的顺序,b对应%0,a对应1%),至于%1究竟代表哪个寄存器则由编译器自己决定。

汇编指令首先把%1所代表的寄存器的值传给eax(为了和%1这种占位符区分,eax前面要求加两个%号),然后把eax的值再传给%0所代表的寄存器。“=r”(b)表示把%0所代表的寄存器的值输出给变量b。

在执行这两条指令的过程中,寄存器eax的值被改变了,所以把"%eax"写在第四部分,告诉编译器在执行这条__asm__语句时eax要被改写,所以在此期间不要用eax保存其他值。

我们看一下这个程序的反汇编结果:

          __asm__("movl %1, %%eax\n\t"
      80483f5:   8b 54 24 1c         mov   0x1c(%esp),%edx
      80483f9:   89 d0               mov   %edx,%eax
      80483fb:   89 c2               mov   %eax,%edx
      80483fd:   89 54 24 18         mov   %edx,0x18(%esp)
                  "movl %%eax, %0\n\t"
                  :"=r"(b)       /* output */
                  :"r"(a)        /* input */
                  :"%eax"        /* clobbered register */
                  );

可见%0和%1都代表edx寄存器,首先把变量a(位于esp+0x1c的位置)的值传给edx然后执行内联汇编的两条指令,然后把edx的值传给b(位于esp+0x18的位置)。

参考资料

《一站式学习C编程》

猜你喜欢

转载自blog.csdn.net/weixin_45264425/article/details/132324809