如若这一生注定磨难,自由与真我千金不换
一、程序的翻译环境和运行环境
任何一个C语言程序在执行时,都会存在两个不同的环境。
第一个是翻译环境:在这个环境中C程序的源代码会被转换为可执行的机器指令(二进制指令)
第二个是执行环境:它用于实际执行代码
1.翻译环境(编译(预编译、编译、汇编)+链接)
a.在一个工程当中,由于需求的多种多样,程序猿往往要写出很多很多的代码,并且不同的程序猿所在的小组需要完成的任务也不一样,那么在这个工程当中就一定会出现很多的源文件,等到所有小组完成任务之后产生这么一个.exe的可执行程序文件。
b.但从代码到可执行程序的过程中要经过的工作可是太多了,总不能从代码直接变出来一个可执行程序吧,我们下面就详细的介绍翻译环境中,程序从代码开始,要经历什么养的步骤。
1.预编译:gcc test.c -E -o test.i
我们在linux下写了一段代码,接下来我们就通过gcc编译器,将这个代码细分为3个步骤执行起来,直到产生目标文件test.o。
在预处理源文件之后,预处理之后的内容默认显示到我们的显示屏上了,并且我们可以发现#include <stdio.h>这条指令没有了,取而代之的是800多行的代码,只不过我们看不懂这个代码罢了,而且我们还发现注释的内容被删除了,#define所定义的符号被替换,并且替换的同时,符号也被删除了。
其实#include <stdio.h>被800多行代码替代的原因就是,stdio.h这个文件被展开在我们的test.c源文件里面,如果不相信的话,我们可以查找一下stdio.h这个文件内容是否和test.c源文件中被替换的#include <stdio.h>之后的内容相同,这便可以证明上面我们所说的话。
由图片便可以证明stdio.h这个文件在预编译的时候,确实被展开在我们的test.c源文件里面。
所以在预编译阶段,编译器做的事情有以下:
2.编译:gcc test.i -S
gcc编译代码过后会产生一个test.s的文件,test.s文件中的内容其实就是预编译、编译阶段过后产生的文件。
下面我们通过vim来查看一下编译过后产生的test.s文件会是什么样的呢?
由图片我们可以看到,编译过后,原来的代码已经被转为汇编代码了。
代码转为汇编其实还需要语法分析、词法分析、符号汇总、语义分析等步骤才可完全转换为汇编代码。
我们重点来说一下符号汇总,这个非常的重要,后面汇编阶段产生目标文件,链接阶段产生可执行程序都会用到。
符号汇总:将全局域里面的变量名,函数名等等都汇总起来。
所以在编译到汇编这个阶段,编译器做的事情有以下:
3.汇编:gcc test.s -c(生成可重定位目标二进制文件)
汇编阶段过后,汇编代码就会被转成机器指令或称之为二进制指令,这些指令会存到可重定位目标二进制文件.obj中。
接下来还是用gcc继续编译下一个阶段,并用vim查看一下test.o里面的内容。
通过vim打开test.o可以查看到其中的内容,当然我们肯定啥都看不懂,因为这个文件已经不是文本文件,而是变成了一个二进制文件,只有机器能读得懂他。
在汇编阶段还有一件非常重要的事情就是形成符号表,在之前的编译阶段,编译器已经给我们把符号都汇总起来了,现在在汇编阶段我们要将汇总的符号以及每个符号所在的地址粘合在一起,形成符号表。
综上所述,在汇编阶段,编译器做的事情如下:
4.链接:符号表的合并(很重要)
前面编译(宏观)阶段生成的.o目标文件,例如add.o,test.o等目标文件在Linux下有一种格式,叫做elf格式,而且.exe这样的可执行程序文件也是elf格式的,所以在链接期间,编译器会做一个准备工作就是合并段表,将相同格式的文件合并,汇总到.exe可执行程序文件当中。
链接期间还要做一件事情就是,符号表的合并和重定位,这个工作就要利用到我们前面汇编阶段产生的符号表了,最终的可执行程序文件.exe肯定只有一个符号表,这个符号表就是我们之前各个目标文件中的符号表合并而来的。
合并的符号表是非常重要的,其中全局域中的变量、函数、结构体等等的地址都会在这个合并之后的符号表,我们可执行程序中能否顺利的使用这些不同文件里面的东西,都取决于合并符号表中是否存放他们的有效地址。
如果是:链接阶段不会产生问题,可以顺利的产生可执行程序文件.exe
如果不是:链接阶段在使用某个函数或其他东西时,发现这个地址是无效的,那么在链接阶段就会产生错误。
下面的错误其实就是典型的链接错误,test.c产生的目标文件test.o中的符号表中存放的就是Add函数的无效地址,所以在链接期间编译器就会报链接错误。
合并符号表就是为了让我们在链接期间能够跨文件,通过符号表中存放的有效地址找到我们所需要的东西,使得各个文件互相关联,不在是独立的个体,更好的解决项目的多种需求
2.运行环境(程序入口main 到 程序终止)
程序执行过程:
1.程序必须载入内存中。
在有操作系统的环境中:一般这个由操作系统完成。在独立的环境(没有OS环境)下,程序的载入必须由手工安排,也可能是通过可执行代码植入只读内存来完成。
2.开始执行程序:
开始调用main函数 (程序的入口)
3.开始执行程序代码:
这个时候程序将使用一个运行时堆栈(stack),也就是函数栈帧,来存储函数的局部变量和返回地址。(运行时堆栈 = = 函数栈帧)
程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值(声明周期长)。
4.终止程序:
正常终止main函数,也有可能是意外终止。
二、预处理(预编译)阶段展开讲解
1.C语言内置预定义符号
__FILE__ 进行编译的源文件 __LINE__ 文件当前的行号 __DATE__ 文件被编译的日期 __TIME__ 文件被编译的时间 __STDC__ 如果编译器遵循ANSI C,其值为1,否则未定义 int main() { int i = 0; for (i = 0; i < 10; i++) { printf("File:%s line=%d date:%s time:%s i=%d\n", __FILE__, __LINE__, __DATE__, __TIME__, i); } return 0; }
我们可以利用C语言的内置符号,将代码所在的文件位置,修改此文件的日期,时间,代码所在的行号等等都输出到终端上面,这就是内置符号,我们可以直接拿来用。
一般情况下,这种内置符号在写日志的时候,会有一些用途,例如我可以用文件操作,将一系列的信息存到一个log.txt文件当中。
int main() { int i = 0; FILE* pf = fopen("log.txt", "w"); if (pf == NULL) { return 1; } for (i = 0; i < 10; i++) { printf("File:%s line=%d date:%s time:%s i=%d\n", __FILE__, __LINE__, __DATE__, __TIME__, i); } fclose(pf); pf = NULL; return 0; }
文件操作结束后,我们便可以在log.txt文件当中,看到我们刚刚输入到文件的信息了。
第五条内置符号__STDC__ 可以检测一下我们的编译器是否严格遵循ANSI C标准。
下面我们在vs和gcc两个编译器中测试到,vs是不支持的,gcc顺利的输出了结果1,也就说明他是严格支持ANSI C标准的。
vs不支持ANSI C 标准
gcc编译器是遵循ANSI C标准的
如果有某些语法问题,vs和gcc两个平台是不一样的时候,以gcc编译器为标准。
因为gcc编译器是严格符合ANSI C标准的
2.#define定义标识符
语法:#define name stuff
#define MAX 1000 #define STR "hello wyn" #define print printf("hello wyn\n") //不要加分号,因为他是纯粹的替换 #define DEBUG_PRINT printf("file:%s\tline:%d\tdate:%s\ttime:%s\n",\ __FILE__,\ __LINE__,\ __DATE__,\ __TIME__) int main() { printf("%s\n", STR); printf("%d\n", MAX); print; DEBUG_PRINT; return 0; }
1.在用#define定义标识符的时候,我们不要加;分号,这很容易导致下面使用标识符时,发生语法错误。
2.如果定义的名字过长,可以利用反斜杠\来当作续行符
3.额外的一个小知识点,vs上可以利用工程中的属性–>预处理器–>预处理到文件–>test.i文件,我们便可以在文件中查找到预处理之后的代码
3.#define定义宏(带有参数)
#define除可以用来定义表示符外,还可以定义宏,与标识符不同的是,在定义宏时,#define机制允许我们将参数替换到文本中,这样的实现我们称之为宏。
宏的声明方式:#define name(parament-list) stuff,name里面是一个参数表,这些参数会出现在stuff内容当中,等到我们使用宏的时候,stuff中的参数就会被直接替换掉。
#define SQUARE(X) X*X #define SQUARE(X) (X)*(X)//修改之后的定义宏 int main() { int r = SQUARE(5); printf("%d\n", r); r = SQUARE(5+1);//你以为答案是6,可惜答案是11 // 5 + 1 * 5 + 1 printf("%d\n", r); return 0; } #define DOUBLE(X) (X)+(X) #define DOUBLE(X) ((X)+(X))//定义宏的时候,不要吝啬括号 int main() { int r = DOUBLE(3 * 2); printf("%d\n", r); r = 10 * DOUBLE(3);//你以为答案是60,可惜答案是33 //10*(3)+(3) //用修改之后的宏,替换后就是10*((3)+(3)),就是你想要的答案60 printf("%d\n", r); return 0; }
值得注意的是,在使用宏的时候,他是在预编译阶段进行替换的,编译器可不会管你参数之间有什么运算符之类的东西,编译器只管替换,并且替换之后,还会把我们定义的宏给删除掉。
所以在使用宏的时候,难免就会出一些运算上的问题,为了避免产生不必要的麻烦,大家在定义宏的时候,不要吝啬我们的括号,它可以给我们省去许多在运算值上面所产生的问题。
4.#define所定义的标识符和宏的替换规则
a. 在调用宏时,首先对宏参数进行检查,看看是否包含由#define定义的标识符。如果有,标识符首先会被替换掉。
例如,我们在使用宏DOUBLE(X)时,传的参数中含有上面定义好的标识符,那编译器在预编译阶段会首先将这个标识符用100给替换掉.
#define M 100 #define DOUBLE(X) ((X)+(X)) int main() { "M";//这些常量字符串如果和宏或标识符重名,预编译阶段是不会被替换的。 "DOUBLE(3)";//都不会被替换 DOUBLE(M + 2); //替换 //((100+2)+(100+2)) return 0; }
b. 代码中使用宏的地方,会被我们定义宏时的替换文本给替换掉。
例如上面的代码,预编译结束之后,DOUBLE(M+2)会被替换为((100+2)+(100+2))
c. 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述过程。
注意:
预处理器在搜索#define所定义的标识符或宏时,字符串常量的内容是不会被搜索到的
例子可见上面代码的注释部分
5.#和##的作用(替换为字符串 && 合并两边宏参数)
1.#的作用:并不是宏参数的单纯替换,而是替换为带有宏参数的字符串
官方定义: ANSI C 会自动将相邻的两个字符串常量连接,如果它们之间没有逗号隔开的话。
//我们先来看这样一段代码,如果你运行后,会发现结果其实是一模一样的, //所以字符串其实是具有自动链接的特点的 int main() { printf("hello wyn\n"); printf("hello ""wyn\n"); return 0; }
有了上面知识的铺垫,我们用一个需求来引出#的作用
我们想要利用一个宏来同时输出下面的the value of a is …和the value of b is …这些内容,值得注意的是,函数是无法完成这样的功能的,函数输出的字符串中的n不能变成我们想要传的a或b的值,它只能是一个固定的值。
这个时候就需要#和宏来实现了。
void print(int n) { printf("the value of n is %d\n", n);//传过来的是一个参数怎么完成下面那样的功能呢? //这时候就要用到我们所学的宏了。 } int main() { int a = 10; printf("the value of a is %d\n", a); int b = 20; printf("the value of a is %d\n", b); return 0; }
这样我们就可以利用字符串自动连接的特点以及#+宏参数替换为字符串等等,来实现上面的需求。
预编译阶段过后,PRINT(a)—>printf(“the value of a is %d\n”,a)和PRINT(b)—>printf(“the value of b is %d\n”,b);自然而然我们的需求就会被解决了
#define PRINT(N) printf("the value of " #N " is %d\n",N) //#N会被预处理为"N"也就是被处理为字符串,然后替换。 int main() { int a = 10; PRINT(a); int b = 20; PRINT(b); return 0; }
2.##的作用:合并左右两边的宏参数
预编译阶段过后,宏就会被替换为lovewyn,也就是word与name两个宏参数进行合并
#define CAT(word,name) word##name int main() { int lovewyn= 100; printf("%d\n", CAT(love, wyn)); return 0; }
6.带副作用的宏参数(a+1 &&a++)
由于a++会自增,所以在预编译阶段宏参数进行替换时,会产生副作用,所以我们尽量使用a+1这样不带有副作用的宏参数,因为如果一旦宏参数过多,宏的内容过大,在替换时,我们极大概率是不能快速分析出宏替换后的答案的,所以建议大家不要使用带有副作用的宏参数。
宏预编译阶段替换后的结果为((a++) > (b++) ? (a++) : (b++)),大家可自行分析答案。
#define MAX(a,b) ((a)>(b)?(a):(b)) int main() { int a = 5, b = 4; int m = MAX(a++, b++); printf("m=%d\n", m); printf("a=%d b=%d\n", a, b); 答案:m=6,a=7,b=5 return 0; }
7.宏和函数对比+两者命名的约定
一、宏的优点:
a.宏比函数在程序运行速度和性能开销方面更好一些。
宏通常被应用于执行简单的计算,例如求出两个数的最大值,等等
#define MAX(a, b) ((a)>(b)?(a):(b))
不使用函数来完成这样简单的计算是因为调用函数代价太大,在预编译、编译、汇编、链接等阶段,函数一直都要参与,而宏只需要参与预编译阶段即可,代价非常小,所以我们选择用宏来实现这些简单的计算。
b.宏是与类型无关的,它只负责替换
函数的参数是必须有类型的,所以我们在给函数传参时,必须考虑类型,但宏根本不需要考虑类型,整型,浮点型,长整型都可以作为我们的宏参数,但函数必须考虑这些问题。
c.宏的参数中可以直接出现类型
例如下面的代码,宏参数中的type允许出现数据的类型
#define MALLOC(num,type) (type*)malloc(sizeof(type)*(num)) int main() { MALLOC(10, int);//如果想用malloc开辟空间,我们可以直接使用宏 return 0; }
二、宏的缺点:
a.宏较长的话,可能大幅增加程序的长度
如果代码中出现多次使用宏的情况,并且宏的内容是较长的话,那极有可能增加我们程序的长度,不容易走读代码和调试。
b.宏是无法调试的
宏在预编译阶段就已经完成替换了,并且#define定义的所有东西都会在预编译阶段被删除的干干净净,而当我们开始调试时,宏的内容已经被替换的面目全非了。
我们肉眼看到的代码和实际的代码已经是不同了,所以我们无法进行调试的工作,因为我们看到的已经不是实际的代码了。
c.宏由于类型无关,也就导致它不够严谨
d.宏可能会带来运算符优先级的问题,容易导致程序出现错误
如果我们定义宏时,括号使用的不到位,在替换时就很有可能出现错误,但函数是不会存在这样的问题的
e.宏的参数可能带有副作用&&宏不可以递归
三、命名的约定:
驼峰法命名函数,全部大写命名宏
当然也不一定宏必须全大写,只不过我们约定俗成全大写,例如下面两个虽然不是全大写,但他们在某些地方的的确确就是宏。
offsetof – 宏 getchar – 宏
8.#undef(有点儿鸡肋)
我们可以使用#define来定义宏,也可以使用#undef来取消我们的宏定义
#define M 100 int main() { printf("%d\n", M); #undef M printf("%d\n", M); return 0; }
三、命令行定义(不在代码中定义符号)
许多C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。
我们写了一段代码,但是sz的长度是未知的,自然代码编译也无法通过,这个时候我们可以采用命令行定义的方式,给出sz的大小
在编译test.c时,我们利用-D选项给出了sz的定义。允许后,就打印出了0到99的数字。
四、条件编译(随心所欲的编译代码)
在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。
比如说为了检测代码是否正确,我们有时会选择写一些调试性的代码,这些代码删除了有些可惜,保留下来又很碍事,所以我们可以选择性的编译。
满足条件我们就让这条指令参与代码的编译,不满足条件就让这条代码不参与编译。
#include <stdio.h> #define __DEBUG__ int main() { int i = 0; int arr[10]; for (i = 0; i < 10; i++) { arr[i] = i; #ifdef __DEBUG__ //这个地方判断为真endif和ifdef之间的语句才会参与编译,否则不参与编译 printf("%d ", arr[i]); #endif } return 0; }
常见的条件编译指令:
a.单分支条件编译
#if 常量表达式(不可以含有变量哦)
//…
#endif
//常量表达式由预处理器求值。
int main() { #if 2==3 //逻辑表达式,判断为true,参与编译,判断为false,不参与编译。 printf("hehe"); #endif }
b. 多分支条件编译
#if 常量表达式
//…
#elif 常量表达式
//…
#else
//…
#endif
#define M 3 int main() { #if M<5 printf("hehe\n");//预处理阶段这些预定义的东西都会被删除 #elif M==5 printf("haha\n"); #else M<5 printf("heihei\n"); #endif return 0; }
我们可以看到预编译之后的代码,所有的预定义符号都被删除掉了。
c.判断是否被定义
条件编译还可以用来判断某些标识符是否被定义。
例如下面代码,如果MAX被定义我们可以让编译器输出一个语句,如果没有定义我们也可以让它输出语句,这完全取决于我们的需求,我们可以控制是否编译的条件。
#define MAX 1000 int main() { #if defined MAX printf("max\n"); #endif #if !defined MAX//!反逻辑操作符,入伏哦没定义MAX,我们输出max语句 printf("max\n"); #endif //上下两段代码表达的意思都是相同的,只是写法上有些不同而已,随便选一种写法即可。 #ifdef MAX printf("max\n"); #endif #ifndef MAX//反逻辑的另一种写法#ifndef printf("max\n"); #endif return 0; }
d.嵌套指令
在实际当中,嵌套指令用的还是比较多的,这其实和我们以前学的分支语句是比较相似的,这里也就不再细致的讲解了。
#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
我们可以看一下stdio.h文件中的源码,嵌套式的条件编译指令用的还是非常多的,所以我们要掌握并且了解条件编译,以后的工作中可能会频繁的用到。
五、文件包含(< >包含 && " "包含)
头文件如果被包含多次的话,会出现代码冗余的这样一种现象,如果你的头文件有个1000行代码,我们一不小心在test.c文件中包含多了,不小心的厉害了,那可就完了。
我们的test.c文件在预编译的时候,就会展开你刚刚包含的头文件,所以test.c文件一下子就多了5000多行代码,这给我们程序可带来了不小的开销啊。
由于我们注释的代码过多,test.i文件中代码和头文件相隔太大了,我截图没办法给大家截全,但是我们只要知道,头文件被包含多次,在一个大型的工程中,还是一个不容忽视的错误的。
解决这样的问题有两种办法:
a.利用条件编译指令
利用条件编译指令,让我们的头文件代码只会参与编译一次,即可解决问题。
如果没有定义标识符__TEST_H,下面语句会参与编译。等到第二次想要再包含头文件时,__TEST_H已经被定义过了,所以下面语句就不会再次参与编译了,这也就变相地实现了test.h文件只会被包含一次的效果。
//防止头文件被重复多次包含 #ifndef __TEST_H #define __TEST_H int Add(int x, int y); #endif
b.利用预处理指令
再头文件中包含一个预处理指令#pragma once 也可以解决头文件被重复包含的问题
还有一个要补充的知识点:
<>和"“两种文件包含方式,其实就是在查找的方式上不同。
1.<>直接去include的库目录下去查找我们的头文件
2.”"先去代码所在的路径下面查找,如果找不到在去库目录下查找
六、offsetof宏的实现
写一个宏,计算结构体中某变量相对于首地址的偏移,并给出说明
这个题还是比较简单的,只要我们结构体的地址从0开始,那么后面每个结构体成员的地址默认其实就是他们相对于首地址的偏移量了。
所以我们只要将数字0强制转换成结构体的地址,也就是struct S*结构体类型的指针,我们便可以通过这个指针找到所有结构体的成语,然后我们对成员变量进行取地址操作符,这样我们就拿到了每个成员的地址,为了输出整数形式的偏移量,我们再把成员变量的地址强转成size_t就好了。
#include <stdio.h> #include <stddef.h> struct S { char c1; int i; char c2; }; #define OFFSETOF(type,M_name) (size_t)&((type*)0)->M_name //如果地址从0开始,那么地址强制类型转换后就是成员的偏移量 //默认对齐数是8,取较小的作为对齐数 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));//总共占了9个字节,但得是4的倍数,所以结构体大小是12字节 printf("%d", sizeof(struct S));//答案就是12 //printf("%d\n", offsetof(struct S, c1)); //printf("%d\n", offsetof(struct S, i)); //printf("%d\n", offsetof(struct S, c2));//总共占了9个字节,但得是4的倍数,所以结构体大小是12字节 //printf("%d", sizeof(struct S));//答案就是12 return 0; }
七、交换整数二进制位的奇数位和偶数位
我们利用1和0或1按位与还是它本身的特点,分别拿出这个整数的偶数部分的二进制位并且向右移动一个比特位,再拿出这个整数的奇数部分的二进制位向左移动一个比特位,最后重新加起来就可以了,这样就交换了整数的奇数位和偶数位。
#include <stdio.h> #define CHANGE(N) ((N&0xaaaaaaaa)>>1)+((N&0x55555555)<<1) int main() { int num = 0; scanf("%d", &num); printf("%d", CHANGE(num)); }