C语言的本质(二):汇编与C

简介: C语言的本质(二):汇编与C

前言

C程序编译之后的汇编是什么样的,C语言的各种语法分别对应什么样的指令,从而更深入地理解C语言。gcc还提供了一种扩展语法可以在C程序中内嵌汇编指令,这在内核代码中很常见.

一、函数调用

1研究函数的调用过程

int bar(int c, int d)
      {
              int e = c + d;
              return e;
      }
      int foo(int a, int b)
      {
              return bar(a, b);
      }
      int main(void)
      {
              foo(2, 3);
              return 0;
      }

如果在编译时加上-g选项(在之前讲过-g选项),那么用objdump反汇编时可以把C代码和汇编代码穿插起来显示,这样C代码和汇编代码的对应关系看得更清楚。反汇编的结果很长,以下只列出我们关心的部分。

$ gcc main.c -g
      $ objdump -dS a.out
      ...
      080483b4 <bar>:
      int bar(int c, int d)
      {
      80483b4:   55                      push   %ebp
      80483b5:   89 e5                   mov   %esp,%ebp
      80483b7:   83 ec 10                sub   $0x10,%esp
          int e = c + d;
      80483ba:   8b 45 0c                mov   0xc(%ebp),%eax
      80483bd:   8b 55 08                mov   0x8(%ebp),%edx
      80483c0:   8d 04 02                lea   (%edx,%eax,1),%eax
      80483c3:   89 45 fc                mov   %eax,-0x4(%ebp)
          return e;
      80483c6:   8b 45 fc                mov   -0x4(%ebp),%eax
      }
      80483c9:   c9                      leave
      80483ca:   c3                      ret
      080483cb <foo>:
      int foo(int a, int b)
      {
      80483cb:   55                      push   %ebp
      80483cc:   89 e5                   mov   %esp,%ebp
      80483ce:   83 ec 08                sub   $0x8,%esp
          return bar(a, b);
      80483d1:   8b 45 0c                mov   0xc(%ebp),%eax
      80483d4:   89 44 24 04             mov   %eax,0x4(%esp)
      80483d8:   8b 45 08                mov   0x8(%ebp),%eax
      80483db:   89 04 24                mov   %eax,(%esp)
      80483de:   e8 d1 ff ff ff          call   80483b4 <bar>
      }
      80483e3:   c9                      leave
      80483e4:   c3                      ret
      080483e5 <main>:
      int main(void)
      {
      80483e5:   55                      push   %ebp
      80483e6:   89 e5                   mov   %esp,%ebp
      80483e8:   83 ec 08                sub   $0x8,%esp
          foo(2, 3);
      80483eb:   c7 44 24 04 03 00 00   movl   $0x3,0x4(%esp)
      80483f2:   00
      80483f3:   c7 04 24 02 00 00 00   movl   $0x2,(%esp)
      80483fa:   e8 cc ff ff ff          call   80483cb <foo>
          return 0;
      80483ff:   b8 00 00 00 00          mov   $0x0,%eax
      }
      8048404:   c9                      leave
      8048405:   c3                      ret
      ...

要查看编译后的汇编代码,其实还有一种办法是gcc -S main.c,这样只生成汇编代码main.s,而不生成二进制的目标文件。

整个程序的执行过程是main调用foo,foo调用bar,我们用gdb跟踪程序的执行,直到bar函数中的int e = c + d;

执行完毕准备返回时,才在gdb中打印函数栈帧。

(gdb) start
      ...
      main () at main.c:14
      14              foo(2, 3);
      (gdb) s
      foo (a=2, b=3) at main.c:9
      9               return bar(a, b);
      (gdb) s
      bar (c=2, d=3) at main.c:3
      3               int e = c + d;
      (gdb) disassemble
      Dump of assembler code for function bar:
        0x080483b4 <+0>:     push   %ebp
        0x080483b5 <+1>:     mov   %esp,%ebp
        0x080483b7 <+3>:     sub   $0x10,%esp
      => 0x080483ba <+6>:     mov   0xc(%ebp),%eax
        0x080483bd <+9>:     mov   0x8(%ebp),%edx
        0x080483c0 <+12>:    lea   (%edx,%eax,1),%eax
        0x080483c3 <+15>:    mov   %eax,-0x4(%ebp)
        0x080483c6 <+18>:    mov   -0x4(%ebp),%eax
        0x080483c9 <+21>:    leave
        0x080483ca <+22>:    ret
      End of assembler dump.
      (gdb) si
      0x080483bd      3               int e = c + d;
      (gdb) si
      0x080483c0      3               int e = c + d;
      (gdb) si
      0x080483c3      3               int e = c + d;
      (gdb) si
      4               return e;
      (gdb) si
      5   }
      (gdb) bt
      #0  bar (c=2, d=3) at main.c:5
      #1  0x080483e3 in foo (a=2, b=3) at main.c:9
      #2  0x080483ff in main () at main.c:14
      (gdb) disassemble
      Dump of assembler code for function bar:
        0x080483b4 <+0>:     push   %ebp
        0x080483b5 <+1>:     mov   %esp,%ebp
        0x080483b7 <+3>:     sub   $0x10,%esp
        0x080483ba <+6>:     mov   0xc(%ebp),%eax
        0x080483bd <+9>:     mov   0x8(%ebp),%edx
        0x080483c0 <+12>:    lea   (%edx,%eax,1),%eax
        0x080483c3 <+15>:    mov   %eax,-0x4(%ebp)
        0x080483c6 <+18>:    mov   -0x4(%ebp),%eax
      => 0x080483c9 <+21>:   leave
        0x080483ca <+22>:    ret
      End of assembler dump.
      (gdb) info registers
      eax          0x5        5
      ecx          0x538696287583074
      edx          0x2        2
      ebx          0x283ff4  2637812
      esp          0xbffff358 0xbffff358
      ebp          0xbffff368 0xbffff368
      esi          0x0        0
      edi          0x0        0
      eip          0x80483c9 0x80483c9 <bar+21>
      eflags       0x282      [ SF IF ]
      cs           0x73       115
      ss           0x7b       123
      ds           0x7b       123
      es           0x7b       123
      fs           0x0        0
      gs           0x33       51
      (gdb) x/12x $esp
      0xbffff358: 0xbffff388  0x08048439  0x00284324  0x00000005
      0xbffff368: 0xbffff378  0x080483e3  0x00000002  0x00000003
      0xbffff378: 0xbffff388  0x080483ff  0x00000002  0x00000003

这里又用到几个新的gdb命令:

  • ● disassemble可以反汇编当前函数或者指定的函数,单独用disassemble命令是反汇编当前函数,如果disassemble命令后面跟函数名或地址则反汇编指定的函数或地址。
  • ● 以前我们讲过step命令可以一行代码一行代码地单步调试,而这里用到的si命令可以一条指令一条指令地单步调试。
  • ● info registers可以显示所有寄存器的当前值。
  • ● 在gdb中表示寄存器名时前面要加个$,例如p $esp可以打印esp寄存器的值。在上例中用info registers命令看到esp寄存器的值是0xbffff358,所以用x/12x $esp命令可以查看内存中从0xbffff358地址开始的12个32位数。

在执行程序时,操作系统为进程分配一块栈空间来保存函数栈帧,esp寄存器总是指向栈顶,在x86平台上这个栈是从高地址向低地址增长的,我们知道每次调用一个函数都要分配一个栈帧来保存参数和局部变量,现在我们详细分析这些数据在栈空间的布局,根据gdb的输出结果如图:

图中每个小方格表示4个字节的内存单元,例如b: 3这个小方格占的内存地址是0xbffff384~0xbffff387,我把地址写在每个小方格的下边界线上,是为了强调该地址是内存单元的起始地址。我们从main函数的这里开始看起:

foo(2, 3);
      80483eb:   c7 44 24 04 03 00 00   movl   $0x3,0x4(%esp)
      80483f2:   00
      80483f3:   c7 04 24 02 00 00 00   movl   $0x2,(%esp)
      80483fa:   e8 cc ff ff ff          call   80483cb <foo>
          return 0;
      80483ff:   b8 00 00 00 00          mov   $0x0,%eax

一开始esp寄存器的值是0xbffff380,在图中标注为esp(main)。

要调用函数foo先要把参数准备好,第二个参数保存在esp+4指向的内存位置,第一个参数保存在esp指向的内存位置,可见参数是从右向左依次压栈的。然后执行call指令,这个指令有两个作用:

  • 1.foo函数调用完之后要返回到call的下一条指令继续执行,所以把call的下一条指令的地址0x80483ff压栈,同时把esp的值减4,esp的值现在是0xbffff37c。
  • 2.修改程序计数器eip,跳转到foo函数的开头执行。现在看foo函数的汇编代码:
int foo(int a, int b)
      {
      80483cb:   55                  push   %ebp
      80483cc:   89 e5               mov   %esp,%ebp
      80483ce:   83 ec 08            sub   $0x8,%esp

push %ebp指令把ebp寄存器的值压栈,同时把esp的值减4。

esp的值现在是0xbffff378,下一条指令把这个值传送给ebp寄存器。

这两条指令合起来是把原来ebp的值保存在栈上,然后又给ebp赋了新值,新值在图中标注为ebp(foo)。

然后esp所指向的地址向下移动8个字节(在图中标注为esp(foo)),为即将压栈的参数d和c留出空间。

在每个函数的栈帧中,ebp指向栈底,而esp指向栈顶,在函数执行过程中esp随着压栈和出栈操作随时变化,而ebp是不动的,函数的参数和局部变量都是通过ebp的值加上一个偏移量来访问,例如foo函数的参数b和a分别通过ebp+12和ebp+8来访问。

所以foo函数接下来的指令把参数b和a取出来,作为参数d和c再次压栈,为调用bar函数做准备,然后把返回地址0x80483e3压栈,调用bar函数:

return bar(a, b);
      80483d1:   8b 45 0c            mov   0xc(%ebp),%eax
      80483d4:   89 44 24 04         mov   %eax,0x4(%esp)
      80483d8:   8b 45 08            mov   0x8(%ebp),%eax
      80483db:   89 04 24            mov   %eax,(%esp)
      80483de:   e8 d1 ff ff ff          call   80483b4 <bar>
      }
      80483e3:   c9                  leave
      80483e4:   c3                  ret

现在看bar函数的指令:

int bar(int c, int d)
      {
      80483b4:   55                  push   %ebp
      80483b5:   89 e5               mov   %esp,%ebp
      80483b7:   83 ec 10            sub   $0x10,%esp
          int e = c + d;
      80483ba:   8b 45 0c            mov   0xc(%ebp),%eax
      80483bd:   8b 55 08            mov   0x8(%ebp),%edx
      80483c0:   8d 04 02            lea   (%edx,%eax,1),%eax
      80483c3:   89 45 fc            mov   %eax,-0x4(%ebp)

这次又把foo函数的ebp压栈保存,然后给ebp赋了新值,指向bar函数栈帧的栈底(在图中标注为ebp(bar)),然后esp所指向的地址向下移动16个字节(在图中标注为esp(bar)),为局部变量e留出空间。

通过ebp+12和ebp+8分别可以访问参数d和c,通过ebp-4可以访问局部变量e。所以后面几条指令的意思是把参数d和c取出来传送到寄存器eax和edx中,然后用lea指令做加法,计算结果保存在eax寄存器中,再把eax的值存回局部变量e的内存单元。lea指令根据第一个操作数的寻址方式计算出所代表的地址,但并不通过这个地址访问内存,而是直接把这个地址传给第二个操作数,我们知道x86的内存寻址方式涉及加法和乘法,lea指令只是利用寻址电路做加法和乘法,而不是真的寻址,lea (%edx,%eax,1),%eax这条指令的意思是eax =edx + eax * 1。

我们知道在gdb中可以用bt命令和frame命令查看每层栈帧上的参数和局部变量,现在可以解释它的工作原理了:如果我当前在bar函数中,我可以通过ebp找到bar函数的参数和局部变量,也可以找到foo函数的ebp保存在栈上的值,有了foo函数的ebp,又可以找到它的参数和局部变量,也可以找到main函数的ebp保存在栈上的值,因此各层函数栈帧通过保存在栈上的ebp的值串起来了。现在看bar函数的返回指令:

return e;
      80483c6:   8b 45 fc            mov   -0x4(%ebp),%eax
      }
      80483c9:   c9                  leave
      80483ca:   c3                  ret

bar函数有一个int型的返回值,这个返回值是通过eax寄存器传递的,所以首先把e的值读到eax寄存器中。然后执行leave指令,这个指令是函数开头的push %ebp和mov %esp,%ebp的逆操作:1.把ebp的值赋给esp,现在esp的值是0xbffff368。2.现在esp所指向的栈顶保存着foo函数栈帧的ebp,把这个值恢复给ebp,同时esp增加4,esp的值变成0xbffff36c。最后是ret指令,它是call指令的逆操作:1.现在esp所指向的栈顶保存着返回地址0x80483e3,把这个值恢复给eip,同时esp增加4,esp的值变成0xbffff370。2.由于修改了程序计数器eip,程序跳转到返回地址处继续执行。地址0x80483e3处是foo函数的返回指令:

80483e3:    c9                  leave
      80483e4:    c3                  ret

这两条指令重复同样的过程,又返回到了main函数。现在思考这样一个问题:如果这时main函数又调用另外一个函数,先前调用foo函数时压栈的参数b和a并没有出栈,再调用另外一个函数又要将新的参数压栈,那么随着main调用的函数越来越多,堆栈岂不是要一直增长下去?当然不是了,读者可以写一段小程序自己反汇编研究一下编译器是怎么处理的。注意函数调用和返回过程中的这些规则:1.参数压栈传递,并且是从右到左依次压栈,传参所使用的栈空间由调用者分配和释放。2.ebp总是指向当前栈帧的栈底。3.返回值通过eax寄存器传递。这些规则并不是体系结构所强加的,ebp寄存器并不是必须这么用,函数的参数和返回值也不是必须这么传,不同的操作系统和编译器可以规定不同的方式来实现C代码中的函数调用,每种实现方式称为一种Calling Convention。本章介绍的规则是gcc默认的Calling Convention,称为cdecl,其它常见的Calling Convention可参考http://en.wikipedia.org/wiki/X86_calling_conventions

二、main函数、启动例程和退出状态

为什么汇编程序的入口是_start而C程序的入口是main函数呢?

要弄清楚这个问题,首先要理解gcc的编译步骤。继续用上一节的代码做实验,以前我们常用gcc main.c -o main命令编译一个程序,其实也可以分三步做,

  • 第一步生成汇编代码,
  • 第二步生成目标文件,
  • 第三步生成可执行文件:
$ gcc -S main.c
      $ gcc -c main.s
      $ gcc main.o

-S选项生成汇编代码,-c选项生成目标文件,此外在第8.2节还讲过-E选项只做预处理而不编译,如果这些选项都不加,则gcc执行完整的编译步骤,直到最后链接生成可执行文件为止。

这些选项都可以和-o搭配使用,给输出文件重新命名而不使用gcc默认的输出文件名(xxx.s、xxx.o和a.out),例如gcc main.o -o main将main.o链接生成可执行文件main。

即使用gcc main.c -o main一步完成编译,gcc内部也还是要分三步来做,用-v选项可以了解详细的编译过程:

$ gcc -v main.c -o main
      Using built-in specs.
      Target: i486-linux-gnu
      ...
      /usr/lib/gcc/i486-linux-gnu/4.4.3/cc1  -quiet  -v  main.c  -D_
      FORTIFY_SOURCE=2-quiet -dumpbase main.c -mtune=generic -march=
      i486-auxbase main -version -fstack-protector -o /tmp/ccMTcipT.s
      ...
      as -V -Qy -o /tmp/ccY0lGoS.o /tmp/ccMTcipT.s
      ...
      /usr/lib/gcc/i486-linux-gnu/4.4.3/collect2--build-id --eh-frame-hdr -m elf_i386--hash-style=both -dynamic-linker /lib/ld-linux.so.2-o  main  -z  relro  /usr/lib/gcc/i486-linux-gnu/4.4.3/../../../../lib/crt1.o /usr/lib/gcc/i486-linux-gnu/4.4.3/../../../../lib/crti.o/usr/lib/gcc/i486-linux-gnu/4.4.3/crtbegin.o-L/usr/lib/gcc/i486-linux-gnu/4.4.3-L/usr/lib/gcc/i486-linux-gnu/4.4.3-L/usr/lib/gcc/i486-linux-gnu/4.4.3/../../../../lib  -L/lib/../lib  -L/usr/lib/../lib  -L/usr/lib/gcc/i486-linux-gnu/4.4.3/../../.. -L/usr/lib/i486-linux-gnu /tmp/ccY0lGoS.o  -lgcc  --as-needed  -lgcc_s  --no-as-needed  -lc  -lgcc--as-needed -lgcc_s --no-as-needed /usr/lib/gcc/i486-linux-gnu/4.4.3/crtend.o /usr/lib/gcc/i486-linux-gnu/4.4.3/../../../../lib/crtn.o

gcc只是一个外壳而不是真正的编译器,真正的C编译器是/usr/lib/gcc/i486-linux-gnu/4.4.3/cc1,gcc调动C编译器、汇编器和链接器完成C代码的编译链接工作

/usr/lib/gcc/i486-linux-gnu/4.4.3/collect2是链接器ld的外壳,它调动ld完成链接。具体步骤如下:

  • 1.main.c被cc1编译成汇编程序/tmp/ccMTcipT.s。
  • 2.这个汇编程序被as汇编成目标文件/tmp/ccY0lGoS.o。
  • 3.这个目标文件连同另外几个目标文件(crt1.o、crti.o、crtbegin.o、crtend.o、crtn.o)一起链接成可执行文件main。
  • 在链接过程中还用-l选项指定了一些库文件,有libc、libgcc、libgcc_s,其中有些库是共享库,需要动态链接,所以用-dynamic-linker选项指定动态链接器是/lib/ld-linux.so.2。

这些链接选项到下一文再详细解释,目前我们只要理解可执行文件main是由main.c生成的目标文件和编译器提供的另外几个目标文件链接在一起生成的就可以了。

现在看看编译器提供的目标文件里都有什么,我们只看符号表,可以用readelf命令的-s选项,也可以用nm命令。我们重点分析crt1.o中的符号。

$ nm /usr/lib/crt1.o
      00000000 R _IO_stdin_used
      00000000 D __data_start
              U __libc_csu_fini
              U __libc_csu_init
              U __libc_start_main
      00000000 R _fp_hw
      00000000 T _start
      00000000 W data_start
              U main

符号表的每一行由地址、符号类型和符号名组成,目标文件中的地址是待定的,所以是00000000,符号类型用一个字母表示,大写字母是全局符号,小写字母是局部符号,具体每种类型的含义请参考nm。

  • U main这一行表示main这个符号在crt1.o中引用了,但是没有定义(U表示Undefined),因此需要别的目标文件提供一个定义并且和crt1.o链接在一起。
  • T _start这一行表示_start这个符号在crt1.o中提供了定义,这个符号的类型是代码(T表示Text)。
  • C程序的入口点其实是crt1.o提供的_start,它首先做一些初始化工作(以下称为启动例程,Startup Routine),然后调用我们写的main函数。

所以,以前我们说main函数是程序的入口点其实不准确,_start才是真正的入口点,而main函数是被_start调用的。

下面我们反汇编查看_start的定义:

$ objdump -d /usr/lib/crt1.o
      /usr/lib/crt1.o:    file format elf32-i386
      Disassembly of section .text:
      00000000 <_start>:
        0:   31 ed               xor   %ebp,%ebp
        2:   5e                  pop   %esi
        3:   89 e1               mov   %esp,%ecx
        5:   83 e4 f0            and   $0xfffffff0,%esp
        8:   50                  push   %eax
        9:   54                  push   %esp
        a:   52                  push   %edx
        b:   68 00 00 00 00      push   $0x0
        10:   68 00 00 00 00      push   $0x0
        15:   51                  push   %ecx
        16:   56                  push   %esi
        17:   68 00 00 00 00      push   $0x0
        1c:   e8 fc ff ff ff      call   1d <_start+0x1d>
        21:   f4                  hlt
        22:   90                  nop
        23:   90                  nop

call指令前面的那条push $0x0指令其实想把main这个符号所代表的地址压栈,但不知道这个地址是多少,因为这个符号在另一个目标文件中定义,到链接时才能确定其地址,所以在指令中暂时写成0x0。

现在我们把main.c编译成目标文件main.o,然后和编译器提供的目标文件链接,对生成的可执行文件main做反汇编分析:

$ gcc -c main.c
      $ gcc main.o -o main
      $ objdump -d main
      ...
      Disassembly of section .text:
      08048300 <_start>:
      8048300:   31 ed           xor   %ebp,%ebp
      8048302:   5e              pop   %esi
      8048303:   89 e1           mov   %esp,%ecx
      8048305:   83 e4 f0        and   $0xfffffff0,%esp
      8048308:   50              push   %eax
      8048309:   54              push   %esp
      804830a:   52              push   %edx
      804830b:   68 10 84 04 08  push   $0x8048410
      8048310:   68 20 84 04 08  push   $0x8048420
      8048315:   51              push   %ecx
      8048316:   56              push   %esi
      8048317:   68 e5 83 04 08  push   $0x80483e5
      804831c:   e8 c7 ff ff ff  call   80482e8 <__libc_start_main@plt>
      8048321:   f4              hlt
      ...
      080483b4 <bar>:
      80483b4:   55              push   %ebp
      80483b5:   89 e5           mov   %esp,%ebp
      80483b7:   83 ec 10        sub   $0x10,%esp
      ...
      080483cb <foo>:
      80483cb:   55              push   %ebp
      80483cc:   89 e5           mov   %esp,%ebp
      80483ce:   83 ec 08        sub   $0x8,%esp
      ...
      080483e5 <main>:
      80483e5:   55              push   %ebp
      80483e6:   89 e5           mov   %esp,%ebp
      80483e8:   83 ec 08        sub   $0x8,%esp
      ...

main.c中除了main函数还定义了foo、bar两个函数,链接完成后,crt1.o中定义的符号_start和main.o中定义的符号bar、foo、main都合并到可执行文件的.text段中。

符号main的地址是0x080483e5,因此_start中的push $0x0指令被链接器改成了push $0x80483e5。

一个目标文件中引用了某个符号,链接器在另一个目标文件中找到这个符号的定义并确定它的地址,这个过程叫做符号解析(Symbol Resolution)

符号解析和重定位都是通过修改指令中的地址实现的链接器也是一种编辑器,vi和emacs编辑的是源文件而链接器编辑的是目标文件所以链接器叫做Link Editor。链接的过程如图所示:

从图可以看出,crt1.o还引用了一个未定义符号__libc_start_main,这个符号在其他几个目标文件中也没有定义,所以链接生成可执行文件之后仍然是个未定义符号。

事实上这个符号在libc中定义,libc是一个共享库,它并不像其他目标文件一样链接到可执行文件main中,而是在运行时做动态链接:

  • 1.操作系统在加载执行main这个程序时,首先查看它有没有需要动态链接的未定义符号。
  • 2.如果需要做动态链接,就查看这个程序指定了哪些共享库,以及用什么动态链接器来做动态链接。我们在链接时用-lc选项指定了共享库libc,用-dynamic-linker/lib/ld-linux.so.2指定了动态链接器,这些信息都会写到可执行文件中。
  • 3.动态链接器加载共享库,在其中查找这些未定义符号的定义,完成链接过程。

我们回头看_start的反汇编。

首先将一系列参数压栈,然后通过call 80482e8指令调用库函数__libc_start_main做初始化工作,其中最后一个压栈的参数push$0x80483e5正是main函数的首地址,__libc_start_main在做完初始化工作之后会根据这个参数调用main函数。

由于__libc_start_main需要动态链接,所以这个库函数的指令在可执行文件main的反汇编中肯定是找不到的,然而我们在地址0x80482e8处找到了这几条指令:

Disassembly of section .plt:
      ...
      080482e8 <__libc_start_main@plt>:
      80482e8:   ff 25 04 a0 04 08       jmp   *0x804a004
      80482ee:   68 08 00 00 00          push   $0x8
      80482f3:   e9 d0 ff ff ff          jmp   80482c8 <_init+0x30>

这几条指令位于.plt段而不是.text段,.plt段协助完成动态链接,我们到下一章再详细解释。

main函数最标准的原型应该是int main(int argc,char *argv[]);,也就是说启动例程会传两个参数给main函数,这两个参数的含义将在后面介绍。

到目前为止我们都把main函数的原型写成int main(void);,这也是C标准允许的,多传了参数而不用是没有问题的,少传了参数却用了则会出问题。

由于main函数是被启动例程调用的,所以从main函数return时就返回到启动例程中,main函数的返回值被启动例程得到,如果将启动例程表示成等价的C代码(实际上启动例程一般是直接用汇编写的),则它调用main函数的形式是:

exit(main(argc, argv));

也就是说,启动例程得到main函数的返回值后,会立刻用它做参数调用exit函数。exit也是libc的库函数,它首先做一些清理工作,然后调上一章讲过的_exit系统调用终止进程,main函数的返回值最终被传给_exit系统调用,成为进程的退出状态。

我们也可以在main函数中直接调用exit函数终止进程而不返回到启动例程,例如:

#include <stdlib.h>
      int main(void)
      {
              exit(4);
      }

注意要包含头文件stdlib.h。这样和int main(void) { return 4; }的效果是一样的。在Shell中运行这个程序并查看它的退出状态:

$ ./a.out
      $ echo $?
      4

按照惯例,退出状态为0表示程序执行成功,退出状态非0表示出错。注意,退出状态只有8位,而且被Shell解释成无符号数,如果将上面的代码改为exit(-1);或return -1;,则运行结果为:

$ ./a.out
      $ echo $?
      255

在C程序中也可以调用_exit函数退出(需要包含头文件unistd.h),它是_exit系统调用的简单包装。

怎么包装呢?它可能是一个C函数,其中内嵌了movl $1, %eax、movl ?, %ebx和int $0x80三条指令(稍后在第后介绍这种语法)。

它也可能是纯用汇编写的,但要符合C编译器的Calling Convention,这样才能当成一个C函数来调用。

这种对int $0x80指令简单包装的C函数通常也称为系统调用,在Man Page中系统调用位于第2个Section,例如_exit(2),而库函数位于第3个Section,例如exit(3)。

第3个Section的库函数有些完全工作在用户模式,例如后面要介绍的strcpy(3),而有些要调第2个Section的系统调用完成它的工作,例如exit(3)函数首先做一些清理工作,然后调用_exit(2)进内核终止当前进程。

那么所谓“清理工作”到底指哪些工作呢?到后续再详细解释。

头文件unistd.h中声明的函数并不是C标准库函数,而是POSIX标准定义的UNIX系统函数,但也在libc中实现。

一个进程调用exit或_exit终止,或者从main函数返回而终止,都属于正常终止(Normal Termination),也称为退出(Exit)。

但并非所有的进程终止都是正常的,比如按Ctrl+C组合键终止一个进程,或者运行时产生段错误,或者用kill命令终止一个进程,这几种情况本质上都是进程收到一个信号然后内核把进程强行终止掉了,进程并没有执行_exit系统调用,也没有退出状态,这称为异常终止(Abnormal Termination)。

三、变量的存储布局

首先看下面的例子:

#include <stdio.h>
      const int A = 10;
      int a = 20;
      static int b = 30;
      int c;
      int main(void)
      {
              static int a = 40;
              char b[] = "Hello world";
              register int c = 50;
              printf("Hello world %d\n", c);
              return 0;
      }

我们在全局作用域和main函数的局部作用域各定义了一些变量,并且引入一些新的关键字const、static、register来修饰变量,那么这些变量的存储空间怎么分配呢?

我们编译之后用readelf命令看它的符号表,了解各变量的地址分布。注意在下面的清单中我把符号表按地址从低到高的顺序重新排列了,并且只截取我们关心的那几行。

$ gcc main.c -g
      $ readelf -a a.out
      ...
          65: 08048570    4 OBJECT  GLOBAL DEFAULT   16 A
          66: 0804a018    4 OBJECT  GLOBAL DEFAULT   24 a
          49: 0804a01c    4 OBJECT  LOCAL  DEFAULT   24 b
          50: 0804a020    4 OBJECT  LOCAL  DEFAULT   24 a.1706
          78: 0804a02c    4 OBJECT  GLOBAL DEFAULT   25 c
      ...

变量A用const修饰,表示A是只读的,不可修改,它被分配的地址是0x8048570,从readelf的输出可以看到这个地址位于.rodata段:

Section Headers:
        [Nr] Name     Type        Addr    Off   Size   ES Flg Lk Inf Al
      ...
        [14] .text    PROGBITS    080483900003900001bc 00  AX  0   0 16
      ...
        [16] .rodata  PROGBITS    0804856800056800001c 00   A  0   0  4
      ...
        [24] .data    PROGBITS    0804a010001010000014 00  WA  0   0  4
        [25] .bss     NOBITS      0804a02400102400000c 00  WA  0   0  4
      ...

.rodata段在内存中的地址是0x08048568~0x08048583,在文件中的地址是0x568~0x583,我们用hexdump命令看看这个段的内容:

$ hexdump -C a.out
      ...
      00000560   5c fe ff ff 59 5b c9 c3   03 00 00 00 01 00 02 00|\...Y[..........|
      00000570  0a 00 00 00 48 65 6c 6c  6f 20 77 6f 72 6c 64 20  |....Hello world |
      00000580   25 64 0a 00 00 00 00 00   00 00 00 00 00 00 00 00|%d..............|
      ...

其中0x570地址处的0a 00 00 00就是变量A。我们还看到程序中的字符串字面值"Hello world %d\n"分配在.rodata段的末尾,在第之前讲过字符串字面值是只读的,相当于在全局作用域定义了一个const数组:

const char helloworld[] = {'H', 'e', 'l', 'l', 'o', ' ',
                  'w', 'o', 'r', 'l', 'd', ' ', '%', 'd', '\n', '\0'};

在链接时.rodata段和.text段合并到Text Segment中,在加载运行时操作系统把Text Segment的页面只读保护起来,防止意外改写。

从readelf的输出可以看出.rodata段和.text段被合并到一个Segment,.data段和.bss段被合并到另一个Segment。

Section to Segment mapping:
        Segment Sections...
        00
        01    .interp
        02    .interp .note.ABI-tag .note.gnu.build-id .hash .gnu.hash
      .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init.plt .text .fini .rodata .eh_frame
        03    .ctors .dtors .jcr .dynamic .got .got.plt .data .bss
        04    .dynamic
        05    .note.ABI-tag .note.gnu.build-id
        06
        07    .ctors .dtors .jcr .dynamic .got

注意,像A这种const变量在定义时必须初始化。因为只有初始化时才有机会给它一个值,一旦定义之后就不能再改写了,如果给它赋值编译器会报错,也就是说,操作系统的内存管理和编译器的语义检查为全局const变量提供了双重保护。

我们知道函数的局部变量在栈上分配,如果把局部变量声明为const就少了一层保护,操作系统无法对栈空间只读保护(因为栈上的其他数据要求可读可写),但编译器仍可以做语义检查。

.data段在内存中的地址是0x804a010~0x804a023,在.data段中有三个变量,a,b和a.1706。a是一个全局符号,而b被static关键字修饰了,导致它成为一个局部符号,所以static在这里的作用是声明b为局部符号,如果把多个目标文件链接在一起,局部符号只能在某一个目标文件中定义和使用,而不能在一个目标文件中定义却在另一个目标文件中引用,因为链接器不会对局部符号做符号解析。

一个函数定义前面也可以用static修饰,表示这个函数名是局部符号。

.bss段在内存中的地址是0x804a024~0x804a02f(紧挨着.data段),变量c位于这个段。

.data段和.bss段在链接时合并到Data Segment中,在加载运行时Data Segment的页面是可读可写的。

bss段和.data段的不同之处在于,.bss段在文件中不占存储空间,加载到内存时这个段用0填充,C语言规定全局变量和static变量(不管是函数里的还是函数外的)如果不初始化则初值为0,未初始化的和明确初始化为0的全局变量、static变量都会分配在.bss段。

现在还剩下main函数中的变量b和c没有分析。b是一个数组,在栈上分配。我们看main函数的反汇编代码:

$ objdump -dS a.out
      ...
          char b[] = "Hello world";
      804845a:   c7 44 24 10 48 65 6c   movl   $0x6c6c6548,0x10(%esp)
      8048461:   6c
      8048462:   c7 44 24 14 6f 20 77   movl   $0x6f77206f,0x14(%esp)
      8048469:   6f
      804846a:   c7 44 24 18 72 6c 64   movl   $0x646c72,0x18(%esp)
      8048471:   00
          register int c = 50;
      8048472:   bb 32 00 00 00          mov   $0x32,%ebx
          printf("Hello world %d\n", c);
      8048477:   b8 74 85 04 08          mov   $0x8048574,%eax
      804847c:   89 5c 24 04             mov   %ebx,0x4(%esp)
      8048480:   89 04 24                mov   %eax,(%esp)
      8048483:   e8 dc fe ff ff          call   8048364 <printf@plt>
      ...

可见,给b初始化用的这个字符串"Hello world"不需要在.rodata段分配,而是直接写在指令里,通过三条movl指令把12个字节写到栈上,这就是b的存储空间,如图18.4所示。

注意,虽然栈是从高地址向低地址增长的,但数组总是从低地址向高地址排列的,按从低地址到高地址的顺序依次是b[0]、b[1]、b[2]等。

变量c并没有在栈上分配存储空间,而是直接存在ebx寄存器里,后面调用printf也是直接从ebx寄存器里取出c的值当参数压栈,这就是register关键字的作用,指示编译器尽可能分配一个寄存器来保存这个变量。

我们还看到调用printf时对于"Hello world %d\n"这个参数压栈的是它在.rodata段中的首地址0x8048574,而不是把整个字符串压栈。在第8.4节讲过,字符串字面值和数组名类似,做右值使用时表示首元素的地址(或者说指向首元素的指针),我们在第22.4节还要继续讨论这个问题。

以前我们用“全局变量”和“局部变量”这两个概念,主要是从作用域上区分的,现在看来用这两个概念给变量分类太笼统了,需要进一步细分。我们总结一下相关的C语法。

作用域(Scope)这个概念适用于所有标识符,而不仅仅是变量,C语言的作用域分为以下几类:

  • ● 函数作用域(Function Scope),标识符在整个函数中都有效。只有标号属于函数作用域。标号在函数中不需要先声明后使用,在前面用一个goto语句也可以跳转到后面的某个标号,但仅限于同一个函数之中。即使在函数内的某个语句块中定义一个标号,它也不局限于这个语句块,也是在整个函数中都有效。
  • ● 文件作用域(File Scope),标识符在函数外声明,从它声明的位置开始直到这个源文件末尾都有效——严格说应该是直到编译单元(Translation Unit)末尾都有效。比如有源文件a.c包含了b.h和c.h,那么经过预处理把b.h和c.h在a.c中展开之后得到的代码称为一个编译单元。编译器将每个编译单元分别编译成一个目标文件,最后链接器把这些目标文件链接到一起成为一个可执行文件。上例中在函数外声明的标识符A、a、b、c以及标识符main都是文件作用域的,标识符printf在stdio.h中声明然后被包含到这个编译单元中,所以也是文件作用域的。此外,在函数外声明的类型名或Tag也属于文件作用域。
  • ● 块作用域(Block Scope),标识符在一对{}括号中声明,即在函数体或语句块中声明,那么从它声明的位置开始到右}括号之间有效。上例中main函数里的a、b、c都是块作用域的。此外,函数定义中的形参也算块作用域的,从声明的位置开始到函数末尾之间有效。
  • ● 函数原型作用域(Function Prototype Scope),如果标识符出现在函数原型中,这个函数原型只是一个声明而不是定义(没有函数体),那么标识符从声明的位置开始到这个原型结束之前有效。例如int foo(int a, int b);中的a和b。这样的函数原型只是告诉编译器函数返回值类型、参数个数以及各参数的类型,参数名其实是无关紧要的,省略掉也可以,比如写成int foo(int, int);也可以。

对属于同一命名空间(Name Space)的重名标识符,内层作用域的标识符将覆盖外层作用域的标识符,例如局部变量名在它的函数中将覆盖重名的全局变量

命名空间可分为以下几类:

  • ● 语句标号单独属于一个命名空间。例如在函数中局部变量和语句标号可以重名,互不影响。由于使用标号的语法和使用其他标识符的语法都不一样,编译器不会把它和别的标识符弄混。
  • struct、enum和union(下一节介绍union)的Tag属于一个命名空间。由于Tag前面总是带struct、enum或union关键字,所以编译器不会把它和别的标识符弄混。
  • struct和union的成员名属于一个命名空间。由于成员名总是通过.或->运算符来访问而不会单独使用,所以编译器不会把它和别的标识符弄混。
  • ● 所有其他标识符,例如变量名、函数名、宏定义、typedef定义的类型名、enum成员等都属于同一个命名空间,如果有重名则按内层作用域覆盖外层作用域的规则处理。
  • ● 如果宏定义和其他标识符重名,则宏定义覆盖所有其他标识符,因为宏定义在预处理阶段先处理,而其他标识符在编译阶段处理。

标识符的链接属性(Linkage)有三种:

  • ● 外部链接(External Linkage),一个标识符在不同的编译单元中可能被声明多次,当这些编译单元链接成一个可执行文件时,如果这些声明都代表同一个变量或函数(即代表同一个内存地址),则这个标识符具有External Linkage。具有External Linkage的标识符编译后在目标文件中是全局符号。上例中在函数外声明的标识符a、c以及main和printf都具有External Linkage。
  • ● 内部链接(Internal Linkage),一个标识符在某个编译单元中可能被声明多次,这些声明都代表同一个内存地址,但如果这个标识符在不同的编译单元中被声明多次,在链接时这些声明就不代表同一个内存地址,这样的标识符具有Internal Linkage。上例中在函数外声明的标识符b具有Internal Linkage。如果有另一个程序foo.c和上例的main.c链接在一起,在foo.c中也声明一个static int b;,则这个b和那个b不代表同一个变量。具有Internal Linkage的标识符编译后在目标文件中是局部符号,在链接时不做符号解析
    注意上例中main函数里面声明的那个static int a = 40;不能算Internal Linkage的。如果在同一编译单元的另一个函数中也声明一个static int a;,则这个a和那个a不代表同一个变量,在编译时会把这两个标识符改名成两个不同的符号。
  • ● 无链接属性(No Linkage)。除以上情况之外的标识符都属于No Linkage,例如函数的局部变量,以及不表示变量和函数的其他标识符。除了函数、全局变量、静态变量之外的标识符在编译时不会变成符号,所以没有链接属性

最后还有两个问题,具有External Linkage的标识符如何在多个编译单元中声明多次?具有Internal Linkage的标识符如何在一个编译单元中声明多次?后面再说,别着急。

存储类修饰符(Storage Class Specifier)指的是以下几个关键字,可以修饰变量或函数声明:

● static,用它修饰的变量的存储空间是静态分配的,用它修饰的文件作用域的变量或函数具有Internal Linkage。

● auto,用它修饰的变量在函数调用时自动在栈上分配存储空间,函数返回时自动释放上例中main函数里的b其实就是用auto修饰的,只不过auto可以省略不写,auto不能修饰文件作用域的变量。

● register,编译器对于用register修饰的变量会尽可能分配一个专门的寄存器来存储,但如果实在分配不开寄存器,编译器就把它当auto变量处理了,**register不能修饰文件作用域的变量。**现在一般编译器的优化都做得很好了,编译器自己会想办法有效地利用CPU寄存器,所以现在register关键字也用得比较少了。

● extern,用于多次声明同一个具有External Linkage或Internal Linkage的标识符,下一章再详细介绍它的用法。

● typedef,在之前讲过这个关键字,它并不是用来修饰变量的,而是定义一个类型名。在那一节也讲过,看typedef声明怎么看呢,首先去掉typedef把它看成变量声明,看这个变量是什么类型的,那么typedef就给什么类型起了一个类型名。因此,typedef在语法结构中出现的位置和前面几个关键字一样,也是修饰变量声明的,所以从语法(而不是语义)的角度把它和前面几个关键字归类到一起。

注意,上面介绍的const关键字不是一个Storage Class Specifier,虽然看起来它也修饰一个变量声明,但是在以后介绍的更复杂的声明中const在语法结构中允许出现的位置和Storage Class Specifier是不完全相同的。

const和volatile、restrict关键字属于同一类语法元素,称为类型限定符**(Type Qualifier)**,volatile关键字在第18.6节介绍,restrict关键字在第24.1.3节介绍。

在一个变量或函数声明的开头如果有修饰或限定,通常按Storage Class Specifier、Function Specifier、Type Qualifier、Type Specifier(例如int、double等关键字)的顺序来写,例如:

static const int i = 1;

其实在语法上这四种修饰限定的位置可以任意排列,例如int const typedef constant_t;也是合乎语法的,但是可读性很差。变量的生存期(Storage Duration,或者Lifetime)分为以下几类:

  • ● 静态生存期(Static Storage Duration),具有External或Internal Linkage,或者被static修饰的变量,在程序开始执行时分配内存和初始化,此后便一直存在直到程序结束。这种变量通常位于.rodata、.data或.bss段,上例中在函数外声明的变量A、a、b、c以及main函数里声明的变量a都属于静态生存期。
  • ● 自动生存期(Automatic Storage Duration),无链接属性并且没有被static修饰的变量,这种变量在进入块作用域时在栈上或寄存器中分配,在退出块作用域时释放。上例中main函数里声明的变量b和c属于自动生存期。
  • ● 动态分配生存期(Allocated Storage Duration),在第23.1.2节会讲到调用malloc函数可以在进程的堆空间分配内存,调用free函数可以释放这块内存。动态分配生存期不同于静态生存期,因为它不是在程序开始执行时就分配,直到程序结束才释放,它是在某个函数中调用malloc分配的;也不同于自动生存期,因为它不是在调用函数时自动分配,也不在函数返回时自动释放,而是需要调用free来释放。

参考资料

《一站式学习C编程》

目录
相关文章
|
5月前
|
编译器 Linux C语言
函数栈帧的创建和销毁(以C语言代码为例,汇编代码的角度分析)(上)
函数栈帧的创建和销毁(以C语言代码为例,汇编代码的角度分析)
|
4月前
|
存储 缓存 Linux
C语言编译过程——预处理、编译汇编和链接详解
C语言编译过程——预处理、编译汇编和链接详解
|
5月前
|
编译器 C语言
函数栈帧的创建和销毁(以C语言代码为例,汇编代码的角度分析)(下)
函数栈帧的创建和销毁(以C语言代码为例,汇编代码的角度分析)
|
12月前
|
C语言
进阶C语言 第七章-------《程序的编译(预处理操作)+链接》 (预编译、编译、汇编、#define、条件编译,#include的包含)知识点+完整思维导图+基本练习题+深入细节+通俗易懂建议收藏(三)
进阶C语言 第七章-------《程序的编译(预处理操作)+链接》 (预编译、编译、汇编、#define、条件编译,#include的包含)知识点+完整思维导图+基本练习题+深入细节+通俗易懂建议收藏(三)
|
12月前
|
编译器 C语言
进阶C语言 第七章-------《程序的编译(预处理操作)+链接》 (预编译、编译、汇编、#define、条件编译,#include的包含)知识点+完整思维导图+基本练习题+深入细节+通俗易懂建议收藏(二)
进阶C语言 第七章-------《程序的编译(预处理操作)+链接》 (预编译、编译、汇编、#define、条件编译,#include的包含)知识点+完整思维导图+基本练习题+深入细节+通俗易懂建议收藏(二)
|
12月前
|
存储 自然语言处理 程序员
进阶C语言 第七章-------《程序的编译(预处理操作)+链接》 (预编译、编译、汇编、#define、条件编译,#include的包含)知识点+完整思维导图+基本练习题+深入细节+通俗易懂建议收藏(一)
进阶C语言 第七章-------《程序的编译(预处理操作)+链接》 (预编译、编译、汇编、#define、条件编译,#include的包含)知识点+完整思维导图+基本练习题+深入细节+通俗易懂建议收藏(一)
|
存储 编译器 C语言
0基础C语言自学教程——第九节 从底层汇编的角度简单理解函数栈帧的创建和销毁
我们在现在,其实已经比较清楚函数是怎么样运行的了,包括怎样传参 、函数调用等等。但是呢,这样也只是理解到了会用的地步。
118 0
0基础C语言自学教程——第九节 从底层汇编的角度简单理解函数栈帧的创建和销毁
|
C语言 芯片
使用 gcc 命令把C语言程序反汇编
之前看过一点汇编,不过现在都忘记得差不多了。最近又很蛋疼地想起反汇编这个东西。这里使用 gcc 命令对 .c 文件进行反汇编,把 C语言 翻译成汇编语言 先准备一个简单的 C 程序 sum.c #include int add(int, int); int mode(int, i...
3768 0
|
程序员 C语言 C++
要想精通C语言,必须先学习汇编吗?
要想精通C语言,必须先学习汇编吗?
1154 0