程序环境和预处理
程序环境
前言
在我们之前编写C语言代码的时候,通常都是书写一个.c
类型的文件,但是计算机底层执行的命令实际上都是二进制的指令(机器指令),那么我们的计算机是如何执行我们写的代码的呢?如果写过一些代码就应该知道,我们的.c
文件会在被运行的时候变成.exe
这种可执行文件然后才运行出来,实际上.exe
文件里面存储的命令就是二进制指令。那么我们的代码是如何从一个.c
文件被转换为计算机可执行的二进制指令的,这就是我们在这篇文章里要简要讲述的
程序的编译环境和执行环境
只要你写的代码是C语言的代码,那么程序运行过程中必定存在两个环境:编译环境和执行环境
通常,一个.c
文件要变成一个.exe
文件要经历两个过程:编译+链接,然后就变成我们的可执行程序.exe
,那么个过程通常就会在编译环境中进行,而执行环境就是用于执行我们的可执行文件.exe
编译和链接的深入探究
上面我们说,一个.c
文件要变成.exe
文件要经历两个过程:编译+链接,那么下面我们就来稍微深入的了解一下这两个过程
一般在一个工程文件里,会有多个源文件.c
或头文件.h
,那么编译过程会首先将各个.c
文件转换为目标文件(VS上生成的是.obj
文件),编译过程中要用到的工具被叫做编译器。然后多个目标文件会被链接这个过程合并为一个.exe
文件,链接过程中要用到的工具叫做链接器
其中,我们的编译过程实际上还可以细分为三个过程:预编译(也被称作预处理)、编译、汇编
预编译(预处理)
这个阶段主要会完成:1.头文件的包含 2. 预处理指令的执行 3. 注释的删除
预处理过程我们会在文章的后半部分进行更加深入的讲解
编译
编译过程主要就是将我们的C语言代码转化为汇编代码,操作有:语法分析、语义分析、词法分析、符号汇总
假如说,我们要将一段中文翻译为英文,那我们是不是也要进行语法、语义、词法的分析,实际上编译过程主要就是做类似这样的操作
那么这个符号汇总是什么呢?实际上他就是汇总类似于函数名这样的全局标识符,因为在这个时候,我们的各个文件还是分开的,为了后面在链接的时候能够互相识别就需要对这些标识符进行一些操作操作,但实际上符号汇总这并不是全部的操作,接下来的一些操作还会在汇编的过程中进行
汇编
汇编这个过程就是编译的最后一个过程,也就是生成目标文件(二进制文件)的过程,那么这过程就需要将编译生成的汇编代码转换为二进制指令,然后还会进行形成符号表的操作
上面我们说过,在编译的过程中会进行符号汇总,而汇编又在这个基础上形成了一个符号表,就类似于你在编译中收集了数据,然后又在汇编中对他们进行了整理,方便我们在链接的阶段进行比较和合并。并且在形成符号表的过程中,会找出每一个标识符的地址,以便在程序运行时能够正确地定位和访问这些符号
链接
链接就是将我们多个目标文件进行合并同时生成可执行程序的过程.c
,它主要进行两个操作:1. 合并段表 2.符号表的合并和重定位
其中,符号表的合并就是将刚刚各个文件的符号表进行合并,而重定位就是将地址更改为有效地址
值得一提的是,在链接过程中链接器会查找每个未定义的符号,并尝试在符号表中找到相应的定义。如果找不到定义,则链接器会报告未定义的符号错误。也就是说,链接这个阶段可以发现被调用的函数未定义。
那么在最后,链接器就会将我们的目标文件、符号表和链接库(.lib
,库函数运行需要依赖这些库文件)一同链接生成可执行程序
程序运行过程
在运行环境中,一个程序运行大概经历以下几个历程:
-
程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成
-
程序的执行便开始。接着便调用main函数
-
开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值
-
终止程序。正常终止main函数;也有可能是意外终止
预处理详解
预处理是C语言编译过程中的第一步,它通过对源代码进行宏展开、条件编译、头文件包含等操作,生成一份经过预处理后的代码
那么我们下面就开始细致讲解有关于预处理的一些知识
预定义符号
预定义符号是由编译器预先定义好的符号,用于表示某些特殊含义,常见的预定义符号如下:
- _FILE_:表示当前源代码所在的文件名。
- _LINE_:表示当前源代码所在的行号。
- _DATE_:表示编译日期,格式为"MMM DD YYYY",例如"Jan 01 2022"。
- _TIME_:表示编译时间,格式为"HH:MM:SS",例如"10:30:15"。
这些预定义符号都可以直接使用,因为是内置在编译器中的
#define
#define
定义无参数宏
宏定义是一种预处理指令,可以将某些字符串或表达式定义为宏,在程序中使用时,预处理程序会将所有宏替换为其定义的内容
#define MAX 100
int arr[MAX];
//实际上就等效于下面的
int arr[100]
并且不止常量我们也可以替换一些标识符,代码等等
//将register定义为REG
#define REG register
//将死循环定义为DO_FOREVER
#define DO_FOREVER for(;;)
//如果定义的 stuff过长,可以分成几行写,除了最后一行外,每行的后面都加一个反斜杠(续行符)
#define DEBUG_PRINT printf("file:%s\tline:%d\t\
date:%s\ttime:%s\n",\
__FILE__,__LINE__ ,\
__DATE__,__TIME__ )
那么在我们用#define
定义宏的时候,是十分不推荐在后面加上;
的,因为这样我们替换的时候也会将分号作为被定义的一部分,然后在替换的时候被替换进去
接下来来看一道题
#define INT_PTR int*
typedef int* int_ptr;
INT_PTR a,b;
int_ptr c,d;
请问a, b, c, d中哪个不是指针变量
由于我们用#define
将INT_PTR
定义为int*
所以实际上a
和b
的创建语句如下
int* a,b;
这里就需要注意,声明多个指针变量的时候,是不能这样写的,这样只会让第一个变量是指针变量,而其他变量是普通的数据类型
但是第二个由于我们将int*
这个类型定义为int_ptr
,那么就创建c
和d
就相当于创建了两个int_ptr
类型的数据,也就是两个int*
的数据
#define
定义有参数宏
#define
的定义宏实际上也是支持替换参数的,也就是类似于函数一样的效果
参数宏的声明格式
#define name( parament-list ) stuff
其中parament-list
就是类似于函数参数一样的东西,会出现在stuff
中
并且宏的参数是不受类型限制的,只要使用合法,什么类型的参数都可以直接传进去
#define CMP(x,y) (x > y)?x:y
int main() {
int a = 1;
int b = 3;
float c = 1.1;
float d = 3.1;
printf("%d\n", CMP(a, b));
printf("%f\n", CMP(c, d));
return 0;
}
下面我们讲几个书写宏要注意的点
#include<stdio.h>
#define SQUARE( x ) x * x
//一个求平方的宏
int main() {
printf("%d", SQUARE(5));
return 0;
}
我们上面说过,#define
定义的宏是在预处理阶段进行直接替换的,而不是像函数一样会运行并计算的,那么我们下面的这种写法就会发生一些问题
#define ADD(x,y) x + y
int main() {
printf("%d", 2 * ADD(5,6));
return 0;
}
我们上面的代码肯定原意是想先算出5
和6
的和然后再乘2
,但是由于#define
是直接进行替换,那么我们的代码经过预处理阶段后其实就变成了
int main() {
printf("%d", 2 * 5 + 6);
return 0;
}
实际上就会先算2*5
然后再加上6
那么为了防止这种错误的发生,我们在定义宏的时候可以尽可能的多加点括号,来保证宏的运算优先级,如下
#define ADD(x,y) (x + y)
那再看一个代码
#define SQUARE(x) (x * x)
int main() {
printf("%d", SQUARE(5 + 1));
return 0;
}
我们预想肯定是算出36
但是实际上输出的11
那么是为什么呢?我们不是已经加上括号了吗?
那么我们就把预处理完的替换结果写出来看一看
int main() {
printf("%d", (5 + 1 * 5 + 1));
return 0;
}
那么这里就会先算1*5
然后再加5
和1
因此为了防止这种情况的发生,我们不仅仅要在宏的外部加上括号,我们也应该给各个用到参数的地方加上括号,如下
#define SQUARE(x) ((x) * (x))
#define
替换规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤
在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首被替换
替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换
最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复述处理过程
注意:
- 宏参数和#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归
- 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索
#
和##
的使用
首先看一段代码
int main() {
int a = 1;
printf("a的值是%d\n", a);
int b = 5;
printf("b的值是%d\n", b);
float c = 1.11;
printf("c的值是%f\n", c);
return 0;
}
上面的代码,明显写起来很麻烦,每一次printf
我们都要讲最开始的a
和调用的参数改掉,那我们有没有办法能用一条语句来代替这类语句呢?
函数肯定不行,因为它不可能知道我传的参数的参数名字叫什么
但是宏可以用#
来帮助它做到这一点
#参数
//上面就相当于下面的
"参数"
那么到底是什么意思呢,我们来实际测试看一下借用这个特性修改后的代码
#define PRINT(FORMAT,VALUE) printf(#VALUE"的值是"FORMAT"\n",VALUE)
int main() {
int a = 1;
PRINT("%d", a);
int b = 5;
PRINT("%d", b);
float c = 1.11;
PRINT("%f", c);
return 0;
}
以第一条语句为例,替换后的代码就会变成
int a = 1;
printf("a" "的值是" "%d" "\n", a);
那么我们其实也需要知道printf
的一个性质:printf
会自动连接里面的字符串,或者也可以理解为就是一个一个打印出来也行
宏的##
应用场景非常少,所以直接介绍作用
#define ZERO(NAME,NUM) NAME##NUM = 0
int main() {
int num1 = 101;
ZERO(num, 1);
return 0;
}
替换后就相当于
int main() {
int num1 = 101;
num1 = 0;
return 0;
}
就是把左右的两个标识符合成为一个标识符,但是一定要保证合成后的标识符是存在的
带副作用的宏参数
什么叫做副作用参数,举一个例子
a++ //副作用参数,会改变变量的值
a+1 //无副作用参数,不会改变变量的值
函数传参的时候,由于我们是先计算后传入,因此其实副作用参数并不会影响函数的运行结果。但是宏不一样,宏是直接对参数进行替换,当你使用的是带副作用的参数,那么这个副作用参数可能会被调用多次,从而产生难以预料的结果
看下面代码
#define MAX(a, b) ( (a) > (b) ? (a) : (b) )
int max(int a, int b) {
return 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);
x = 5;
y = 8;
z = MAX(x++, y++);
printf("x=%d y=%d z=%d\n", x, y, z);
return 0;
}
//输出结果
x=6 y=9 z=8
x=6 y=10 z=9
大部分人下意识的计算估计都是函数的结果,但是由于宏是替换所以实际上代码是
int z = ( (x++) > (y++) ? (x++) : (y++) );
在比较的时候x
和y
都会++
然后由于y=8 > x=5
,所以到后面又会对y
进行一次++
但是返回的值是只++
过一次的y
也就是9
那么x++
了一次所以x=6
,y++
了两次所以y=10
宏和函数对比
属性 | #define定义宏 | 函数 |
---|---|---|
代码长度 | 由于是替换,所以当宏过长时代码长度会被大幅度增长 | 每一次运行都是调用同一个函数 |
执行速度 | 相当于去除了函数的调用的返回,稍微快一点 | 相对慢一些 |
操作符优先级 | 宏由于是直接替换,因此为了保证优先级必须加上括号,防止被替换后周围的其他操作符影响 | 函数实际参数只会在传参的时候调用一次,易控制 |
带有副作用的参数 | 宏由于是直接替换,因此带有副作用的参数可能会被多次调用,扩大副作用 | 函数实际参数只会在传参的时候调用一次,易控制 |
参数类型 | 宏对于参数类型没有严格限制,使用合法即可 | 函数对参数类型有严格限制 |
调试 | 宏不能调试 | 函数可以逐句调试 |
递归 | 宏内虽然可以包含无参数宏但是不能递归 | 函数可以递归 |
一般在一些计算过程复杂的代码里,还是更加推荐用函数,因为更容易控制,且可以进行调试。同时,如果是计算量大的代码,实际上宏所快的那点执行速度也可以算是微乎其微
命名规定
一般规定,宏名全大写,函数名不要全大写
#undef
用于移除宏的定义
#undef NAME
//如果现存的一个NAME要被重新定义,那么之前定义的NAME首先要被移除
条件编译
条件编译,顾名思义,就是根据条件去判断是否编译
那么具体应该是怎么操作的呢,我们先看看有哪些常见的条件编译指令
#if 常量表达式
//假如常量表达式的结果为是,那么中间的代码将被忽略
#endif
//例如下面
#define __DEBUG__ 1
#if __DEBUG__
//..
#endif
和普通的if
选择结构一样,条件编译也支持多个分支
#if 常量表达式
//...
#elif 常量表达式
//...
#else
//...
#endif
另外还有一种语句用于根据宏是否被定义然后选择性编译,这种做法在头文件里非常常见
#if defined(symbol)
#ifdef symbol
//上面两个都是一样的
//如果被定义了就不编译
#endif
#if !defined(symbol)
#ifndef symbol
//上面两个都是一样的
//如果被定义了就不编译
#endif
文件包含
头文件包含
头文件的包含有两种方式,如下
#include "filename"
#include <filename>
其中第一种通常用于包含我们工程内的头文件,第二种通常用于包含标准库里面的头文件
这两种写法对应的查找策略是不同的第一种有两步:1. 先去工程文件内找 2.然后去标准库路径下找
如果上面两个路径都找不到就会报错
而第二种包含方式则是只有第二步,那有些人肯定会觉得是不是我包含标准库的头文件也可以用第一个写法?
确实可以,但是不推荐,因为做查找的效率会变低,也不容易区分包含的是库文件还是本地文件
防止重复包含的解决方法
有时候头文件会被头文件包含,而这种嵌套的包含就可能会导致一些头文件的内容被重复包含
那么一般为了防止被重复包含,头文件都会有类似于这样的条件编译结构
#ifndef __TEST_H__
#define __TEST_H__
//头文件的内容
#endif
或者
#pragma once
那么这样就可以防止头文件的重复引入