文章目录
- 0. 前言
- 1. 程序的翻译环境和执行环境
- 2. 被隐藏的过程
- 2.1 翻译环境
- 2.2 编译
- 3.2.1 预编译
- 3.2.2 编译
- 2.2.3 汇编
- 2.3 链接
- 2.4 运行环境
- 3. 预处理
- 3.1 预定义符号
- 3.2 #define
- 3.2.1 #define定义标识符
- 3.2.2 #define定义宏
- 3.2.3 #define替换规则
- 3.2.4 #和##
- 3.2.5 带副作用的宏参数
- 3.2.6 宏和函数对比
- 3.2.7 命名约定
- 3.3 undef
- 3.4 命令行定义
- 3.5 条件编译
- 3.6 文件包含
- 3.6.1 头文件被包含的方式
- 3.6.2 嵌套文件包含
- 4. 结语
0. 前言
现在的我们写代码大多数用的集成开发环境(IDE),比如Visual Studio、Idea等,这样的IDE一般都将编译和链接的过程一步完成。一句简简单单的Hello World,在我们看来,这一步到位,小菜一碟。可是一句话说的好不是岁月静好,只是有人在替你负重前行。这里面一些复杂的过程,集成工具已经默默的处理掉了。可是当我们写的程序出了一些莫名其妙的错误,让我们头大且掉发,我们只能看到这些问题的表象,难以看清本质,这些问题的本质就是软件运行背后的机理及支撑软件运行的各种平台和工具,如果能够了解这些机制,那么对待这些问题,就会有新的看法。
1. 程序的翻译环境和执行环境
在ANSI C存在两个不同的环境:
- 翻译环境,在这个环境中源代码被转换为可执行的机器指令。
- 执行环境,实际用于执行代码。
我们知道,计算机只能执行二进制指令,但我们一般写的代码,都不是以二进制形式写,以C语言为例:
#include<stdio.h>
int main()
{
printf("C 语言\n");
return 0;
}
这段C语言代码,如果要执行,那么就需要翻译环境将其翻译为二进制指令。我们所使用的一些编译器,就充当着"翻译官"的角色。当然了将我们的源代码,转换成可执行程序,这个翻译的过程能细分为2个步骤编译和链接。
2. 被隐藏的过程
2.1 翻译环境
组成一个程序的每个源文件通过编译过程转换成目标代码。
每个目标文件由链接器捆绑在一起,形成一个单一而完整的可执行程序。
链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且也可以搜索到我们自己写的函数,将需要的函数也链接到程序中。
2.2 编译
编译可细分为三个小的过程:
3.2.1 预编译
首先是源代码文件和相关的头文件,被编译成一个 .i 文件。对于C程序来说,它的源文件拓展名是 .c,头文件拓展名是 .h ,而预编译后的文件拓展名是 .i 。
预编译命令(-E表示只进行预编译):
$ gcc -E test.c -o test.i
预编译过程主要处理那些源代码文件中的以 **#**开始的预编译指令。主要处理规则如下:
- 将所有的 #define 删除,并且展开所有的宏定义。
- 处理 #include 预编译指令,将被包含的文件插入到该预编译指令的位置。
- 删除所有的注释 // 和 / ** /。
- 添加行号和文件标识,比如 #7 “test.h” 2,以便于编译时编译器产生调试用的行号信息及用于产生编译错误或警告时能产生行号。
- 报了所有的 #pragma 编译器指令,因为编译器要使用他们。
经过预编译后,.i 文件不包含任何宏定义,因为所有的宏定义已经被展开,并且包含的文件也已经被插入到 .i 。所以无法判断宏定义和头文件是否包含正确,那么接下来就是通过查看编译后的文件进行判断。
3.2.2 编译
编译过程就是把预处理完的文件进行一系列的词法分析、语法分析、语义分析、符号汇总及优化后产生相应的汇编代码文件,这是这个程序构建的核心部分。
编译命令:
$ gcc -S test.i
2.2.3 汇编
汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎对应一条机器指令。所以汇编器的汇编过程相当于编译器来讲比较简单,它没有复杂的语法,也不用做优化指令,只根据汇编指令和机器指令的对照表一一翻译就可以了。原来汇编才是一个血统纯正的“翻译官”啊,不带任何“感情色彩”。
汇编命令:
$ gcc -c test.s
汇编完成后输出目标文件、将汇编代码翻译成二进制代码(存放到目标文件中),同时形成符号表。
2.3 链接
现在软件开发过程中,软件的规模往往都很大,动辄数百万行的代码,如果将这些代码全部都放在一个模块肯定无法想象。所以我们一般在写代码的时候,会分模块,这些模块之间相互依赖又相互独立。
那么链接就能将这些模块拼接起来,最后产生一个可执行的程序。
2.4 运行环境
程序执行的过程:
- 程序必须载入到内存中。在有操作系统的环境中,这个一般由操作系统来完成。在独立的环境中,程序的载入必须手工来安排,也可能是通过可执行代码置入只读内存来完成。
- 程序的执行便开始,接着便调用main函数。
- 开始执行程序代码。这时候程序将使用一个运行时堆栈,存储函数的局部变量和返回地址。程序同时也可以使用静态内存,存储于静态内存中的变量会一直保留。
- 终止程序。正常终止main函数,也可能意外终止,程序挂掉了。
3. 预处理
3.1 预定义符号
int main()
{
printf("%s\n", __FILE__);//进行编译的源文件的路径
printf("%d\n", __LINE__);//文件当前行号
printf("%s\n", __DATE__);//文件被编译的日期
printf("%s\n", __TIME__);//文件被编译的时间
//VS2022不支持
printf("%s\n", __STDC__);//如果编译器遵循ANSI C,其值为1,否则未定义
return 0;
}
通过gcc编译器可以发现,这些确实是在预编译阶段,就完成了替换
3.2 #define
3.2.1 #define定义标识符
语法:
#define name stuff
代码示例:
#define MAX 100
#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 的内容将会被替换,加上 ; 会造成不必要的麻烦。
比如下面的场景:
#define MAX 100;
int main()
{
int m = 0;
if (m >= 0)
m = MAX;
else
m = -1;
return 0;
}
我们通过gcc编译器可以看到在预编译阶段,100 后面的 ==;==也被添加上去了,导致else匹配不到if语句。
3.2.2 #define定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种通常称为宏或者定义宏。
宏的声明方式:
#define name( parament-list ) stuff
注意:
- 其中的partment-list是一个逗号隔开的符号表,它可能出现在sturff中。
- 参数列表的左括号必须与name紧邻。
- 如果两者之间有任何空白存在,参数列表就会解释为stuff的一部分。
代码示例:
#define SQUARE(X) X*X //求一个数的平方
int main()
{
printf("%d\n", SQUARE(5));
printf("%lf\n", SQUARE(5.0));
return 0;
}
但是呢,这段代码还存在一定的风险,如果在宏里面输入的是 (5+1),那么就会被替换成 5 + 1 * 5 + 1,这样输出的值就和我们原本的意愿不符合。
这里,我们在宏定义上加括号,就能很好的解决问题。
代码示例:
#define SQUARE(X) (X)*(X) //求一个数的平方
int main()
{
printf("%d\n", SQUARE(5+1));
return 0;
}
//输出 36
那这样就真的避免了风险吗?当然,避免了刚才出的问题,但是又产生了新的问题。
代码示例:
#define DOUBLE(X) (X)+(X) //求一个数的平方
int main()
{
printf("%d\n", 10*DOUBLE(5));
return 0;
}
//输出 55
我们原意是 10 * (5 + 5),可是这里替换成了 10 * 5 + 5,又违背了我们的意愿。
这个问题的解决办法是在宏定义表达式两边加上一对括号就可以了。
#define DOUBLE(X) ((X)+(X)) //求一个数的平方
int main()
{
printf("%d\n", 10 * DOUBLE(5));
return 0;
}
//输出 100
小贴士:
- 以用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中的操作符或邻近操作符之间不可预料的相互作用。
3.2.3 #define替换规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤。
- 在调用宏时,首先对参数进行检查,看看是否包含由 #define 定义的符号。如果是,它们首先被替换。
- 替换后的文本随后被插入到程序原来的位置。对于宏,参数名被他们的值所替换。
- 最后,再次对结果文件进行扫描,看看是否包含由 #define 定义的符号。如果是,重复上述过程。
注意:
1.宏参数和 #define 定义中可以出现其他 #define 定义的符号。但是对于宏,不能出现递归。
2.当预处理器搜索 #define 定义的符号的时候,字符串常量的内容并不被搜索。
3.2.4 #和##
我们先来看这段代码:
int main()
{
printf("hello world\n");
printf("hello " "world\n");
return 0;
}
这两句printf输出的内容其实都是一样的hello world,那我们就能得出结论:字符串是有自动连接特点的。
有这个结论后,我们就可以这样写代码:
#define PRINT(format,x) printf("the value of "#x" is "format"\n",x)
int main()
{
int a = 10;
PRINT("%d", a);
float b = 1.5;
PRINT("%f", b);
return 0;
}
这里 # 的作用就是把一个宏参数变成对于的字符串。
##的作用
##可以把位于它两边的符号合成一个符号。
它允许宏定义从分离的文本片段创建标识符。
注:这样的连接必须产生一个合法的标识符。否则其结果就是未定义的。
代码示例:
#define CAT(x,y) x##y
// RMB##100
// RMB100
int main()
{
int RMB100 = 20;
printf("%d", CAT(RMB, 100));
return 0;
}
3.2.5 带副作用的宏参数
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果。
例如:
int main()
{
int a = 1;
int b = a + 1;//无副作用
int c = ++a; //有副作用
return 0;
}
同理,下面这段代码就能充分证明宏参数所引起的副作用
#define MAX(x,y) ((x)>(y)?(x):(y))
int main()
{
//printf("%d\n", MAX(2, 3));
int a = 4;
int b = 5;
int m = MAX(a++, b++);
//预处理之后
//int m = ((a++)>(b++)?(a++):(b++));
// 5 6 7
printf("%d\n", m);
printf("a = %d b = %d\n", a,b);//5 7
return 0;
}
3.2.6 宏和函数对比
宏通常用于执行简单的运算(两数中求大值)。
那用函数求,和这个有什么区别呢?
- 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹。
- 更为重要的是函数的参数必须声明为特定的类型。
所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以用于来比较的类型。
宏是类型无关的。
当然了,现在写代码时,大部分还是写函数,很少写宏。
宏的缺点:
- 宏定义是插入代码中,除非宏比较短,否则将大幅度增大程序的长度。
- 宏在预处理就替换了,无法调试发现问题。
- 宏与类型无关,自然也就不够严谨(双刃剑)。
- 宏会带来运算符优先级的问题,容易导致出错。
宏和函数的一个对比:
属性 | #define宏定义 | 函数 |
---|---|---|
代码长度 | 每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长 | 函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码 |
执行速度 | 更快 | 存在函数的调用和返回的额外开销,所以相对慢一些 |
操作符优先级 | 宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多些括号 | 函数参数只在函数调用的时候求值一次,它的结果值传递给函数。表达式的求值结果更容易预测 |
带有副作用的参数 | 参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果 | 函数参数只在传参的时候求值一次,结果更容易控制 |
参数类型 | 宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型 | 函数的参数是与类型有关的,如果参数的类型不同,就需要不同的函数,即使他们执行的任务是相同的 |
调试 | 宏不方便调试 | 函数可以逐语句调试 |
递归 | 宏不能递归 | 函数可以递归 |
3.2.7 命名约定
一般来讲函数的宏的使用语法很相似。所以语言本身没法帮我们区分二者。
那我们平时的一个习惯是:
把宏名全部大写
函数名不要全部大写
3.3 undef
用于移除宏定义
#define NAME RMB
#undef NAME
//如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除。
3.4 命令行定义
许多C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。
例如:当我们根据同一个源文件要编译出一个程序的不同版本的时候,这个特性有点用处。(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器内存大些,我们需要一个数组能够大些。)
#include <stdio.h>
int main()
{
int array [SZ];
int i = 0;
for(i = 0; i< SZ; i ++)
{
array[i] = i;
}
for(i = 0; i< SZ; i ++)
{
printf("%d " ,array[i]);
}
printf("\n" );
return 0;
}
这里用gcc以命令行的形式操作,可以观察到,SZ通过命令行定义,发生了替换。
3.5 条件编译
在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。
比如说:
调试性的代码,删除可惜,保留碍事,那么我们可以选择性的编译
代码示例:
#define _DEBUG_ 1
int main()
{
#ifdef _DEBUG_
printf("1\n");
#endif
#ifdef DEBUG //未定义,所以不会编译
printf("0\n");
#endif
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
3.6 文件包含
在C语言写的大大大部分的代码中,我们都会用到 #include 这条指令,这条指令可以使另一个文件被编译。
3.6.1 头文件被包含的方式
本地文件被包含:
#include "filename"
查找策略:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。
如果找不到就提示编译错误。
库文件包含:
#include <filename.h>
那这样来说,是不是包含库文件,就直接用 ==" "==包含不久行了吗?
理论可行,但是不切实际。
3.6.2 嵌套文件包含
当项目十分庞大或者我们不小心多次包含同一个文件时:
这样会造成文件的内容重复,那么我们可以通过条件编译来解决这个问题。
#ifndef _TEST_H_
#define _TEST_H_
//头文件的内容
#endif //_TEST_H_
这样写可能会有些麻烦,写成下面这种形式就简单很多:
//在VS2022中,创建本地头文件,编译器会自动加上
#pragma once
4. 结语
本篇文章参考《程序员的自我修养——链接、装载与库》的第一章内容,之后也会慢慢更新从书本中学到的知识。本周上课听老师讲,计算机的一些专业名称的含义。
比如:计算机科学与技术,科学是摆在技术前面的,扎实的理论基础,更利于我们技术的提升,所以在有一定技术基础的前提下,可尝试学习部分理论,这样会让我们的水平再往上升。