1、程序的翻译环境和执行环境
在ANSI C的任何一种实现中,存在两个不同的环境。
第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。
第2种是执行环境,它用于实际执行代码。
1、每个源文件单独经过编译器处理,或生成一个对应的目标文件。
在windows环境下目标文件是以.obj
后缀的文件。
在Linux环境下目标文件是以.o
后缀的文件。
在Linux环境下目标文件是以.out
后缀的文件是可执行程序。
2、所有的目标文件+链接库,使用链接器,链接生成一个可执行程序。
我们在引用头文件的时候,需要用到库函数依赖的东西,在链接库中提供。
3、所需要的两个工具:在windows、VS环境下
- 编译器:cl.exe
- 链接器:link.exe
1、组成一个程序的每个源文件通过编译过程分别转换为目标文件。
2、所有目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。
3、链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库(程序员自己写的函数),将其需要的函数也链接到程序中。
2、编译、链接细节剖析
其实编译阶段由可以细分为三部分:预编译/预处理、编译、汇编
如下图:
这里使用Linux环境下的Gcc编译器,来观察具体每一步都发生了什么?
1、预处理 选项:
#-o output,此选项是把输出在屏幕上的信息,改为输出在文件中。
gcc -E test.c -o test.i
预处理完成后就停下来,预处理之后产生的结果都放在test.i文件中。
通过观察可以发现,预处理做如下事情:
-
头文件的包含:将指令
#include
所包含的内容,放在test.c文件中。 -
将
define
定义符号的替换并且删除define指令比如://预处理之前 #define MAX 100; int m = MAX; //预处理之后,把#define MAX 100删除,且将MAX替换为100。 int m = 100;
-
删除注释。
-
…
总结:预处理阶段做的都是文本操作。比如:一些符号的替换,一些符号的删除,头文件的包含。
2、编译 选项:
gcc -S test.i
编译完成后就停下来,结果保存在test.s中。
- 生成了test.s文件。
- 把C语言代码转换成汇编代码。
- 语法分析、词法分析、符号分析、语义分析。
- 这里重点说一下—符号分析。符号分析就是会把:全局变量,自定义函数,main函数先全部统计一下。
3、汇编 选项:
gcc -c test.s
汇编完成之后就停下来,结果保存在test.o中。
- 将上面编译产生的test.s文件会变为test.o文件,这个test.o文件就是目标文件。
- 此操作会把汇编代码转为二进制指令。
- 形成符号表。这个对应编译中的
符号分析
什么是符号表呢?
比如现在工程中有一个test.c和add.c文件,add.c文件里面由个Add函数,test.c文件有个main函数和对Add和拿书的声明。它们经过每个.c文件都会经过预处理
和编译
和汇编
,最终生成test.o和add.o目标文件。
在生成add.o文件时,Add是个符号,会给Add函数一个地址,用来关联Add函数,用符号表存放地址来关联Add函数。
然后在生成test.o文件时,声明的Add是个符号,会声明的Add一个无效的地址,并且给main函数一个地址。全部都用符号表关联起来。
所以一共生成两个符号表。
4、如果想要一步直接走完以上三步:
gcc test.c
5、链接
- 合并段表。
- 符号表的合并和重定位。
上面汇编过程生成个两个符号表:
现在在链接阶段需要将符号表进行合并和重定位。
就是把上面的两个符号表,给合并为一个符号表,并且Add是重复的,需要筛选出一个真实的Add符号,肯定会把声明的Add给抛弃掉,因为声明Add是个无效的地址,而真实的Add函数是有效的地址。所以会保留Add函数的符号表。这就是符号表的重定位。合并后的符号表,如下:
__链接过程将项目中的多个目标文件以及所需的库文件链接成最终的可执行文件 (Executable File) 。 __
3、运行环境
程序执行的过程:
1、程序必须载入内存中,在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必 须由手工安排,也有可能是通过可执行代码置入只读内存来完成。
2、程序的执行便开始,接着便调用main函数。
3、开始执行程序代码,这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址,程序 同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留它们的值。
4、终止程序。正常终止main函数,也有可能时意外终止。
4、预处理详解
4.1、预定义符号
__FILE__ //进行编译的源文件
__LINE__ //文件当前的行号
__DATE__ //文件被编译的日期
__TIME__ //文件被编译的时间
__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义,VS编译器不遵循ANSI C。Gcc遵循
这些预定义符号都是语言内置的。
举个例子:
printf("file:%s line:%d\n",__FILE__,__LINE__);
#include <stdio.h>
int main()
{
printf("file:%s line=%d date:%s time:%s\n", __FILE__, __LINE__, __DATE__, __TIME__);
return 0;
}
输出:
用处:可以在记录日志时使用。
4.2、#define
4.2.1、#define定义标识符
语法:
#define name stuff //后面不要加分号;
举个例子:
#define MAX 100
#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__);
4.2.2、#define定义宏
#define机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义(define macro)。
下面是宏的声明方式:
//parament-list 参数列表
#define name(parament-list) stuff //后面不要加分号;
其中的parament-list
是一个由逗号隔开的符号表,它们可能出现在stuff中。
注意:
参数列表的左括号必须与name紧邻。
如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。
eg1:写一个x+y的宏
#include <stdio.h>
//定义宏
#define Add(x,y) ((x)+(y));
int main()
{
int a = 10;
int b = 20;
int c = Add(a, b);
printf("%d\n", c);
return 0;
}
如:写一个x*x的宏
#include <stdio.h>
//定义一个x*x的宏
#define SQUARE(x) x*x
int main()
{
int ret = SQUARE(5);
printf("%d\n", ret);
return 0;
}
重点:宏是完成替换的,而不是先计算的。
那是如何个替换呢?
如下:
#include <stdio.h>
//定义一个x*x的宏
#define SQUARE(x) x*x
int main()
{
int ret = SQUARE(5+1); //把此参数变为5+1。
printf("%d\n", ret);
return 0;
}
输出:
分析:
宏只是替换操作,5+1
在SQUARE里面不会计算为6,然后在传给#define定义的宏。而是直接把5+1
传给#define定义的宏,然后x*x就变为了:5+1*5+1=5+5+1=11。
那如何改进呢?
如下:
#include <stdio.h>
//定义一个x*x的宏
#define SQUARE(x) ((x)*(x)) //加上小括号,每个参数加个括号,然后整体的宏也加小括号。
int main()
{
int ret = SQUARE(5+1);
printf("%d\n", ret);
return 0;
}
输出:
4.2.3、#define宏的替换规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤。
1、在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换。
2、替换文本随后被插入到程度中原来文本的位置。对于宏,参数名被它们的值所替换。
3、最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过 程。
注意:
1、宏参数和#define定义中可以出现其它#define定义的符号。但是对于宏,不能出现递归。
2、当预处理器搜索#define定义的符号的时候,字符串常量的内容并不内搜索。
4.2.4、#和##
1、#
#
----------把参数插入到字符串中
如下代码:
#include <stdio.h>
//定义一个x*x的宏
#define PRINT(N) printf("the value of "#N" is %d\n",N);
int main()
{
int a = 10;
PRINT(a);
return 0;
}
输出:
这个输出结果其实就是:
printf("the value of ""a"" is %d\n");
#
的作用就是把参数当作字符串给插入到字符串中去。并不是替换为相对应的值。
2、##
##可以把位于它两边的符号合成一个符号。
它允许宏定义从分离的文本片段创建标识符。
如下代码:
#include <stdio.h>
#define CAT(Class,num) Class##num
int main()
{
int Class111 = 100;
printf("%d\n", CAT(Class, 111));
return 0;
}
输出:
4.2.5、带副作用的宏参数
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现永久性效果。
例如:
x+1; //不带副作用
x++; //带副作用
4.2.6、宏和函数的对比
宏通常被应用于执行简单的运算。
比如:在两个数中找出较大值。宏和函数的对比
#define MAX(x,y) ((x)>(y)?(x):(y))
int Max(int x, int y)
{
return (x>y?x:y);
}
int main()
{
return 0;
}
那为什么不用函数来完成这个任务呢?
原因有二:
1、用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。
_所以宏比函数在程序的规模和速度方面更胜一筹。
2、更为重要的是函数的参数必须声明为特定的类型。
所以函数只能在类型合适的表达式上使用。反之这个宏可以适用于整型,长整型,浮点型等可以用来比较> 来比较的类型。
宏是类型无关的。
适用宏时,就相当于运行个表达式。
而使用函数时,需要函数调用(参数传参,栈帧创建)、计算、函数返回。
宏的缺点:
- 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。
- 宏时没法调式的。
- 宏由于类型无关,也就不够严谨。
- 宏可能会带来运算符优先级的问题,导致容易出现错误。
宏有时候可以做函数做不到的事情。比如:宏的参数可以出现类型,但是函数做不到。
比如:要实现malloc(40)的功能,我们在计算我们需要多少大小的字节时,如果数字的情况下就不方便,我们能不能这样写:malloc(10,int)。如果能这样写就很方便了。但是很遗憾,函数不支持,那现在只能使用宏来实现了。
如下:
#include <stdio.h>
#define MALLOC(num,type) (type*)malloc((num)*sizeof(type))
int main()
{
int* p = MALLOC(10, int);
return 0;
}
//以上代码就转换为如下:
int* p = (int*)malloc(10*sizeof(type));
宏和函数的区别总结如下:
属性 | #define宏 | 函数 |
---|---|---|
代码长度 | 每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长 | 函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码 |
执行速度 | 更快 | 存在函数的调用和返回的额外开销,所以相对慢一些 |
操作符优先级 | 宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多些括 | 函数参数只在函数调用的时候求值- -次,它的结果值传递给函数。表达式的求值结果更容易预测。 |
带有副作用的参数 | 参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果。 | 函数参数只在传参的时候求值一次结果更容易控制。 |
参数类型 | 宏的参数与类型无关,只要对参数的操作是合法的.它就可以使用于任何参数类型。 | 函数的参数是与类型有关的,如果参数的类型不同,就需要不同的函数,即使他们执行的任务是相同的。 |
调试 | 宏不方便调试 | 函数是可以逐语句调试的 |
递归 | 宏是不能递归的 | 函数可以递归。 |
4.2.7、命名约定
一般来讲函数的宏的使用语法相似,所以语言本身没法帮我们区分二者。
那可以通过平时习惯:
- 把宏名全部大写。
- 函数名不要全部大写。
4.3、#undef
这条指令用于移除一个宏定义。
#undef NAME
//如果现存的一个名字需要被重新定义,那么它的旧名字首先要移除。
#include <stdio.h>
#define M 100
int main()
{
printf("%d\n", M); //这个正常运行
#undef M
printf("%d\n", M); //这个会报错。
return 0;
}
4.5、条件编译
常用编译指令:可以选择是否进行编译某个语句,或者说同时有多个语句,我们选择判断来决定编译那个语句。
也可以指是否判断被定义。
#define M 3
1、单分支条件编译
#if M<5
//...
#endif
//常量表达式由预处理器求值
eg:
#define __DEBUG__ 1
#if __DEBUG__
//...
#endif
2、多分支条件编译
#define ...
#if M<3
//...
#elif M>6
//...
#else
//...
#endif
3、判断是否被定义
#define ...
#if define(symbol)
#ifdef symbol
#if !define(symbol)
#ifndef symbol
4、嵌套指令
#if define(AAA)
#ifdef OPTION1
//...
#endif
#ifdef OPTION2
//...
#endif
#elif defined(BBB)
#ifdef OPTION3
//...
#endif
#endif
在编译一个程序的时候我们如果将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。
比如说:
调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。
#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 // __DEBUG__
}
return 0;
}
输出:
4.6、文件包含
我们已经知道,#define
指令可以使另外一个文件被编译,就像它实际出现于#define
指令的地方一样。
我们来说一下文件包含的重要性。
现有有个test.c文件和test.h文件。
//test.c
#include "test.h" //这个引入的使test.h
int main()
{
return 0;
}
//test.h
int Add(int x, int y);
如上场景在Linux下观察,执行命令gcc -E test.c -o test.i
进行预编译后,我们会发现,会将test.h文件的Add函数声明内容给放进test.c里面,这个很正常。
但是如果,我不小心多次引用头文件呢?
//test.c
#include "test.h" //这个引入的使test.h
#include "test.h"
#include "test.h"
#include "test.h"
#include "test.h"
int main()
{
return 0;
}
//test.h
#pragma once
int Add(int x, int y);
这回我引用了5次头文件,那是否会将test.h中的Add函数声明内容也放test.c文件里面5次呢?
答案:是的,会!!!。
所以这问题就出来了,那如果我们在引用<stdio.h>的时候不小心多引用了几次呢?那者会多出几千甚至上万行重复的代码。因此我们需要利用上面条件编译来防止这种事情发生。
我们可以这样做:
#ifndef __TEST_H__
#define __TEST_H__
int Add(int x, int y);
#endif
//这里我们使用条件编译来判断一下,就行了。
//分析过程:第一次引用头文件时,由于__TEST_H__我们没有定义,所以#ifndef为真,然后向下运行,#define 定义__TEST_H__。然后如果我们重复引用头文件,那第二次执行#ifndef,由于第一次定义了__TEST_H__,所以if哦按段为假,不在执行。这样就避免了重复引用头文件。
当然也有个更简单的写法可以避免以上问题:
#pragma once //添加这一行内容,这个作用:防止头文件被多次重复的包含
int Add(int x, int y);
4.7、在引用头文件时,<>和""的区别
我们在以往的学习中会发现,在引用库自带的头文件和引用我们自己创建的头文件的时候,方式不一样。
- 引用库自带的头文件:
#include <stdio.h>
。 - 引用自创建的头文件:
#include "stdio.h"
主要区别就是符号的不同。
<>
和""
的区别是查找的策略不同
<>
查找的策略:直接去编译器提供的库目录下查找。""
查找的策略:- 先去代码所在的路径下查找。
- 如果上面的找不到,再去编译器提供的库目录下查找。
所以说,我们在引用系统自带的头文件时,也可以用"",但是不推荐,因为需要查找两次,效率会变低。
#include "stdio.h"
Linux环境的标准头文件的路径:
/usr/include
4.8、笔试题
1、头文件中的ifndef/define/endif是干什么用的?
2、#icnlude<filename.h>和#include "filename.h"有什么区别。
5、模拟实现offsetof
那如何模拟实现一个offsetof宏呢?如下思路:
我们将每一个成员的存储首位置减去结构体的起始位置就可以得到偏移量。
那可不可以这样:我们把结构体的起始位置变为0,那么每个成员的首地址,其实就是偏移量了。
下面代码实现:
#include <stdio.h>
#include <stddef.h>
//type*就是struct S*
//(struct S*)0是吧结构体起始位置强制转为从0开始
//(struct S*)0)->m_name 指向每一个成员变量
//(size_t)将每一成员变量的地址强制类型转为int类型的,从而求出偏移量。
#define OFFSETOF(type,m_name) (size_t)&(((type*)0)->m_name)
struct S
{
char c1;
int i;
char c2;
};
int main()
{
struct S s = { 0 };
printf("%d\n", OFFSETOF(struct S, c1));
printf("%d\n", OFFSETOF(struct S, i));
printf("%d\n", OFFSETOF(struct S, c2));
return 0;
}
输出: