程序编译和链接的过程/预处理符号和用法【C语言】
1. 程序的翻译环境和执行环境
标准规定C程序中需要有两种环境
- 翻译环境:源代码被转换为可执行的机器指令的环境
- 执行环境:用于执行代码的环境
2. 编译与链接
注:
.c后缀的文件称为源文件,需要编译
.h后缀的文件不需要编译
2.1 翻译环境
每个源文件(.c)都需要经过编译器单独处理,生成目标文件(.obj)。目标文件再与链接库结合,由编译器处理,生成可执行程序(.exe)
组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)。
每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。
链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中。
2.2 编译的三个阶段
2.2.1 预编译(.i)
- 包含头文件(#include)
- 删除注释
- 符号和宏(#define)的替换
可见,预编译(预处理)的阶段是对代码文本的操作
2.2.2 编译(.s)
将C语言代码翻译为汇编代码
- 词法分析:将一长串的代码分割为若干部分,让编译器知道哪里是循环,哪里是main函数等
- 语法分析:将若干部分串回来,让编译器判断代码是否符合规定的法则
- 语义分析:在将代码翻译为汇编代码之前,也要让编译器知道代码是怎么做的(它不能判断其是否符合逻辑)
- *符号汇总:
2.2.3 汇编(.o)
- 将编译产生的汇编代码翻译为机器能直接接收的二进制指令(机器指令)
- 生成符号表:将函数的地址和名字记录,形成表
2.3 链接
-
合并段表
-
符号表的合并与重定位
如上述的add函数,合并段表以后就有两个add的地址,编译器取有效的地址作为add函数的地址
2.4 运行环境
-
程序必须载入内存中。
在有操作系统的环境中:一般由操作系统完成。
在独立的环境中:程序的载入必须由手动操作,也可能是通过可执行代码植入只读内存来完成。
-
程序的执行便开始。接着便调用main函数。
-
开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。
-
终止程序:a. 正常终止main函数;b. 意外终止,如断电、崩溃等。
3. 预处理
3.1 预定义符号
部分语言内置的预定义符号
__FILE__ //进行编译的源文件
__LINE__ //文件当前的行号
__DATE__ //文件被编译的日期
__TIME__ //文件被编译的时间
__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义
例子
printf("file:%s line:%d\n", __FILE__, __LINE__);
3.2 #define
3.2.1 #define 定义标识符
#define name stuff
例子
#define MAX 1000
//不要加分号
// 如果定义的 stuff过长,可以分成几行写,除了最后一行外,
//每行的后面都加一个反斜杠(续行符)
//但不能加空格
#define DEBUG_PRINT printf("file:%s\tline:%d\t \
date:%s\ttime:%s\n" ,\
__FILE__,__LINE__ , \
__DATE__,__TIME__ )
仅示例,以下这三种写法是不被大多数人接受的,因为实际中代码不是为了自己而写
#define reg register //为 register这个关键字,创建一个简短的名字
register int num1 = 0;
reg int mun2 = 0;
//两种定义方式等价
#define CASE break;case //在写case语句的时候自动把 break写上。
#define do_forever for(;;) //用更形象的符号来替换一种功能
3.2.2#define定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义
宏(define macro)。
声明
#define name( parament-list ) stuff
parament-list
是一个由逗号隔开的符号表,它们可能出现在stuff中。
注意:
参数列表的左括号必须与name紧邻。
如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。
例
#include<stdio.h>
#define SQUARE(x) x*x
int main()
{
int a = 5;
printf("%d\n", SQUARE(a));
return 0;
}
结果为25
把printf("%d\n", SQUARE(a));
改为printf("%d\n", SQUARE(a+1));
结果会是36吗?
答案是11= 1 + 5 * 1 + 5
通过这个例子可以体会到宏仅仅是替换,而不是像函数那样传参
我们期望结果是36,但因为操作符优先级,导致了结果错误。所以为了达到期望的结果,我们可以使用括号
#define SQUARE(x) (x)*(x)
(1 + 5) * (1 + 5) = 36
仅仅这样够吗?
例
#include<stdio.h>
#define DOUBLE(x) (x)+(x)
int main()
{
int a = 5;
int ret = 10 * DOUBLE(a);
printf("%d\n", ret);
return 0;
}
以上面的思路,结果不是10 * 10 = 100,而是10 * 5 + 5 = 55
所以为了完全规避错误,应该再加一层括号
#define DOUBLE(x) ( (x)+(x) )
3.2.3 #define 替换规则
在程序中扩展#define定义符号和宏时,分为以下几个步骤。
- 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换。
- 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被它们的值替换。
- 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述过程,直到文本不包含宏定义的符号。
注意:
-
宏参数和#define 定义中可以出现其他#define定义的变量。但是对于宏,不能递归。
-
当预处理器(预编译)搜索#define定义的符号的时候,字符串常量的内容并不被包含在内。
#define MAX = 1000 #include<stdio.h> int main() { int a MAX; printf("MAX = %d", a); //如这里的常量字符串中的MAX是不会被扫描的 return 0; }
3.2.4 #和##
任何将参数插入字符串中?
例
void print(int a)
{
printf("a的值为%d\n", a);
}
#include<stdio.h>
int main()
{
int a = 10;
int b = 20;
print(a);
print(b);
return 0;
}
以上代码希望每次打印出来的语句x的值是与x对应的,但这种效果函数无法做到
再来看一例代码
#include<stdio.h>
int main()
{
printf("hello world\n");
printf("hello" " world\n");
return 0;
}
在使用打印函数时,将字符串拆开后打印的效果是一样的,因为该函数会将各部分的字符串连接成一个字符串来打印。
启发:
如果将上述的x变成一个“x”常量字符串插入要打印的语句中,那么打印出来的结果就会随x的变化而变化
下面介绍#在此处的用法
#include<stdio.h>
#define PRINT(x) printf(#x"的值为%d\n", x);
int main()
{
int a = 10;
int b = 20;
PRINT(a);
PRINT(b);
return 0;
}
##可以把位于它两边的符号合成一个符号。
它允许宏定义从分离的文本片段创建标识符。
用例
#include<stdio.h>
#define F(x, y) x##y
int main()
{
int xy = 88;
printf("%d\n", F(x, y));
return 0;
}
3.2.5 部分宏参数的副作用
引例
x = x + 1;
x++;
前者未改变x本身的值,无副作用
后者改变了x本身的值,有副作用
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果,如上例中的x++。
例
#define MAX(X, Y) ((X)>(Y)?(X):(Y))
#include<stdio.h>
int main()
{
int a = 10;
int b = 11;
int c = MAX(a++, b++);
printf("%d\n", a);
printf("%d\n", b);
printf("%d\n", c);
return 0;
}
分析
前面已经强调,宏在编译阶段是直接将#define定义的符号替换,而不是向函数那样传参
对于int c = MAX(a++, b++);
和#define MAX(X, Y) ((X)>(Y)?(X):(Y))
在编译阶段实际上代码已经是
int c = ( (a++)>(b++)?(a++):(b++) );
b实际上自增了两次
像这样带有副作用的宏,实际中谨慎使用
3.2.6 宏和函数对比
就上面的比较大小的功能,使用宏还是使用函数实现,哪个更好?
答案是宏,为什么?
#define MAX(X, Y) ((X)>(Y)?(X):(Y))
#include<stdio.h>
int Max(int x, int y)
{
return x > y ? x : y;
}
int main()
{
int a = 10;
int b = 11;
int ret1 = MAX(a, b);
int ret2 = Max(a, b);
printf("%d\n", ret1);
printf("%d\n", ret2);
return 0;
}
计算机的处理速度很快,我们无法体会到它们的差异
首先回想之前的知识:函数需要接收参数,也要返回参数,在调用函数之前需要做准备工作;宏仅仅是替换。
让我们大概看一下汇编代码,只需体会它们在数量上的差别即可
Max函数的汇编代码
MAX宏的汇编代码
单从汇编代码的数量上我们可以直观的感受到两者的差别。
为什么不用函数来完成这个任务?
- 宏比函数在程序的规模和速度方面更胜一筹,因为宏不需要像函数那样在调用前和调用完毕后进行的一系列工作,省去了函数调用和返回的开销,直接替换宏定义的符号
- 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在特定类型的表达式上使用,如上例只能比较int型的数据。然而宏可适用于任何可比较的数据类型。所以宏是和数据类型无关的,它做的事只有一个——替换。
一个例子体会宏的妙处
//malloc的使用有时会让人感觉有点麻烦
//用宏定义该函数,只需传数量和类型即可
#define MALLOC(num, type) ( type *)malloc(num * sizeof(type) )
MALLOC(10, int);//类型作为参数
//等价于(被替换后)
(int *)malloc(10 * sizeof(int));
宏并不是完美的
-
每次使用宏的时候,将宏定义的符号替换,然后插入到代码中。除非宏比较短,否则可能大幅度增加程序的长度。
#define ADD(X, Y) ((X)+(Y)) #include<stdio.h> int main() { int a = 1; int b = 2; int c = 1; int d = 2; int e = 1; int f = 2; //...... ADD(a, b); ADD(c, d); ADD(e, f); //...... return 0; } //在编译时,ADD被替换以后,这些代码会变得很多 //如果是函数,仅仅返回的是一个值,相比之下函数更简洁
-
宏本身产生的错误是不能通过调试发现的
宏定义的符号在编译时已经被替换,而我们调试的代码是生成.exe以后的代码,这时我们眼前的宏定义的符号在计算机看来符号已经不是它了,而是符号对应的宏
-
宏与类型无关,虽然它很妙,但使用起来不够严谨
-
宏可能会带来运算符优先级的问题,导致程容易出现错,就如前面的例子(a++)提到的那样
注:
约定:宏名全部大写;函数名部分大写或不大写
3.3 #undef
用于移除一个宏定义语句
#undef NAME
//如果现存的一个符号需要被重新定义,那么它的旧符号首先要被移除。
3.4 命令行定义
许多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;
}
在Lunix环境下的编译指令
gcc -D ARRAY_SIZE=10 programe.c
//programe是当前源文件的文件名
//gcc - 编译
// -D - 定义(define)
当按下回车,编译器便能进行编译,生成.exe文件
3.5 条件编译
在日常初学编程时,我们通常要对不同的代码分别编译,这时我们的习惯是将某部分代码注释掉,以便它们不被编译。但实际上,这样做不适合工程量很大的实际项目,我们需要有个像条件开关(switch)一样的工具,控制某些语句在某些条件下是否被编译。
编译指令1
#if 常量表达式
//...
#endif
//常量表达式由预处理器求值。
例
#include<stdio.h>
int main()
{
#if 0//常量表达式的值为真即可
printf("1 ");
#endif
printf("2 ");
return 0;
}
结果:1 2
如果将#if 1 == 1
改为#if 0
呢?
编译指令2:多个分支的条件编译
#if 常量表达式
//...
#elif 常量表达式
//...
#else
//...
#endif
例
#include<stdio.h>
int main()
{
#if 1 == 1
printf("1 ");
#elif 2 == 1//elif是else if的简写
printf("3 ");
#endif
printf("2 ");
return 0;
}
结果:1 2
编译指令3:判断是否被定义
//两种写法等价
#if defined(symbol)
#ifdef symbol
//两种写法等价
#if !defined(symbol)
#ifndef symbol
//两者互为否定
例
#include<stdio.h>
#define DEBUG1 0//这里的常量存在与否,不论何值都没关系
//只要有定义即可
int main()
{
#ifdef DEBUG1
printf("DEBUG ");
#endif
#ifndef DEBUG2
printf("NOT DEBUG2");
#endif
return 0;
}
注:#ifndef
的n是not的意思
结果为DEBUG NOT DEBUG2
第一部分:因为DEBUG1有定义,所以#ifdef DEBUG1
值为真,打印
第二部分:因为DEBUG2未定义,所以#ifndef DEBUG2
值为真,打印
编译指令4:嵌套指令
#if defined(A)
#ifdef OPTION1
a_option1();
#endif
#ifdef OPTION2
a_option2();
#endif
#elif defined(B)
#ifdef OPTION2
b_option1();
#endif
#endif
用缩进表示了各个嵌套的配对情况
小结
实际上,条件编译是被广泛应用的,比如这里随意打开一个编译器给出的头文件源代码
所以这个知识点蛮重要的
3.6 文件包含
在编译的第一个阶段预编译中,头文件被包含在当前源代码文件中(.c),是如何被包含的呢?
例如#include<stdio.h>这条语句,在编译时预处理器删除这条指令,如何将该头文件(.h)的内容替换至该位置,其实是有几百行的。
而每增加一条包含头文件的语句,都会将内容复制一次。
3.6.1 头文件被包含的方式
库文件包含
#include <filename.h>
本地文件包含
#include "filename"
查找流程:
先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在默认位置查找头文件。若找不到会提示编译错误。
由此看来用双引号包含头文件似乎是万能的方式,但这么做会影响效率。
举个栗子,假设一个工程要使用到很多个人做的函数,它们本身就已经包含了不少头文件,再加上自己引用的头文件,实际上已经包含了很多次头文件了,这就得让机器多找很多次头文件,影响效率。
所以两种引用头文件的方式要区分开,形成习惯。
那有没有什么方法解决这个问题呢?且看~
3.6.2 嵌套文件包含
像出现以上这种嵌套文件包含的情况,我们可以使用条件编译提高包含的效率,减少头文件包含的次数
每个头文件的开头写:
#ifndef __TEST_H__
#define __TEST_H__
//头文件的内容
#endif //__TEST_H__
这是一种久远的写法
或:
#pragma once
这是一种较新的写法,一些老旧的编译器无法通过编译
比如stdio.h的源代码在开头就这么写了
4. 一道笔试题
这是某年百度公司招聘系统工程师的笔试题
请编写宏,计算结构体中某变量相对于首地址的偏移,并给出说明
分析:这其实是让我们模拟实现offsetof函数,首先请回顾该函数的用法
#include<stdio.h>
#include<stddef.h>
struct Stu
{
char name[20];
int age;
char adrs[20];
};
int main()
{
int ret = offsetof(struct Stu, age);
printf("%d\n", ret);
return 0;
}
传入参数为结构体名和成员名
在模拟实现之前,要明确一点:某位置的偏移量是由该位置的地址与起始位置做差得到的,然而在宏定义中,我们无法得知它们的地址,因为我们都没有使用它,自然找不到成员的地址。
offsteof函数本身用了一种巧妙的方法:将0作为结构体的起始地址,成员变量的地址即为偏移量
即将0这个数字强转为结构体指针类型,这时就可以认为在0地址处创建了一个结构体,然后访问成员变量(此时类型是成员变量的类型),接着取其地址(因为偏移量是地址之差),再强转为int或size_t(unsignud
int)类型
#include<stdio.h>
#define OFFSETOF(struct_name, member_name) (int)(&(((struct_name*)0)->member_name))
struct Stu
{
char name[20];
int age;
char adrs[20];
};
int main()
{
int ret = OFFSETOF(struct Stu, age);
printf("%d\n", ret);
return 0;
}
结果:20
4/5/2022
Man9o
欢迎指正!