前言:
正所谓,万物c为首。在我们较为深入的学完c语言之后,可以说是打开了编程的第一扇大门。代码我们会敲了,可是这些代码到底是咋运行起来的呢?这些源文件,头文件里的代码又是怎么“整合”在一起的呢?从代码的生成到运行,中间经历了什么步骤呢?
带着以上问题,我与大家一起学习c语言之无敌内功修炼---程序环境和预处理。
1.程序的翻译环境和执行环境
1.编译环境是指在开发阶段,,程序员使用的工具和设置,用于将源代码转换成可执行文件或库文件。编译环境通常包括编译器、链接器、调试器等工具。不同的编程语言可能有不同的编译环境,例如C语言常用的编译环境包括gcc、clang等。
2.执行环境是指在程序运行时,程序所依赖的软件和硬件环境。执行环境通常包括操作系统、运行时库、硬件平台等。不同的操作系统和硬件平台可能有不同的执行环境要求,例如在Windows操作系统上运行的程序需要使用Windows所提供的执行环境。
3.编译环境和执行环境之间密切相关,编译环境生成的可执行文件或库文件需要在相应的执行环境中才能正确运行。
大概意思就是,一个程序的正常运行需要翻译和执行,翻译就是将你写的代码转换成一个可执行文件,这个可执行文件的正常运行又需要程序需要的环境。
2.编译和链接
2.1编译环境
程序编译的过程:
组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)。 每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。 链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人 的程序库,将其需要的函数也链接到程序中。
2.2编译的过程
给出代码:
sum.c文件:
1. int add(int x, int y) { 2. return x + y; 3. }
test.c文件:
#include<stdio.h> #define a 10 #define b 20 extern int add(int x, int y); int main() { printf("%d", add(a, b));//求两数之和 return 0; }
我们知道,源文件通过编译生成目标文件,那么这个“编译”的过程又是什么呢?
编译有三个阶段:
1.预编译(预处理):
在预编译阶段,预处理器会处理源代码中的预处理指令,例如宏定义、条件编译等。它会根据这些指令对源代码进行修改和替换,并生成一个经过预处理的文件。
根据代码,预处理阶段生成test.i文件和sum.i文件。
怎么看到这个预处理生成的文件呢?
用vs(2022)举例,鼠标右击项目,再单击属性:
将预处理文件这一项选择“是”,然后在debug文件夹里面就可以看到预处理生成的文件:
test.i:
我们可以看到test.i文件里多了很多代码,在最后面才是我们看到的test.c文件里的源码,我们发现,跟test.c文件不一样的是,test.i文件里的注释不见了,被替换为了空格。头文件#include<>包含的信息也被替换了,而且define定义的符号a,b也直接被替换了。
其实,#include 和#define 都是预处理指令,而所有的预处理指令在预处理阶段也就是预编译阶段就被处理了(替换成了本来的面目),这些操作属于文本操作。
2.编译阶段:
编译器会将预处理后的文件进行词法分析、语法分析、语义分析和符号汇总,生成相应的中间表示形式(通常是汇编代码)。
1.词法分析:
词法分析(Lexical Analysis):词法分析将源代码划分为一个个词法单元(Tokens),如关键字、标识符、操作符等。词法分析器(Lexer)通过根据预先定义的词法规则来识别和生成这些词法单元。词法分析器会忽略空格、注释等不具有实质意义的字符,并将词法单元传递给语法分析器进行后续处理.
2.语法分析:
语法分析(Syntax Analysis):语法分析将词法单元组织成语法树(Syntax Tree)或抽象语法树(Abstract Syntax Tree,AST)。语法分析器(Parser)根据语法规则和上下文无关文法,验证源代码的语法结构是否正确。它确定语言的语法规则是否遵循,并产生一种结构化的表示形式,以便后续的语义分析和代码生成。
3.语义分析:
语义分析(Semantic Analysis):语义分析器对语法树进行静态检查,以验证源代码的语义正确性。它会检查变量的声明和使用是否匹配、类型的一致性、函数调用的正确性等。语义分析通过查找和解析符号引用,进行类型检查,并执行其他语义规则来捕获潜在的错误或不一致之处。
4.符号汇总:
符号汇总(Symbol Table):符号汇总是在编译过程中维护的一个数据结构,用于记录程序中出现的符号(如变量、函数名等)的信息。符号汇总表存储了符号的名称、类型、作用域等信息,以及与之相关联的属性和值。符号汇总用于在语义分析和代码生成阶段解析符号引用,确保符号在正确的作用域内被引用和使用。
总结:
在编译阶段,将预处理生成的test.i文件进行处理,生成汇编代码并存放在test.s文件中,这个时候的代码还是不能直接让机器运行,还需要下一步操作。
3.汇编:
汇编阶段是整个编译的最后一步,将比编译阶段生成的汇编代码翻译成二进制指令,生成目标文件(test.o),同时生成符号表,作用于链接阶段.
具体过程如下:
1.汇编指令生成:在汇编阶段,编译器将中间代码(如汇编代码或抽象语法树)转换为目标机器代码的汇编指令。每个汇编指令对应于特定的机器指令,用于执行特定的操作。这些指令描述了程序在底层硬件上的操作。
2.符号解析和地址分配:编译器会解析各个标识符的引用,并为其分配具体的内存地址或位置。这包括变量、函数和常量等。符号解析和地址分配是为了生成正确的目标代码,使得代码中的符号引用能够正确地映射到内存中的对应位置。
3.生成目标文件:在汇编阶段,编译器将生成转换后的目标机器代码,并将其保存为目标文件。目标文件包含了汇编指令、符号表以及其他与链接和加载相关的信息。目标文件通常采用特定的格式,如ELF(Executable and Linkable Format)。
4.代码优化:在汇编阶段,有些编译器也会进行一些简单的代码优化。这些优化旨在提高目标代码的执行效率和性能。例如,常量合并、死代码消除、循环展开等技术可以在汇编阶段应用,以改善目标代码的质量。
2.3链接
在链接阶段,链接目标文件和链接库生成可执行程序二进制的程序。
1.合并段表
2.符号表的合并和重定位
2.4 运行环境
程序执行的过程:
1. 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序 的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
2. 程序的执行便开始。接着便调用main函数。
3. 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回 地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程 一直保留他们的值。
4. 终止程序。正常终止main函数;也有可能是意外终止。
3.预处理详解
在上面的讲解中,我们已经大概知道了程序中的源代码是如何=生成可执行文件并运行的。
接下来我着重解释编译过程中的预处理阶段。
3.1预定义符号
预定义符号 | 含义 |
__FILE__ | 进行编译的源文件 |
__LINE__ | 文件当前的行号 |
__DATE__ | 文件被编译的日期 |
__TIME__ | 文件被编译的时间 |
__STDC__ | 如果编译器遵循ANSI C,其值为1,否则未定义 |
这些预定义符号都是语言内置的。
举例:
其中__FILE__是当前编译源文件的路径 ,__LINE__表示的则是文件当前的行号。
3.2 #define
3.2.1#define 定义标识符
语法: #define name stuff
举例:
#define MAX 1000 #define reg register //为 register这个关键字,创建一个简短的名字 #define do_forever for(;;) //用更形象的符号来替换一种实现 #define CASE break;case //在写case语句的时候自动把 break写上。 // 如果定义的 stuff过长,可以分成几行写,除了最后一行外,每行的后面都加一个反斜杠(续行符)。 #define DEBUG_PRINT printf("file:%s\tline:%d\t \ date:%s\ttime:%s\n" ,\ __FILE__,__LINE__ , \ __DATE__,__TIME__ )
3.2.2 #define 定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义 宏(define macro)。 下面是宏的申明方式:
#define name( parament-list ) stuff
其中的 parament-list 是一个由逗号隔开的符号表,它们可能出现在stuff中。
注意: 参数列表的左括号必须与name紧邻。 如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。 如:
#define SQUARE( x ) x * x
这个宏接收一个参数 x . 如果在上述声明之后,你把
SQUARE( 5 );
置于程序中,预处理器就会用下面这个表达式替换上面的表达式:
5 * 5
警告:
这个宏存在一个问题:
观察下面的代码段:
1. int a = 5; 2. printf("%d\n" ,SQUARE( a + 1) );
乍一看,你可能觉得这段代码将打印36这个值。 事实上,它将打印11. 为什么?
替换文本时,参数x被替换成a + 1,所以这条语句实际上变成了:
printf ("%d\n",a + 1 * a + 1 );
这样就比较清晰了,由替换产生的表达式并没有按照预想的次序进行求值。
在宏定义上加上两个括号,这个问题便轻松的解决了:
#define SQUARE(x) (x) * (x)
这样预处理之后就产生了预期的效果:
printf ("%d\n",(a + 1) * (a + 1) );
提示:
所以用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中 的操作符或邻近操作符之间不可预料的相互作用。
提示:
所以用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中 的操作符或邻近操作符之间不可预料的相互作用。
3.2.3 #define替换规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤。
1. 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先 被替换。
2. 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
3. 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上 述处理过程。
注意:
1. 宏参数和#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归。
2. 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
3.2.4 #和##
#的作用
如何把参数插入到字符串中?
看以下代码:
char* p = "hello ""bit\n"; printf("hello"" bit\n"); printf("%s", p);
现字符串是有自动连接的特点的,所以这段代码输出“hello bit”.
于是我们可以这么写代码:
#define PRINT(FORMAT, VALUE) printf("the value of " #VALUE "is "FORMAT "\n", VALUE); PRINT("%d", 3+5)
这里我们使用#,把一个宏参数变成对应的字符串。
最终的输出的结果应该是:
the value of 3+5 is 8
## 的作用
##可以把位于它两边的符号合成一个符号。
它允许宏定义从分离的文本片段创建标识符
#define ADD_TO_SUM(num, value) sum##num += value; ADD_TO_SUM(5, 10);//作用是:给sum5增加10.
这里sum##num就变成了sum5.
需要注意的是,连接之后的符号必须是合法的标识符
4.常见预处理指令
指令 | 用途 |
#include | 包含一个源代码文件 |
#define | 定义宏 |
#if | 取消已定义的宏 |
#undef | 如果给定条件为真,则编译下面代码 |
#ifdef | 如果宏已经定义,则编译下面代码 |
#ifndef | 如果宏没有定义,则编译下面代码 |
#elif | 如果前面的#if给定条件不为真,当前条件为真,则编译下面代码 |
#endif | 结束一个#if……#else条件编译块 |
#error | 停止编译并显示错误信息 |