• 说说C/C++编译的那些事儿


    一个稍稍令人惊讶的事实:C语言编译器其实是用C语言编写的。
    ——编者

    广义的编译(compile)/构建(build),是将由源语言编写的一个或多个程序文件进行解析和转换,再结合共享库等资源,生成可以在目标机器上运行的可执行文件的过程。本书无意讨论编译器的详细工作原理,那是《编译原理》课程的内容。在这一章里,我们从使用者的角度,简单讨论编译及构建的过程概要。

    本文引用自作者编写的下述图书; 本文允许以个人学习、教学等目的引用、讲授或转载,但需要注明原作者"海洋饼干叔
    叔";本文不允许以纸质及电子出版为目的进行抄摘或改编。
    1.《Python编程基础及应用》,陈波,刘慧君,高等教育出版社。免费授课视频 Python编程基础及应用
    2.《Python编程基础及应用实验教程》, 陈波,熊心志,张全和,刘慧君,赵恒军,高等教育出版社Python编程基础及应用实验教程
    3. 《简明C及C++语言教程》,陈波,待出版书稿。免费授课视频

    12.1 gcc编译示例

    考虑到Linux操作系统及gcc编译器(GNU Compiler Collection)在工业界的基础性地位,本章的讨论以Linux操作系统及gcc编译器为基础。当然,读者也可以在Windows操作系统上使用mingw或其它编译器完成类似工作,详细过程请扫描二维码了解。

    在一台树莓派4B卡片式计算机(gcc版本10.2.1,Linux内核版本5.15)上,作者在/home/pi/C12_Build目录下准备好了三个源代码文件,如图12-1所示。
    图12-1 编译示例的源代码文件

    💥
    警告树莓派4B基于一个使用ARM V8指令集的CPU,如果读者的计算机不基于ARM,且运行着非Linux系统,那么在后续操作中得到的编译及操作结果的细节可能与本书存在较大差异,但基本的流程和理论大体相当。

    其中,文件compute.h的内容如下:

    #ifndef _COMPUTE_H
    #define _COMPUTE_H
    #define PI 3.1415926  //comment
    #define SQUARE(x)  x*x
    float circleArea(const float r);
       
    #endif
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    文件compute.c的内容如下,请注意作者有意在第1行和第2行重复引入了compute.h头文件。

    #include "compute.h"
    #include "compute.h"
       
    float circleArea(const float r){
        float t = PI * SQUARE(r);  //comment
        return t;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    文件area.c的内容如下:

    #include 
    #include "compute.h"
       
    int main(){
        float r = 4.1f;
        float a = circleArea(r);   //comment
        printf("Area of the circle = %f.\n",a);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    程序的结构极其简单:函数circleArea()根据圆的半径计算圆的面积,其声明位于compute.h,定义在compute.c中;文件area.c中的main()函数调用circleArea()计算半径为4.1的圆的面积,然后通过printf()打印到控制台。
    图12-2 在终端里使用gcc编译示例程序并运行
    我们在Linux终端(terminal)中依次执行了如表12-1所列的多个命令,完成了上述3个源代码文件的编译,并成功执行了名为area的可执行目标文件(executable object file),详见图12-2。在该图中,pi是操作系统用户名,MVE则是计算机名称。

    表12-1 在终端里使用gcc编译示例程序并运行

    命令cd /home/pi/C12_Build
    说明将当前目录切换为/home/pi/C12_Build,示例的3个源代码文件在该目录里。cd是操作系统终端命令,意为改变目录(change directory)。
    命令ls (注意是字母l)
    说明终端命令,显示当前目录下的所有文件及子目录。执行结果显示当前目录下有area.c、compute.h及compute.c三个文件。
    命令gcc area.c compute.c –o area
    说明使用gcc编译器编译area.c及compute.c两个C语言程序文件,生成名为area的可执行目标文件。请注意,头文件compute.h不被视为一个单独的编译单元,所以未在命令中列出。上述命令行中的-o选项意为指定输出文件的名称。该文件执行完成后,再次执行ls,可见当前目录下多了一个名为area的文件,其绿色的文件名代表该文件是可执行目标文件。
    命令./area
    说明载入并执行当前目录下名为area的可执行目标文件。在执行该命令后,操作系统会将该程序载入内存,然后将执行点转移至该程序的起始处。随后的执行结果可见,area成功计算并打印了半径为4.1的圆的面积。

    图12-3展示了上述示例程序编译/构建过程的详细步骤。如图所示,构建过程至少包含预处理、编译、汇编和链接四个阶段。这里的编译是狭义的编译,其对应整个广义编译过程的第二个阶段:对预处理后的源程序文件进行解析,生成汇编语言代码文件。 图12-3 示例程序的编译/构建过程
    同大多数编译器一样,C/C++也采用了分散编译(separate compilation)的技术。每个.c或者.cpp源代码文件被视为一个编译单元(compilation unit),编译器将程序中所有的编译单元逐一、分别进行预处理、编译和汇编,生成各自独立的可重定位目标文件(relocatable object file),然后再由链接器组合链接为一个整体的可执行目标文件(executable object file)。

    12.2 预处理

    预处理器主要完成下述任务:
    (1) 对宏(macro)定义(#define)进行替换或展开;

    (2) 处理所有的条件预处理指令,比如#ifdef、#ifndef、#if、#elif、#else、#endif等。

    (3) 处理#include预处理指令,将被包含的头文件的内容展开,插入到对应文件。

    (4) 删除所有注释,包括/* */以及//。

    (5) 添加行号和文件名标识,这些信息在软件调试中被用于定位与错误或者警告有关的代码行。

    在图12-2所示编译过程中,我们并没有观察到预处理的结果文件compute.i和area.i,在默认情况下,gcc编译器不会生成这些文件。

    通过执行表7-2所述终端命令之一,可以使用预处理器(preprocessor)对compute.c进行预处理,从而得到结果文件compute.i。

    表12-2 预处理命令(终端)

    命令gcc -E compute.c -o compute.i
    说明对compute.c进行预处理,生成compute.i。-E选项要求gcc只进行预处理,-o选项用于指定输出文件名。
    命令cpp compute.c compute.i
    说明cpp即是gcc中的预处理器程序,上述命令对compute.c进行预处理,生成compute.i。

    同源代码文件一样,预处理器的输出文件compute.i是个文本文件,其内容如下(删除了部分空行):

    # 1 "compute.c"
    # 1 ""
    # 1 ""
    # 31 ""
    # 1 "/usr/include/stdc-predef.h" 1 3 4
    # 32 "" 2
    # 1 "compute.c"
    # 1 "compute.h" 1
       
    float circleArea(const float r);
    # 2 "compute.c" 2
       
    float circleArea(const float r){
        float t = 3.1415926 * r*r;
        return t;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    🚩第1 ~ 8行、第11行:一些行号和文件名标识。

    🚩第10行:circleArea()函数的声明。该声明源自头文件compute.h的第6行。由于条件预处理指令#ifndef、#endif的“保护”,虽然我们在compute.c中“故意”两次#include头文件compute.h,但却没有引起circleArea()函数的重复声明。当预处理器第一次在compute.c中“包含”compute.h时,名为_COMPUTE_H的宏尚未定义,预处理器正常展开并插入compute.h中的内容;在预处理器第二次“包含”compute.h时,发现_COMPUTE_H宏已定义,#ifndef _COMPUTE_H预处理条件语句不成立,忽略相关内容。

    🚩第14行:对应compute.c的第5行。请读者注意该行代码的三项变化:

    (1) 原代码第5行中的注释被删除了;

    (2) 原PI宏按照compute.h中的宏定义被文本替换为3.1415926;

    (3) 原SQUARE®宏按照compute.h中的宏定义被展开为r*r。

    12.3 编译

    编译器(compiler)对预处理器输出的扩展名为.i的文本文件进行解析和转换,得到扩展名为.s的文本文件,其内容是平台相关的汇编语言代码。

    编译是整个程序构建过程中最复杂的阶段,可以分为5步:词法分析、语法分析、语义分析、中间代码生成及优化、目标代码生成及优化。

    同样,编译器默认不会生成.s的中间文件。通过执行下述终端命令,可以将compute.i编译成compute.s。-S选项要求gcc只进行编译,-o选项用于指定输出文件名。

    linux> gcc -S compute.i -o compute.s

    本例中,汇编语言文件compute.s的内容如下。

    .arch armv8-a
    .file "compute.c"
    .text
    .align 2
    .global circleArea
    .type circleArea, %function
    circleArea:
    .LFB0:
    .cfi_startproc
     sub sp, sp, #32
    .cfi_def_cfa_offset 32
     str s0, [sp, 12]
     ldr s0, [sp, 12]
     fcvt d0, s0
     adrp x0, .LC0
     ldr d1, [x0, #:lo12:.LC0]
     fmul d1, d0, d1
     ldr s0, [sp, 12]
     fcvt d0, s0
     fmul d0, d1, d0
     fcvt s0, d0
     str s0, [sp, 28]
     ldr s0, [sp, 28]
     add sp, sp, 32
     .cfi_def_cfa_offset 0
     ret
     .cfi_endproc
    .LFE0:
     .size circleArea, .-circleArea
     .section .rodata
     .align 3
    .LC0:
     .word 1293080650
     .word 1074340347
     .ident "GCC: (Debian 10.2.1-6) 10.2.1 20210110"
     .section .note.GNU-stack,"",@progbits
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36

    相对于C语言这样的高级语言,汇编语言是所谓的低级语言,读者可以将其视为机器指令的助记符。特定的汇编语言与特定的机器语言指令集是一一对应的,因此,上述汇编代码是平台相关的。本例中,它们基于armv8架构而不是intel的x86架构。

    读者或许从未学习过汇编语言,也不了解计算机的指令集,但这并不妨碍我们对上述代码的功能进行合理“推测”。

    🚩第1行:arch是平台架构(architecture)的缩写,armv8是ARM公司发布的版本为8的指令集,这种指令集广泛用于移动终端及嵌入式设备。

    🚩第5行:表明circleArea名字是全局的,即该名字可以被其它目标文件所引用。

    🚩第6行:名字circleArea代表一个函数。

    🚩第10行:对sp寄存器做减法,sub是substract的简写,sp是栈顶指针(stack pointer)寄存器。对照本书8.1节可知,本行代码即是在为cirleArea()函数分配栈空间,以存储局部变量、参数等信息。

    🚩第17行、第20行:两个乘法指令,对应.i代码中的3.1415926 * r * r。fmul中表示浮点数(float)的乘法(multiply)。

    🚩第24行:对栈顶指针寄存器sp做加法。对照本书8.1节可知,本行代码即是在函数返回前释放栈空间。分配栈空间时,栈顶指针减小,释放空间时,栈顶指针变大,这说明栈确实是由高低址向低地址方向生长的。

    🚩第26行:函数执行完成,返回调用点,ret为子程序返回指令。

    12.4 汇编

    汇编器(assembler)负责将编译器输出的.s汇编语言程序翻译成可重定位目标文件(relocatable object file),扩展名为.o,其为包含机器语言指令的二进制文件。

    汇编过程相对简单,因为特定平台的汇编指令与机器指令是一一对应的。

    在Linux终端中,执行下述命令之一可以将compute.s汇编至compute.o。其中,-c选项要求gcc仅执行汇编。

    linux> as compute.s -o compute.o

    linux> gcc -c compute.s -o compute.o

    由于compute.o是包含机器指令的二进制文件,我们无法在这里展示其内容。可重定位目标文件在不同的平台上格式会有差异,在Linux终端上,可以使用objdump命令查看其反汇编代码:

    linux>  objdump -d compute.o
    compute.o:     file format elf64-littleaarch64
    Disassembly  of section .text:
    0000000000000000  <circleArea>:
       0:   d10083ff    sub sp,  sp, #0x20
       4:   bd000fe0    str s0,  [sp, #12]
       8:   bd400fe0    ldr s0,  [sp, #12]
       c:   1e22c000    fcvt   d0,  s0
      10:  90000000    adrp   x0,  0 <circleArea>
      14:  fd400001    ldr d1,  [x0]
      18:  1e610801    fmul   d1,  d0, d1
      1c:  bd400fe0    ldr s0,  [sp, #12]
      20:  1e22c000    fcvt   d0,  s0
      24:  1e600820   fmul   d0, d1, d0
      28:  1e624000    fcvt   s0,  d0
      2c:  bd001fe0    str s0,  [sp, #28]
      30:  bd401fe0    ldr s0,  [sp, #28]
      34:  910083ff    add sp,  sp, #0x20
      38:  d65f03c0    ret
    linux>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    执行结果可见,compute.o的文件格式为elf64-littleaarch64,其中,elf是Executable and Linkable Format(可执行可链接格式)的简写,该格式广泛应用于Linux及Unix系统中。elf文件由很多段(section)构成,其中,.text段包含目标文件的机器指令。

    12.5 链接

    链接(linking)是将各目标代码和数据片段收集并组合成为单一可执行目标文件的过程。在操作系统的帮助下,可执行目标文件可以被加载(复制)到内存并运行。广义的链接既可以发生在编译时(compile time),也可以发生在加载时(load time)甚至运行时(run time),这里我们先讨论编译时链接。

    本例中,如图12-3所示,compute.c以及area.c分别经过预处理、编译、汇编之后,我们得到两个可重定位目标文件compute.o及area.o。执行下述终端命令,我们可以将compute.o及area.o链接为单一的可执行目标文件area。

    linux> gcc area.o compute.o -o area

    链接使得分散编译(separate compilation)成为可能。以分散编译为前提,我们可以把一个大型程序分散成多个源文件,这些源文件可以各自独立的修改和编译。当我们重新构建整个应用程序时,集成的开发环境或者构建工具会检查每个源文件的时间戳,只对那些自前次编译后被再次修改的源文件进行编译,然后再将全部的可重定位目标文件进行链接,以避免不必要的重复编译。

    链接器主要完成两项任务:

    (1) 符号解析(symbol resolution)。在对象文件中,符号与函数或者全局变量相关联,文件A可能引用了文件B中的符号,所谓符号解析,目的是找到该符号在全部被链接的目标文件中的“唯一”定义。就本例而言,compute.c中包含了circleArea()函数的定义,circleArea作为一个符号存在于compute.o中;area.c调用了函数circleArea(),area.o包含了对符号cirleArea的引用。链接器需要在所有的被链接对象文件中找到符号circleArea,并将其实体包含在最终的可执行目标文件中,从而使得main()函数可以调用cirleArea()。

    在下述Linux终端命令中,我们只向链接器提供了area.o而没有提供compute.o,由于链接器无法解析符号circleArea,即无法找到circleArea()函数的机器代码,链接器ld报错。

    linux>  gcc area.o -o area
    /usr/bin/ld:  area.o: in function `main':
    area.c:(.text+0x1c):  undefined reference to `circleArea'
    collect2:  error: ld returned 1 exit status
    linux>
    
    • 1
    • 2
    • 3
    • 4
    • 5

    (2) 重定位。当一个编译单元被单独编译时,编译器和汇编器并不清楚该编译单元中的函数、全局对象在最终的可执行目标文件中的相对位置,因此,可重定位目标文件中的代码和数据的编址均从地址0开始。链接器需要确定每个符号在可执行目标文件中的相对地址,并根据这个确定的地址来修改该符号的全部引用。

    为了观察链接器的符号解析与重定位的效果,请读者回顾12.4中compute.o的反汇编结果,可见,符号circleArea在compute.o中起始于地址0。我们执行objdump对可执行目标文件area进行了反汇编:

    linux>  objdump –d area
    //此处有大量删节
    0000000000000774  <main>:
     774:  a9be7bfd    stp x29,  x30, [sp, #-32]!
    //…此处有删节
    78c:  bd401fe0    ldr s0,  [sp, #28]
     790:  9400000a    bl  7b8  <circleArea>
     794:  bd001be0    str s0,  [sp, #24]
    //…此处有大量删节
    00000000000007b8  <circleArea>:
     7b8:  d10083ff    sub sp,  sp, #0x20
     7bc:  bd000fe0    str s0,  [sp, #12]
     7c0:  bd400fe0    ldr s0,  [sp, #12]
    //…此处有大量删节
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    从上述执行结果可见,函数circleArea()的二进制代码从compute.o复制到了可执行目标文件area,其新的起始地址变成了0x7b8;函数main()的二进制代码从area.o复制到了可执行目标文件area,其起始地址为0x774。在main()函数的机器代码中,我们可以看到机器指令bl 7b8,该机器指令即是main()调用circleArea()时的跳转指令:0x790是这条指令的地址;0x9400000a是指令的字节码形式;bl 7b8则是这条指令的汇编语言形式。

    📍
    注意前述讨论里的0x774、0x7b8仅仅是可执行目标文件中的逻辑地址。当操作系统把可执行目标文件装入内存中后,这些符号将获得实际的物理地址。在操作系统的管理和协助下,CPU的内存管理单元(Memory Management Unit)使用一种名为段式/页式内存管理的技术把逻辑地址映射到实际的物理地址。
    🍵
    总结float g; 在当前的编译单元中为名为g的对象分配空间,由于未使用static存储类型说明符,该对象作为一个全局符号(global symbol)可以被其它编译单元引用。 extern float g; 声明一个“外部”对象g,在当前的编译单元中只引用该符号,而不为该对象分配空间。该引用必须在链接阶段得到解析,即链接器必须在其它的可重定位目标文件中找到名为g的全局对象,否则将产生链接错误。

    12.6 项目依赖

    程序的构建过程还需要一些共享的头文件以及库文件的支持,我们称之为项目依赖(project dependency)。本例中,area.c包含头文件stdio.h,area.o包含了对符号printf的引用,这些依赖需要在构建过程中加以解决。

    我们以“啰嗦”模式运行gcc进行示例程序编译,并从冗长的输出中摘取了如下片段。-v选项即为verbose(啰嗦),要求gcc详细汇报整个编译构建过程。

    linux>  gcc -v area.c compute.c -o area
    //有删减
    Target:  aarch64-linux-gnu
    //此处有大量删减
    #include  "..." search starts here:
    #include  <...> search starts here:
     /usr/lib/gcc/aarch64-linux-gnu/10/include
     /usr/local/include
     /usr/include/aarch64-linux-gnu
     /usr/include
    //此处有大量删减
    LIBRARY_PATH=/usr/lib/gcc/aarch64-linux-gnu/10/:/usr/lib/gcc/aarch64-linux-gnu/10/../../../aarch64-linux-gnu/:/usr/lib/gcc/aarch64-linux-gnu/10/../../../../lib/:/lib/aarch64-linux-gnu/:/lib/../lib/:/usr/lib/aarch64-linux-gnu/:/usr/lib/../lib/:/usr/lib/gcc/aarch64-linux-gnu/10/../../../:/lib/:/usr/lib/
    //此处有大量删减
    
    linux>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    在gcc的上述输出中,我们看到了一系列的头文件路径及库文件路径,其中库文件路径(LIBRARY_PATH)是一个由冒号分隔的多个路径的合并字符串。这些路径是gcc内置的,其在gcc的设计以及安装阶段被确定。在编译/构建的过程中,gcc会在头文件路径中查找头文件stdio.h,会在库文件路径中查找对象文件printf.o。

    作者在计算机的/usr/include目录下找到了头文件stdio.h。

    作者在计算机的/usr/lib/aarch64-linux-gnu目录下找到了库文件lic.a,其中包含了printf.o。参见下述Linux终端命令及其执行结果。

    linux>  ar -vt /usr/lib/aarch64-linux-gnu/libc.a | grep printf.o
    //有删减
    rw-r--r--  0/0   1704 Jan  1 08:00 1970 fprintf.o
    rw-r--r--  0/0   1768 Jan  1 08:00 1970 printf.o
    rw-r--r--  0/0   1664 Jan  1 08:00 1970 snprintf.o
    rw-r--r--  0/0   1720 Jan  1 08:00 1970 sprintf.o
    //有删减
    linux>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    这里的libc.a属于静态链接库(static library),该静态链接库由含printf.o在内的多个预编译好的可重定位目标文件组成。ar系Linux下的库管理工具,它可以帮助我们创建、查看和修改库文件。上述命令中的“|”是操作系统管道,大意是将ar命令的输出经管道流向grep命令,作为其输入。grep命令在输入文本中查找包含printf.o的内容。

    使用者可以通过gcc的-I选项为预处理器指定额外的头文件搜索路径;通过-L选项,使用者可以为gcc指定额外的库文件搜索路径;通过-l选项,可以为链接器指定要参与链接的库文件名称。

    静态链接库中的机器代码是在编译时(compile time)被链接器“复制”到可执行目标文件里的,链接完成后,可执行目标文件可以不依赖于静态链接库而运行。

    还有一种动态链接(dynamic linking)的共享库(shared library),在Linux系统里,其文件扩展名为.so,在Windows系统里,其文件扩展名为.dll。共享库可以在装载时(load time)被链接,简单描述就是当操作系统复制可执行目标文件至内存时,也会将相关的共享库代码复制到内存并进行相关重定位操作。共享库的存在可以有效减小可执行目标文件的尺寸,作为“共享”的库,它可以被多个应用程序所引用。

    事实上,程序还可以在运行时(run time)加载或卸载共享库。这对于大型应用程序十分有用,因为一个大型程序的全部机器码规模可能十分庞大,仅在需要时加载相关机器码有助于节省内存和程序启动时间。

    📕
    二维码扩展阅读 cmake构建工具一个名为cmake的构建工具允许开发者编写一个平台无关的CMakeLists.txt文件来定制应用的编译/构建过程。http://codelearn.club/2022/05/cmakeexample/

    为了帮助更多的年轻朋友们学好编程,作者在B站上开了两门免费的网课,一门零基础讲Python,一门零基础C和C++一起学,拿走不谢!

    简洁的C及C++
    由编程界擅长教书,教书界特能编程的海洋饼干叔叔打造
    Python编程基础及应用
    由编程界擅长教书,教书界特能编程的海洋饼干叔叔打造

    如果你觉得纸质书看起来更顺手,目前Python有两本,C和C++在出版过程中。

    Python编程基础及应用

    Python编程基础及应用实验教程
    在这里插入图片描述

  • 相关阅读:
    思科防火墙应用NAT
    云IDE产品评测感受
    【chat】 1:Ubuntu 20.04.3 编译安装moduo master分支
    基于单片机设计的防煤气泄漏装置
    2023华为杯数学建模D题-域碳排放量以及经济、人口、能源消费量的现状分析(如何建立指标和指标体系1,碳排放影响因素详细建模过程)
    什么是分布式锁?几种分布式锁分别是怎么实现的?
    贪心算法 Greedy Algroithm
    死锁的常见例子及 Python 模拟
    【react-native】关于ios与android的环境搭建
    抖音直播招聘报白企业人力资源公司多渠道展示职位
  • 原文地址:https://blog.csdn.net/SeaBiscuitUncle/article/details/126555431