C语言
语雀文章链接:https://www.yuque.com/tintoki/znb576/wg5k4y
一、C语言入门
先说一下,本来之前是在中文网上简单学过一下C相关,因为和C++差距不是很大所以当时没有放在心上,但是最近在使用C做项目的时候,发现C使用最多的就是指针(C语言没什么高级特性,除了指针真的就没什么难点了),而恰好我对指针这一章其实理解的也不算透彻,故参考书籍《C和指针》做如下阅读笔记。
注意笔记里面我只会记录一些比较重要的概念,那些基本的、与C++重合的几乎不会出现;
2022/7/28 22:19 简单看了一下这本书,看到第二部分不太看得进去了,个人认为关于C指针这部分的内容应该不是光看书就能看懂的,实际上还是需要我们动手去练习才行,在做项目的过程中不断巩固指针相关的知识;
2022/10/31 15:38 这段时间稍微有点空,所以我打算把语雀上之前整理的知识点(参考的是C语言技术网 - 首页 (freecplus.net))整合一下,也方便之后索引查找;
0.规范
C语言代码的多行书写
在我们之前学习的过程中,编写的程序的功能很简单,一句代码很短,但是在实际开发中,函数参数往往很长很多,一句代码可能会很长,需要用多行才能书写。
如果我们在一行代码的行尾放置一个反斜杠,c语言编译器会忽略行尾的换行符,而把下一行的内容也算作是本行的内容。这里反斜杠起到了续行的作用
1 |
|
main函数的参数
main函数有三个参数,argc、argv和envp,它的标准写法如下:
1 |
|
注意事项:
1)argc的值是参数个数加1,因为程序名称是程序的第一个参数,即argv[0],在上面的示例中,argv[0]是./book101。
2)main函数的参数,不管是书写的整数还是浮点数,全部被认为是字符串。
3)参数的命名argc和argv是程序员的约定,您也可以用argd或args,但是不建议这么做。
1.基本概念
主要记录一些零碎的C的概念;
1.1 注释
C中的注释与C++相同是块注释/**/或者行注释//,然而当代码中已经存在块注释时(行注释不影响)使用块注释去禁用代码会出问题,如果想要注释掉包含块注释部分的代码可以使用条件预处理命令
1 |
|
1.2 链接属性
除了作用域以外,标识符的链接属性非常重要,我们知道作用域是指限制变量在程序的一定区域才能够被访问,而链接属性是指源文件被编译之后得到的目标文件中出现的相同标识符代表的意义;
连接属性可分为三种:
- external(外部):external链接属性的标识符不论声明多少次、位于几个源文件中都表示同一个实体;
- internal(内部):属于intemal链接属性的标识符在同一个源文件内的所有声明中都指同一个实体,但位于不同源文件的多个声明则分属不同的实体;
- none(无):没有链接属性的标识符总是被当作单独的个体,即该标识符的多个声明会被当作多个独立不同的实体;
标识符的作用域与其链接属性相关,但这两个属性并不相同;
关键字extern和static用于在声明中修改标识符的链接属性;
static使得external的标识符变为源文件私有,这可以防止被其他源文件调用;(static在不同的上下文中充当了不同的角色)
- 当它用于函数定义时,或用于代码块之外的变量声明时,static关键字用于修改标识符的链接属性,从external 改为internal,但标识符的存储类型和作用域不受影响。用这种方式声明的函数或变量只能在声明它们的源文件中访问;
- 当它用于代码块内部的变量声明时,static关键字用于修改变量的存储类型,从自动变量修改为静态变量,但变量的链接属性和作用域不受影响。用这种方式声明的变量在程序执行之前创建,并在程序的整个执行期间一直存在,而不是每次在代码块开始执行时创建,在代码块执行完毕后销毁;
extern使得该标识符可以在其他任何位置访问该实体;
1.3 指针简介
我们知道C语言中只有4种基本数据类型:
- 整型
- 浮点型
- 指针
- 聚合类型(数组、结构等)
其他所有的类型都是从这4种基本类型的某种组合派生而来,下面我们将详细介绍指针;
高级语言所提供的特性之一就是通过变量名而非地址来访问内存,注意变量名与内存之间的关联并不是硬件提供的,由编译器为我们实现,而硬件仍然通过地址访问内存;
1.3.1 NULL指针
NULL指针作为一个特殊的指针变量,表示不指向任何地址;
因为NULL指针未指向任何地址,所以对NULL指针进行解引用(*)是违法的,只能对非NULL指针进行解引用操作;
如果已经知道指针将被初始化执行什么地址,就将其初始化为该地址,否则可以将该指针初始化为NULL;
1.4 字符串变量
在C语言中,没有“字符串”这个数据类型,而是用字符数组来存放字符串,并提供了丰富的库函数来操作字符串。
1 |
|
注意几个细节:
1)如果要定义一个存放20个英文的字符串,数组的长度应该是20+1,因为字符串末位以空字符结束
2)中文的汉字和标点符号需要两个字符(两个char存放一个中文)宽度来存放(GBK编码)。
例如name[21]可以存放20个英文字符,或10个中文字符。
3)字符串不是C语言的基本数据类型,不能用“=”赋值,不能用“>”和“<”比较大小,不能用“+”拼接,不能用==和!=判断两个字符串是否相同,要用函数。
4)字符串的初始化不建议采用把第一个元素的值置为0的方式(strname[0]=0),这样会导致后面全都是垃圾值
1 |
|
1 |
|
1.5 C的输入和输出
在C语言中,有三个函数可以从键盘获得用户输入。
getchar
:输入单个字符,保存到字符变量中。gets
:输入一行数据,保存到字符串变量中。scanf
:格式化输入函数,一次可以输入多个数据,保存到多个变量中。
在C语言中,有三个函数可以把数据输出到屏幕。
putchar
:输出单个字符。puts
:输出字符串。printf
:格式化输出函数,可输出常量、变量等。
1.5.1 printf输出
- 函数格式
1 |
|
输出描述性文字
1 |
|
输出整数
1 |
|
输出字符
1 |
|
输出浮点数
1 |
|
输出字符串
1 |
|
注意,printf函数第一个参数(格式化字符串)的格式与后面的参数列表(常量或变量的列表)要一一对应,一个萝卜一个坑的填进去,不能多,不能少,顺序也不能错,否则会产生意外的结果。
当然我们上面介绍的并不是全部的printf,如输出指针地址等,这些需要使用的时候自行Google即可;
1.5.2 scanf输入
- 格式
1 |
|
输入整数
1 |
|
输入字符
1 |
|
输入浮点数
1 |
|
输入字符串
1 |
|
注意,scanf函数第一个参数(格式化字符串)的格式与后面的参数列表(变量的列表)要一一对应,一个萝卜一个坑的填进去,不能多,不能少,顺序也不能错,否则会产生意外的结果
2.变量
2.1 变量的作用域
作用域是程序中定义的变量存在(或生效)的区域,超过该区域变量就不能被访问。C语言中有四种地方可以定义变量。
1)在所有函数外部定义的是全局变量。
2)在头文件中定义的是全局变量。
3)在函数或语句块内部定义的是局部变量。
4)函数的参数是该函数的局部变量。
2.1.1 全局变量
全局变量是定义在函数外部,通常是在程序的顶部(其它地方也可以)。
全局变量在整个程序生命周期内都是有效的,在定义位置之后的任意函数中都能访问。
全局变量在主程序退出时由系统收回内存空间
2.1.2 局部变量
在某个函数或语句块的内部声明的变量称为局部变量,它们只能在该函数或语句块内部的语句使用。
局部变量在函数或语句块外部是不可用的。
局部变量在函数返回或语句块结束时由系统收回内存空间。
PS:局部变量和全局变量的名称可以相同,在某函数或语句块内部,如果局部变量名与全局变量名相同,就会屏蔽全局变量而使用局部变量。
2.2 数据类型
C语言许多程序员使用 typedef 关键字来给数据类型定义一个别名,别名一般有两个特点:1)名称更短;2)更符合程序员的习惯。
1 |
|
2.2.1 整数
C中的数字(整数只是数字的一部分而已)默认就是十进制的,表示一个十进制数字不需要任何特殊的格式。但是,表示一个二进制、八进制或者十六进制数字就不一样了,为了和十进制数字区分开来,必须采用某种特殊的写法,具体来说,就是在数字前面加上特定的字符,也就是加前缀
- 二进制由 0 和 1 两个数字组成,书写时必须以0b或0B(不区分大小写)开头(并不是所有的编译器都支持二进制数字,只有一部分编译器支持,并且跟编译器的版本有关系)
1 |
|
- 八进制由 0~7 八个数字组成,书写时必须以0开头(注意是数字 0,不是字母 o)(在C语言中,不要在十进制数前加0,会被计算机误认为是八进制数)
1 |
|
- 十六进制由数字 0
9、字母 AF 或 a~f(不区分大小写)组成,书写时必须以0x或0X(不区分大小写)开头
1 |
|
2.2.2 字符
字符就是整数,字符和整数没有本质的区别。可以给 char 变量一个字符,也可以给它一个整数;反过来,可以给 int 变量一个整数,也可以给它一个字符
char 变量在内存中存储的是字符对应的 ASCII 码值。如果以 %c 输出,会根据 ASCII 码表转换成对应的字符,如果以 %d 输出,那么还是整数。int 变量在内存中存储的是整数本身,如果以 %c 输出时,也会根据 ASCII 码表转换成对应的字符。
char类型占内存一个字节,signed char取值范围是-128-127,unsigned char取值范围是0-255。描述再准确一些,在char的取值范围内(0-255),字符和整数没有本质区别。
字符肯定是整数,0-255范围内的整数是字符,大于255的整数不是字符。
2.2.3 字符串
字符串初始化
1 |
|
字符串长度
1 |
|
字符串赋值
1 |
|
1 |
|
字符串拼接
1 |
|
1 |
|
字符串比较
1 |
|
1 |
|
字符查找
1 |
|
1 |
|
1 |
|
3.数组
- 语法
1 |
|
数组的初始化
1 |
|
字符串就是一个以空字符’\0’结束的字符数组,是一个特别的字符数组,这是约定也是规则。(空字符’\0’也可以直接写成0。)
如果字符串不用0结束,会出现乱码,且每次执行程序的结果都随机不可知;如果字符串以0结束了,但是后面的内容并不是0,则后面的内容将被丢弃
4.函数
C的函数主要分为自定义函数和库函数:
自定义函数
如果自定义函数只在调用者程序中使用,可以在调用者程序中声明和定义,声明一般为调用者程序的上部,定义一般在调用者程序的下部,这并不是C语言的规定,而是为了让程序更方便阅读,程序员约定的写法。
如果自定义函数是一个通用的功能模块,可以在公共的头文件中声明,在公共的程序文件中定义。如果某程序需要调用公共的函数,在调用者程序中用#include指令包含公共的头文件,编译的时候把调用者程序和公共的程序文件一起编译。
#include <>
用于包含系统提供的头文件,编译的时候,gcc在系统的头文件目录中寻找头文件。
#include ""
用于包含程序员自定义的头文件,编译的时候,gcc先在当前目录中寻找头文件,如果找不到,再到系统的头文件目录中寻找。
库函数
C语言标准库函数的声明的头文件存放在/usr/include目录中
5.指针
关于地址:
不管是整型、浮点型、字符型,还是其他的数据类型的内存变量,它的地址都是一个十六进制数,可以理解为内存单元的编号
C语言采用运算符&来获取变量的地址
在printf函数中,输出内存地址的格式控制符是%p,地址采用十六进制的数字显示
1 |
|
关于指针:
- 指针是一种特别变量,全称是指针变量,专用于存放其它变量在内存中的地址编号
- 把指针指向具体的内存变量的地址,就是对指针赋值
- 调用scanf函数的时候,需要在变量前面加符号&,其实就是把变量的地址传给scanf函数,scanf函数根据传进去的地址直接操作内存,改变内存中的值
- 指针也是一种内存变量,是内存变量就要占用内存空间,在C语言中,任何类型的指针占用8字节的内存
数组&指针:
- 在C语言中,数组占用的内存空间是连续的,数组名是数组元素的首地址,也是数组的地址
- 数组名、对数组取地址和数组元素的首地址是同一回事(都表示数组的地址,因此可以认为数组名就是一个指针)
- 地址可以用加(+)和减(-)来运算,加1表示下一个存储单元的地址(不是数学意义上的加1),减1表示上一个存储单元的地址,一般情况下,地址的运算适用于数组,对单个变量的地址运算没有意义
- 在C语言中,数组名是数组元素的首地址,字符串是字符数组,所以在获取字符串的地址的时候,不需要用&取地址
6.结构体
结构体初始化
1 |
|
结构体赋值
在C语言中,结构体的成员如果是基本数据类型(int、char、double)可以用=号赋值,如果是字符串,字符串不是基本数据类型,可以用strcpy函数赋值,如果要把结构体变量的值赋给另一个结构体变量,有两种方法:
1)一种是把结构体变量成员的值逐个赋值给另一个结构体变量的成员,这种方法太笨,没人使用;
2)另一种方法是内存拷贝,C语言提供了memcpy(memory copy的简写)实现内存拷贝功能
1 |
|
strcpy&memcpy
这两个函数从功能和实现原理上完本不同,甚至不应该放在一起比较
1)复制的内容不同,strcpy只能复制字符串,而memcpy可以复制任意内容,例如字符数组、整型、结构体、类等。
2)用途不同,通常在复制字符串时用strcpy,而需要复制其他类型数据时则一般用memcpy。
3)复制的方法不同,strcpy不需要指定长度,它遇到被复制字符的串结尾符0才结束,memcpy则是根据其第3个参数决定复制的长度。
二、C语言进阶
1.文件操作
对计算机来说,一切皆数据。数据的存放方式有很多种,如内存、文件、数据库等,文件是极其重要的一种
根据文件中数据组织形式的不同,可以把文件分为文本文件和二进制文件,C语言源代码是文本文件,编译后的可执行程序是二进制文件
1.1 文本数据&二进制数据
文本数据由字符串组成,存放了每个字符的 ASCII 码值(假如采用的是ASCLL编码的话),每个字符占一个字节,每个字节存放一个字符;
二进制数据是字节序列,数字123的二进制表示是01111011;
例如数字 123,如果用文本格式存放,数据内容是’1’、’2’、’3’ 三个字符,占三个字节;如果用二进制格式形式存储,字符、短整型、短整型、长整型都可以存储123
1 |
|
1.2 文本文件&二进制文件
按文本格式存放数据的文件称为文本文件或ASCII文件,文件可以用vi和记事本打开,看到的都是ASCII字符。
按二进制格式存放数据的文件称为二进制文件,如果用vi打开二进制文件,看到的是乱码,没有意义。
两者的区别:
二进制文件中存储的数据是二进制数据即01串,文本文件中存储的数据是字符串
文本文件只能存储char型字符变量。二进制文件可以存储char/int/short/long/float/……各种变量值
文本文件每条数据通常是固定长度的。以ASCII为例,每条数据(每个字符)都是1个字节。进制文件每条数据不固定。如short占两个字节,int占四个字节,float占8个字节
文本文件编辑器(记事本等文本编辑器内部自带解码工具)就可以读写。比如记事本、NotePad++、Vim等。二进制文件需要特别的解码器。比如bmp文件需要图像查看器,rmvb需要播放器
二进制文件是把内存中的数据按其在内存中的存储形式原样输出到磁盘上存放,也就是说存放的是数据的原形式,因此二进制文件的读写速度非常快,但是可读性差;文本文件是把数据的终端形式的二进制数据输出到磁盘上存放,也就是说存放的是数据的终端形式,文本文件会选择一种编码方式(ASLL或者Unicode),在读写时先将数据按照选择的编码方式转为对应的编码,再将这个编码写进文件中,故读写速度较慢但可读性好
1.3 文件的打开和关闭
C 语言对文件进行操作之前必须先“打开”文件,操作(读和写)完成后,再“关闭”文件。
文件指针
操作文件的时候,C语言为文件分配一个信息区,该信息区包含文件描述信息、缓冲区位置、缓冲区大小、文件读写到的位置等基本信息,这些信息用一个结构体来存放(struct _IO_FILE),这个结构体有一个别名FILE(typedef struct _IO_FILE FILE),FILE结构体和对文件操作的库函数是在 stdio.h 头文件中声明的。
FILE结构体指针习惯称为文件指针(结构体属于自定义数据类型,因此有结构体变量)
打开文件的时候,fopen函数中会动态分配一个FILE结构体大小的内存空间,并把FILE结构体内存的地址作为函数的返回值,程序用FILE结构体指针存放这个地址。关闭文件的时候,fclose函数除了关闭文件,还会释放FILE结构体占用的内存空间。
打开文件
使用 C语言提供的库函数fopen来创建一个新的文件或者打开一个已存的文件,调用fopen函数成功后,返回一个文件指针( FILE *)
在Linux平台下,打开文本文件和二进制文件的方式没有区别
1 |
|
关闭文件
1 |
|
注意:
1)调用fopen打开文件的时候,一定要判断返回值,如果文件不存在、或没有权限、或磁盘空间满了,都有可能造成打开文件失败。
2)文件指针是调用fopen的时候,系统动态分配了内存空间,函数返回或程序退出之前,必须用fclose关闭文件指针,释放内存,否则后果严重。
3)如果文件指针是空指针或野指针,用fclose关闭它相当于操作空指针或野指针,后果严重。
1.4 文件读写
1.4.1 文本文件的读写
向文件中写入数据
使用fprintf函数
1 |
|
1 |
|
从文件中读取数据
使用gets函数
1 |
|
1 |
|
1.4.2 二进制文件的读写
二进制文件没有行的概念,没有字符串的概念。
我们把内存中的数据结构直接写入二进制文件,读取的时候,也是从文件中读取数据结构的大小一块数据,直接保存到数据结构中。注意,这里所说的数据结构不只是结构体,是任意数据类型(当然我们最常用的还是结构体)。
向文件中写入数据
使用fwrite函数
1 |
|
1 |
|
从文件中读取数据
使用fread函数
1 |
|
1 |
|
注意:
- fwrite和fread函数也可以写入和读取文本文件,但是没有换行的概念,不管是换行符或其它的特殊字符,无区别对待
- 一般来说,二进制文件有约定的数据格式,程序必须按约定的格式写入/读取数据,book115.c写入的是超女结构体,book117.c就要用超女结构体来存放读取到的数据(首先就需要定义相同的超女结构体)。这道理就像图片查看软件无法打开音频文件,音频播放软件也无法打开图片文件,因为音频文件和图片文件的格式不同
1.5 文件定位
在文件内部有一个位置指针(注意不是文件指针),用来指向文件当前读写的位置。在文件打开时,如果打开方式是r和w,位置指针指向文件的第一个字节,如果打开方式是a,位置指针指向文件的尾部。每当从文件里读取n个字节或文件里写入n个字节后,位置指针也会向后移动n个字节。
文件位置指针与C语言中的指针不是一回事。位置指针仅仅是一个标志,表示文件读写到的位置,不是变量的地址。文件每读写一次,位置指针就会移动一次,它不需要您在程序中定义和赋值,而是由系统自动设置,对程序员来说是隐藏的。
在实际开发中,偶尔需要移动位置指针,实现对指定位置数据的读写。我们把移动位置指针称为文件定位。
C语言提供了ftell、rewind和fseek三个库函数来实现文件定位功能。
1 |
|
1 |
|
1 |
|
三、C工具
1.编译器简介
C语言的代码对于CPU来说是无法识别的,CPU只能识别一些特定的二进制形式的指令,这时候就需要一个中间的工具用于将C语言代码转化为CPU能够识别的二进制指令(直观上来说就是将.c程序转换为.exe可执行文件),该工具我们称为编译器
(实际上这个名称很容易引起混淆,实际上编译器只负责编译为汇编代码,汇编器接着将汇编代码转换为二进制指令,然而我们为了方便将编译器和汇编器常整合到一起称为编译器);
C语言的编译器有很多种,不同的平台下有不同的编译器,例如:
- Windows 下常用的是微软编译器(cl.exr),它被集成在 Visual Studio 或 Visual C++ 中,一般不单独使用;
- Linux 下常用的是 GUN 组织开发的GCC,很多 Linux 发行版都自带 GCC;
- Mac 下常用的是 LLVM/Clang,它被集成在 Xcode 中(Xcode 以前集成的是 GCC,后来由于 GCC 的不配合才改为 LLVM/Clang,LLVM/Clang 的性能比 GCC 更加强大)。
2.集成开发环境
当然除了编译器(GCC)是必须的工具外,在实际开发过程中我们还需要一些辅助工具如代码编辑器(notepad++、sublime text)、项目管理工具、调试器(GDB)等,这些工具被打包在一起发布被称为集成开发环境
关于使用IDE创建项目的时候选择空项目还是控制台项目等可以参考(21条消息) Visual studio 中win32控制台应用程序和空项目有什么却别?-微软技术-CSDN问答,最本质的区别就是除了空项目,其他类型的项目会自动配置一些必要的环境,空项目不包含任何默认的库,链接器等页都使用的默认值;
下面是一些常用的C语言开发环境
Windows下环境
MinGW:MinGW 是一个在 Windows 平台下的 GCC(GNU Compiler Collection)环境,可以通过官网下载安装包并进行安装。安装完成后需要将 MinGW 的 bin 目录添加到系统环境变量 PATH 中,以便于在命令行中运行 gcc 和 g++ 命令。
Visual Studio:Visual Studio 是一个非常流行的开发环境,可以用于 C 语言的编写。需要下载安装 Visual Studio,并在安装时选择 C++ 的工作负载。安装完成后可以在 Visual Studio 中创建 C++ 项目进行编程。
Linux下环境
- GCC:GCC 是一个开源的编译器,可以通过包管理器进行安装。在 Ubuntu 中,可以使用命令 sudo apt-get install build-essential 安装 GCC。安装完成后可以在命令行中使用 gcc 命令进行编译。
- Code::Blocks:Code::Blocks 是一个跨平台的开发环境,可以用于 C 语言的编写。可以通过包管理器进行安装,或者从官网下载安装包进行安装。安装完成后可以创建 C 语言项目并进行编程。
3.GCC编译器
GCC编译器是Linux系统下常用的C/C++编译器,然而GCC又并不仅仅只是一个C语言编译器;
早期GCC全称为GNU C Compile,也就是在GNU计划中诞生的C语言编译器,而随着不断的更新迭代,GCC已经能够编译C++、Go语言等,而GCC的全称也被解释为 GNU Compiler Collection(GNU 编译器套件);
GCC编译器通常以gcc
命令的形式在Linux终端中使用,该命令带有多个可选项(当然你要使用Linux下的集成IDE来编译文件就不需要使用这些晦涩的终端命令)
GCC编译器是由许多组件组成的,下载安装GCC一般会得到以下组件
当然GCC工作离不开Linux系统中其他一些软件工具,这些软件工具与GCC协同工作完成编译;
3.1 gcc和g++
之前我们在学习过程中也看到过这两个命令,并不是很清楚有什么区别,只是简单的认为gcc指令用于编译C语言,g++指令用于编译C++语言(然而实际上gcc指令也可以编译C++,g++指令也可以编译C语言)
实际上,只要是 GCC 支持编译的程序代码,都可以使用 gcc 命令完成编译。可以这样理解,gcc 是 GCC 编译器的通用编译指令
,根据程序文件的后缀名,gcc 指令可以自行判断出当前程序所用编程语言的类别(没有后缀名的文件使用-x选项手动指定文件类型即可):
如果使用 g++ 指令,则无论目标文件的后缀名是什么,该指令都一律按照编译 C++ 代码的方式编译
该文件(因为C语言某些地方与C++不兼容,所以误用g++的话很容易导致编译报错);
并且C++的特性原因,它经常用到一些标准库中现有的函数或类对象,这意味着需要在编译完成后使用链接,然而仅仅使用gcc
指令不会自动链接标准库文件(比如标准库中的iostream、string类对象就无法使用),实在要使用gcc命令则还需要手动加上-lstdc++ -shared-libgcc
选项(使用gcc编译C语言的时候对于一些简单的系统库不需要手动指定链接如studio.h);
3.2 分步编译
C语言由源代码生成可执行程序的过程如下:
C源程序->编译预处理->编译->优化程序->汇编程序->链接程序->可执行文件;
我们直接使用gcc
指令会直接得到可执行文件,然而对于想要学习编译原理的人来说这显然不合适,所以需要用到分步编译,实现分步编译非常简单,只需要在gcc指令添加特定的指令选项即可
无论是 C 还是 C++ 程序,其从源代码转变为可执行代码的过程,具体可分为 4 个过程,分别为预处理(Preprocessing)、编译(Compilation)、汇编(Assembly)和链接(Linking)
3.2.1 预处理
预处理操作,主要是处理那些源文件和头文件中以 # 开头的命令(比如 #include、#define、#ifdef 等),并删除程序中所有的注释 // 和 /* … */
编译预处理阶段,读取C源程序,对其中的预处理指令(以#开头的指令如#include)和特殊符号进行处理。或者说是扫描源代码,对其进行初步的转换,产生新的源代码提供给编译器。编译预处理(简称预处理)过程先于编译器对源代码进行处理(即编译),读入源代码,检查包含预处理指令的语句和宏定义,并对源代码进行转换。预处理过程还会删除程序中的注释和多余的空白字符
在C语言的程序中包括各种以符号#开头的编译指令,这些指令称为预处理命令。预处理命令属于C语言编译器,而不是C语言的组成部分,通过预处理命令可扩展C语言程序设计的环境。预处理指令是以#号开头的代码行,#号必须是该行除了任何空白字符外的第一个字符。#后是指令关键字,在关键字和#号之间允许存在任意个数的空白字符,整行语句构成了一条预处理指令,该指令将在编译器进行编译之前对源代码做某些转换。
预处理指令主要有以下三种:
1)包含文件:将源文件中以#include格式包含的文件复制到编译的源文件中,可以是头文件,也可以是其它的程序文件。
2)宏定义指令:#define指令定义一个宏,#undef指令删除一个宏定义。
3)条件编译:根据#ifdef和#ifndef后面的条件决定需要编译的代码。
默认情况下 gcc -E 指令只会将预处理操作的结果输出到屏幕上,并不会自动保存到某个文件。因此该指令往往会和 -o 选项连用,将结果导入到指令的文件中
1 |
|
- Linux 系统中通常用 “.i” 作为 C 语言程序预处理后所得文件的后缀名;
(1)包含文件
当一个C语言程序由多个文件模块组成时,主模块中一般包含main函数和一些当前程序专用的函数。程序从main函数开始执行,在执行过程中,可调用当前文件中的函数,也可调用其他文件模块中的函数。
如果在本模块中要调用其他文件模块中的函数,首先必须在主模块中声明该函数原型。一般都是采用文件包含的方法,包含其他文件模块的头文件(头文件中定义了函数原型)。
文件包含中指定的文件名既可以用引号括起来,也可以用尖括号括起来
1 |
|
如果使用尖括号<>括起文件名,则编译程序将到C语言开发环境中设置好的 include文件中去找指定的文件(/usr/include)。因为C语言的标准头文件都存放在/usr/include文件夹中,所以一般对标准头文件采用尖括号;对程序员自己编写的文件,则使用双引号。
如果自己编写的文件不是存放在当前工作文件夹,可以在#include命令后面加在文件路径。
#include命令的作用是把指定的文件模块内容插入到#include所在的位置(并不是一定插入到文件首部),当程序编译时,系统会把所有#include指定的文件一起编译生成可执行代码。
#include包含文件,可以是 “.h”,表示C语言程序的头文件,也可以是“.c”,表示包含普通C语言源程序。
(2)宏定义指令
使用#define命令并不是真正的定义符号常量,而是定义一个可以替换的宏。被定义为宏的标识符称为“宏名”。在编译预处理过程时,对程序中所有出现的“宏名”,都用宏定义中的字符串去代换,这称为“宏替换”或“宏展开”。
在C语言中,宏分为有参数和无参数两种
无参数的宏
1 |
|
无参数的宏注意:
- 预处理命令语句后面一般不会添加分号,如果在#define最后有分号,在宏替换时分号也将替换到源代码中去。在宏名和字符串之间可以有任意个空格。
- 宏定义是宏名来表示一个字符串,在宏展开时又以该字符串取代宏名。这只是一种简单的代换,字符串中可以含任何字符,可以是常数,也可以是表达式,编译预处理时不会对它进行语法检查,如有错误,只能在编译已被宏展开后的源程序时发现。
- 宏定义允许嵌套,在宏定义的字符串中可以使用已经定义的宏名。在宏展开时由预处理程序层层替换。建议不要这么做,会把程序复杂化
- 习惯上宏名用大写字母表示,以方便与变量区别。但也可以用小写字母
带参数的宏
1 |
|
带参数的宏注意:
- 在定义带参数的宏时,宏名和形参表之间不能有空格出现,否则,就将宏定义成为无参数形式,而导致程序出错
(3)条件编译
- #ifdef条件编译格式
1 |
|
1 |
|
- #ifndef条件编译格式
1 |
|
3.2.2 编译
编译,简单理解就是将预处理得到的程序代码,经过一系列的词法分析、语法分析、语义分析以及优化,加工为当前机器支持的汇编代码
通过给 gcc 指令添加 -S(注意是大写)选项,即可令 GCC 编译器仅将指定文件加工至编译阶段,并生成对应的汇编代码文件,默认情况下,编译操作会自行新建一个文件名和指定文件相同、后缀名为 .s 的文件,并将编译的结果保存在该文件中
1 |
|
3.2.3 汇编
汇编操作将汇编代码文件转换为目标文件(也就是二进制文件/机器指令,但是没有经过链接所以无法直接运行)
通过为 gcc 指令添加 -c 选项(注意是小写字母 c),即可让 GCC 编译器将指定文件加工至汇编阶段,并生成相应的目标文件(.o)
1 |
|
3.2.4 链接
链接器把多个二进制的目标文件(object file)链接成一个单独的可执行文件。在链接过程中,它必须把符号(变量名、函数名等一些列标识符)用对应的数据的内存地址(变量地址、函数地址等)替代,以完成程序中多个模块的外部引用。链接器也必须将程序中所用到的所有C标准库函数加入其中。对于链接器而言,链接库不过是一个具有许多目标文件的集合,它们在一个文件中以方便处理。
GCC 的-l
选项(小写的 L)可以让我们手动添加链接库
(GCC 会自动在标准库目录中搜索文件,例如 /usr/lib,如果想链接其它目录中的库,就得特别指明),这一步类似我们在IDE中添加库文件路径
1 |
|
3.3 多文件编译
我们知道一个项目中不止一个源文件需要编译(头文件不用单独编译),为了处理这种需求我们可以参考gcc指令一次处理多个文件 (biancheng.net)(当然这种方式是最低效的,之后我们会介绍MakeFile来处理这种情况)
3.4 关于链接
C/C++的库文件用于程序的链接阶段,因而编译器在实现链接的时候采用动态链接方式链接动态库
或静态链接方式链接静态库
;
有关来链接这部分其实挺复杂的,因为有些时候就算使用了g++指令有些库还是不能自动被添加进来(某些标准库、非标准库或者自定义的库、第三方库),而使用gcc指令就更需要手动指定需要链接的库文件是什么,在确定链接的库文件之后还要区分是采用动态链接还是静态链接,这些都是链接过程中的难题;
- 对C语言,编译的时候 gcc 只会默认链接一些基本的C语言标准库,很多源文件依赖的标准库都需要手动链接;
3.4.1 静态库
先声明一点,这上面和之后讲的内容虽然是基于Linux系统下的,但是同样适用于Windows,之所以我们在Windows下没这些概念就是因为IDE用多了把人给用傻了;
静态链接库实现链接操作的方式很简单,即程序文件中哪里用到了库文件中的功能模块,GCC 编译器就会将该模板代码直接复制
到程序文件的适当位置,最终生成可执行文件:
- 优势是生成的可执行文件不再需要任何静态库文件的支持就可以独立运行(可移植性强);
- 劣势是如果程序文件中多次调用库中的同一功能模块,则该模块代码势必就会被复制多次,生成的可执行文件中会包含多段完全相同的代码,造成代码的冗余;
Linux 发行版系统中,静态链接库文件的后缀名通常用 .a 表示;在 Windows 系统中,静态链接库文件的后缀名为 .lib
3.4.2 动态库
动态链接库(Windows),又称为共享链接库(Linux),和静态链接库不同,采用动态链接库实现链接操作时,程序文件中哪里需要库文件的功能模块,GCC 编译器不会直接将该功能模块的代码拷贝到文件中,而是将功能模块的位置信息
记录到文件中,直接生成可执行文件。显然,这样生成的可执行文件是无法独立运行的。采用动态链接库生成的可执行文件运行时,GCC 编译器会将对应的动态链接库一同加载在内存中,由于可执行文件中事先记录了所需功能模块的位置信息,所以在现有动态链接库的支持下,也可以成功运行:
- 优势是由于可执行文件中记录的是功能模块的地址,真正的实现代码会在程序运行时被载入内存,这意味着,即便功能模块被调用多次,使用的都是同一份实现代码(这也是将动态链接库称为共享链接库的原因);
- 劣势是此方式生成的可执行文件无法独立运行,必须借助相应的库文件(可移植性差);
Linux 发行版系统中,动态链接库的后缀名通常用 .so 表示;在 Windows 系统中,动态链接库的后缀名为 .dll
总结:GCC 编译器生成可执行文件时(自动链接),默认情况下会优先使用动态链接库实现链接操作,除非当前系统环境中没有程序文件所需要的动态链接库,GCC 编译器才会选择相应的静态链接库
4.MakeFile
Makefile 文件描述了 Linux 系统下 C/C++ 工程的编译规则,它用来自动化编译 C/C++ 项目
。Makefile 文件定义了一系列规则,指明了源文件的编译顺序、依赖关系、是否需要重新编译等
,一旦写编写好 Makefile 文件,只需要一个 make 命令,整个工程就开始自动编译,不再需要手动执行 GCC 命令;
- MakeFile可以简化链接操作,把要链接的库文件放在 Makefile 中,制定相应的规则和对应的链接顺序,只需要执行 make 命令,工程就会自动编译省略参数选项和命令;
- MakeFile可以支持多线程并发操作,会极大的缩短编译时间,并且当修改了源文件之后,编译整个工程的时候,make 命令只会编译修改过的文件,没有修改的文件不用重新编译;
4.1 书写规则
MakeFile描述文件编译的规则,主要由两部分组成:
- 依赖的关系
- 执行的命令
1 |
|
具体实例可以查看Makefile文件中包含哪些规则? (biancheng.net)
4.2 使用MakeFile
编写好MakeFile文件后在shell中执行make命令,当然执行make命令也可以加上一些参数保证程序的正常执行make命令参数和选项大汇总 (biancheng.net);
Linux下没有后缀名这一说,实在要有后缀名也是makefile.linux、hello.tar(Linux中,带有扩展名的文件只能代表程序的关联,没什么意义),后缀名是给Windows用的;
windows系统通常是用后缀名来区分不同类型的文件的,而linux文件后缀名无关要紧,linux通过文件内的属性来区分文件;
4.3 工作流程
参考Makefile的工作流程 (biancheng.net)
5.CMake工具
CMake介绍文档:CMake是什么?有什么用?_cmake是干什么的_AndrewZhou924的博客-CSDN博客;
CMake下载和cl.exe都借助Build Tools完成,不要单独安装两个不同渠道的,容易导致出现问题;
- CMake是一个跨平台的编译(Build)工具,可以用简单的语句来描述所有平台的编译过程;
- Makefile可以看作是整个工程的编译规则,定义了一系列的规则来指定,哪些文件需要先编译,哪些文件需要后编译,哪些文件需要重新编译,甚至于进行更复杂的功能操作(详细介绍参考:[Makefile文件](# 4.MakeFile)。现代的 IDE 提供了更高级别的构建功能,使开发者能够更方便地管理项目的构建过程,而无需手动编写 Makefile
- 在某些特定的项目中,使用非常定制的构建过程或依赖管理系统时,手动编写 Makefile 可能更灵活和可控;
- 一些开发者可能更喜欢手动管理构建过程,以便更好地了解和掌控项目的构建细节;
- make是一个命令工具,是一个解释makefile中指令的命令工具,一般来说,大多数的IDE都有这个命令;
举个实际的例子帮助理解为什么需要CMake。假如我们有一个深度学习框架的部分工程列表,里面有超过40个互相调用的工程共同组成,一些用于生成库文件,一些用于实现逻辑功能。他们之间的调用关系复杂而严格,如果需要在这样复杂的框架下进行二次开发,显然只拥有源码是远远不够的,还需要清楚的明白这几十个项目之间的复杂关系,在没有原作者的帮助下进行这项工作几乎是不可能的。即使是原作者给出了相关的结构文档,对新手来说建立工程的过程依旧是漫长而艰辛的,因此CMake的作用就凸显出来了。原作者只需要生成一份CMakeLists.txt文档,框架的使用者们只需要在下载源码的同时下载作者提供的CMakeLists.txt,就可以利用CMake,在“原作者的帮助下”进行工程的搭建(python在调用的时候才编译,不需要任何makefile)
Cmake是用来makefile的一个工具:读入所有源文件之后,自动生成makefile。
5.1 CMake配置
Cmake没办法离开C编译器独立使用,而C/C++编译器主要分为两个流派(在Windows系统上):
gcc
(GNU Compiler Collection)是开源的编译器工具集,由 GNU 项目开发和维护。它主要用于编译和构建 C、C++、Objective-C 和其他语言的源代码。gcc
是一个跨平台的编译器,可以在多个操作系统上使用,包括 Linux、macOS 和 Windows。在 Linux 和 macOS 上,gcc
通常是默认的 C/C++ 编译器(安装Codeblocks的过程中会顺便安装MinGW,该软件包中会包含GCC编译器)cl
(Microsoft C/C++ Compiler)是 Microsoft Visual C++ 编译器,属于 Microsoft Visual Studio 的一部分。它是一个专为 Windows 平台开发的编译器工具链。cl
提供了对 Windows 平台特定功能和扩展的支持,并与 Visual Studio 的开发工具和环境集成(一般安装Visual Studio的同时就默认安装了cl.exe编译器,如果想要单独下载安装cl.exe也是可以的。Microsoft 提供了一个称为Build Tools for Visual Studio的独立安装程序,它包含了编译器、链接器和其他构建工具,可以让你在没有完整 Visual Studio 的情况下进行 C++ 编译)
尽管这两个编译器都可以构建C和C++代码,但对于Cmake来说,它只会使用CMakeLists.txt指定的编译器而非本地拥有的编译器;
分别安装GCC编译器和cl.exe,其中gcc被包含在codeblocks下载的MinGW文件夹中。因为不想重新安装VS,所以这里参考Visual C++独立版安装:Microsoft C++ 生成/构建工具(Build Tools),通过VS Installer(vs_BuildTools.exe安装在D盘下便于寻找)安装Build_Tools(D盘的空间不够了暂时安装在C盘)。安装完成后如下(其实我感觉还是把VS下载下来了…)
下面分别配置gcc和cl.exe的路径为环境变量并分别验证是否配置成功(因为cl.exe可能会经常改变,所以一般不固定设置为环境变量)
在Developer Command Prompt for VS 2022中(管理员权限)使用如下命令进行cmake操作
1 |
|