摘要:总结上面的过程中,我们已经将源程序经过预处理编译汇编阶段变成了二进制代码,这三个过程我们都是用两种方法完成的,一种是参数的方法,另一种是使用系统默认的预处理器,编译器,汇编器。
目录
在ANSIC的任何一种实现中,存在两个不同的环境。
第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。
第2种是执行环境,它用于实际执行代码。
组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)。
每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。
链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中。
编译一个 C程序可以分为四阶段:预处理阶段 ---> 生成汇编代码阶段 ---> 汇编阶段 ---> 链接阶段。
gcc 指令的一般格式为:
gcc [选项] 要编译的文件 [选项] [目标文件] 其中,目标文件可缺省,gcc默认生成可执行的文件名为:a.out gcc main.c 直接生成可执行文件 a.out gcc -E main.c -o hello.i 生成预处理后的代码(还是文本文件) gcc –S main.c -o hello.s 生成汇编代码 gcc –c main.c -o hello.o 生成目标代码
C程序 目标文件和可执行文件 结构
目标文件和可执行文件可以有几种不同的格式,有ELF(Excutable and linking Format,可执行文件和链接)格式,也有COFF(Common Object-File Format,普通目标文件格式)。
虽然格式不一样,但具有一个共同的概念,那就是 段(segments),这里段指二进制格式文件中的一块区域。
linux下的可执行文件有三个段:( 可用 nm 命令查看目标文件的符号清单 )
预编译:主要处理那些源代码文件中的以 # 开始的预编译指令,如 #include、#define、#if,同时并删除注释行,还会添加行号和文件名标识,以便于编译时编译器产生调试用的行号信息,及用于编译时产生编译错误或警告时能够显示行号。
经过预编译的 .i 文件不包含任何宏定义,因为所有的宏已经被展开并且包含的文件也已经被插入到 .i 文件中。
所以当我们无法判断 宏定义是否正确 或 头文件包含是否正确 时,可以查看已编译后的文件来确认问题。比如:hello.c 中第一行的 #include
用法:#gcc -E main.c -o main.i作用:将main.c预处理输出main.i文件[user:test] lsmain.c[user:test] gcc -E main.c -o main.i[user:test] lsmain.c main.i
使用 gcc -E 参数完成。
预处理会干什么事情:
处理完成之后看看我们的 Hello.i,发现原来8行代码现在变成了接近700行,因为将
使用系统默认的预处理器 cpp 完成。
预处理除了使用 GCC -E 参数完成之外,我们还可以使用系统默认的预处理器 cpp 完成。如下所示
我们看看Hello.ii的代码:
虽然 Hello.i 和 Hello.ii 的代码对应的行数不同,但是内容却是一模一样的,只是中间空行的数量不同而已。
OK ,接下来,继续向编译出发。
gcc -S
编译是将 源文件 转换成 汇编代码 的过程,具体的步骤主要有:词法分析 ---> 语法分析 ---> 语义分析及相关的优化 ---> 中间代码生成 ---> 目标代码生成(汇编文件.s)。
具体生成过程可以参考《编译原理》。在这个阶段中,gcc 首先要检查代码的规范性、是否有语法错误等,以确定代码的实际要做的工作,在检查无误后,gcc 把代码翻译成汇编语言。
用户可以使用 -S 选项来进行查看,该选项只进行编译而不进行汇编,生成汇编代码。
选项 -S用法:[user]# gcc –S main.i –o main.s作用:将预处理输出文件main.i汇编成main.s文件。[user:test] lsmain.c main.i[user:test] gcc -S main.i -o main.s[user:test] lsmain.c main.i main.s
注意:gcc 命令只是一个后台程序的包装,会根据不同的参数要求去调用预编译编译程序cc1(c)、汇编器 as、连接器 ld。
使用 gcc -S 参数完成。
查看 Hello.s 发现已经是汇编代码了。
使用系统默认的编译器 cc1 完成这个过程。
前面的预处理命令 cpp
可能大家的系统上都有,我们输入cp
,然后 Tab
两下(Linux系统上表示提示补全命令),系统提示如下:
倒数第二个命令就是 cpp
了。但是我们 cc
同样的过程的时候却发现:
并没有 cc1
这个命令,但是 cc1
确实是 Linux
系统上默认的编译器呀,我们在系统上找找看:
看上图第二条,/usr/libexec/gcc/x86_64-redhat-linux/4.8.2/cc1
,尝试着去看下:
有可执行权限,那为何不试试能不能用来编译 Hello.ii
呢?
好像没有什么报错,迫不及待的看看 Hello.ss
的内容:
发现和 Hello.s
的是一样的。编译成功。
汇编阶段是把编译阶段生成的 ”.s” 文件转成二进制目标代码。汇编器(as)将 hello.s 翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,它的字节编码是机器语言指令而不是字符。如果我们在文本编译器中打开 hello.o 文件,看到的将是一堆乱码。
选项 -c用法:[user]# gcc -c main.s -o main.o作用:将汇编输出文件main.s编译输出main.o文件。[user:test] lsmain.c main.i main.s[user:test] gcc -c main.s -o main.o[user:test] lsmain.c main.i main.o main.s
使用 gcc -c 参数完成。
其实也可以查看下 Hello.o 的内容:
只是乱码罢了。要是想看,我们可以使用 hexedit, readelf 和 objdump 这三个工具。
hexedit 只是个将二进制文件用十六进制打开的工具,我们执行:
$ sudo yum install hexedit$ hexedit Hello.o
可以看到:
最右边是源文件被翻译成可见字符,点.表示的都是不可见字符。这样看当然没有多大实际意义,但是一些输出的字符串 Hello World,包括整个文件的类型 ELF 都是可以看到的。
readelf 和 objdump 我们后面再说。
使用系统默认的汇编器as完成。
hexedit 看看 :
使用 cmp 命令比较 Hello.oo 和 Hello.o
只有极少数字符不同。可能也是格式问题。
总结:上面的过程中,我们已经将 Hello.c 源程序经过预处理、编译、汇编阶段变成了二进制代码,这三个过程我们都是用两种方法完成的,一种是 GCC + 参数的方法,另一种是使用系统默认的预处理器,编译器,汇编器。这两种方法都达到了我们的目的,最后给它加上x权限。然后运行
chmod a+x a.out./a.out
这阶段就是把汇编后的机器指令集变成可以直接运行的文件,而对目标文件进行链接主要是因为在目标文件中可能用到了在其他文件当中定义的字段(或者函数),通过链接来把多个不同目标文件关联到一起。
比如:有2个目标文件 a 和 b,在 b 中定义了一个函数 "method",而在文件 a 中则使用到了b文件中的函数 "method",通过链接文件a才能调用到函数"method",不然文件a根本就不知道到函数 "method" 底做了些什么操作。
hello 程序调用了一个 printf 函数,它是每个 C 编译器都会提供的标准C库中的一个函数,printf 函数存在于一个名为 printf.o 的多带带预编译好了的标准文件中,而这个文件必须以某种方式合并到我们的 hello.o 程序中,链接器(ld)就负责处理这种合并,结果就得到 hello 文件,他是一个可执行目标文件(简称:可执行文件),可以被加载到内存中,有系统执行。
gcc的无选项的编译就是链接用法:[user]# gcc main.o -o main.elf作用:将编译输出文件main.o链接成最终可执行文件main.elf[user:test] lsmain.c main.i main.o main.s[user:test] gcc main.o -o main.elf[user:test] lsmain.c main.elf* main.i main.o main.s
模块之间的通信有两种方式:一种是模块间的函数调用,另一种是模块间的变量访问。函数访问需知道目标函数的地址,变量访问也需要知道目标变量的地址,所以这两种方式都可以归结为一种方式,那就是模块间符号的引用。模块间依靠符号来通信类似于拼图版,定义符号的模块多出一块区域,引用该符号的模块刚好少了那一块区域,两者一拼接刚好完美组合。这个模块的拼接过程就是“链接”。
在链接中,函数和变量统称为符号(symbol),函数名或变量名就是符号名(symbol name)。可以将符号看做是链接中的粘合剂,整个链接过程正是基于符号才能够正确完成。链接过程中很关键的一部分就是符号的管理,每一个目标文件都会有一个相应的符号表(symbol table),这个表里面记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值,叫做符号值(symbol value),对于变量和函数来说,符号值就是它们的地址。符号表中所有的符号分类:
链接过程主要包括了地址和空间分配、符号决议和重定位。符号决议有时候也叫做符号绑定、名称绑定、名称决议,甚至还有叫做地址绑定、指令绑定,大体上它们的意思都一样,但从细节角度来区分,它们之间还存在一定区别,比如“决议”更倾向于静态链接,而“绑定”更倾向于动态链接,即它们所使用的范围不一样。
每个目标文件都可能定义一些符号,也可能引用到定义咋其他目标文件的符号。重定位的过程中,每个重定位的入口都是对一个符号的引用,那么当链接器须要对某个符号的引用重定位时,它就是要确定这个符号的目标地址。这时候链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。
看代码:
sum.c
int g_val = 2016;void print(const char *str){printf("%s/n", str);}
test.c
#include int main(){extern void print(char *str);extern int g_val;printf("%d/n", g_val);print("hello bit./n");return 0;}
如何查看编译期间的每一步发生了什么呢?
test.c
#include int main(){int i = 0;for(i=0; i<10; i++){printf("%d ", i);}return 0;}
1. 预处理 选项 gcc -E test.c -o test.i
预处理完成之后就停下来,预处理之后产生的结果都放在test.i文件中。
2. 编译 选项 gcc -S test.c
编译完成之后就停下来,结果保存在test.s中。
3. 汇编 gcc -c test.c
汇编完成之后就停下来,结果保存在test.o中。
程序执行的过程:
1. 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序
的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
2. 程序的执行便开始。接着便调用main函数。
3. 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回
地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程
一直保留他们的值。
4. 终止程序。正常终止main函数;也有可能是意外终止。
__FILE__ //进行编译的源文件__LINE__ //文件当前的行号__DATE__ //文件被编译的日期__TIME__ //文件被编译的时间__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义
这些预定义符号都是语言内置的。
举个栗子:
printf("file:%s line:%d/n", __FILE__, __LINE__);
语法:#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__ )
在define定义标识符的时候,要不要在最后加上 ; ?
比如:
#define MAX 1000;#define MAX 1000
建议不要加上 ; ,这样容易导致问题。
比如下面的场景:
if(condition)max = MAX;elsemax = 0;
这里会出现语法错误。
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定
义宏(define macro)。
下面是宏的申明方式:
#define name( parament-list ) stuff
其中的 parament-list 是一个由逗号隔开的符号表,它们可能出现在stuff中。
注意:
参数列表的左括号必须与name紧邻。
如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。
如:
#define SQUARE( x ) x * x
这个宏接收一个参数 x .
如果在上述声明之后,你把
SQUARE( 5 );
置于程序中,预处理器就会用下面这个表达式替换上面的表达式:
5 * 5
警告:
这个宏存在一个问题:
观察下面的代码段:
int a = 5;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) );
这里还有一个宏定义:
#define DOUBLE(x) (x) + (x)
定义中我们使用了括号,想避免之前的问题,但是这个宏可能会出现新的错误。
int a = 5;printf("%d/n" ,10 * DOUBLE(a));
这将打印什么值呢?
warning:
看上去,好像打印100,但事实上打印的是55.
我们发现替换之后:
printf ("%d/n",10 * (5) + (5));
乘法运算先于宏定义的加法,所以出现了 55
这个问题,的解决办法是在宏定义表达式两边加上一对括号就可以了。
#define DOUBLE( x) ( ( x ) + ( x ) )
提示:
所以用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数
中的操作符或邻近操作符之间不可预料的相互作用。
在程序中扩展#define定义符号和宏时,需要涉及几个步骤。
1. 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先
被替换。
2. 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值替换。
3. 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上
述处理过程。
注意:
1. 宏参数和#define 定义中可以出现其他#define定义的变量。但是对于宏,不能出现递归。
2. 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
如何把参数插入到字符串中?
首先我们看看这样的代码:
char* p = "hello ""bit/n";printf("hello"," bit/n");printf("%s", p);
这里输出的是不是 hello bit ?
答案是确定的:是。
我们发现字符串是有自动连接的特点的。
1. 那我们是不是可以写这样的代码?:
#define PRINT(FORMAT, VALUE)/printf("the value is "FORMAT"/n", VALUE);...PRINT("%d", 10);
这里只有当字符串作为宏参数的时候才可以把字符串放在字符串中。
1. 另外一个技巧是:
使用 # ,把一个宏参数变成对应的字符串。
比如:
int i = 10;#define PRINT(FORMAT, VALUE)/printf("the value of " #VALUE "is "FORMAT "/n", VALUE);...PRINT("%d", i+3);//产生了什么效果?
代码中的 #VALUE 会预处理器处理为:
"VALUE" .
最终的输出的结果应该是:
the value of i+3 is 13
## 的作用
##可以把位于它两边的符号合成一个符号。
它允许宏定义从分离的文本片段创建标识符
#define ADD_TO_SUM(num, value) /sum##num += value;...ADD_TO_SUM(5, 10);//作用是:给sum5增加10.
注:
这样的连接必须产生一个合法的标识符。否则其结果就是未定义的。
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能
出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果。
例如:
x+1;//不带副作用x++;//带有副作用
MAX宏可以证明具有副作用的参数所引起的问题。
#define MAX(a, b) ( (a) > (b) ? (a) : (b) )...x = 5;y = 8;z = MAX(x++, y++);printf("x=%d y=%d z=%d/n", x, y, z);//输出的结果是什么?
这里我们得知道预处理器处理之后的结果是什么:
z = ( (x++) > (y++) ? (x++) : (y++));
所以输出的结果是:
x=6 y=10 z=9
宏通常被应用于执行简单的运算。比如在两个数中找出较大的一个。
#define MAX(a, b) ((a)>(b)?(a):(b))
那为什么不用函数来完成这个任务?
原因有二:
1. 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比
函数在程序的规模和速度方面更胜一筹。
2. 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之
这个宏怎可以适用于整形、长整型、浮点型等可以用于>来比较的类型。宏是类型无关的。
当然和宏相比函数也有劣势的地方:
1. 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序
的长度。
2. 宏是没法调试的。
3. 宏由于类型无关,也就不够严谨。
4. 宏可能会带来运算符优先级的问题,导致程容易出现错。
宏有时候可以做函数做不到的事情。比如:宏的参数可以出现类型,但是函数做不到。
#define MALLOC(num, type)/(type *)malloc(num * sizeof(type))...//使用MALLOC(10, int);//类型作为参数//预处理器替换之后:(int *)malloc(10 * sizeof(int));
宏和函数的一个对比
属 性 | #define定义宏 | 函数 |
代 码 长 度 | 每次使用时,宏代码都会被插入到程序中。除了非 常小的宏之外,程序的长度会大幅度增长 | 函数代码只出现于一个地方;每 次使用这个函数时,都调用那个 地方的同一份代码 |
执 行 速 度 | 更快 | 存在函数的调用和返回的额外开 销,所以相对慢一些 |
操 作 符 优 先 级 | 宏参数的求值是在所有周围表达式的上下文环境 里,除非加上括号,否则邻近操作符的优先级可能 会产生不可预料的后果,所以建议宏在书写的时候 多些括号。 | 函数参数只在函数调用的时候求 值一次,它的结果值传递给函 数。表达式的求值结果更容易预 测。 |
带 有 副 作 用 的 参 数 | 参数可能被替换到宏体中的多个位置,所以带有副 作用的参数求值可能会产生不可预料的结果。 | 函数参数只在传参的时候求值一 次,结果更容易控制。 |
参 数 类 型 | 宏的参数与类型无关,只要对参数的操作是合法 的,它就可以使用于任何参数类型。 | 函数的参数是与类型有关的,如 果参数的类型不同,就需要不同 的函数,即使他们执行的任务是 不同的。 |
调 试 | 宏是不方便调试的 | 函数是可以逐语句调试的 |
递 归 | 宏是不能递归的 | 函数是可以递归的 |
命名约定
一般来讲函数的宏的使用语法很相似。所以语言本身没法帮我们区分二者。
那我们平时的一个习惯是:
把宏名全部大写
函数名不要全部大写
这条指令用于移除一个宏定义。
#undef NAME//如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除。
许多C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。
例如:当我们根据同一个源文件要编译出不同的一个程序的不同版本的时候,这个特性有点用处。(假
定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一
个机器内存大写,我们需要一个数组能够大写。)
#include int main(){int array [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;}
编译指令:
gcc -D ARRAY_SIZE=10 programe.c
在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件
编译指令。
比如说:
调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。
#include #define __DEBUG__int main(){int i = 0;int arr[10] = {0};for(i=0; i<10; i++){arr[i] = i;#ifdef __DEBUG__printf("%d/n", arr[i]);//为了观察数组是否赋值成功。#endif //__DEBUG__}return 0;}
常见的条件编译指令
1.#if 常量表达式//...#endif//常量表达式由预处理器求值。如:#define __DEBUG__ 1#if __DEBUG__//..#endif2.多个分支的条件编译#if 常量表达式//...#elif 常量表达式//...#else//...#endif3.判断是否被定义#if defined(symbol)#ifdef symbol#if !defined(symbol)#ifndef symbol4.嵌套指令#if defined(OS_UNIX)#ifdef OPTION1unix_version_option1();#endif#ifdef OPTION2unix_version_option2();#endif#elif defined(OS_MSDOS)#ifdef OPTION2msdos_version_option2();#endif#endif
我们已经知道, #include 指令可以使另外一个文件被编译。就像它实际出现于 #include 指令的地方
一样。
这种替换的方式很简单:
预处理器先删除这条指令,并用包含文件的内容替换。
这样一个源文件被包含10次,那就实际被编译10次。
本地文件包含
#include "filename"
查找策略:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标
准位置查找头文件。
如果找不到就提示编译错误。
Linux环境的标准头文件的路径:
/usr/include
VS环境的标准头文件的路径:
C:/Program Files (x86)/Microsoft Visual Studio 12.0/VC/include
注意按照自己的安装路径去找。
库文件包含
#include
查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。
这样是不是可以说,对于库文件也可以使用 “” 的形式包含?
答案是肯定的,可以。
但是这样做查找的效率就低些,当然这样也不容易区分是库文件还是本地文件了。
如果出现这样的场景:
comm.h和comm.c是公共模块。
test1.h和test1.c使用了公共模块。
test2.h和test2.c使用了公共模块。
test.h和test.c使用了test1模块和test2模块。
这样最终程序中就会出现两份comm.h的内容。这样就造成了文件内容的重复。
如何解决这个问题?
答案:条件编译。
每个头文件的开头写:
#ifndef __TEST_H__#define __TEST_H__//头文件的内容#endif //__TEST_H__
或者:
#pragma once
就可以避免头文件的重复引入。
文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。
转载请注明本文地址:https://www.ucloud.cn/yun/122325.html
摘要:程序预处理本章节研究的是,源代码文件是如何一步步得到一个可执行程序的。如的语句被称为预处理指令,还有注释文本的删除,都在此阶段完成替换。目的是能够将所有文件中的代码组合到一起成一个完整的程序。终止程序可以正常也可以意外终止程序。 ...
摘要:如的语句被称为预处理指令,还有注释文本的删除,都在此阶段完成替换。故宏在程序规模和执行速度方面更胜一筹。宏替换发生在预编译期间,故无法调试。宏可能由于运算符优先级的问题,会导致程序出错。 ...
阅读 3271·2023-04-26 02:10
阅读 2891·2021-10-12 10:12
阅读 4592·2021-09-27 13:35
阅读 1530·2019-08-30 15:55
阅读 1074·2019-08-29 18:37
阅读 3435·2019-08-28 17:51
阅读 1967·2019-08-26 13:30
阅读 1207·2019-08-26 12:09