一、翻译环境与运行环境
1.源代码运行的整体过程
当我们在源文件中写完代码时,通过翻译环境中的编译(预处理、编译、汇编)操作从而得到目标文件(.obj),接着通过翻译环境中的链接操作得到可执行程序(.exe)(即得到机器指令也就是2进制指令程序),然后通过执行/运行环境就可以在屏幕上看到代码的运行结果。
注:windows下目标文件后缀.obj,Linux下目标文件后缀.o。
在C语言标准实现中,存在两种环境:翻译环境和运行环境。翻译环境中包含编译和链接两大操作,编译又包含预编译/预处理、编译、汇编三个操作阶段,翻译环境用来将源代码转换为机器指令(2进制指令)的exe程序;运行环境旨在让已经转换的2进制指令程序正常运行得到结果。
我们的编译操作在编译器上进行,链接操作在链接器上完成。
翻译环境下,每个源文件在翻译器中被单独处理为对应的目标文件,所有目标文件同链接库一起在链接器中链接得到二进制指令的可执行程序;
运行环境下,将二进制指令的可执行程序运行,得到结果。
我们可以在VS中看看预处理的效果:
然后我们选择重新生成解决方案,到源文件目录下找一下.i的文件:
2.程序执行的过程
① 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
② 程序的执行便开始。接着便调用main函数(找到main的二进制表现形式)。
③ 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回 地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程 一直保留他们的值。
④终止程序。正常终止main函数;也有可能是意外终止。
二、符号汇总--->符号表形成--->符号表合并与重定位详解
我们在前面讲到了,在编译阶段的编译操作下会将C语言代码转换为汇编语言,进行的四个操作为词法语法语义分析以及符号汇总;在编译阶段的汇编操作下会将汇编语言转换为二进制指令的目标文件,形成符号表;在链接阶段会将二进制指令的目标文件转换为可执行程序,进行段表的合并和符号表的合并、重定位两个操作。
那么在这里,主要解释一下有关符号的这几个操作:
符号汇总--->符号表形成--->符号表合并与重定位。
对于很多的项目而言,源文件都是不止一个的,可能会在一个源文件中使用到另一个源文件的代码,这个时候就与符号以及符号表密不可分了。我们这里假如在Function.c的源文件中定义一个和Add函数,在我们的test.c源文件中使用extern声明该函数并在主函数中使用它:
源文件Function.c:
int Add(int x, int y)
{
return x + y;
}
源文件test.c:
#include<stdio.h>
//在test.c源文件中使用Function源文件中的Add函数
extern int Add(int, int);
//使用extern声明一下Add函数
int main()
{
int a = 0;
int b = 10;
printf("%d\n", Add(a, b));//想在主函数中使用Add
return 0;
}
段表合并是对各个目标文件(.obj/.o)相同段落位置处的数据进行合并,最后得到可处理文件(.exe)
以图表的形式展现:
如果Function.c中的Add定义成了ADD,那么符号表的合并就是4行了,也就是说Add和ADD是两个不同的符号,对于Add而言,它的地址2仍然是个无意义地址。
编译器在查找函数时,都是根据符号表中函数符号与地址的对应关系来查找函数的。
三、预处理详解
注:include、define等并不是关键字,#include与#define等是预处理指令!
1.预定义符号
不需要前提可以直接使用
__FILE__ ------进行编译的源文件
__LINE__ ------文件当前行号
__DATE__ ------文件被编译的日期
__TIME__ ------文件被编译的时间
__STDC__ ------编译器如果遵循ANSI C标准--->值为1,不遵循--->未定义
__FUNCTION__ ------文件当前在哪个函数下
上图说明VS2022不遵循ANSI C标准(可能遵循99%)。
我们看看其他的预定义符号:
2.#define重定义
①#define定义标识符
#define可以将符号重定义为各种数
#define MAX 500
#define MIN -500
#define INT int
#define STR "abcd"
#define whi while(1)
有关#define定义标识符的有意思的用法:
//关于#define定义标识符的好玩用法
#define CASE break;case
int main()
{
int input = 0;
scanf("%d", &input);
switch (input)
{
case 1:
CASE 2 :
CASE 3 :
CASE 4 :
CASE 5 :
;
}
return 0;
}
使用#define,末尾不需要分号,预处理指令末尾都不需要分号。
如果不小心给了分号,可能会出现问题,举例如下图:
当我们用前面讲过的,单独看预处理后的这个源.i这个文件,也许我们就能够轻松发现问题:
由于预处理(预编译)阶段会帮助我们处理文本,写出了包含的全部头文件内容,将#define的符号全部替换成值,将注释替换为空格,因此我们就能够看到,这个分号;简直是罪大恶极了。
②#define定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)。
声明方式:
#define macro(...,...) number
macro后的括号中是一个包含逗号的符号表,可以理解为类似于函数参数的形式,#define将这个macro(...,...)的宏替换为后面的值number,number可能是一个综合计算式,其中可能会包含传入macro宏中的参数。
macro左侧的括号必须紧邻macro,否则括号与其后的内容都会被#define当作值number的一部分!
我们在number的设置时,尽量地多使用括号来确定优先计算关系。
不管是使用#define定义标识符还是宏,归根结底都是替换,不会参与实际计算!
#define定义宏同样末尾不需要分号;
例:使用#define定义一个加法宏ADD:
复杂计算式要对值进行多重括号来保证运行的有效性与正确:
我们使用括号来约束:
③#define替换规则
1. 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换。即#define定义宏的参数中出现#define定义符号,先替换。
2. 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。即把宏的替换置入程序中相应位置。
3. 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程。
注:#define定义宏,宏的参数中可以出现#define定义的其他符号,但是是不允许递归的。同时,字符串中出现了某个字符或者子串是#define定义的符号,这个时候并不会进行替换。
④#和##
#:参数---->字符串形式:#宏的参数---->"参数",即将参数转换为字符串的形式。
##:将##两边的符号合成为一个符号:a##10---->a10,将两个符号合成为一个符号,前提是合成后的符号确实有定义是合法的符号。
我们在使用打印的时候,很多时候会有重复的操作,这个时候我们可以使用#define定义PRINT宏来完成简易操作:
如果按下图打印,我们发现重复,写的比较繁杂。
那么可以使用#define定义一个PRINT宏来完成:
对于##而言,我们在#define中定义宏时,值中使用##号来对两端符号进行合成:
⑤带副作用的宏参数
我们知道#define重定义只是进行替换而不进行计算,那么当我们使用宏替换时,如果传入的参数带有自增、自减等操作,会改变参数本身的值,那么就相当于传入了带有副作用的宏参数。特别是当这个替换成值的这个部分,如果含有多个副作用的宏参数,可能会产生无法预料的后果。
x++;
y--;
//自增自减操作---带有永久改变自身大小的副作用
那我们举一个两数最大值的例子:
正常情况下,我们在MAX这个宏中传入没有副作用的两个参数:
#define MAX(x,y) ((x)>(y)?(x):(y))
int a = 10;
int b = 5;
int c = MAX(a, b);
printf("%d\n", c);
return 0;
那么如果传入的是a++和b++呢?
#define MAX(x,y) ((x)>(y)?(x):(y))
int a = 10;
int b = 5;
int c = MAX(a++, b++);
printf("%d\n", c);
return 0;
由于#define定义宏是一个替换操作,它只负责把值的这个表达式替换到符号上,不负责任何的计算,因此,对于有副作用的参数,我们在遇到这种问题时要额外谨慎!
那么这里其实也反映出,宏其实是不便于进行调试的,因为你所看到的这个宏,与调试时编译器所使用到的替换后的这个表达式其实有区别的,不是很方便直接观察调试信息。
⑥宏与函数
其实从上面对于宏的讲解,我们可以了解到,如果我们需求一个较简单的计算式,使用宏无疑比函数更为方便,但是这只是其中的一方面的比较。
在时间开销上,定义宏使用宏,只需要完成目标的计算式,但是对于函数而言,我们不仅需要完成计算式,我们还需要调用函数、传递参数、创建函数栈帧,同时最后计算完会函数返回,在时间开销上比宏更多。
同时,宏是不受类型限制的,是类型无关的。如上方用到的这个 ((x)>(y)?(x):(y)) ,只要传入的参数x,y能够用>号进行比较,无论是int、long int还是float等类型,均可以传入我们的宏,但是对于函数而言,函数的参数是必需声明特定类型的。我们对于函数的定义是要求类型的,如果我们想要快捷地对于多个类型数据进行一个判断,使用函数无疑是较麻烦的。
宏也可以做到函数无法做到的事情---宏参数中传入类型:如malloc函数的简化
上面所说无疑是宏的优点,但是宏自身也是存在缺点的:
1.如果定义宏太冗长,会导致程序代码的增长:每次使用宏时,一份宏定义的代码就会插入到程序中,如果宏定义冗长,会大幅度增加程序代码的长度。
2.宏不受类型限制,这是不够严谨的。
3.宏不便于进行调试。
4.宏的使用,由于由#define定义,本质上是替换,所以设计到了计算式的操作符优先级,如果设计不够全面,括号没有给足,很可能导致运算优先级出现问题,使结果发生不可预料的错误。
5.宏的使用上,如果不小心传入了带有副作用的参数,也是很可能出现问题的。而函数由于在调用时就会对于参数进行计算,因此传入的一般都是计算后的结果,也就没有副作用一说了。
下面给出宏与函数的区分表:
属性 | #define定义宏 | 函数 |
代码长度 | 每次使用宏,宏代码会插入到程序中,除了非常小的宏以外,程序长度会大幅度增长 | 函数代码只存在于一处,每次使用在该处调用即可 |
执行速度 | 更快,只需要执行计算 | 相对较慢,需要函数调用、参数传递、栈帧创建、计算执行以及函数返回,存在额外时间开销 |
操作符优先级 | 宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,建议宏在书写的时候多些括号 | 函数传递的参数在调用时求值一次,将结果传递给函数,不会产生优先级的问题 |
带有副作用的参数 | 参数可能被替换到宏体中的多个位置,所以带有副作 用的参数求值可能会产生不可预料的结果 |
函数参数只在传参的时候求值一
次,不会产生副作用
|
参数类型 | 宏没有类型限制,相对而言不严谨,只要对参数操作合法,符合值的表达式判断规则,可以使用任何类型 | 参数需要严格的类型声明,虽然默认返回类型void,但是最好返回值也进行类型声明 |
调试 | 宏不便于进行调试,替换前与替换后的式子可能存在不便于观察的细节 | F11进入函数内部逐语句调试 |
递归 | 宏无法递归 | 函数可以递归 |
⑦命名约定
一般而言,#define定义宏,我们将宏的名称全大写如MAX,而定义函数,将函数名部分大写如Max。
//命名约定---宏名称全大写;函数名称部分大写
#define MAX(a,b) ((a)>(b)?(a):(b))
int Max(int x, int y)
{
return x > y ? x : y;
}
3.#undef
就是去除重定义:
4.命令行定义
许多C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。
例如:当我们根据同一个源文件要编译出不同的一个程序的不同版本的时候,这个特性有点用处。假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器内存大写,我们需要一个数组能够大写。
在gcc中可以使用命令行定义设置数组长度。
//命令行定义
//VS下无法演示
int main()
{
int arr[sz];
int i = 0;
for (i = 0; i < sz; i++)
{
arr[i] = i;
}
for (i = 0; i < sz; i++)
{
printf("%d ", arr[i]);
}
return 0;
}
//gcc中设置
gcc test.c -D sz=100//设置为100个元素
5.条件编译
我们在编译的时候可以设置条件来选择性地编译,这就是条件编译。
条件编译有一些形式:
#if 常量表达式
//条件编译内容
#endif
#if 常量表达式
//......编译内容
#elif 常量表达式
//......编译内容
#elif 常量表达式
//......编译内容
#else
//......编译内容
#endif
#if defined(Macro)
//......编译内容
#endif
#ifdef Macro
//......编译内容
#endif
#if defined(Macro) 与#ifdef Macro是等效的。表示#define重定义了Macro则为真,否则为假。
#if !defined(Macro)
//......编译内容
#endif
#ifndef Macro
//......编译内容
#endif
#if !defined(Macro) 与#ifndef Macro是等效的。表示#define没有定义Macro则为真,定义了则为假。
#if defined(Macro1)
#ifndef M
#define M 20
printf("%d\n", M);
#endif
#elif defined(Macro2)
#ifndef N
#define N 30
printf("%d\n", N);#endif
#endif
嵌套条件编译如上:
那么如果我们只重定义Macro1,那么就只会编译上半:
如果我们只重定义Macro2,那么就编译下半:
6.文件的包含
头文件的包含使用#include指令,有两种包含方式,分别是:
#include<xxx.h>
#include"xxx.h"
#include<xxx.h> :使用尖括号时,编译器直接在标准库路径下寻找头文件xxx.h。找不到则报错无法打开头文件xxx.h。
#include"xxx.h":使用双引号时,编译器首先在源文件路径下寻找头文件xxx.h,若没有找到,编译器会选择再去标准库路径下寻找xxx.h,最终也没有的话,报错,错误是无法打开头文件xxx.h。
Linux环境下标准头文件路径:
/ usr / include
VS 环境的标准头文件的路径:视安装路径而定(可以直接通过everything寻找stdio.h,找到路径)
一般而言,库函数中的头文件,我们使用< >来包含,我们自己创建的头文件使用" "来包含。
虽然库函数的头文件能够使用双引号 " " 来包含,但是会降低效率,使用双引号会搜索两次,而尖括号只需要一次就能够找到库函数的头文件,同时,这样用久了,我们也会分不清楚需要的头文件到底在源路径还是在库函数路径下。因此还是建议按照规则来。库函数头文件使用尖括号< >,自己创建的头文件使用双引号" "。
嵌套文件包含
在项目中,一般设计多个程序员,写的多个源文件与头文件,如果对于程序员A的头文件,程序员B想要引入进行使用,同时程序员C也引入了A的头文件进行使用,那么在最终的汇总时,其实两个源文件中均对于A的头文件进行了引入,但是实际上,只需要引入一次就能够使用A的头文件了,这个时候我们会在头文件中进行条件编译,来防止多次引入同一个头文件的重复引入操作。
如上图,如果不进行处理,会造成严重的内容重复!
那么如何处理呢?通过条件编译处理
方法一:我们给每个头文件附上一个如下的条件编译:
#ifndef __test_h__//头文件名
#define __test_h__//头文件名
int Add(int, int);//头文件中内容
#endif
方法二:在头文件中使用#pragma once
#pragma once
那么以上就是两种避免头文件重复引入的方法了,使用方法二是比较常见的方法。
提一嘴,其实我们在前面结构体内存对齐时,讲到了#pragma pack()调整编译器默认对齐数。
头文件中的 ifndef / define / endif 是干什么用的?------是用来防止头文件重复引入的。
#include <filename.h> 和 #include "filename.h"有什么区别?------尖括号表示编译器从库函数路径下查找头文件filename.h;双引号表示编译器先从源文件路径下查找头文件filename.h,若无法找到,再到库函数路径下查找是否有头文件filename.h。