一、程序的翻译环境和执行环境
在ANSIC的任何一种实现中,存在两个不同的环境。
第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。
第2种是执行环境,它用于实际执行代码。
🍑1、详解翻译环境:编译+链接
通过上图我们可知翻译环境可以细分为:编译+链接的过程,大体上翻译有以下实现过程:
组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)。
每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。
链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人 的程序库,将其需要的函数也链接到程序中将翻译环境继续拆解,我们还可以得到下面几个过程:
🍑2、运行环境
介绍了程序的翻译环境,下面我们来简单了解一下程序的执行环境:
程序执行的过程:
程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
载入内存后,程序的执行便开始。接着便调用main函数。
开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack)(函数栈帧),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。
终止程序。正常终止main函数;也有可能是意外终止。
二、预处理详解
🍑1、预定义符号
__FILE__ 进行编译的源文件
__LINE__ 文件当前的行号
__DATE__ 文件被编译的时间
__STDC__ 如果编译器遵循ANSI C,其值为1,否则未定义
上面这些预定义符号都是语言内置的,不需要再次定义,可以直接使用。
📝例如:(由于VS不遵循ANSIC所以这里就不再展示__STDC__的使用了)
#include<stdio.h> int main() { printf("%s\n", __FILE__); printf("%d\n", __LINE__); printf("%s\n", __DATE__); printf("%s\n", __TIME__); return 0; }
🍑2、#define 定义标识符
语法规则:
#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 100; if(condition) max = MAX; //预编译时替换为:max=100;; else max = 0;
🍑3、#define 定义宏
#define
机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏
(macro)或定义宏(define macro)
宏的声明方式:
#define name( parament-list ) stuff
其中的 parament-list 是一个由逗号隔开的符号表,它们可能出现在stuff中。
注意: 参数列表的左括号必须与name紧邻。
🌰栗子
//实现两个数相加 int Max(int x, int y) { return (x > y ? x : y); } #define MAX(x,y) (x>y?x:y) int main() { int a = 10; int b = 20; //函数实现 int n = Max(a, b); //宏实现 int m = MAX(a, b); //预编译时替换为:int m=(a>b?a:b); printf("%d\n", m); return 0; }
🌳(1)使用宏的注意事项
#include<stdio.h> //参数整体都不加() #define SQUARE(X) X*X //参数加()而整体不加括号 #define DOUBLE(X) (X)+(X) int main() { printf("%d\n", SQUARE(5+1));//预期:6*6=36 //预编译时替换为:printf("%d\n", 5+1*5+1);//实际:11 printf("%d\n", 10*DOUBLE(6));//预期:10*12=120 //预编译时替换为:printf("%d\n", 10*6+6);//实际:66 return 0; }
对于上面的两种宏定义,在特定的使用场景下,由于优先级的不同,会出现一些问题导致结果不符合预期。
所以用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中 的操作符或邻近操作符之间不可预料的相互作用。
🌳(2)#define 替换规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤。
- 在调用宏时,首先对参数进行检查,看看是否包含任何由
#define
定义的符号。如果是,它们首先被替换。 - 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
- 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程。
注意:
- 宏参数和#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归。
- 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
🌰栗子
#include<stdio.h> #define M 10 #define MAX(x,y) ((x)>(y)?(x):(y)) int main() { int m = MAX(2 + 3, M); //预处理 //第一步:int m=MAX(2+3,10); //第二步:int m=((2+3)>(10)?(2+3):(10)); printf("hello M");//字符串中的M不被替换 return 0; }
🌳(3) #和##
1、#
我们运行下面的代码:(假设头尾已写好)
char* p = "hello ""world\n"; printf("hello"" world\n"); printf("%s", p);
结论: 我们发现字符串是有自动连接的特点的。
#
的作用: 使用 # ,把一个宏参数变成对应的字符串。
在以上结论基础上我们可以在宏中使用#
实现下面的效果:(函数不能实现相同的效果)
#include<stdio.h> #define PRINT(val, format) printf("the value of "#val" is "format"\n", val) int main() { int a = 10; PRINT(a, "%d"); //预处理:printf("the value of a is %d\n", a); double b = 5.0; PRINT(b, "%lf"); //预处理:printf("the value of b is %d\n", b); float f = 3.5f; PRINT(f, "%f"); //预处理:printf("the value of f is %f\n", f); return 0; }
2、##
##
的作用: ##可以把位于它两边的符号合成一个符号。它允许宏定义从分离的文本片段创建标识符。
🌰栗子:
#include<stdio.h> #define CAT(A,B) A##B int main() { int Num01 = 100; printf("%d\n", CAT(Num, 01));//输出100 //预处理时替换为:printf("%d\n",Num01); return 0; }
🌳(4)带副作用的宏参数
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果。
x+1;//不带副作用 x++;//带有副作用
🌰举个栗子:比较两个数的值,并输出较大值
#include<stdio.h> #define MAX(x,y) ((x)>(y)?(x):(y)) int main() { int a = 3; int b = 4; int m = MAX(++a, ++b); //预编译时替换为: //int m = ((++a) > (++b) ? (++a) : (++b)); //int m= ((4)>(5) ? (5) : (6)); printf("m = %d a=%d b=%d\n", m, a, b); return 0; }
4<5
输出较大值理应输出5
,由于宏参数具有副作用,导致实际输出6
。
🌳(5)宏和函数对比
宏通常被应用于执行简单的运算。比如在两个数中找出较大的一个:
#define MAX(a, b) ((a)>(b)?(a):(b))
为什么不使用函数完成这样的任务?相对于函数:
宏的优点
- 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。 所以宏比函数在程序的规模和速度方面更胜一筹。
- 更为重要的是函数的参数必须声明为特定的类型。 所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以 用于>来比较的类型。即:宏是类型无关的。
宏的参数可以出现类型,函数做不到这点:
#define MALLOC(num, type) (type*)malloc(num*sizeof(type)) int main() { int*p2 = MALLOC(10, int);//宏可以直接传类型 //预编译时替换: //int*p2 = (int*)malloc(10*sizeof(int)); return 0; }
宏的缺点
- 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序 的长度。
- 宏是没法调试的。
- 宏由于类型无关,也就不够严谨。
- 宏可能会带来运算符优先级的问题,导致程容易出现错。
通过对宏的介绍我们我们可将宏与函数进行一个全面的对比:
属性 | #define定义宏 | 函数 |
代码长度 | 每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长 | 函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码 |
执行速度 | 更快 | 存在函数的调用和返回的额外开销,所以相对慢一些 |
操作符优先 | 级宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多些括号。 | 函数参数只在函数调用的时候求值一次,它的结果值传递给函数。表达式的求值结果更容易预测。 |
带有副作用的参数 | 参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果。 | 函数参数只在传参的时候求值一次,结果更容易控制。 |
参数类型 | 宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型。 | 函数的参数是与类型有关的,如果参数的类型不同,就需要不同的函数,即使他们执行的任务是相同的。 |
调试 | 宏是不方便调试的 | 函数是可以逐语句调试的 |
递归 | 宏是不能递归的 | 函数是可以递归的 |
🌳(6)命名约定
一般来讲函数和宏的使用语法很相似。所以语言本身没法帮我们区分二者。
那我们平时的一个习惯是:
- 把宏名全部大写
- 函数名不要全部大写
🌳(7)#undef
功能:这条指令用于移除一个宏定义。
🍑4、命令行定义
许多C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。
🌰栗子:当我们根据同一个源文件要编译出一个程序的不同版本的时候,这个特性有点用处。(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器内存大些,我们需要一个数组能够大些。)
#include<stdio.h> 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; }
//linux 环境使用下面命令行 gcc -D SZ=数值 (文件名).c //如:gcc -D SZ=10 test.c
🍑5、条件编译
🌳(1)概述
在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。
比如说:调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。(在预编译时选择的保留和删除)
#include <stdio.h> #define __DEBUG__ int main() { int i = 0; int arr[10] = {0}; for(i=0; i<10; i++) { arr[i] = i; #ifdef __DEBUG__ //由于上面定义了__DEBUG__所以编译时这段代码不会被舍弃 printf("%d\n", arr[i]);//为了观察数组是否赋值成功。 #endif } return 0; }
🌳(2)常见的条件编译指令
1.条件编译指令
#if 常量表达式 //... #elif 常量表达式 //... #else //... #endif
比如:
#define __DEBUG__ 1 #if __DEBUG__ //.. #endif
2.判断是否被定义
如果定义:#if defined(symbol)
等价于#ifdef symbol
如果未定义:#if !defined(symbol)
等价于#ifndef symbol
#if defined(OS_UNIX) #ifdef OPTION1 //... #endif #ifdef OPTION2 //... #endif #elif defined(OS_MSDOS) #ifdef OPTION2 //... #endif #endif
条件编译指令在实际开发中的应用也是十分广泛的,比如在头文件stdio.h中就存在这大量的条件编译指令:
🍑6、文件包含
我们已经知道, #include 指令可以使另外一个文件被编译。就像它实际出现于 #include 指令的地方一样。这种替换的方式很简单:
- 预处理器先删除这条指令,并用包含文件的内容替换。
- 这样一个源文件被包含10次,那就实际被编译10次。
🌳(1)头文件被包含的方式
本地文件包含
形如:#include "filename"
查找策略:先在源文件所在目录下查找(注意按照自己的安装路径去找),如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。如果找不到就提示编译错误。
库文件包含
形如:#include <filename.h>
查找策略:查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。
对于库文件也可以使用""
的形式包含?
答案是肯定的。可以,但是不建议。因为这样做查找的效率就低些,当然这样也不容易区分是库文件还是本地文件了。
🌳2、避免嵌套文件重复包含
当我们在实际开发的工程中,可能会包含大量的头文件,如有不慎就可能导致重复包含的情况。我们已知包含几次头文件,编译时就会替换几次头文件的内容,这样的话会导致编译时间的增加,为了防止这种情况的出现,我们可以使用条件编译,避免头文件的重复包含。
1、老式写法
在每个头文件的开头写:
#ifndef __TEST_H__ #define __TEST_H__ //头文件的内容 #endif
2、新式写法
#pragma once
三、其他预处理指令
如果对预处理指令感兴趣,推荐阅读 《C语言深度解剖》 里面有对预处理指令的详细介绍!
本章完
历时3个月,C语言知识的更新也接近尾声,在此感谢各位的支持与陪伴,C语言只是一个起点,编程之路,道阻且长,愿与君共勉!
写在最后: Keep coding!