C和CPP程序是如何运行起来的?
个人见解,谨慎阅读。
如有错误,欢迎指正!
代码均在Linux下编译运行。
1. C语言程序从源码到可执行文件的过程
C语言程序从源码到可执行文件的过程主要分为以下几个步骤:预处理、编译、汇编、链接。
flowchart LR A1[代码] --"预处理"--> B1[预处理文件] --"编译"--> C1[编译代码] --"汇编"--> D1[机器代码] --"链接"--> F[可执行文件]- 源码:首先,编写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;
}
- 预处理:预处理是在编译之前进行的,主要是对源代码进行一些处理,比如把
#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;
}
- 编译:编译器将预处理后的源代码翻译成汇编代码。生成的汇编代码通常以
.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:
- 汇编:汇编器将汇编代码翻译成机器代码,生成一个目标文件。目标文件通常以
.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
-
链接:链接器将目标文件与标准库和其他必要的库文件链接在一起,创建可执行文件。链接的过程包括符号解析、地址重定向和符号解析等步骤。生成的可执行文件通常没有文件扩展名,或者以
.exe
(在Windows系统中)或 无扩展名(在Unix/Linux系统中)作为文件扩展名。 -
可执行文件:最终生成的可执行文件包含了程序的所有机器代码和数据,可以在相应的操作系统上运行。
2. C语言程序是如何预处理的
预处理器执行宏替换、条件编译以及包含指定的文件,以#
开头的命令行(#
前可以有空格)就是预处理器处理的对象。这些命令行的语法独立于语言的其它部分,它们可以出现在任何地方,其作用可延续到所在翻译单元的末尾(与作用域无关)。
2.1 预处理过程
预处理过程主要分为以下几个步骤:
- 处理多字节字符和三字符序列:将多字节字符和三字符序列转换为单字节字符。这里需要提到的是,编译器会把多个连续字符串常量合并为一个字符串常量,这个过程发生在预处理阶段。比如:
"hello" "world"
会被合并为"helloworld"
。 - 处理行连接符:将行连接符
\
和换行符\n
组合成一个新行。 - 划分序列:编译器将文本划分成预处理记号序列、空白序列和注释序列。
- 预处理记号序列:预处理记号是C源代码中的基本单位,它们是编程语言中的关键字、标识符、文字、操作符等。预处理记号序列是一组连续的预处理记号,它们在预处理阶段被处理和解析。
- 空白序列:由一个或多个空格、制表符、换行符等空白字符组成的序列。
- 注释序列:由注释组成的序列。值得注意的是,编译器将用一个空格替换每个注释序列,以便于后续处理。
- 处理预处理指令:编译器将预处理指令替换为相应的文本。
- 头文件包含:将
#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*2
,X
实际上起到参数的作用。
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
参考与注释: