文章目录
一、程序的翻译环境和执行环境
在ANSI C的任何一种实现中,存在两个不同的环境:
第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。
第2种是执行环境,它用于实际执行代码
计算机只能够识别和执行二进制指令,但是我们写出的C语言代码是文本信息,计算机不能直接理解,所以需要在翻译环境把 C语言的代码转换成二进制的指令(可执行程序),然后在执行环境执行二进制的代码。
- 翻译环境
翻译环境有两个大的步骤:编译和链接,组成一个程序的每个源文件首先通过编译过程分别转换成目标代码(object code),再由链接器把每个目标文件(linker)捆绑在一起,形成一个单一而完整的可执行程序(exe)。
目标代码已经是二进制代码,但还不能直接执行,需要链接器引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中。
- 执行环境
可执行程序在执行环境中进行执行,程序执行的过程:
程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
程序的执行便开始。接着便调用main函数。
开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。
终止程序。正常终止main函数;也有可能是意外终止。
二、C语言程序的编译+链接
1. 编译的具体过程
预处理
预处理阶段通常会完成以下操作:
- 注释的删除
- #define 定义的符号、宏的替换以及删除。
- 各种条件编译的处理。
- 头文件的展开:将头文件中的代码展开到到当前代码中。
下面通过一段代码预处理前后的对比就可以看出发生了哪些变化:
预处理前:
#include <stdio.h> //包含的头文件
#define N 5 //define定义的一个符号
#define ADD(m,n) ((m)+(n)) //define定义的一个宏
int main()
{
int a = 0;
int b = 0;
int c = N + ADD(2, 3);
printf("%d\n", c);
return 0;
}
预处理后:
{
此代码块表示头文件stdio.h 中的代码,太多了,不方便展示
}
int main()
{
int a = 0;
int b = 0;
int c = 5 + ((2)+(3));
printf("%d\n", c);
return 0;
}
编译
**编译阶段(Compilation)**是C语言编译过程中的一个重要阶段。在这个阶段中,编译器将预处理后的C语言代码转换为汇编语言。这个阶段主要包括以下几个步骤:
-
词法分析
在这个步骤中,编译器会将源代码分解成一系列的标记(tokens)。一个标记可以是一个关键字,一个标识符,一个常量,一个符号,等等。例如,下面这行C语言代码:
int sum = a + b;
会被分解为以下几个标记:
int
,sum
,=
,a
,+
,b
,;
。 -
语法分析
语法分析阶段也叫做解析阶段。在这个阶段中,编译器会根据语言的语法规则检查标记的组合是否有效。例如,C语言不允许一个变量被定义两次,所以以下的代码会在语法分析阶段被标记为无效:
int a; int a;
-
语义分析
语义分析阶段是检查源代码是否有语义错误的阶段。比如,尝试将一个字符串赋值给一个整数类型的变量,或者使用未定义的变量,这些都是语义错误。
int a = "hello"; // 语义错误 b = 10; // 语义错误,因为b未定义
-
符号汇总:
符号汇总阶段会将我们代码中的全局的符号全部汇总起来,比如全局变量名、函数名;符号汇总不会将局部的变量名汇总进来,因为局部变量只有当程序运行起来,进入该变量所在的局部范围时才会被创建,而编译是在编译阶段进行的。
汇编
在汇编阶段,由编译器生成的低级代码(汇编代码)被转换为可以被机器直接解读的机器语言代码。
汇编阶段的一个重要步骤:符号表创建
编译器会创建一个符号表,这是一个数据库,存储了源代码中定义的所有标识符的信息。例如标识符的类型(是变量还是函数),数据类型(如int,float等),作用域(全局或局部)等。
我们知道,每一个源文件都会单独经过编译器编译生成目标文件,在上面代码中,add.c 经过预处理、编译后形成 add.s,并且会汇总 Add 符号;test.c 经过预处理、编译后形成 test.s,并且会汇总 main 、Add (在test.c函数中声明了Add函数) 符号。
然后,add.s 经过汇编会生成 add.o 文件,test.s 经过汇编会生成 test.o 文件(Linux下,Windows下为 .obj 文件)。
在汇编过程中,add.s 和 test.s 都会单独生成自己的符号表,所谓的符号表其实就是把 Add main 这些符号与一个地址相关联;add.s 中的 Add 符号与一个地址相关联,test.s 中的 main与一个地址相关联、Add 也与一个地址相关联;
这里需要注意:因为 test.s 中的 Add 是函数的声明,编译器不知道 Add 函数是否真的存在,所以 test.s 中与 Add 符号相关联的地址是无效的。
汇编阶段生成的符号表会在链接阶段被使用。
这个阶段的主要任务是将编译器生成的汇编代码转换为机器代码。这个过程通常是一对一的,即每条汇编指令通常会被转换为一条机器指令。然而,这个过程可能会受到目标硬件平台的影响,因为不同的硬件平台可能会有不同的汇编语言和机器语言。
2. 链接的具体过程
在C语言的编译过程中,**链接阶段(Linking Stage)**是最后一个阶段。用于将多个编译后的目标文件(通常为.o文件或.obj文件)和库文件链接成一个单一的可执行程序。以下是链接阶段的主要任务:
- 解析外部引用:在一个C程序中,可能会有一些函数或变量是在其他源文件中定义的,这些函数或变量在其他文件中被引用时,被称为外部引用(External References)。在链接阶段,链接器会找到这些外部引用在哪个目标文件或库文件中定义,然后将它们链接起来。
- 符号表的合并和重定位:符号表的合并是指编译器会把在汇编阶段生成的多个符号表合并为一个符号表;重定位则是指当同一个符号出现在两个符号表中时,编译器会选取其中和有效地址相关的那一个,舍弃另外一个。
- 库链接:如果程序中使用了库函数,链接器会将这些库函数链接到可执行文件中。这些库函数可以是静态库中的函数,也可以是动态库中的函数。静态库在链接阶段被合并入可执行文件,而动态库在程序运行时被加载。
- 合并: 最后,链接器将所有的目标文件和库文件合并为一个单一的可执行文件。这个文件包含了程序的所有代码和数据,以及用于操作系统加载和运行程序的信息。
链接过程符号表的合并和重定向的实际意义是非常大的,因为它保证了符号表中的每一个符号都和有效的地址相关联,我们可以通过该地址找到对应符号,可以让我们跨文件地调用函数,使得我们链接生成的 .exe 文件能够被正常执行。
- 编译器调用函数的规则
在知道了程序编译链接的具体过程之后,我们需要知道编译器调用函数的规则:
首先,编译器会在当前文件中寻找函数的定义,如果找到了,就直接调用函数,在调用函数期间会形成函数的符号表;所以对于定义在本文件内的函数,编译器不需要再去确认符号表中函数的地址,也就不需要进行后续的链接操作;
如果编译器在本文件中没有找到函数的定义,那么编译器就会去寻找函数的声明,找到之后生成一个符号表,并将符号表关联一个无效的地址;这时候,编译器就需要通过后续链接阶段符号表的合并来匹配有效地址,从而实现跨文件调用函数;当然,也有可能合并不到有效的地址,从而在重定位时发生链接型错误;
最后,如果编译器在本文件内既没有找到函数的定义,也没有找到函数的声明,那么编译器就会直接报出编译型错误。
三、详解C语言程序的预处理
C语言的预处理阶段(Preprocessing Stage) 是编译过程中的第一步。在这个阶段,C预处理器会对源代码进行一系列的操作,这些操作主要包括处理预处理指令,如#include
, #define
, #if
,#ifdef
等。以下是一些具体的预处理操作:
- 包含指令(Include Directives)
#include是一个预处理指令,用于在当前文件中插入指定头文件的内容。例如#include <stdio.h>
会将这个头文件的内容插入到当前文件中。这样可以将一些常用的函数声明或者宏定义等放在头文件中,然后在需要的地方通过#include
指令来引用。
- 宏定义(Macro Definitions)
#define
是用于定义宏的预处理指令。例如,#define PI 3.14159
会定义一个名为PI的宏,其值是3.14159。在预处理阶段,预处理器会将代码中所有的PI替换为3.14159。
- 条件编译(Conditional Compilation)
#if
, #ifdef
, #ifndef
, #else
, #elif
, #endif
等预处理指令用于条件编译。这些指令可以让某些代码块在特定条件下才会被编译。例如,下面的代码只有在DEBUG被定义的情况下才会被编译:
#ifdef DEBUG
printf("Debugging\n");
#endif
- 移除注释
在预处理阶段,预处理器会移除源代码中的所有注释,因为注释对于编译过程来说是无用的。
以上是C语言预处理阶段的主要操作,需要注意的是,预处理只是文本替换操作,预处理器不会检查语法和语义。例如,宏替换只是简单的文本替换,预处理器并不会检查替换后的代码是否合法
1. 预定义符号介绍
C语言中有许多内置的预定义符号,我们可以在程序中直接使用:
__FILE__ // 进行编译的源文件
__LINE__ // 文件当前的行号
__DATE__ // 文件被编译的日期
__TIME__ // 文件被编译的时间
__STDC__ // 如果编译器遵循ANSI C,其值为1,否则未定义
int main()
{
printf("%s\n", __FILE__);
printf("%s\n", __DATE__);
printf("%s\n", __TIME__);
printf("%d\n", __LINE__);
return 0;
}
2. 预处理指令 #define和#undef
#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__ )
这条指令用于移除一个宏定义:
#undef NAME
//如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除。
注意事项
- #define 定义的标识符会在程序的预处理阶段被全部替换掉。
- 用 #define 定义标识符的时候,不要在末尾加上分号,避免造成程序逻辑的错误。
3. #define定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)
宏的声明方式如下:
#define name( parament-list ) stuff
# 其中的 parament-list 是一个由逗号隔开的参数列表,stuff是宏的功能主体
注意:参数列表的左括号必须与name紧邻,如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分
- #define定义宏的易错点
以下是两种不完善的定义,在某些情况下可能会出错:
#define SQUARE(x) x * x
#define DOUBLE(x) (x) + (x)
通过以下代码来调用它们会出现与我们预期不符的结果
#define SQUARE(x) x * x
#define DOUBLE(x) (x) + (x)
int main()
{
printf("%d\n", SQUARE(3 + 1));
printf("%d\n", 10 * DOUBLE(3 + 1));
return 0;
}
因为以上代码被替换成了如下形式:
#define SQUARE(x) x * x
#define DOUBLE(x) (x) + (x)
int main()
{
printf("%d\n", 3 + 1 * 3 + 1);
printf("%d\n", 10 * (3 + 1) + (3 + 1));
return 0;
}
正确的,万无一失的宏定义应该是如下形式:
#define SQUARE(x) ((x) * (x))
#define DOUBLE(x) ((x) + (x))
总结:用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中的操作符或邻近操作符之间不可预料的相互作用。
- #define替换规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤。
- 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先
被替换。
替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上
述处理过程。
- 注意事项
-
宏参数和#define 定义中可以出现其他#define定义的符号,但是对于宏,不能出现递归。
-
当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
4. 预处理操作符#和##的介绍
我们以下面的代码为例介绍 # 的作用:
int main()
{
int a = 10;
printf("The value of a is %d\n", a);
int b = 20;
printf("The value of b is %d\n", b);
return 0;
}
对于上面的代码,我们发现 printf 函数里面的内容有一些冗余,可不可以封装一个函数或者一个宏,来实现既可以打印 a,也可以打印 b?首先,函数是做不到的,因为字符串中的a和b在函数中是固定的,我们只能改变打印的值的大小;而宏就可以利用 # 把参数插入到字符串:
#define PRINT(n) printf("The value of "#n" is %d\n", n)
int main()
{
int a = 10;
PRINT(a);
int b = 20;
PRINT(b);
return 0;
}
我们以下面的代码为例介绍 ## 的作用:
#define CLA(class, num) class##num
int main()
{
int class001 = 100;
printf("%d\n", CLA(class, 001));
return 0;
}
可以看出 ## 的作用就是:可以把位于它两边的符号合成一个符号
5. 带副作用的宏参数
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果,副作用就是表达式求值的时候出现的永久性效果。
x+1;//不带副作用
x++;//带有副作用
MAX宏可以证明具有副作用的参数所引起的问题
#define MAX(a, b) ( (a) > (b) ? (a) : (b) )
int main()
{
int x = 5;
int y = 8;
int z = MAX(x++, y++);
printf("x=%d y=%d z=%d\n", x, y, z);//输出的结果是什么?
return 0;
}
这里我们得知道预处理器处理之后的结果是什么:
z = ( (x++) > (y++) ? (x++) : (y++));
所以上面的代码不仅不能得到我们想要的 z 的值,还会让 x 和 y 的值变得不可控
6. 宏和函数的对比
宏通常被应用于执行简单的运算
原因有以下两点:
用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹。
更为重要的是函数的参数必须声明为特定的类型,所以函数只能在类型合适的表达式上使用。反之宏是类型无关的,可以适用于整型、长整型、浮点型等可以用 > 来比较的类型
而复杂、代码量大的运算通常由函数来完成,因为宏和函数相比有很多劣势:
每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,使用宏的地方少,否则可能大幅度增加程序的长度。
宏是没法调试的。
宏由于类型无关,也就不够严谨
宏可能会带来运算符优先级的问题,导致程容易出现错
宏有时候可以做函数做不到的事情,比如宏的参数可以出现类型,但是函数做不到
#define MALLOC(num, type) (type *)malloc(num * sizeof(type))
//使用
MALLOC(10, int);//类型作为参数
//预处理器替换之后:
(int *)malloc(10 * sizeof(int));
宏和函数的对比
属性 | #define定义宏 | 函数 |
---|---|---|
代码长度 | 每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长。 | 函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码。 |
执行速度 | 更快 | 存在函数的调用和返回的额外开销,所以相对慢一些。 |
操作符优先级 | 宏参数的求值是在所有周围表达式的上下文环境里, 除非加上括号,否则邻近操作符的优先级可能会产生 不可预料的后果,所以建议宏在书写的时候多些括号。 | 函数参数只在函数调用的时候求值一次,它的结果值传递给函数。表达式的求值结果更容易预测、 |
带有副作用的参数 | 参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果。 | 函数参数只在传参的时候求值一次,结果更容易控制。 |
参数类型 | 宏的参数与类型无关,只要对参数的操作是合法的, 它就可以使用于任何参数类型。 | 函数的参数是与类型有关的,如果参数的类型不同,就需要不同的函数,即使他们执行的任务是相同的。 |
调试 | 宏是不方便调试的 | 函数是可以逐语句调试的 |
递归 | 宏是不能递归的 | 函数是可以递归的 |
函数和宏的命名约定
由于函数和宏的使用语法很相似,所以语言本身没法帮我们区分二者; 为了区分宏和函数,我们平时的一个命名习惯是:
- 宏名全部大写。
- 函数名不要全部大写。
7. 命令行定义
许多C 的编译器提供了一种能力,允许在命令行中定义符号,用于启动编译过程。例如:当我们想根据同一个源文件编译出不同的版本的时候,就可以使用命令行定义。(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一 个机器内存大些,我们需要一个数组能够大些。)
以下是一个例子:
#include <stdio.h>
int main()
{
int array[ARRAY_SIZE];//ARRAY_SIZE未定义,需要在编译阶段定义
int i = 0;
for (i = 0; i < ARRAY_SIZE; i++)
{
array[i] = i;
}
for (i = 0; i < ARRAY_SIZE; i++)
{
printf("%d ", array[i]);
}
printf("\n");
return 0;
}
编译指令:
//linux 环境演示
gcc -D ARRAY_SIZE=10 programe.c
8. 条件编译
在编译一个程序的时候,我们如果要将一条语句或一组语句编译或者放弃,我们就可以使用条件编译指令。比如说:调试性的代码,删除可惜,保留又碍事,所以我们可以选择性地编译。
#include <stdio.h>
#define __DEBUG__
int main()
{
int i = 0;
int arr[10] = { 0 };
for (i = 0; i < 10; i++)
{
#ifdef __DEBUG__
printf("%d\n", arr[i]);//为了观察数组是否赋值成功。
#endif //__DEBUG__
}
return 0;
}
常见条件编译指令:
1.
#if 常量表达式
//...
#endif
//常量表达式由预处理器求值。
如:
#define __DEBUG__ 1
#if __DEBUG__
//..
#endif
2.多个分支的条件编译
#if 常量表达式
//...
#elif 常量表达式
//...
#else
//...
#endif
3.判断是否被定义
#if defined(symbol)
#ifdef symbol
#if !defined(symbol)
#ifndef symbol
4.嵌套指令
#if defined(OS_UNIX)
#ifdef OPTION1
unix_version_option1();
#endif
#ifdef OPTION2
unix_version_option2();
#endif
#elif defined(OS_MSDOS)
#ifdef OPTION2
msdos_version_option2();
#endif
#endif
9. 预处理指令 #include
我们已经知道, #include 指令可以使另外一个文件被编译,就像它实际出现于 #include 指令的地方一样。
这种替换的方式很简单:预处理器先删除这条指令,并用包含文件的内容替换,这样一个源文件被包含10次,那就实际被编译10次。
- 头文件被包含的方式
我们常用的文件包含方式有两种:库文件包含和本地文件包含。
库文件包含形式:#include <filename>
查找策略:查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。
本地文件包含形式:#include “filename”
查找策略:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准路径下去查找,如果找不到就提示编译错误。
所以实际上对于库文件也可以使用 “ ” 的形式包含,但是这样做查找的效率就低一些,并且也不容易区分库文件和本地文件
- 嵌套文件包含
有些时候我们的程序中难免会出现同一个头文件被重复包含的情况,比如一个 stdio.h 头文件被包含多次,这时我们可以使用条件编译来避免头文件被重复包含:
#ifndef __TEST_H__
#define __TEST_H__
//包含头文件
#endif //__TEST_H__
我们更常用的是在头文件中加入这句话来防止重复包含:#pragma once
四、总结
掌握C语言程序编译过程对于程序员来说是非常重要的,虽然它不能直接帮助我们提高代码能力,但是它能帮助我们更好地理解和调试代码:
通过了解编译过程,程序员可以深入了解代码是如何被转换成机器码并在计算机上执行的,就可以通过优化算法、数据结构和代码结构,以及使用编译器选项进行代码优化,改善代码的运行速度和内存使用
了解编译过程还可以帮助程序员理解错误和警告信息的含义,并且更好地进行代码调试