说明

这是一个完善了但又不完善的笔记,或许以后会更新

可以参考但请务必超越

源文件


Tools

VS2019
VScode
Typora
PicGo

程序的编译

test.c是怎么变成test.exe的呢?

本篇内容:

VS2019 集成开发环境 - 封装起来不是很容易观察

Linux 使用gcc更容易观察

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

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

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

第2种是执行环境,它用于实际执行代码。

我们经常说 编译-链接-运行。

接下来就开始整。

1.翻译环境

翻译环境有两个步骤,编译和链接,里面又有很多的细节

以通讯录为例。每一个源文件都要经过编译器处理,变成目标文件。然后通过链接器处理变成可执行文件。

在VS环境下。编译器为cl.exe,连接器为link.exe。

在文件中查看:

.obj - object - 目标文件

我们在使用一个函数的时候,都会有函数的说明。

其中就有lib - 也就是库。我们在使用的时候都会链接这些库。

例如printf:

编译 - 链接 - 运行

前面大概的说明了一下。那么接下来就讲讲详细的内容。

是的,编译就有3个步骤

在VS2019上我们先写一个简单代码:

如果我们创建了2个源文件,把加法函数写在add.c中,那么test.c需要使用就要函数声明一下。

当然在这里就直接简化成c=a+b好了。

预编译

接下来,我们在Linux中查看。

虽然vs也可以查看,但是使用Linux其实更容易一些。

另外Linux也有很多发行版的系统,我们一般用服务器版的CentOS。Linux也是有桌面版本拥有完整UI界面的。

本次使用环境为:Linux CentOS 7.6 64bit。由死妈腾讯云提供支持。

Linux环境的部署及其他相关知识这里概不讲述。

使用vim命令,会新建一个空的test.c文件并直接打开。

image-20211007144731970

我们点击“i”键切换到INSERT输入模式

输入代码即可。

点击ESC退出输入模式,输入:wq保存退出。

使用ls可以看到当前目录下已经创建好了test.c文件。

image-20211007144530261

在Linux上我们使用gcc来编译C/C++程序

那么,使用gcc test.c来编译一下,ls查看

可以看到生成了一个a.out的文件。

a.out是默认生成的可执行程序。在windows上就是test.exe嘛。

./执行她看一看。

打印出了c=a+b也就是30。

可以看到,gcc一口气把预编译-编译-汇编-链接全部完成了。

那么我们想看一下预编译之后的情况,可以使用-E的选项来使她停下来。

回车确认后会发现,我了个大草。居然直接生成了大概800多行的代码

那么我们这样,使用-o选项output输出到别的文件中假设为test.i。或者也可以使用一个大于号箭头> test.i重定向到test.i中。都可以。

使用ls查看当前目录下的文件,可以看到test.i已经生成了。

那么我们打开他看一看

emm,我没有给vim设置显示行号,所以不知道这是显示在了哪里。没关系,直接使用“大写G”键跳转到最后一行。可以看到我写的代码了。

这里有一点要注意,鼠标滚轮是不能上下查看文件的,需要使用键盘上下箭头。

那么问题来了,这800多行代码是个什么鬼东西呢?

其实到这里大致就已经能猜到了。那么我们回头再次编写一下test.c文件吧。

把头文件删掉了。再次gcc编译并且-E -o重定向到test.i。

可以看到,就剩下几行代码了。

#include <stdio.h>;

原来,预处理阶段会处理一些事情,其中就有预处理包含

举个栗子

我们自己写一个头文件

就写一行代码。退出保存ls查看一下。

好,然后在test.c中包含一下。并且再写一如一个MAX的值吧。

gcc编译-E -o test.i

对比一下

小结

预编译阶段所做的预处理:

  1. 头文件的包含 #include
  2. defube 定义符号的替换

  3. 删除注释

以上这些都是属于文本操作

编译

在上面预编译我们使用了-E选项,预处理完成后就停下来。那么

  1. 预处理 选项 gcc -E test.c -o test.i 预处理完成之后就停下来,预处理之后产生的结果都放在test.i文件中。
  2. 编译 选项 gcc -S test.c 编译完成之后就停下来,结果保存在test.s中。
  3. 汇编 gcc -c test.c 汇编完成之后就停下来,结果保存在test.o中。

讲到这里,对于Linux其实应该推荐一下陈皓编写的VIM学习资料

简明 VIM 练级攻略:https://coolshell.cn/articles/5426.html

给程序员的VIM速查卡:https://coolshell.cn/articles/5479.html

前面我们已经预处理完了,test.i文件已经有了,那么我们就直接-S就可以了

ls查看一下

vim打开test.s查看一下内容

可以看到里面的内容虽然有点能看得懂,当然也不是二进制文件。哦,想起来了,之前函数栈帧的时候看到过,这是汇编代码。那么

编译这一步就是把C语言代码转化为汇编代码

我们看到了她转变成汇编代码,那么实际上她进行了什么事情呢?

  1. 语法分析
  2. 词法分析
  3. 语义分析
  4. 符号汇总

有这样一个《编译原理》,可以教你从0开始做一个编译器!你甚至可以直接百度,百度就都有内容。

这里主要可以讲一下符号汇总

符号汇总:汇总的都是全局的符号

就比如我们写了一个Add,他就会汇总一个Add。我们写了一个Add和main,就会汇总这2个符号。

汇编

和前面一样。gcc使用-c选项编译一下test.s,注意哦是小写c

ls查看一下

.o,就类似于windows下的.obj。他们都是目标文件

打开看一下

笑死,二进制,根本看不懂。当然vs的.obj也一样

那么,汇编这一步就是把汇编代码转化成了二进制指令(机器指令)。

这一步会生产一个表:形成符号表

假设还是Add和main

链接

前面在vs中写的2个源文件生成add.o和test.o后,把他们链接起来

做两件事:

  1. 合并段表
  2. 符号表的合并和重定位

首先看看段

在Linux系统下,test.o的二进制文件,会使用elf的格式来组织文件,当然我们看不懂。前面我们生成的a.out其实也是elf的文件格式。

有一个工具叫readelf可以看得懂

可以看到他的全部指令说明

我们使用-a来查看,看看段的组织

这里可以看到很多段,就比如[ 1]的.text文本段什么什么的。

符号

符号在readelf的最下面

可以看到main。当然如果在test.c中使用Add或者其他,都会在这里显示。

小结

合并段表、符号表的合并和重定位

用图来表示,段表合并就很好理解。符号表合并也容易理解,那么重定位其实也不难。

试想一下如果add.c的Add被删除,那么使用Add函数的test.c不就找不到Add这个符号了吗?test.c只是使用了,但是他并没有Add这个符号的地址。

这就是重定向,这个Add函数的地址在add.c中,使用这个函数的test.c是没有这个符号地址的。所以在合并的时候,要把Add符号的地址重定向以确保Add函数可以使用。

到此时,预编译-编译-汇编-链接这4个大的步骤就讲完了。一个代码变成程序的过程就是这样。

简述Linux操作视频

使用ssh链接这一块还有其他的软件和方式,我这里还推荐使用Windows Terminal来操作,我会专门出一篇文章。

注:有一本书《程序员的自我修养》,写得非常深入,是国内少有的好书。对于以上内容有很深的讲解。

2.运行环境

这个部分其实就很简单了,我们现在使用的电脑,windows系统,上面用过的Linux,就是环境嘛。这里也就简单说一下。

程序执行的过程

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

3.预处理详解

3.1预定义符号

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

直接打印看看

STDC的话,在vs下并没有定义.因为vs不完全支持ANSI C 。在linux下的gcc是支持的。

gcc对c语言语法的支持非常好

3.2#define

#define name stuff

在C语言下#define能做2件事:

  1. 定义符号
  2. 定义宏

定义符号就简单了嘛

在预处理阶段,就会把MAX替换成100

查看一下test.i

可以看到预处理阶段结束后,MAX被替换成了100

定义宏和定义符号的区别就是有参数。

3.2.1 定义符号(定义标识符常量)

有的时候觉得一个符号太长,就会使用#define定义一下,这样就会变得很好写。当然在预处理阶编译器就会帮我们替换掉。

#define reg register    //为 register这个关键字,创建一个简短的名字
int main()
{
    //register int num1 = 100;
    //就可以写成
    //ret int num2 = 200;
    //预处理结束后就都会变成
    register int num1 = 100;
    register int num2 = 200;
    return 0;
}
注意

#define定义的时候不要加分号!";" 也会被算进去的。不加基本上就没有问题。

比如说

#define MAX 100;//这里有分号的话
int main()
{
    int m = MAX;
    //预处理阶段就会变成
    //int m = 100;;
    //你会发现这里有2个分号,那不就是2条语句吗。好像也没问题
    //那么如果打印一下呢?
    printf("%d\n", MAX);
    //这不就变成了
    //printf("%d\n", 100;);
    //这指定是不行的
    
    //还有if语句
    //if后面只能跟一条语句
    return 0;
}
define和typecho的区别

还有一点,#define是定义,在预处理阶段就会被替换。typedef是重命名,typedef重命名一个int为int_t是不会被替换的,int_t就和int一样是一个类型!

尤其是关于指针!

#define ptr_t int*
typedef int* ptr_t2;

int main()
{
    ptr_t p1, p2;
    //预处理后就会被替换为
    int *p1, p2;//p1是指针,p2是整型
    
    ptr_t2 p3, p4;//p3和p4都是指针类型
    return 0;
}

3.2.2 定义宏

#define name( parament-list ) stuff

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

比如一个平方

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

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

定义宏在预处理阶段一样会全部替换,所以如果是表达式的话,必须要合理使用小括号!

参数列表的左括号必须与name紧邻。 如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。(那不就变成定义符号了呗)

上面的平方宏,建议写成带括号的形式

#define SQUARE(x) (x)*(x)
//或者再加一个括住整体
#define SQUARE(x) ((x)*(x))

如果不加括号会有什么问题呢?

#define SQUARE(x) x*x

int main()
{
       int a = 5;
    int ret = SQUARE(a+5);
    //注意,如果我们想要5+5也就是10的平方100的话
    printf("ret = %d\n", ret);//35
    //这里为什么打印了35呢
    return 0;
}

预处理完成后,替换符号,我们看一下。注意a+5是以一个整体的形式传入x

#define SQUARE(x) x*x

int main()
{
       int a = 5;
    int ret = a + 5 * a + 5;
    //替换后可以看到没有括号,就变成了5+5*5+5
    printf("ret = %d\n", ret);
    return 0;
}

表达式中有符号,定义宏中的符号比表达式中的符号优先级高,那肯定先算优先极高的啦。

加上括号就没事了

反过来如果宏在表达式中,宏中的符号没有表达式的符号优先极高,也会有问题。

可以看到

int ret = 10 * DOUBLE(2);
//预处理后替换为
int ret = 10 * 2 + 2;

3.2.3 #define替换规则

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

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

注意:

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

3.2.4 #和

这个不是markdown语法的一级二级标题。

这里有一个点

如何把参数插入到字符串中?

自动连接

打印字符串有一个特点:

int main()
{   
    printf("hello world\n");
    printf("hello ""world\n");
    printf("hello"" world\n");
    return 0;
}

打印看看会发现他们都会自动连接合成hello world。C语言中相邻的字符串自然会这样。

那么有一个代码

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;
}

如果我们需要打印内容。还要变化a和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;
}

可以看到,这个地方#n在预处理阶段就被替换成了"a"和"b"。

printf("the value of ""a"" is %d\n", a);
printf("the value of ""b"" is %d\n", b);
\

##可以把位于它两边的符号合成一个符号。 它允许宏定义从分离的文本片段创建标识符。

很好理解,假设ab=100,那么现在拆分开a和b,使用##就可以连接起来。

#define CAT(X,Y) X##Y

int main()
{    
    int ab = 100;
    printf("%d\n", CAT(a, b));//把a和b合成ab
    return 0;
}

3.2.5 带副作用的宏参数

int main()
{
    int a = 10;
    //int b = a + 1;//b得到的是11,a不变
    int b = ++a;//b得到的是11,但是a变了,这个表达式是有副作用的
    return 0;
}

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

举个栗子,求两个数的较大值
#define MAX(X,Y) ((X)>(Y)?(X):(Y))

int main()
{
       int a = 5;
    int b = 8;
    int m = MAX(a++, b++);
    //替换一下
    //int m = ((a++) > (b++) ? (a++) : (b++))
    printf("%d\n", m);//9
    //宏的参数是替换进去再计算
    printf("%d\n", a);//6
    printf("%d\n", b);//10
    return 0;
}

如果是函数的形式

int Max(int x, int y)
{
    return x > y ? x : y;
}

int main()
{
       int a = 5;
    int b = 8;
    int m = MAX(a++, b++);
    printf("%d\n", m);//8
       printf("%d\n", a);//6
    printf("%d\n", b);//9
    //函数的参数是计算后再传进去的
       return 0;
}

3.2.6 宏和函数对比

上面两个数求较大值的对比,当然这里刨除副作用。那么,如此简单的任务,宏要好一些。

那为什么不用函数来完成这个任务?

原因有二:

  1. 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹

函数是需要调用的。调用函数和从函数返回都是有时间开销的。此处回顾函数栈帧,可以记得很多很多的操作。那么宏呢?在此处求较大值的2个汇编代码我们看一下。

可以看到使用宏就这么点。那么对比函数:

call之前

数一下mov push mov push call jmp这就6条

call(进入函数)

emm...

call之后

可以看到函数是前后都做了很多的铺垫,函数栈帧都有说。

  1. 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以用于>来比较的类型。宏是类型无关的

只要传参就行了,根本没有类型。函数的话,如果要比较浮点数那就得用float。

当然和宏相比函数也有劣势的地方:

  1. 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。

什么意思呢,意思就是说函数写一份就够了,用的时候进函数就行了。宏用的时候,要把宏替换进去,用多少次就有多少个宏写进去,就算宏的代码少,那使用的多了肯定也不行的呀。

  1. 宏是没法调试的。

预处理就替换完运行完了,还调试个寂寞?

  1. 宏由于类型无关,也就不够严谨。

根本没有类型嘛。

  1. 宏可能会带来运算符优先级的问题,导致程容易出现错

副作用嘛。

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

例如malloc

#define MALLOC(num,type) \
    (type*)malloc(num*sizeof(type))//'\' - 续航符号

int main()
{
    //int* p = (int*)malloc(100 * sizeof(int));
    //malloc(100, int);//能不能这样写,这岂不是很爽?可惜不行。函数没法传类型。
    
    //那我们写宏
    int* p = MALLOC(100, int);
    //这时候int就能传了,因为宏是预处理时替换
    //替换一下就是
    //int* p = (int*)malloc(100 * sizeof(int));
    return 0;
}
宏和函数的对比
属性#defifine定义宏函数
代码长度每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码
执行速度更快存在函数的调用和返回的额外开销,所以相对慢一些
操作符优先级宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多些括号。函数参数只在函数调用的时候求值一次,它的结果值传递给函数。表达式的求值结果更容易预测。
带有副作用的参数参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果。函数参数只在传参的时候求值一次,结果更容易控制。
参数类型宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型。函数的参数是与类型有关的,如果参数的类型不同,就需要不同的函数,即使他们执行的任务是不同的。
调试宏是不方便调试的函数是可以逐语句调试的
递归宏是不能递归的函数是可以递归的
命名约定

一般来讲函数和宏的使用语法很相似。所以语言本身没法帮我们区分二者。

那我们平时的一个习惯是:

宏的名字一般都是大写的!

MAX

函数的名字一般不全大写!(首字母大写)

Max

当然也不绝对,例如getchar在某些编译器下是宏。

注意:命名约定是程序猿们使用时的习惯。一般我们最好也必须遵守。因为这样统一可以使大家都适应得了,尤其是读代码的时候。

如果就非要和别人不一样,非要和别人对着干,那只能打死了。

3.3#undef

用于移除一个宏定义

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

这个就简单了,一个宏不要了移除他就ok了。

#define MAX 100

int main()
{
    int m = MAX;
#undef MAX
    int n = MAX;//err    
    return 0;
}

3.4命令行定义

这个只能在linux下展示,并不是在所有的编译器下都好处理。

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

举个栗子

现在我们有一个test.c(可以看到SZ现在没有定义)

gcc编译一下会看到裂开,提示SZ是个什么玩意gcc不认识

那么这时,我们使用-D选项

可以看到,我们是在命令行输入命令,定义SZ=10,然后gcc编译通过生成了a.out。这就是命令行定义

-D的后面有没有空格无所谓

具体情况具体分析

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

3.5条件编译

条件编译指的是:满足条件代码就参与编译,不满足条件,代码就不会编译。

emm,字面就很好理解。

在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。

接下来举亿个栗子

就很简单

1.单分支条件编译

#if 1/0
#endif
int main()
{
#if 1
    printf("hehe\n");//1为真,那就打印。
#endif
#if 0
    printf("hehe\n");//0为假,那就不打印。
#endif
    return 0;
}

Linux下看一看

就没了嘛。当然编译一下也什么都没有。编译了个寂寞。

某种意义上相当于注释,只不过是使用代码注释

#if 0
int main()
{
    //这是第一个main函数
    return 0;
}

int main()
{
    //这是第二个main函数
    !@#$%^&*随便写
        反正没有用
    return 0;
}
#endif

VS2019下直接提示为灰色

判断只要是真假即可,但是不能使用变量,只能是常量

预处理阶段就要进行的事情,变量压根参与不到

#define m 2

int main()
{
#if m == 2
    printf("hehe\n");
#endif
    int a = 2;
#if a == 2
    printf("hehe\n");//err
#endif    
      return 0;
}  

2.多分支条件编译

注意,既然看到条件编译指令是使用了if,那么必然会有多分支的情况

#if 1/0
#elif
#else

我的定义在上面,现在M是100,可以看到elif和else是灰色的,if M == 100显得高亮

那么200和其他情况

我们仍未知道当时M定义了多少,反正前2个是100和200。

这是#define M 500的情况。

3.判断是否被定义

#if defined()/!defined()
#endif

#ifdef()/!ifdef()
#endif
#define MAX 0
//随便定义,只要是MAX

int main()
{
#if defined(MAX)
    printf("hehe:MAX");
    //只要有MAX这个定义,就打印
#endif
    
#ifdef(MAX)//一模一样
    printf("hehe:MAX");
#endif
    return 0;
}

反面就在前面加个!嘛。

#define MAX 0

int main()
{
#if !defined(MAX)
    printf("hehe:MAX");
#endif
    
#!ifdef(MAX)
    printf("hehe:MAX");
#endif
    return 0;
}

4.嵌套指令

就套进去嘛。这个容易理解了,前面的指令可以嵌套的。

经典代码:

#if defined(OS_UNIX)//如果是unix操作系统
    #ifdef OPTION1//如果是unix下的设置1
        unix_version_option1();//执行这条指令
    #endif
    #ifdef OPTION2//如果是unix下的设置2
        unix_version_option2();//执行这条指令
    #endif
#elif defined(OS_MSDOS)//如果是windows操作系统
    #ifdef OPTION2//如果是windows下的设置2(可能只有2没有别的,别的就为假不执行了呗)
        msdos_version_option2();//执行这条指令
    #endif 
#endif

在小的工程其实一般也用不到,但是大工程会用到。就比如我们的编译器。

可以看看VS的头文件,stdio.h。里面就有很多

你可以在程序目录下找,也可以在解决方案资源管理器中直接打开外部依赖项文件夹找

s开头的话应该在下面

他妈的这么长。

3.6文件包含

我们在包含自己写的头文件时,使用的是“”双引号。包含库目录下的头文件时使用的是尖括号<>。

哎对,#define “ ” 就是文件包含。

“”和<>区别

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

也就是说,对于自己定义的头文件使用“”

  1. 首先是在当前工程的目录下查找
  2. 如果第一步找不到,就去库函数的目录下查找

如果使用<>就直接去库函数所在的目录下查找

低版本的VS很容易找,但是VS2019就不容易找了,不过还是找得到。可以自己搜索,每个人VS安装的文件都不一样嘛。

Linux环境的标准头文件的目录:

cd /usr/include/

那么有个问题,stdio.h能不能用“”

当然可以

#include "stdio.h"

这样的话查找效率会低一些,而且也不容易区分是库文件还是本地文件。

嵌套文件包含

有这么一种情况,看图发现comm.h在test1.c中包含了一下,test2.h包含了一下。这时应该是没问题的。但是test.c包含了test1.h和test2.h。那么comm.h其实就是包含了2次。

是真的包含了2次。这样其实就会影响效率。(只是一般没感觉因为现在科技太发达)

极端一点:

现在有一个test.c

可以看到包含了3次自己写的test.h头文件

test.h中只有一行代码,那么预处理完成后会是什么样的呢

可以看到,草,3次。

那么怎么避免类似的问题出现呢?

很简单,条件编译

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

或者

#pragma once

防止头文件被重复多次的包含。即使你test.c写错了包含了很多次,也不会出问题。

VS2019会默认包含

再次编译看看

首先是头文件

test.c保持不变

编译后看看test.i

注: 推荐《高质量C/C++编程指南》中附录的考试试卷(很重要)。

其中的笔试题:

  1. 头文件中的 ifndef/define/endif是干什么用的?
  2. #include <filename.h> 和 #include "filename.h"有什么区别?

4.其他预处理指令

这个就太多了

#error
#pragma
#line
...

光这篇文章就讲了很多

#define
#elif
#else
#endif
#include
#if
#ifdef
#ifndef
#undef

可以参考《C语言深度解剖》学习

总结

呼。C语言基本上就到这里吧。全部的内容基本上都整理完成了。

还有再深入的内容可以以后再深入。

小头图版权:《♡》by Pナツ 2021年10月5日凌晨1点24分 pid:93233351

广告位招租
最后修改:2021 年 10 月 12 日 05 : 09 AM
如果觉得我的文章对你有用,请喂饱我!(理直气壮)