写点什么

探索 C 语言程序奥秘——C 语言程序编译与预处理

作者:未见花闻
  • 2022 年 6 月 24 日
  • 本文字数:6636 字

    阅读完需:约 22 分钟

🌴1.代码如何编译

🌲1.1 从代码到程序

我们所写的代码并不是直接进行编译得到程序的,再此之前,会先对代码进行预编译(所谓预编译,就是对代码进行翻译和预处理)。从代码进化到程序需要经过四个过程:1.预编译 2.编译 3.汇编 4.链接



对于链接,是先将所有.c文件编译,最后汇总链接。


🌲1.2 预编译

在预处理之前,编译器必须对该程序进行一些翻译处理。首先,编译器把源代码中出现的字符映射到源字符集。该过程处理多字节字符和三字符序列——字符扩展让 C 更加国际化。第二,编译器定位每个反斜杠后面跟着换行符的实例,并删除它们。也就是说,把下面两个物理行(physical line):


 printf("That's wond\    erful!\n"); 
复制代码


转换成一个逻辑行(logical line):


  printf("That's wonderful\n!"); 
复制代码


注意,在这种场合中,“换行符”的意思是通过按下 Enter 键在源代码文件 中换行所生成的字符,而不是指符号表征\n。 由于预处理表达式的长度必须是一个逻辑行,所以这一步为预处理器做好了准备工作。一个逻辑行可以是多个物理行。 第三,编译器把文本划分成预处理记号序列、空白序列和注释序列(记号是由空格、制表符或换行符分隔的项)。这里要注意的是, 编译器将用一个空格字符替换每一条注释。因此,下面的代码:


int/* 这看起来并不像一个空格*/fox; 
复制代码


将变成:


int fox; 
复制代码


而且,实现可以用一个空格替换所有的空白字符序列(不包括换行符)。最后,程序已经准备好进入预处理阶段,预处理器查找一行中以#号开始的预处理指令。


🌲1.3 编译,汇编,链接

编译过程是将预编译处理好的 C 代码经过一系列处理转换为汇编代码。主要有以下几个方面的处理:


  1. 语法分析

  2. 词法分析

  3. 语义分析

  4. 符号汇总


编译过程完成后,进入汇编过程,该过程会将汇编代码转换为二进制代码(机器语言),并会形成符号表。


链接过程会对所有源文件编译汇编后的代码进行合并段表和对符号表进行合并和重定义,也就是链接。

🌴2.预处理

预处理器的行为是由预处理指令(由推字符开头的一些命令)控制的。比如常见的#define #include

🌲2.1 预处理指令

大多数预处理指令都属于下面 3 种类型之一。


  • [ ] 宏定义。#define指令定义一个宏,#undef指令删除一个宏定义。

  • [ ] 文件包含。#include指令会将一个指定文件的内容被包含到程序中。

  • [ ] 条件编译。#if#ifdef#ifndef#elif#else#endif指令可以根据预处理器可以测试的条件来确定是将一段文本块包含到程序中还是将其排除在程序之外。


剩下的#error#line#pragma指令是更特殊的指令,较少用到。在进一步讨论之前,先来看几条适用于所有指令的规则:


  1. 指令都以#开始。#符号不需要在一行的行首,只要它之前只有空白字符就行。在 #后是指令名,接着是指令所需要的其他信息。

  2. 在指令的符号之间可以插入任意数量的空格或水平制表符。例如,下面的指令是合法的∶


#define     N     100 
复制代码


  1. 指令总是在第一个换行符处结束,除非明确地指明要延续。如果想在下一行延续指令,我们必须在当前行的末尾使用字符\

  2. 指令可以出现在程序中的任何地方。但我们通常将#define#include指令放在文件的开始,其他指令则放在后面,甚至可以放在函数定义的中间。

  3. 注释可以与指令放在同一行。实际上,在宏定义的后面加一个注释来解释宏的含义是一种比较好的习惯∶


#define N 48       /* max arr size */
复制代码

🌲2.2 宏(#define)

🌰2.2.1 定义不带参数的宏

我们可以使用宏来替换一些常量,关键字等标识符。注意宏末尾不要加;。宏的替换列表可以包括标识符、关键字、数值常量、字符常量、字符串字面量、操作符和排列。当预处理器遇到一个宏定义时,会做一标识符代表 替换列表的记录。在文件后面的内容中,不管标识符在哪里出现,预处理器都会用替换列表代替它。


#define 标识符 替换列表#define MAX 100#define MIN 1#define _CRT_SECURE_NO_WARNINGS 1 //vs编译器下,防止使用不安全函数而报错,如scanf。#define reg register          //为 register这个关键字,创建一个简短的名字#define do_forever for(;;)     //用更形象的符号来替换一种实现#define CASE break;case        //在写case语句的时候自动把 break写上。// 如果定义的 被替换符 过长,可以分成几行写,除了最后一行外,每行的后面都加一个反斜杠(续行符)。#define DEBUG_PRINT printf("file:%s\tline:%d\t \                          date:%s\ttime:%s\n" ,\                          __FILE__,__LINE__ ,       \                          __DATE__,__TIME__ )
复制代码

🌰2.2.2 定义带参数的宏

宏还可以带参数,可以当做函数使用,但是宏与函数完全不同,只是在少数情况下可能与函数一样。


#define 标识符(x1,x2,….,xm) 替换列表#define ADD(a,b) a+b   //使用宏求两数之和#define MAX(a,b) (a > b ? (a) : (b))  //使用宏求两数最大值#define IS_EVEN(n) ((n) % 2 == 0)  //使用宏定义判断是否为偶数的条件
复制代码


在宏的名字和左括号之间必须没有空格。如果有空格,预处理器会认为是在定义一个简单的宏(不带参数的宏),其中(x1,x2,…,xm)是替换列表的一部分。


//预处理前i= MAX(j+k,m-n);if(IS EVEN(i)) i++;//预处理后i =((j+k) > (m-n) ? (j+k) : (m-n));if(((i) % 2 == 0)) i++;
复制代码

🌰2.2.3#和 ##

宏定义可以包含两个专用的运算符∶###。编译器不会识别这两种运算符,它们会在预处理时被执行。🌵#运算符将宏的一个参数转换为字符串字面量。它仅允许出现在带参数的宏的替换列表中。


#define PRINT(FORMAT, VALUE)\   printf("the value is "FORMAT"\n", VALUE);...PRINT("%d", 3);//相当于printf("the value is ""%d""\n", VALUE);//等价与printf("the value is %d\n", VALUE);
复制代码


运行结果:


the value is 3
D:\gtee\C-learning-code-and-project\test_1005\Debug\test_1005.exe (进程 41188)已退出,代码为 0。按任意键关闭此窗口. . .
复制代码


C 语言中相邻的字符串字面量会被合并。


int i = 10;#define PRINT(FORMAT, VALUE)\  printf("the value of " #VALUE "is "FORMAT "\n", VALUE);...PRINT("%d", i + 3);//相当于printf("the value of " "i + 3" "is ""%d" "\n", i + 3);//等价于printf("the value of i + 3is %d\n", i + 3);
复制代码


运行结果:


the value of i + 3is 13
D:\gtee\C-learning-code-and-project\test_1005\Debug\test_1005.exe (进程 31644)已退出,代码为 0。按任意键关闭此窗口. . .
复制代码


🌵##运算符可以把位于它两边的符号合成一个符号。它允许宏定义从分离的文本片段创建标识符。具体来说,##运算符可以将两个记号(如标识符)“粘合”在一起。成为一个记号。(无需惊讶,##运算符被称为“记号粘合”。)如果其中一个操作数是宏参数,“粘合”会在形式参数被相应的实际参数替换后发生。考虑下面的宏:


#define ADD_TO_SUM(num, value) \ sum##num += value;...ADD_TO_SUM(5, 10);//作用是:给sum5增加10.
复制代码


注:这样的连接必须产生一个合法的标识符。否则其结果就是未定义的。

🌰2.2.4 宏与函数

宏通常被应用于执行简单的运算。比如在两个数中找出较大的一个。


#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));
复制代码



命名约定一般来讲函数的宏的使用语法很相似。所以语言本身没法帮我们区分二者。那我们平时的一个习惯是:


  1. 把宏名全部大写

  2. 函数名不要全部大写

🌰2.2.5 使用宏的小贴士

现在我们已经讨论过了简单的宏和带参数的宏,我们来看一下它们都需要遵守的规则。


  • [ ] 宏的替换列表可以包含对其他宏的调用。例如,我们可以用宏PI来定义宏TwO_PI


#define PI 3.14159#define TWO_PI (2*PI)
复制代码


当预处理器在后面的程序中遇到TWO PI时,会将它替换成(2*PT)。接着,预处理器会重新检查替换列表,看它是否包含其他宏的调用(在这个例子中,调用了宏PI)。预处理器会不断重新检查替换列表,直到将所有的宏名字都替换掉为止。


  • [ ] 预处理器只会替换完整的记号,而不会替换记号的片断。因此,预处理器会忽略嵌在标识符、字符常量、字符串字面量之中的宏名。例如,假设程序含有如下代码行∶


#define SIZE 256int BUFFER_SIZE;if(BUFFER_SIZE > SIZE)  puts("Error: SIZE exceeded");
复制代码


预处理后这些代码行会变为


int BUFFER_SIZE;if(BUFFER_SIZE >256)  puts ("Error:SIZE exceeded");
复制代码


尽管标识符BUFFER_SIZE和字符串Error∶SIZE exceeded都包含SIZE,但是它们没有被预处理影响。


  • [ ] 宏定义的作用范围通常到出现这个宏的文件末尾。由于宏是由预处理器处理的,它们不遵从通常的作用域规则。定义在函数中的宏并不是仅在函数内起作用,而是作用到文件末尾。

  • [ ] 宏不可以被定义两遍,除非新的定义与旧的定义是一样的。小的间隔上的差异是允许的,但是宏的替换列表(和参数,如果有的话)中的记号都必须一致。

  • [ ] 宏可以使用#undef指令"取消定义"。#undef指令有如下形式∶


#undef 标识符
复制代码


其中标识符是一个宏名。例如,指令


#undef N
复制代码


会删除宏N当前的定义。(如果N没有被定义成一个宏,#undef指令没有任何作用。)#undef指令的一个用途是取消宏的现有定义,以便于重新给出新的定义。


  • [ ] 在前面定义的宏的替换列表中有大量的圆括号。确实需要它们吗?答案是绝对需要。如果我们少用几个圆括号,宏有时可能会得到意想不到的(而且是不希望有的)结果。

🌰2.2.6 预定义宏

C 语言有一些预定义宏,每个宏表示一个整数常量或字符串字面量。如下,这些宏提供了当前编译或编译器本身的信息。


__FILE__      //进行编译的源文件__LINE__     //文件当前的行号__DATE__    //文件被编译的日期__TIME__    //文件被编译的时间__STDC__    //如果编译器遵循ANSI C,其值为1,否则未定义
复制代码


C99 新增的一些宏:


🌲2.3 头文件包含(#include)

#include指令主要有两种书写格式。第一种格式用于属于 C 语言自身库的头文件∶


#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__
复制代码


在 vs 编译器下还可以使用:


#pragma once
复制代码

🌲2.4 条件编译

🌵C 语言的预处理器可以识别大量用于支持条件编译的指令。条件编译是指根据预处理器所执行的测试结果来包含或排除程序的片断。调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。如:


#if 常量表达式 //常量表达式为非0,则编译,否则不编译...//选择编译取#endif
复制代码


假如我们正在调试一个程序。我们想要程序显示出特定变量的值,因此将printf函数调用添加到程序中重要的部分。一旦找到错误,经常需要保留这些printf函数调用,以备以后使用。条件编译允许我们保留这些调用,但是可以让编译器忽略它们。下面是我们需要采取的方式。首先定义一个宏,并给它一个非零的值∶


#define DEBUG 1
复制代码


宏的名字并不重要。接下来,我们要在每组printf函数调用的前后加上#if#endif


if DEBUGprintf("Value of i : gd\n",i);printf("Value of j: td\n",j);#endif
复制代码


在预处理过程中,#if指令会测试DEBUG的值。由于DEBUG的值不是0,因此预处理器会将这两个printf函数调用保留在程序中(但#if#endif行会消失)。如果我们将DEBUG的值改为0 并重新编译程序,预处理器则会将这 4 行代码都删除。编译器不会看到这些printf函数调用,所以这些调用就不会在目标代码中占用空间,也不会在程序运行时消耗时间。我们可以将#if-#endif保留在最终的程序中,这样如果程序在运行时出现问题,可以(通过将DEBUG改为1并重新编译来)继续产生诊断信息。


值得注意的是,#if指令会把没有定义过的标识符当作是值为0的宏对待。因此,如果省略 DEBUG的定义,测试


#if DEBUG
复制代码


会失败(但不会产生出错消息),而测试


#if !DEBUG
复制代码


会成功。


还有一个专用于预处理器的运算符——defined。当defined 应用于标识符时,如果标识符是一个定义过的宏则返回1,否则返回0defined运算符通常与#if指令结合使用,可以这样写


#if defined(DEBUG)#endif
复制代码


仅当DEBUG被定义成宏时,#if#endif之间的代码会被保留在程序中。DEBUG两侧的括号不是必需的,因此可以简单写成


#if defined DEBUG
复制代码


由于defined运算符仅检测DEBUG是否有定义,所以不需要给DEBUG赋值。


🌵#ifdef指令测试一个标识符是否已经定义为宏;#ifndef指令与#ifdef指令类似,但测试的是标识符是否没有被定义为宏∶


#ifdef#ifndef指令的使用与#if指令类似∶


#ifdef 标识符//相当于 #if defined 标识符当标识符被定义为宏时需要包含的代码#endif
#ifndef 标识符//相当于 #if defined !标识符当标识符被定义为宏时需要包含的代码#endif
复制代码


比如:


#include <stdio.h>#define __DEBUG__ 1int 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;}
复制代码


🌵#if指令、#ifdef指令和#ifndef指令可以像普通的if语句那样嵌套使用。当发生嵌套时,最好随着嵌套层次的增加而增加缩进。一些程序员对每一个#endif都加注释,来指明对应的#if 指令测试哪个条件。为了提供更多的便利,预处理器还支持#elif#else指令。#elif指令和#else指令可以与#if指令、#ifdef指令和#ifndef指令结合使用,来测试一系列条件∶


#if 表达式1当表达式1非0时需要包含的代码#elif 表达式2当表达式1为0,但表达式2非0时需要包含的代码#else其他情况下需要包含的代码#endif
复制代码

🌲2.4 其他预处理指令

🌵#error指令


#error 消息
复制代码


其中,消息是任意的记号序列。如果预处理器遇到#error指令,它会显示一条包含消息的出错消息。对于不同的编译器,出错消息的具体形式也可能会不一样。格式可能类似∶Error directive∶消息或者#error 消息遇到#error指令预示着程序中出现了严重的错误,有些编译器会立即终止编译而不再检查其他错误。


🌵#line指令#line指令是用来改变程序行编号方式的。(程序行通常是按 1,2,3,…来编号的。)我们也可以使用这条指令使编译器认为它正在从一个有不同名字的文件中读取程序。


#line n#line n "file name"
复制代码


这个指令没什么用,为什么要把行编号改了呢?这不是徒添麻烦吗?🌵#pragma指令#pragma指令为要求编译器执行某些特殊操作提供了一种方法。这条指令对非常大的程序或需要使用特定编译器的特殊功能的程序非常有用。#pragma指令有如下形式∶


 #pragma 记号 //如 #pragma pack()  //vs编译器有使用 #pragma once   //vs中防止包含重复头文件
复制代码


其中,记号是任意记号。#pragma指令可以很简单(只跟着一个记号),也可以很复杂。#pragma指令中出现的命令集在不同的编译器上是不一样的。你必须通过查阅你所使用的编译器的文档来了解可以使用哪些命令,以及这些命令的功能。顺便提一下,如果#pragma指令包含了无法识别的命令,预处理器必须忽略这些#pragma指令,不允许给出出错消息。

发布于: 刚刚阅读数: 4
用户头像

未见花闻

关注

坚持+努力=诗+远方 2021.11.15 加入

一位热爱技术热爱分享的大学生!

评论

发布
暂无评论
探索C语言程序奥秘——C语言程序编译与预处理_6月月更_未见花闻_InfoQ写作社区