zl程序教程

您现在的位置是:首页 >  其他

当前栏目

程序员内功心法之程序环境和预处理

程序程序员预处理 环境 内功 心法
2023-06-13 09:17:43 时间

文章目录

一、程序的翻译环境和执行环境

在ANSI C的任何一种实现中,都存在两个不同的环境:

  • 第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。
  • 第2种是执行环境,它用于实际执行代码。

1、翻译环境

一个程序编译、链接的过程在翻译环境中执行,其具体流程为:

  1. 每个源文件 (.c文件) 单独 经过编译器处理后形成目标文件。(在Windows环境下目标文件的后缀为 .obj,在Linux环境下目标文件的后缀为.0)
  2. 所有的目标文件通过链接器捆绑在一起,形成一个单一而完整的可执行程序 (.exe 文件)。
  3. 链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它也可以搜索程序员个人的程序库,将其需要的函数也链接到程序中(链接库操作)。

2、执行环境

程序的实际执行值执行环境中进行,程序执行的具体过程如下:

  1. 程序必须先被载入内存中。在有操作系统的环境中,此过程一般由操作系统完成;在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
  2. 接下来程序开始执行,调用main函数。
  3. 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),用于存储函数的局部变量和返回地址;程序同时也可以使用静态(static)内存,存储于静态内存中的变量其值在程序的整个执行过程将会被一直保留。
  4. 终止程序。正常终止main函数,也有可能是意外终止。

二、编译的具体过程

现在我们知道了程序的编译链接是在翻译环境中进行的,接下来我们来探讨程序编译链接的具体过程。首先,我们来探讨编译,编译其实分为三个阶段,分别是:预处理(预编译)、编译、汇编。这三个阶段所执行的具体操作如下。

1、预处理

预处理也叫预编译,程序在预处理阶段会完成如下操作:

  • 注释的删除。
  • #define 定义的符号、宏的替换以及删除。
  • 各种条件编译的处理。
  • 头文件的展开:将头文件中的代码展开到到当前代码中。

在Linux下我们可以通过如下命令来得到预处理之后的代码:

gcc -E test.c -o test.i
# gcc:表示用 gcc 编译器来编译此代码
# -E:表示让代码在完成预处理后停下来,不再继续往后编译
# test.c:我们要编译的代码
# -o test.i:重定向操作,表示将预处理后得到的代码保存到 test.i 文件中,没有此命令代码将会直接显示到终端

下面我们以一个例子来说明预处理所执行的各种操作:

预处理之前的代码:

#include <stdio.h>            //这是我包含的头文件
#define N 5                   //这是用define定义的一个符号
#define ADD(m,n) ((m)+(n))    //这是用define定义的一个宏
int main()
{
	int a = 0;
	int b = 0;
	int c = N + ADD(2, 3);
	printf("%d\n", c);
	return 0;
}

预处理之后的代码:

{
   此代码块表示stdio.h 中的代码
}

int main()
{
	int a = 0;
	int b = 0;
	int c = 5 + ((2)+(3));
	printf("%d\n", c);
	return 0;
}

2、编译

程序在编译阶段会完成如下操作:

  • 语法分析。
  • 词法分析。
  • 语义分析。
  • 符号汇总。

这里我们重点关注符号汇总,因为在这里汇总出来的符号在后面汇编以及链接阶段都会用到;符号汇总会将我们代码中的全局的符号全部汇总起来,比如全局变量名、函数名;符号汇总不会将局部的变量名汇总进来,因为局部变量只有当程序运行起来,进入该变量所在的局部范围时才会被创建,而编译是在编译阶段进行的。

经过编译,我们的C语言代码会被转化为汇编代码。

在Linux下我们可以通过如下命令来得到编译之后的代码:

gcc -S test.c  //编译器会对代码进行预处理+编译操作
gcc -S test.i      //当代码已经经过预处理,形成了test.i文件时,可以使用此命名
# -S:表示让代码在完成编译后停下来,不再继续往后编译
# 注意:编译、汇编阶段形成的代码会被自动保存到对应文件中,不需要进行重定向操作
# 编译产生的文件为 test.s

3、汇编

经过汇编,我们的汇编代码会被转化为二进制代码/机器代码,并且会形成一个/多个符号表。

我们以下面的代码为例:

Add.c 文件中代码:

int Add(int x, int y)
{
	return (x + y);
}

test.c 文件中代码:

#include <stdio.h>
extern Add(int, int);  //函数声明
int main()
{
	int a = 10;
	int b = 20;
	int sum = Add(a, b);
	printf("%d\n", sum);
	return 0;
}

我们知道,每一个源文件都会单独经过编译器编译生成目标文件,在上面代码中,Add.c 经过预处理、编译后形成 add.s,并且会汇总 Add 符号;test.c 经过预处理、编译后形成 test.s,并且会汇总 main 、Add (在test.c函数中声明了Add函数) 符号。 然后,add.s 经过汇编会生成 add.o 文件,test.s 经过汇编会生成 test.o 文件(Linux下,Windows下为 .obj 文件)。 在汇编过程中,add.s 和 test.s 都会单独生成自己的符号表,所谓的符号表其实就是把 Add main 这些符号与一个地址相关联;add.s 中的 Add 符号与一个地址相关联,test.s 中的 main与一个地址相关联、Add 也与一个地址相关联; 这里需要注意:因为 test.s 中的 Add 是函数的声明,编译器不知道 Add 函数是否真的存在,所以 test.s 中与 Add 符号相关联的地址是无效的。 汇编阶段生成的符号表会在链接阶段被使用。

在Linux下我们可以通过如下命令来得到编译之后的代码:

gcc -c test.c  
gcc -c test.i  //已经经过了预处理,生成了.i文件
gcc -c test.s  //已经经过了编译,生成了.s文件
# -c:表示让代码在完成编译后停下来,不再继续往后编译
# 汇编产生的文件为 test.o

三、链接的具体过程

程序在链接阶段会完成如下操作:

  • 合并段表:编译器会把在汇编阶段生成的多个目标文件中相同格式的数据合并在一起,最终形成一个 .exe 文件。
  • 符号表的合并和重定位:符号表的合并是指编译器会把在汇编阶段生成的多个符号表合并为一个符号表;重定位则是指当同一个符号出现在两个符号表中时,编译器会选取其中和有效地址相关的那一个,舍弃另外一个。

链接过程符号表的合并和重定向的实际意义是非常大的,因为它保证了符号表中的每一个符号都和有效的地址相关联,我们可以通过该地址找到对应符号,可以让我们跨文件的调用函数,使得我们链接生成的 .exe 文件能够被正常执行。

如果在合并符号表的过程中与某一符号相关联的地址是无效的,程序就会抛出链接性错误;比如我们把上面 add.c 文件中的代码删去,再运行程序:

经过链接,我们的C程序就会从 .c 文件被转化为 .exe 文件,这时我们只需要打开 .exe 文件就可以让我们的程序运行起来了。

上面就是一个C程序如何一步一步被执行起来的,实际上编译器在编译链接过程中还有许多许多需要去注意和设计的地方,我们这里也只是浅浅的学习了一下程序编译链接的过程,更深入的知识需要我们去学习编译原理等相关知识,这里就不再探讨,如果有同学对这些知识很感兴趣,可以去看看 《程序员的自我修养》 这本书,里面做了较为详细的介绍,电子版书籍我放在阿里云盘里面了,有需要的同学自取。

阿里云盘链接:https://www.aliyundrive.com/s/9zqTyvaBu13
提取码:x7g1

四、编译器调用函数的规则

在知道了程序编译链接的具体过程之后,我们需要知道编译器调用函数的规则:

首先,编译器会在当前文件中寻找函数的定义,如果找到了,就直接调用函数,在调用函数期间会形成函数的符号表;所以对于定义在本文件内的函数,编译器不需要再去确认符号表中函数的地址,也就不需要进行后续的链接操作;

如果编译器在本文件中没有找到函数的定义,那么编译器就会去寻找函数的声明,找到之后生成一个符号表,并将符号表关联一个无效的地址;这时候,编译器就需要通过后续链接阶段符号表的合并来匹配有效地址,从而实现跨文件调用函数;当然,也有可能合并不到有效的地址,从而在重定位时发生链接型错误;

最后,如果编译器在本文件内既没有找到函数的定义,也没有找到函数的声明,那么编译器就会直接报出编译型错误。


五、预处理操作

在了解了程序在编译链接时所进行的各种操作后,我们再来学习一下预处理相关的操作。

1、预处理符号

C语言中有许多内置的预定义符号,我们可以在程序中直接使用:

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

例如:

#include <stdio.h>
int main()
{
	int i = 0;
	for (i = 1; i <= 10; i++)
	{
		printf("%s %d %s %s %d\n", __FILE__, __LINE__, __DATE__, __TIME__, i);
	}
	return 0;
}

2、#define 定义标识符

我们可以使用 #define 来定义标识符:

#define name stuff

例如:

#define MAX 1000               //用 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 定义标识符的时候,不要在末尾加上分号,避免造成程序逻辑的错误。

3、#define 定义宏

#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)。

宏的声明方式如下:

#define name( parament-list ) stuff
# 其中的 parament-list 是一个由逗号隔开的符号表(参数列表),它们可能出现在stuff中

注意:参数列表的左括号必须与name紧邻。 如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。

#define 定义宏易错的地方:

我们可能这样来定义一个宏:

#define SQUARE(x) x*x

然后以这样的方式来调用它:

#define SQUARE(x) x*x

int main()
{
	int a = 5;
	int ret = SQUARE(a);
	printf("%d\n", ret);
	return 0;
}

乍一看这样写没问题,当然,在这里,SQUARE 的使用确实没问题,但是如果我这样来使用呢?

#define SQUARE(x) x*x

int main()
{
	int a = 5;
	printf("%d\n", SQUARE(a + 1));
}

当我们以上面这种方式来使用 SQUART 宏的时候,结果还会是我们想要的 36 吗?答案是否定的。我们知道,宏是在预处理的时候被替换的,所以上面这段代码结果预处理后编程了这样:

int main()
{
	int a = 5;
	printf("%d\n", a+1*a+1);
}

在经过了这次的教训后,你恍然大悟,把宏改成了这样:

#define SQUARE(x) (x)*(x)

这样定义的宏遇到上面的使用当然不会出错,但如果我换一个宏使用呢?

#define DOUBLE(x) (x)+(x)

int main()
{
	int a = 5;
	printf("%d\n", 10 * DOUBLE(a));
}

上面的宏会像我们预想中的那样输出100吗?也不会;所以正确的宏定义应该像如下这样:

#define SQUART(x) ((X)*(x))
#define DOUBLE(x) ((X)+(X))

这样不管我们以何种方式来使用宏,答案都不会出错;所以用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中的操作符或邻近操作符之间不可预料的相互作用。

4、#define 替换规则

在程序中扩展#define定义符号和宏时,需要涉及如下几个步骤:

  1. 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号;如果是,它们首先被替换。
  2. . 替换文本随后被插入到程序中原来文本的位置;对于宏来说,参数名被他们的值所替换。
  3. 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号;如果是,就重复上述处理过程。

需要注意的是:

  1. 宏参数和 #define 定义中可以出现其他 #define 定义的符号;但是对于宏,不能出现递归。
  2. 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。

5、# 和 ##

对于 # 的作用,我们以下面的代码为例:

int main()
{
	int a = 10;
	printf("The value of a is %d\n", a);
	int b = 20;
	printf("The value of b is %d\n", b);
	return 0;
}

对于上面的代码,我们发现 printf 函数里面的内容有一些冗余,有人就想我们可不可以封装一个函数或者一个宏,来实现既可以打印 a,也可以打印 b?首先,函数是做不到的,因为 the value of a / the value of b 这里不同的地方在函数中是固定的,我们只能改变打印的值得大小;这时有人就设计出了一个宏:

#define PRINT(n) printf("The value of "#n" is %d\n", n)

int main()
{
	int a = 10;
	PRINT(a);
	int b = 20;
	PRINT(b);
	return 0;
}

我们可以看到,在PRINT 宏中,我们配合 # 实现了我们想要的功能,这时 # 的作用也体现出来了:# 可以把参数插入到字符串中。

对于 ## 的作用,我们也用一个例子来说明:

#define CLA(class, num) class##num

int main()
{
	int class001 = 100;
	printf("%d\n", CLA(class, 001));
	return 0;
}

所以 ## 的作用就是:##可以把位于它两边的符号合成一个符号

注意:# 和 ## 在我们日常代码中基本不用,大家在遇到这样的代码时知道它的意思就行了。

6、带副作用的宏参数

当宏参数在宏的定义中出现超过一次的时候,如果参数本身带有副作用,那么我们在使用这个宏的时候就可能出现危险,导致不可预测的后果;副作用就是表达式求值的时候出现的永久性效果。

x+1;  //不带副作用
x++;  //带有副作用

下面的例子可以模拟具有副作用的参数所引起的问题:

#define MAX(a,b) ((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);
	return 0;
}

实际上上面的代码经过预处理之后 z 的表达式变成:

z = ((x++) > (y++) ? (x++) : (y++))

所以上面的代码不仅不能得到我们想要的 z 的值,还会让 x 和 y 的值变得不可控。

7、宏和函数对比

宏相较于函数的优点

  1. 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多;所以宏比函数在程序的规模和速度方面更胜一筹。
  2. 函数的参数必须声明为特定的类型,而宏是类型无关的,一个宏可以完成不同类型的计算任务。

宏相较于函数的缺点

  1. 每次使用宏的时候,一份宏定义的代码将插入到程序中;除非宏比较短,否则可能大幅度增加程序的长度。
  2. 宏是没法调试的,因为在预处理阶段宏就会被全部替换掉。
  3. 宏由于类型无关,也就不够严谨。
  4. 宏可能会带来运算符优先级的问题,导致程容易出现错。
  5. 宏不能递归。

基于上面的结论,宏通常被应用于执行简单的运算,而复杂、代码量大的运算通常由函数来完成。

宏有时候可以做函数做不到的事情;比如:宏的参数可以出现类型,但是函数做不到。例如:

#define MALLOC(num, type) (type *)malloc(num * sizeof(type))

MALLOC(10, int);//类型作为参数

//预处理器替换之后:
(int*)malloc(10 * sizeof(int));

宏与函数的详细对比

-属性

-#define 定义宏

函数

代码长度

每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长。

函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码。

执行速度

更快。

存在函数的调用和返回的额外开销,所以相对慢一些。

操作符优先级

宏参数的求值是在所有周围表达式的上下文环境里, 除非加上括号,否则邻近操作符的优先级可能会产生 不可预料的后果,所以建议宏在书写的时候多些括号。

函数参数只在函数调用的时候求值一次,它的结果值传递给函数。表达式的求值结果更容易预测、

带有副作用的参数

参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果。

函数参数只在传参的时候求值一次,结果更容易控制。

参数类型

宏的参数与类型无关,只要对参数的操作是合法的, 它就可以使用于任何参数类型。

函数的参数是与类型有关的,如果参数的类型不同,就需要不同 的函数,即使他们执行的任务是 不同的。

调试

宏是不方便调试的

函数是可以逐语句调试的

递归

宏是不能递归的

函数是可以递归的

8、命名约定

由于函数的宏的使用语法很相似,所以语言本身没法帮我们区分二者; 为了区分宏和函数,我们平时的一个习惯是:

  • 宏名全部大写。
  • 函数名不要全部大写。

9、#undef

这条指令用于移除一个宏定义:

#undef NAME
//如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除

10、命令行定义

许多C 的编译器提供了一种能力,允许在命令行中定义符号,用于启动编译过程。例如:当我们想根据同一个源文件编译出不同的一个程序的不同版本的时候,就可以使用命令行定义。(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一 个机器内存大写,我们需要一个数组能够大写。)

#include <stdio.h>
int main()
{
    int array[ARRAY_SIZE];
    int i = 0;
    for (i = 0; i < ARRAY_SIZE; i++)
    {
        array[i] = i;
    }
    for (i = 0; i < ARRAY_SIZE; i++)
    {
        printf("%d ", array[i]);
    }
    printf("\n");
    return 0;
}

11、条件编译

在编译一个程序的时候,我们如果要将一条语句(一组语句)编译或者放弃,我们就可以使用条件编译指令。比如说:调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。

#include <stdio.h>
#define __DEBUG__
int main()
{
	int i = 0;
	int arr[10] = { 0 };
	for (i = 0; i < 10; i++)
	{
#ifdef __DEBUG__
		printf("%d\n", arr[i]);//为了观察数组是否赋值成功。
#endif //__DEBUG__
	}
	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
    
4.嵌套指令
#if defined(OS_UNIX)
	#ifdef OPTION1
    	unix_version_option1();
    #endif
    #ifdef OPTION2
 		unix_version_option2();
    #endif
#elif defined(OS_MSDOS)
	#ifdef OPTION2
 		msdos_version_option2();
    #endif
#endif

12、文件包含

1、头文件被包含的方式

我们常用的文件包含方式有两种:库文件包含和本地文件包含。

库文件包含

包含形式:#include <filename>

查找策略:查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。

本地文件包含

包含形式:#include “filename”

查找策略:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件;如果找不到就提示编译错误。

所以实际上对于库文件也可以使用 “” 的形式包含,但是这样做查找的效率就低一些,并且也不容易区分是库文件还是本地文件了。

2、嵌套文件包含

有些时候我们的程序中会出现同一个头文件被重复包含的情况,比如一个 stdio.h 头文件被包含多次,这时我们可以使用条件编译来避免头文件被重复包含:

#ifndef __TEST_H__
#define __TEST_H__
//头文件的内容
#endif   //__TEST_H__

我们也可以在程序中加入这句话来防止重复包含:

#pragma once

六、相关练习题

1、习题1

答案:A 程序经过预处理生成 test.i 文件,然后通过对 test.i 文件进行编译汇编生成 test.o 文件。

2、习题2

答案:C 我们知道,程序在编译阶段会进行符号汇总,然后在汇编阶段生成符号表,最后在链接阶段进行符号表的合并与重定位,如果我们调用的函数未定义,那么在汇编阶段生成符号表的时候与该函数符号相关联的地址就是无效的,那么我们链接进行合并时就会抛出链接性错误。

3、习题3

下面文件中定义的四个变量中,哪个变量不是指针类型?

#define INT_PTR int*
typedef int* int_ptr;
INT_PTR a,b;
int_ptr c,d;

答案:b 我们知道 #define 最后会进行符号替换,而 typedef 是类型重命名,相当于给 int* 重新起一个名字叫 int_ptr,所以 c d 都是指针类型,而 b 的前面还应该有一颗 * 。

4、习题4

下面代码的执行结果是什么?

#define N 4
#define Y(n) ((N+2)*n) 
int main()
{
	int z = 2 * (N + Y(5 + 1));
	printf("%d\n", z);
	return 0;
}

答案:70

上面代码经过预处理之后的结果是:

#define N 4
#define Y(n) ((4+2)*5+1) 
int main()
{
	int z = 2 * (4 + ((4+2)*5+1));
	printf("%d\n", z);
	return 0;
}

所以结果为 70。

5、习题5

模拟实现 offsetof 宏:由于 offsetof 宏的模拟实现我在结构体中已经讲了,所以这里我就直接给结论了,如果对 offset 的模拟实现有问题的同学可以看看我前面的文章 – 【C语言】自定义类型详解:结构体、枚举、联合

#include <stdio.h>
#define OFFSETOF(type, member) (size_t)&(((type*)0)->member)
struct S1
{
	char c1;
	int i;
	char c2;
};

int main()
{
	printf("%d\n", OFFSETOF(struct S1, c1));
	printf("%d\n", OFFSETOF(struct S1, i));
	printf("%d\n", OFFSETOF(struct S1, c2));
	return 0;
}

6、习题6

写一个宏,可以将一个整数的二进制位的奇数位和偶数位交换。

思路分析

要把一个整数的二进制位的奇数位和偶数位交换,我们可以分为三步:第一步,将该数的奇数位全部向右移动一位;第二步,将该数的偶数位全部向左移动一位;第三步,将移动后的奇数位和偶数位相加。(假设位数从0开始)下面,我们来具体实现。 第一步:我们可以让该数按位与上二进制的 10101010 10101010 10101010 10101010,使得该数的偶数位全部变为0,然后将该数向右移动一位。 第二步:我们可以让该数按位与上二进制的 01010101 01010101 01010101 01010101,使得该数的奇数位全部变为0,然后将该数向左移动一位。 第三步,将移动后的奇数位和偶数位相加。

代码实现

#define MOVE_BIN_DIG(n) n=((n&0xaaaaaaaa)>>1)+((n&11111111)<<1)
// aaaaaaaa:10101010 10101010 10101010 10101010的十六进制形式
// 11111111:01010101 01010101 01010101 01010101的十六进制形式