叙述 C语言编译

作者: orientlu | 来源:发表于2016-08-13 14:35 被阅读241次

    @(C语言)[code]

    用一段简单的代码,探讨下从C代码到最终可执行文件的编译过程,追根究底。
    偶尔了解下底层,也就没那么多莫名其妙了。

    工作原因有时候会用python写写测试工具,感受到其快速实现应用的便利,但由于偏底层开发,主力语言依然是C。对于开发语言没有什么优劣概念,在特定的情景下哪种实现更佳就用哪种,工具合适才是最好的。

    个人开发环境 ubuntu 14.04


    编译的作用

    相比python,lua等脚本语言解释执行方式,编译C是为了提高程序的运行效率。把对用户友好的语言文本编译成对机器友好的特定指令直接执行,而不是执行时一条一条通过解释器解析执行,很大地提高了执行的效率。对应C主要用于底层,系统层次,追求高性能表现,亦或者,平台资源限制。

    编译的过程

    gcc 的编译流程分为四个步骤:
    计算机系统设计基本原则:层次化和抽象。

    编译flow

    编写一个最简单的程序 hello.c,以此为例,看看各个过程做了什么事情。

    #include<stdio.h>
    
    #define NUM(x) ((x) + 1)
    int main(void)
    {
        printf("Hello world %d\\\\r\\\\n", NUM(1));
        return 0;
    }
    

    预处理(Pre-Processing)

    预处理主要完成的工作:

    • 根据#if后面的条件决定需要编译的代码
    • 将源文件中#include格式包含的文件直接复制到编译的源文件中
    • 用实际值替换用#define定义的字符串

    对源代码进行预处理操作

    $ gcc -E hello.c -o hello.i
    

    使用编辑器打开输出hello.i,一看吓一跳,原本7、8的代码变成800多行
    截取开头结尾如下

    # 1 "hello.c"
    # 1 "<built-in>"
    # 1 "<command-line>"
    # 1 "/usr/include/stdc-predef.h" 1 3 4
    # 1 "<command-line>" 2
    ...
    ...
    int main(void)
    {
     printf("Hello world %d\\\\r\\\\n", ((1) + 1));
     return 0;
    }
    

    我打开文件 stdio.h 对比发现,hello.i 文件开头多出来的一大堆东西,就是stdio.h 经过#if条件选择后留下的(包括其他包含文件的展开,同理)。同时在最下面看到熟悉的printf函数中定义的宏被直接替换成对应的文本。
    在这里提出两个问题

    • 预处理宏展开可能陷入死循环?
      我修改了了代码, 宏里面调用了自己,并且没有递归退出条件
    #include<stdio.h>
    
    #define NUM(x) (NUM(x) + 1)
    int main(void)
    {
        printf("Hello world %d\\\\r\\\\n", NUM(1));
        return 0;
    }
    

    输出hello.i可以看到,宏展开遇到自己就会停止,避免陷入死循环

    int main(void)
    {
     printf("Hello world %d\\\\r\\\\n", (NUM(1) + 1));
     return 0;
    }
    
    • include 包含头文件重复?

    预处理会直接把对应的头问题展开,如果包含的头文件本身包含了自己,是否也会陷入死循环? 简单编写文件测试

    inc.h 文件

    #include "inc.h"
    

    inc.c 文件

    #include "inc.h"
    
    int main(void)
    {
        return 0;
    }
    

    预处理结果出错,提示如下:

    inc.h:1:17: error: #include nested too deeply
     #include "inc.h"
    

    说明对于文件的展开是可能出现重复,递归的,也说明了为什么在每个被包含的头文件,需要添加如下代码段。

    #ifndef _XXX__XXX
    #define _XXX_XXX
    
    #endif
    

    编译(Compiling)

    这一环节,是把C代码转换为汇编代码并根据需求进行一定程度的优化处理。
    执行命令进行编译

    $ gcc -S hello.i -o hello.s
    # gcc -S 实际调用cc1,所以也可以直接使用cc1编译
    

    生成hello.s (AT&T 格式)
    这代码初看起来晦涩难懂,再细细看起来,还是很难懂。

        .file   "hello.c"
        .section    .rodata
    .LC0:
        .string "Hello world %d\\\\r\\\\n"
        .text
        .globl  main
        .type   main, @function
    main:
    .LFB0:
        .cfi_startproc
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        
        movl    $2, %esi  # 编译器直接替换为宏 NUM(1) 的结果
        movl    $.LC0, %edi # 设置字符串保存的地址
        movl    $0, %eax
        call    printf    
        #  调用printf子例程,只有一个参数的printf gcc
        #  会把它替换成_puts提高效率, 加-fno-builtin 取消
        
        movl    $0, %eax  # main return 0
        popq    %rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
    .LFE0:
        .size   main, .-main
        .ident  "GCC: (Ubuntu 4.8.4-2ubuntu1~14.04.1) 4.8.4"
        .section    .note.GNU-stack,"",@progbits
    

    编译器的优化

    编译会有一个中间过程,进行优化(前端)后再最终输出汇编代码(后端), gcc 可以通过以下命令查看, 感觉不是给人类看的。

    $ gcc -S -fdump-rtl-expand hello.c
    

    使用clang(<-编译器)也可以查看输出中间过程:

    $ clang-3.5 -S -emit-llvm hello.c
    

    clang 输出的可读性更强,可以大概看出程序的面貌(因为这个程序很简单...)

    ; ModuleID = 'hello.c'
    target datalayout = "e-m:e-i64:64-f80:128-n8:16:32:64-S128"
    target triple = "x86_64-pc-linux-gnu"
    
    @.str = private unnamed_addr constant [17 x i8] c"Hello world %d\\\\0D\\\\0A\\\\00", align 1
    
    ; Function Attrs: nounwind uwtable
    define i32 @main() #0 {
      %1 = alloca i32, align 4
      store i32 0, i32* %1
      %2 = call i32 (i8*, ...)* @printf(i8* getelementptr inbounds ([17 x i8]* @.str, i32 0, i32 0), i32 2)
      ret i32 0
    }
    
    declare i32 @printf(i8*, ...) #1
    
    attributes #0 = { nounwind uwtable "less-precise-fpmad"="false" "no-frame-pointer-elim"="true" "no-frame-pointer-elim-non-leaf" "no-infs-fp-math"="false" "no-nans-fp-math"="false" "stack-protector-buffer-size"="8" "unsafe-fp-math"="false" "use-soft-float"="false" }
    attributes #1 = { "less-precise-fpmad"="false" "no-frame-pointer-elim"="true" "no-frame-pointer-elim-non-leaf" "no-infs-fp-math"="false" "no-nans-fp-math"="false" "stack-protector-buffer-size"="8" "unsafe-fp-math"="false" "use-soft-float"="false" }
    
    !llvm.ident = !{!0}
    
    !0 = metadata !{metadata !"Ubuntu clang version 3.5.0-4ubuntu2~trusty2 (tags/RELEASE_350/final) (based on LLVM 3.5.0)"}
    

    我尝试在hello.c 的源代码中添加一个无用的循环

    for (int i = 0; i < 10; ++i) {
        i = i;    
    }
    

    然后分别用以下两个条命令编译,查看输出中间文件.ll (使用clang是因为输出结果比较适合阅读)

    # 默认不优化处理 -O0
    $ clang-3.5 -S -emit-llvm hello.c
    # 开启代码优化
    $ clang-3.5 -O3 -S -emit-llvm hello.c
    

    第一种不优化情况下,编译器老老实实把我写的"没啥作用"的代码原原本本的编译出来.
    第二种进行了优化, 那段代码不见了......
    我想起工作上遇到的,使用for 进行简单延时匹配一些硬件操作的时序,悲剧了.
    (输出结果我就不贴上来了。)

    中间层优化是和体系代码无关的情况下进行的,优化后再调用对应体系的后端生成汇编代码。 M中体系都可以共用中间层优化,而不是M中体系重新实现M中优化。

    汇编(Assembling)

    这一步骤相对简单,将汇编代码转换为对应的机器执行指令,由于这一步丢失的信息很少,所以可以通过反汇编把机器码还原为汇编代码,但是再进一步还原到高级语言就不可能了。

    $ gcc -c hello.s -o hello.o
    # 可以直接调用汇编器 as
    $ as hello.s -o hello.o。
    

    使用objdump对生成的ELF进行反汇编

    $ objdump  -S hello.o
    
    hello.o:     file format elf64-x86-64
    
    
    Disassembly of section .text:
    
    0000000000000000 <main>:
       0:   55                      push   %rbp
       1:   48 89 e5                mov    %rsp,%rbp
       4:   be 02 00 00 00          mov    $0x2,%esi
       9:   bf 00 00 00 00          mov    $0x0,%edi
       e:   b8 00 00 00 00          mov    $0x0,%eax
      13:   e8 00 00 00 00          callq  18 <main+0x18> # 看这里
      18:   b8 00 00 00 00          mov    $0x0,%eax
      1d:   5d                      pop    %rbp
      1e:   c3                      retq   
    

    看到 13行, 原本call printf 的那句被替换为一个跳转,而且跳转到下一条指令。因为printf是一个外部调用,这个地址需要下一步链接的时候才能确定,这时候只是一个占位。

    链接(Linking)

    主要是在不同模块间对符号进行重定位

    在ELF文件 hello.o 里保存一张重定位表(relocation table),保存了其他地方的函数、变量(统称符号)的名字和地址。
    可以通过readelf读取出来

    $ readelf --relocs hello.o
    
    Relocation section '.rela.text' at offset 0x5a0 contains 2 entries:
      Offset          Info           Type           Sym. Value    Sym. Name + Addend
    00000000000a  00050000000a R_X86_64_32       0000000000000000 .rodata + 0
    000000000014  000a00000002 R_X86_64_PC32     0000000000000000 printf - 4
    
    Relocation section '.rela.eh_frame' at offset 0x5d0 contains 1 entries:
      Offset          Info           Type           Sym. Value    Sym. Name + Addend
    000000000020  000200000002 R_X86_64_PC32     0000000000000000 .text + 0
    

    可以看到,汇编后, printf的地址还是空的,没有填写上对应的地址。

    使用nm可以查看文件的符号定义, 可以看到 "U", 表示该符号未定义。

    $ nm hello.o
    0000000000000000 T main
                     U printf
    

    printf 是在lib.a库(由多个.O文件打包就成了.a库)里面实现所,所以查看下里面的定义,可以看到具体是到printf.o这个文件。

    $ objdump  -t /usr/lib/x86_64-linux-gnu/libc.a | grep "printf"
    ...
    printf.o:     file format elf64-x86-64
    0000000000000000 g     F .text  000000000000009e __printf
    0000000000000000         *UND*  0000000000000000 vfprintf
    0000000000000000 g     F .text  000000000000009e printf
    ...
    

    而当我手动尝试链接的时候,又被提示一堆未定义,而这些工作gcc会自动递归查找去解决。

    $ gcc -static hello.c
    $ ./a.out 
    Hello world 2
    $ du -h a.out 
    856K    a.out
    $ nm a.out  | grep " printf"
    0000000000407ea0 T printf
    

    编译后执行,发现一切正常,printf已经定义了,但是一个简单的程序竟然是856K....

    $ gcc hello.c
    $ ./a.out 
    Hello world 2
    $ du -h a.out 
    12K a.out
    $ nm a.out  | grep " printf"
                     U printf@@GLIBC_2.2.5
    

    采用动态加载的模式编译,应用体积减小了很多,但是看到printf提示未定义,标记改了,表示是一个动态链接。
    通过file也可以查看执行文件是否动态链接
    dynamically linked 和 statically linked

    $ gcc hello.c
    $ file a.out 
    a.out: ELF 64-bit LSB  executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.24, BuildID[sha1]=8bdbcefb6289597b2123017d2678b11a6f742f23, not stripped
    $ gcc -static hello.c
    $ file a.out 
    a.out: ELF 64-bit LSB  executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.24, BuildID[sha1]=25ff17d24016dd4a453a5ac53e3a3fee0f00a5ec, not stripped
    

    这就是动态链接库的好处了,把共用的代码加载到系统,每个程序需要用到时候直接调用,而不需要都包含到每个可执行文件中,减少开销。在执行的时候,通过加载器获取实际地址执行。

    其实动态链接库是不知道自己会被加载到内存哪个位置的,所以对于这个种链接,程序在执行的时候,才能获取到实际的地址,涉及到GOT和PLI。
    GOT中的信息需要在动态链接库被程序加载后立刻填写正确。这就给采用动态链接库的程序在启动时带来了一定额外开销,从而减缓了启动速度。ELF采用了做延迟绑定的做法来解决这一问题。基本思想就是通过增加另外一个间接层,使得函数第一次被用到时才进行绑定,这就是PLT(Procedure Linkage Table)的作用。

    相关文章

      网友评论

        本文标题:叙述 C语言编译

        本文链接:https://www.haomeiwen.com/subject/ktaesttx.html