C和CPP程序是如何运行起来的?

发布时间 2023-10-05 12:01:23作者: BryceAi

C和CPP程序是如何运行起来的?


个人见解,谨慎阅读。
如有错误,欢迎指正!


代码均在Linux下编译运行。

1. C语言程序从源码到可执行文件的过程

C语言程序从源码到可执行文件的过程主要分为以下几个步骤:预处理、编译、汇编、链接。

flowchart LR A1[代码] --"预处理"--> B1[预处理文件] --"编译"--> C1[编译代码] --"汇编"--> D1[机器代码] --"链接"--> F[可执行文件]
  1. 源码:首先,编写C语言源代码文件,通常以.c作为文件扩展名。这个源代码包括程序的逻辑、变量、函数等。
    这里为了举例,我们设置了一个头文件和一个源文件,并在源文件里包含头文件,并设置了宏用于举例。

h.h

//这里是头文件,包含了两个变量的声明
int a;
int b;
int c;

c.c

#include "h.h"
#define A 3
//这里是源码,包含了两个变量的定义
//一个宏的定义
//一个函数的定义
int main(void)
{
    //这里是函数体
    a = 1;
    b = 2;
    c = a + b;
    c = A;
    return 0;
}
  1. 预处理:预处理是在编译之前进行的,主要是对源代码进行一些处理,比如把#include的文件包含进来,把#define定义的宏展开,把注释去掉等。生成一个经过预处理的源文件,通常以 .i 作为文件扩展名。
    上述代码经过预处理后的结果如下:

c.i

# 1 "c.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
# 1 "c.c"
# 1 "h.h" 1

int a;
int b;
int c;
# 2 "c.c" 2




int main(void)
{

    a = 1;
    b = 2;
    c = a + b;
    c = 3;
    return 0;
}
  1. 编译:编译器将预处理后的源代码翻译成汇编代码。生成的汇编代码通常以.s作为文件扩展名。
    上述代码经过编译后的结果如下:

c.s

        .file   "c.c"
        .text
        .comm   a,4,4
        .comm   b,4,4
        .comm   c,4,4
        .globl  main
        .type   main, @function
main:
.LFB0:
        .cfi_startproc
        endbr64
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        movl    $1, a(%rip)
        movl    $2, b(%rip)
        movl    a(%rip), %edx
        movl    b(%rip), %eax
        addl    %edx, %eax
        movl    %eax, c(%rip)
        movl    $3, c(%rip)
        movl    $0, %eax
        popq    %rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
.LFE0:
        .size   main, .-main
        .ident  "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0"
        .section        .note.GNU-stack,"",@progbits
        .section        .note.gnu.property,"a"
        .align 8
        .long    1f - 0f
        .long    4f - 1f
        .long    5
0:
        .string  "GNU"
1:
        .align 8
        .long    0xc0000002
        .long    3f - 2f
2:
        .long    0x3
3:
        .align 8
4:
  1. 汇编:汇编器将汇编代码翻译成机器代码,生成一个目标文件。目标文件通常以.o(在Unix/Linux系统中)或.obj(在Windows系统中)作为文件扩展名。目标文件包含二进制指令、数据和符号表信息。
    使用objdump -t -S c.o命令,上述代码经过汇编后的结果如下:

c.o

c.o:     file format elf64-x86-64

SYMBOL TABLE:
0000000000000000 l    df *ABS*  0000000000000000 c.c
0000000000000000 l    d  .text  0000000000000000 .text
0000000000000000 l    d  .data  0000000000000000 .data
0000000000000000 l    d  .bss   0000000000000000 .bss
0000000000000000 l    d  .note.GNU-stack        0000000000000000 .note.GNU-stack
0000000000000000 l    d  .note.gnu.property     0000000000000000 .note.gnu.property
0000000000000000 l    d  .eh_frame      0000000000000000 .eh_frame
0000000000000000 l    d  .comment       0000000000000000 .comment
0000000000000004       O *COM*  0000000000000004 a
0000000000000004       O *COM*  0000000000000004 b
0000000000000004       O *COM*  0000000000000004 c
0000000000000000 g     F .text  0000000000000041 main



Disassembly of section .text:

0000000000000000 <main>:
   0:   f3 0f 1e fa             endbr64
   4:   55                      push   %rbp
   5:   48 89 e5                mov    %rsp,%rbp
   8:   c7 05 00 00 00 00 01    movl   $0x1,0x0(%rip)        # 12 <main+0x12>
   f:   00 00 00
  12:   c7 05 00 00 00 00 02    movl   $0x2,0x0(%rip)        # 1c <main+0x1c>
  19:   00 00 00
  1c:   8b 15 00 00 00 00       mov    0x0(%rip),%edx        # 22 <main+0x22>
  22:   8b 05 00 00 00 00       mov    0x0(%rip),%eax        # 28 <main+0x28>
  28:   01 d0                   add    %edx,%eax
  2a:   89 05 00 00 00 00       mov    %eax,0x0(%rip)        # 30 <main+0x30>
  30:   c7 05 00 00 00 00 03    movl   $0x3,0x0(%rip)        # 3a <main+0x3a>
  37:   00 00 00
  3a:   b8 00 00 00 00          mov    $0x0,%eax
  3f:   5d                      pop    %rbp
  40:   c3                      retq
  1. 链接:链接器将目标文件与标准库和其他必要的库文件链接在一起,创建可执行文件。链接的过程包括符号解析、地址重定向和符号解析等步骤。生成的可执行文件通常没有文件扩展名,或者以.exe(在Windows系统中)或 无扩展名(在Unix/Linux系统中)作为文件扩展名。

  2. 可执行文件:最终生成的可执行文件包含了程序的所有机器代码和数据,可以在相应的操作系统上运行。

2. C语言程序是如何预处理的

预处理器执行宏替换、条件编译以及包含指定的文件,以#开头的命令行(#前可以有空格)就是预处理器处理的对象。这些命令行的语法独立于语言的其它部分,它们可以出现在任何地方,其作用可延续到所在翻译单元的末尾(与作用域无关)。

2.1 预处理过程

预处理过程主要分为以下几个步骤:

  1. 处理多字节字符和三字符序列:将多字节字符和三字符序列转换为单字节字符。这里需要提到的是,编译器会把多个连续字符串常量合并为一个字符串常量,这个过程发生在预处理阶段。比如:"hello" "world"会被合并为"helloworld"
  2. 处理行连接符:将行连接符\和换行符\n组合成一个新行。
  3. 划分序列:编译器将文本划分成预处理记号序列、空白序列和注释序列。
    • 预处理记号序列:预处理记号是C源代码中的基本单位,它们是编程语言中的关键字、标识符、文字、操作符等。预处理记号序列是一组连续的预处理记号,它们在预处理阶段被处理和解析。
    • 空白序列:由一个或多个空格、制表符、换行符等空白字符组成的序列。
    • 注释序列:由注释组成的序列。值得注意的是,编译器将用一个空格替换每个注释序列,以便于后续处理。
  4. 处理预处理指令:编译器将预处理指令替换为相应的文本。
    • 头文件包含:将#include指令替换为指定的头文件内容。
    • 宏替换:将#define指令替换为指定的替换体内容。
    • 条件编译:根据#if#ifdef#ifndef等指令的真假来决定是否编译后面的代码块。
    • 其他指令:将#undef#pragma等指令替换为相应的文本。

2.2 明示常量:#define指令

#define指令用于定义宏,它有两种形式:

  • #define 宏:用于定义宏,简单地标记宏为已定义,而不进行任何文本替换。这通常用于创建宏常量或用于条件编译中的标记。
  • #define 宏 替换体:用于定义宏,将代码中的宏名替换为指定的替换体内容。从宏变成最终替换文本的过程称为宏展开。

“明示常量”这一词来自《C Primer Plus第6版 中文版》[1],个人认为这是一个历史遗留词语,因为C语言一开始没有const关键字,所以用#define来定义常量。

预处理器指令从#开始,直到后面的第一个换行符为止。使用\可以将一行代码分成多行,但是\后面不能有空格。按照前文所述,预处理器会处理行连接符。

每行#define(逻辑行)都由3部分组成。第1部分是#define指令本身。第2部分是选定的缩写也称为宏。有些宏代表值,这些宏被称为类对象宏。C语言还有类函数宏,稍后讨论。宏的名称中不允许有空格,而且必须遵循C变量的命名规则:只能使用字符、数字和下划线(_)字符,而且首字符不能是数字。第3部分(指令行的其余部分)称为替换列表或替换体。一旦预处理器在程序中找到宏的示实例后,就会用替换体代替该宏(也有例外,稍后解释)。从宏变成最终替换文本的过程称为宏展开。注意,可以在#define行使用标准C注释。如前所述,每条注释都会被一个空格代替。[1:1]

2.2.1 宏定义的替换体

宏定义的替换体可以是任何字符序列,除特别声明的几个特殊形式,其余字符在对应的宏的位置以纯文本形式进行替换。例如:

#define TWO 2
#define OW "Consistency is the last refuge of the unimagina\
tive. - Oscar Wilde"
#define FOUR TWO*TWO
#define PX printf("X is %d.\n", x)

2.2.2 类函数宏

类函数宏是一种特殊的宏,它们看起来像函数调用。例如:

#define SQUARE(X) X*X

这里,SQUARE是宏标识符,SQUARE(X)中的X是宏参数,X*X是替换列表。后续代码里,出现SQUARE(X)的地方都会被X*X替换。这与前面的示例不同,使用该宏时,既可以用X,也可以用其他符号。宏定义中的X由宏调用中的符号代替。因此,SUARE(2)替换为2*2X实际上起到参数的作用。

2.2.3 记号

从技术角度来看,可以把宏的替换体看作是记号(token)型字符串,而不是字符型字符串。C预处理器记号是宏定义的替换体中单独的“词”。用空白把这些词分开[1:2]。例如:

#define FOUR 2*2

这个宏定义用一个记号:2*2。但是,如果宏定义是这样的:

#define SIX 2 * 3

那么,宏定义用三个记号:2*3

解释为字符型字符串,把空格视为替换体的一部分;解释为记号型字符串,把空格视为替换体中各记号的分隔符。在实际应用中,一些C编译器把宏替换体视为字符串而不是记号[1:3]

这里提到这个概念,主要是想要说明,宏替换体中的空格是有意义的,不是随便加的。这表明宏替换是源代码上的纯文本替换。

2.2.4 #运算符

#运算符是一元运算符,它的作用是将宏参数转换为字符串。例如:

#define PRINT(x) printf(#x " = %d\n", x)

这个宏定义用到了#运算符,它的作用是将宏参数x转换为字符串。这样,PRINT(a)就会被替换为printf("a" " = %d\n", a),这样就可以打印出变量名和变量值了。

2.2.5 ##运算符

##运算符是连接运算符,它的作用是将两个记号连接成一个记号。例如:

#define PRINT(n) printf("x" #n " = %d\n", x##n);

这个宏定义用到了##运算符,它的作用是将两个记号连接成一个记号。这样,PRINT(1)就会被替换为printf("x" "1" " = %d\n", x1);,这样就可以打印出变量名和变量值了。

2.3 预处理命令

C语言里的预处理命令主要有以下几个:

  • #include <头文件>:用于包含系统标准库头文件,通常用于引入标准库函数和类型的声明。
  • #include "头文件":用于包含用户自定义的头文件,通常用于引入自定义函数和类型的声明。
  • #define 宏:用于定义宏,简单地标记宏为已定义,而不进行任何文本替换。这通常用于创建宏常量或用于条件编译中的标记。
  • #define 宏 替换体:用于定义宏,将代码中的宏名替换为指定的替换体内容。从宏变成最终替换文本的过程称为宏展开。
  • #undef 宏:用于取消已定义的宏,将其从预处理符号表中删除。
  • #ifdef 宏:用于检查某个宏是否已经定义,如果定义了就执行后面的代码块。
  • #ifndef 宏:用于检查某个宏是否未定义,如果未定义就执行后面的代码块。
  • #if 表达式:用于条件编译,根据指定的表达式的真假来决定是否编译后面的代码块。
  • #elif 表达式:用于在多个条件之间切换,类似于if-else if结构,用于条件编译。
  • #else:与#ifdef#ifndef#if等一起使用,表示条件不成立时要执行的代码块。
  • #endif:用于结束条件编译的代码块。
  • #pragma 指令:用于向编译器发送特定的指令或控制编译器的行为,具体指令和效果因编译器而异。

2.4 文件包含:#include指令

#include指令用于包含头文件,它有两种形式:

  • #include <头文件>:用于包含系统标准库头文件,通常用于引入标准库函数和类型的声明。
  • #include "头文件":用于包含用户自定义的头文件,通常用于引入自定义函数和类型的声明。

具体而言,对于<>包含的头文件,预处理器会在系统标准库的目录下查找;对于""包含的头文件,预处理器会在当前目录下查找,如果找不到,再去系统标准库的目录下查找。
当预处理器发现#include指令时,会查看后面的文件名并把文件的内容包含到当前文件中,即替换源文件中的#include指令。这相当于把被包含文件的全部内容输入到源文件#include指令所在的位置。

2.4.1 头文件

头文件包含了函数和类型的声明。头文件通常以.h作为文件扩展名。头文件中最常见的内容如下[1:4]

  • 明示常量:例如,stdio.h中定义的EOF、NULL和 BUFSTZE(标准IO缓冲区大小)。
  • 宏函数:例如,getc(stdin)通常用getchar()定义,而getc()经常用于定义较复杂的宏,头文件ctype.h通常包含ctype系列函数的宏定义。
  • 函数声明:例如,string. h头文件(一些旧的系统中是strings.h)包含字符串函数系列的函数声明。在ANSIC和后面的标准中,函数声明都是函数原型形式。
  • 结构模版定义:标准VO函数使用FILE结构,该结构中包含了文件和与文件缓冲区相关的信息。FILE结构在头文件stdio.h 中。
  • 类型定义:标准IO 函数使用指向FILE的指针作为参数。通常,stdio.h 用#define或typedef把FILE定义为指向结构的指针。类似地,size_t和time_t类型也定义在头文件中。

2.5 实例分析:预处理过程

h.h

//这里是头文件,包含了两个变量的声明
int a;
int b;
int c;

c.c

#include "h.h"
#define A 3
//这里是源码,包含了两个变量的定义
//一个宏的定义
//一个函数的定义
int main(void)
{
    //这里是函数体
    a = 1;
    b = 2;
    c = a + b;
    c = A;
    return 0;
}

预处理后:

c.i

# 1 "c.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
# 1 "c.c"
# 1 "h.h" 1

int a;
int b;
int c;
# 2 "c.c" 2




int main(void)
{

    a = 1;
    b = 2;
    c = a + b;
    c = 3;
    return 0;
}

尽管有部分符号还不理解,但是通过上文的内容,通过这个例子,我们仍然可以看出预处理器的大致逻辑。首先,预处理器会把#include的文件包含进来,然后把#define定义的宏展开,最后把注释去掉。比如:

  • 预处理器把#include "h.h"替换为int a; int b; int c;
  • 预处理器把#define A 3替换为c = 3;
  • 预处理器把//这里是源码,包含了两个变量的定义替换为空。

//其实到这个位置对于C和C++入门来说,已经足够了。后期有时间,再补充。
//TODO



参考与注释:


  1. 《C Primer Plus第6版 中文版》 Stephen Prata 人民邮电出版社 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎