2022-03-01  2022-06-27    11644 字  24 分钟

编译器

什么是编译器

可执行程序(Executable Program) 的内部是一系列计算机指令和数据的集合,它们是二进制形式的,CPU 可以直接识别,但对于程序员来说几乎不具备可读性。

比如,在屏幕上输出“VIP 会员”,C 语言的写法为: puts("VIP会员"); ,但其二进制写法如下:

直接使用二进制指令编程对程序员来说简直是噩梦!

于是,编程语言就诞生了。比如,C 语言代码由固定的词汇按照固定的格式组织起来,简单直观,程序员容易识别和理解,但是对于 CPU 来说,C 语言代码就是天书(CPU 只认识几百个二进制形式的指令)!

这就需要一个工具,将 C 语言代码转换成 CPU 能够识别的二进制指令(即可执行程序),这个工具是一个特殊的软件,叫做 编译器(Compiler)

用来保存代码文件叫做 源文件 ,它就一个纯文本文件,内部并没有特殊格式(其后缀仅仅是为了表明文件中保存的是某种语言的代码,易于程序员区分和编译器识别)。

在实际开发中,程序员将代码分门别类地放到多个源文件中。

IDE(集成开发工具) 会为每一个程序都创建一个专门的目录,将用到的所有文件都集中到这个目录下进行管理。 不同的程序对应不同的项目类型(i.e.工程类型) ,不同的工程类型本质上是对 IDE 各个参数的不同设置。当然,我们也可以创建一个空白的工程类型,然后自己去设置各种参数(不过一般没有会这样做)。

源代码要经过编译(Compile)和链接(Link)两个过程才能变成可执行文件。

编译器一次只能编译一个源文件(如果当前程序包含了多个源文件,那么就需要编译多次),编译器每次编译的结果是产生一个中间文件(不是最终的可执行文件,但已经非常接近可执行文件了,它们都是二进制格式,内部结构也非常相似)。

将当前程序的所有中间文件以及系统库组合在一起,才能形成最终的可执行文件,这个组合的过程就叫做 链接(Link) ,完成链接功能的软件叫做链接器(Linker)。

不管有多少个源文件(哪怕只有一个),都必须经过编译和链接两个过程才能生成可执行文件。(为什么呢?因为你至少还需要和系统库组合。)

综上可以发现,一个完整的编程过程是:

  1. 编写源文件(保证代码语法正确,否则编译不通过);
  2. 预处理(Processing);
  3. 编译(Compile 将源文件转换为目标文件);
  4. 汇编(Assembly);
  5. 链接(Linking 将目标文件和系统库组合在一起,转换为可执行文件);
  6. 运行(可以检验代码的正确性)。

默认情况下, gcc 指令会直接将源代码转变为可执行代码(2-5 四个过程),且不会保留各个阶段产生的中间文件。

GCC 是什么

GCC 编译器是 Linux 系统下最常用的 C/C++ 编译器,大部分 Linux 发行版中都会默认安装。

GNU 操作系统的 logo:非洲牛羚

早期的 GCC 全拼为 GNU C Compiler ,最初定位确实只用于编译 C 语言。经过不断迭代扩展,GCC 现在还可以处理 C++、Go、Ojbect-C 等多种编译语言编写的程序,故其全称被重新定义为 GNU Compiler Collection,即 GNU 编译器套件。

可以通过 gcc --help 查看其常用指令选项如下:

--version                Display compiler version information.
-std=<standard>          Assume that the input sources are for <standard>.

-E                       Preprocess only; do not compile, assemble or link.
-S                       Compile only; do not assemble or link.
-c                       Compile and assemble, but do not link.
-o <file>                Place the output into <file>.
-pie                     Create a dynamically linked position independent
						executable.
-shared                  Create a shared library.
-x <language>            Specify the language of the following input files.
						Permissible languages include: c c++ assembler none
						'none' means revert to the default behavior of
						guessing the language based on the file's extension.

前面说过 GCC 是支持编译多种编程语言的,可以通过 -x 选项指定要编译的语言类型,如 gcc -xc++ xxx 表示以编译 C++ 代码的方式编译 xxx 文件。

使用 GCC 编译器编译 C 或者 C++程序,必须经历 4 个过程: 预处理 → 编译 → 汇编 → 链接 (通常 gcc/g++ 支持该过程的自动化)。

#+BEGIN_QUOTE g++ 是什么?可以认为 g++ →(等价于) gcc -xc++ -lstdc++ -shared-libgcc (因为 gcc 不会自动引入 C++ 相关的库,必须手动引入)。 #+END_QUOTE

默认情况下, gcc 指令会一气呵成将源代码历经这 4 个过程转变为可执行代码,且不会保留各个阶段产生的中间文件。

如果我们想查看这 4 个阶段各自产生的中间文件,该怎么办呢?最简单直接的方式就是对源代码进行“分步编译”。即控制 GCC 编译器逐步对源代码进行预处理、编译、汇编及链接操作。

#+CAPTION: GCC 常用的编译选项

gcc/g++ 指令选项 功能
-E 预处理指定的源文件,不进行编译
-S 编译指定的源文件,不进行汇编
-c 编译、汇编指定的源文件,但是不进行链接
-o 指定生成文件的文件名
-llibrary 用于手动指定链接环节中程序可以调用的库文件,如 ~-lstdc++~ ,不建议 =-l= 和 =library= 之间有空格
-ansi 对于 C 语言程序来说,其等价于 -std=c90;对于 C++ 程序来说,其等价于 -std=c++98
-std= 手动指令编程语言所遵循的标准

假如我们编写了一个 source.c 的源程序,如下:

#include <stdio.h>

int main() {
	printf("c program.\n");
	return 0;
}

_1.预处理 - 生成预处理文件 _.i*

通过为 gcc 指令添加 -E 选项,即可控制 GCC 编译器仅对源代码做预处理操作。默认情况下, gcc -E 指令只会将预处理操作的结果输出到屏幕上,并不会自动保存到某个文件,因此,该指令往往会和 -o 选项连胜,将结果导入到指定文件中。

jack@jk:~/cemo/cporj$ gcc -E source.c -o source.i
jack@jk:~/cemo/cporj$ ls
source.c  source.i
jack@jk:~/cemo/cporj$ cat source.i
# 1 "source.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# ....

Linux 系统中通常用 ".i" 作为 C 语言程序预处理后所得文件的后缀名。显然, source.i 中的内容不是那么容易看懂的,好在可以为 gcc 指令再添加一个 -C 选项,来阻止 GCC 删除源文件和头文件中的注释,即 gcc -E -C source.c -o source.i

_2.编译 - 生成汇编文件 _.s*

jack@jk:~/cemo/cporj$ gcc -S source.i
jack@jk:~/cemo/cporj$ ls
source.c  source.i  source.s
jack@jk:~/cemo/cporj$ cat source.s
		.file   "source.c"
		.text
		.section        .rodata
.LC0:
		.string "c program."
....

通过执行 gcc -S 指令,生成了个名为 source.s 的文件,这就是经过编译的汇编代码文件。(默认情况下,编译操作会自行新建一个文件名和指定文件相同、后缀名为 .s 的文件,并将编译的结果保存在该文件中。)

同样,想要提高文件内汇编代码的可可主动性,可以借助 -fverbose-asm 选项,GCC 编译器会自动为汇编代码添加必要的注释,即 gcc -S source.i -fverbose-asm

_3.汇编 - 生成目标文件 _.o*

jack@jk:~/cemo/cporj$ gcc -c source.s
jack@jk:~/cemo/cporj$ ls
source.c  source.i  source.o  source.s

上面生成的 source.o 文件就是目标文件,其本质为二进制文件(但尚未经过链接操作,所以无法直接运行)。

4.链接 - 生成可执行文件

gcc 通过 -o 选项来指定输出文件,缺省默认输出 a.out ,其语法格式如下:

gcc [-E|-S|-c] [infile] [-o outfile]

通过 -l 选项手动添加链接库

链接器把多个二进制的目标文件(object file)链接成一个单独的可执行文件。在链接过程中,它必须把符号(变量名、函数名等一些列标识符)用对应的数据的内存地址(变量地址、函数地址等)替代,以完成程序中多个模块的外部引用。

而且,链接器也必须将程序中所用到的所有 C 标准库函数加入其中。对于链接器来说,链接库不过是一个具在许多目标文件的集合,它们放在一个文件中以方便处理。

标准库的大部分函数通常放在文件 libc.a 中(文件名后缀 .a 代表 achieve 读取),或者放在共享的动态链接文件 libc.so 中(文件名后缀 .so 代表 share object 共享对象)。

如,通过 gcc source.c -o source.out -lm 链接数学库 libm.a ,前缀 lib 和后缀 .a 是标准的, m 是基本名称。(GCC 会在 -l 选项后紧跟着的基本名称的基础上自动添加这些前缀、后缀)


GCC 使用静态链接库和动态链接库

库文件的产生,极大的提高了程序员的开发效率,因为很多功能根本不需要从 0 开发,直接调取包含该功能的库文件即可。并且,库文件的调用方法也很简单,以 C 语言中的 printf() 输出函数为例,程序中只需引入 <stdio.h> 头文件,即可调用 printf() 函数。

调用库文件为什么还要牵扯到头文件呢?

头文件和库文件并不是一码事,它们最大的区别在于:

  • 头文件只存储变量、函数或者类等这些功能模块的声明部分,库文件才负责存储各模块具体的实现部分;
  • 所有的库文件都提供有相应的头文件作为调用它的接口,即库文件是无法直接使用的,只能通过头文件间接调用。

头文件和库文件相结合的访问机制,最大的好处在于,有时候我们只想让别人使用自己实现的功能,并不想公开实现功能的源码,就可以将其制作为库文件,这样用户获取到的是二进制文件,而头文件又只包含声明部分,这样就实现了“将源码隐藏起来”的目的,且不会影响用户使用。

其实,就是一种封装。

事实上,库文件只是一个统称,代指的是一类压缩包,它们都包含有功能实用的目标文件。要知道,虽然库文件用于程序的链接阶段,但编译器提供有 2 种实现链接的方式,分别称为静态链接方式和动态链接方式,其中采用静态链接方式实现链接操作的库文件,称为 静态链接库 ;采用动态链接方式实现链接操作的库文件,称为 动态链接库

它们有什么不同呢?

静态链接库 实现链接操作的方式很简单,即程序文件中哪里用到了库文件中的功能模块,GCC 编译器就会将该模板代码直接复制到程序文件的适当位置,最终生成可执行文件。

好处是生成的可执行文件不再需要任何静态库文件的支持就可以独立运行(可移植性强),坏处如果程序文件中多次调用库中的同一个模块,则该模块代码会被复制多次(冗余),生成的可执行文件体积更大(与使用动态链接库生成的可执行文件相比)。

在 Linux 发行版中,静态链接库文件的后缀通常用 .a 表示;在 Windows 系统中,静态链接库文件的后缀名为 .lib

动态链接库 ,又称为共享链接库。和静态链接库不同,采用动态链接库实现链接操作时,程序文件中哪里需要库文件的功能模块,GCC 编译器不会直接将该功能模块的代码拷贝到文件中,而是将功能模块的位置信息记录到文件中,直接生成可执行文件。

显然,这样生成的可执行文件是无法独立运行的。

采用动态链接库生成的可执行文件运行时,GCC 编译器会将对应的动态链接库一同加载在内存中,由于可执行文件中事先记录了所需功能模块的位置信息,所以在现有动态链接库的支持下,也可以成功运行。

在 Linux 系统中,动态链接库的后缀名通常用 .so 表示;在 Windows 系统中,动态链接库的后缀名为 .dll

值得一提的是,GCC 编译器生成可执行文件时,默认情况下会优先使用动态链接库实现链接操作,除非当前系统环境中没有程序文件所需要的动态链接库,GCC 编译器才会选择相应的静态链接库。如果两种都没有(或者 GCC 编译器未找到),则链接失败。

GDB

GNU symbolic debugger,简称「GDB 调试器」,是 Linux 平台下最常用的一款程序调试器。

要知道,哪怕是开发经验再丰富的程序员,编写的程序也避免不了出错。程序中的错误主要分为 2 类,分别为:

  • 语法错误(可以借助编译器解决);
  • 逻辑错误(只能程序员<自己或借助调试工具>调试解决)。

调试是每个程序员必须掌握的基本技能,没有选择的余地!

所谓调试(Debug),就是让代码一步一步慢慢执行,跟踪程序的运行过程。通过调试程序,我们可以监控程序执行的每一个细节,包括变量的值、函数的调用过程、内存中数据、线程的调度等,从而发现隐藏的错误或者低效的代码。

GDB 就是 Linux 下使用最多的一款调试器,也有 Windows 的移植版。

总的来说,借助 GDB 调试器可以实现以下几个功能:

  • 程序启动时,可以按照我们自定义的要求运行程序,例如设置参数和环境变量;
  • 可使被调试程序在指定代码处暂停运行,并查看当前程序的运行状态(如当前变量的值,函数执行的结果等),即支持断点调试;
  • 程序执行过程中,可以改变某个变量的值,还可以改变代码的执行顺序,从而尝试修改程序中出现的逻辑错误。

默认情况下,程序不会进行调试模式,代码会瞬间从开关执行到末尾。要想观察程序运行的内部细节,可以借助 GDB 调试器在程序中的某个地方设置断点(BreakPoint),如此当程序执行到这个地方时就会停下来。

GDB 调试器支持在程序中打 3 种断点:

  • 普通断点(break):指定打断点的具体位置;
  • 观察断点(watch):可以监控程序中某个变量或者表达式的值,只要发生改变,程序就会停止执行;
  • 捕捉断点(catch):监控程序中某一事件的发生。

……

具体调试细节,略过……

数据类型

关于数据

数据是放在内存中的,变量是给这块内存起的名字,有了变量就可以找到并使用这份数据。

诸如数字、文字、符号、图形、音频、视频等数据都是以二进制形式存储在内存中的,它们并没有本质上的区别。我们需要用数据类型用来说明数据的类型,确定了 数据的解释方式 ,让计算机和程序员不会产生歧义。另外在 C 语言中,每一种数据类型所占用的字节数都是固定的,知道了数据类型,也就知道了 数据的长度

数据是放在内存中的,在内存中存取数据要明确三件事情:数据存储在哪里、数据的长度以及数据的处理方式。

打印输出各种类型的数据

puts (output string) 只能用来输出字符串; printf (print format)格式化输出,功能强大,不仅可以输出字符串,还可以输出整数、小数、单个字符等,并且输出格式可以自定义。

数据类型

1.整数类型 short、 int、 long

short(短整型)、int(整型)、long(长整型) 是 C 语言中常见的整数类型。C 语言并没有严格规定 short、int、long 的长度,只做了宽泛的限制:

2 ≤ short ≤ int ≤ long

其中,int 建议为一个机器字长。32 位环境下机器字长为 4 字节,64 位环境下机器字长为 8 字节。

获取某个数据类型的长度可以使用 sizeof 操作符,如下:

#include <stdio.h>
int main()
{
	short a = 10;
	int b = 100;

	int short_length = sizeof a;
	int int_length = sizeof(b);
	int long_length = sizeof(long);
	int char_length = sizeof(char);

	printf("short=%d, int=%d, long=%d, char=%d\n", short_length, int_length, long_length, char_length);

	return 0;
}

// 在 64位 Linux下的输出 → short=2, int=4, long=8, char=1

*注意, sizeof 是 C 语言中的操作符,不是函数(故可不带括号)。

2.浮点类型 float、 double

一个数字,是有默认类型的:对于整数,默认是 int 类型;对于小数,默认是 double 类型。

……

3.字符类型 char

字符类型由单引号 ' ' 包围,字符串由双引号 " " 包围。

计算机在存储字符时并不是真的要存储字符实体,而是存储该字符在字符集中的编号(也可以叫编码值)。对于 char 类型来说,它实际上存储的就是字符的 ASCII 码。

可以说,是 ASCII 码表将英文字符和整数关联了起来。

无论在哪个字符集中,字符编号都是一个整数;从这个角度考虑,字符类型和整数类型本质上没有什么区别。

在 C 语言中,并没有单独定义字符串类型,字符串实际上是使用空字符 \0 结尾的一维字符数组,如 char site[7] = {'R', 'U', 'N', 'O', 'O', 'B', '\0'};

4.构造类型 - 数组

数组(Array)就是一些列具有相同类型的数据的集合,这些数据在内存中依次挨着存放,彼此之间没有缝隙。

数组的定义方式:

dataType  arrayName[length];
// - dataType  为数据类型
// - arrayName 为数组名称
// - length    为数组长度

// 数组中每个元素都有一个索引(下标),从 0 开始,使用元素时指明下标即可:
arrayName[index]
// - index     为下标

数组的初始化:

// 当赋值的元素少于数组总体元素的时候,剩余的元素自动初始化为 0
int arr[10] = {1, 1, 1, 1, 1, 1, 1, 1, 1, 1};

// 给全部元素赋值,那么在定义数组时可以不给出数组长度
int arr[] = {1, 2, 3, 4, 5};      // 等价于
int arr[5] = {1, 2, 3, 4, 5};

如何获取数组的长度呢? 通过 sizeof arr / sizeof arr[0]

5.字符数组

在 C 语言中,没有专门的字符串变量,没有 string 类型,通常就用一个字符数组来存放一个字符串。

在 C 语言中,字符串总是以 ‘\0’ 作为结尾,所以 ‘\0’ 也被称为字符串结束标志,或者字符串结束符。

‘\0’ 是 ASCII 码表中的第 0 个字符,英文称为 NUL,中文称为“空字符”。该字符既不能显示,也没有控制功能,输出该字符不会有任何效果,它在 C 语言中唯一的作用就是作为字符串结束标志。

由" “包围的字符串会自动在末尾添加 ‘\0’ 。例如,“abc123” 从表面看起来只包含了 6 个字符,其实不然,C 语言会在最后隐式地添加一个 ‘\0’,这个过程是在后台默默地进行的,所以我们感受不到。

char str[6] = "abc123";          // ✘
char str[7] = "abc123";          // ✔,别忘记 '\0',使用 "xyzbnm.." 赋值会自动在末尾添加 '\0'
char str[7] = { 'a', 'b', 'c', '1', '2', '3', '\0' }; // 不嫌烦,你也可以这样

*另外,要注意字符数组只有在定义时才能将整个字符串一次性地赋值给它,一旦定义完了,就只能一个字符一个字符地赋值了。如:

char str[7];
str = "abc123";  // ✘
//正确
str[0] = 'a'; str[1] = 'b'; str[2] = 'c';
str[3] = '1'; str[4] = '2'; str[5] = '3';

所谓 字符串长度 ,就是字符串包含了多少个字符( 不包含最后的结束字符 '\0' ),如 "abc" 的长度是 3 ,而不是 4。(注意和定义时的数组长度做区分)

在 C 语言中,我们使用 string.h 头文件中的 strlen() 函数来求字符串的长度,它的用法为:

length strlen(strname);
// - length   字符串长度,一个整数
// - strname  字符串的名字或字符数组的名字

6.指针

所谓指针,也就是内存的地址;所谓指针变量,也就是保存了内存地址的变量。

7.构造类型 - 结构体

C 语言结构体(Struct)从本质上讲是一种自定义的数据类型,只不过这种数据类型比较复杂,是由 int、char、float 等基本类型组成的。

结构体定义形式为:

struct tag { member-list } variable-list;
// - tag           结构体名(标签)
// - member-list   结构体成员(列表)
// - variable-list 该结构体定义的类型变量

*注意,结构体是一种自定义的数据类型,是创建变量的模板,不占用内存空间;结构体变量才包含了实实在在的数据,需要内存空间来存储。

#include <stdio.h>

int main() {
struct student {
	char *name;                   // 姓名
	int age;                      // 年龄
	float score;                  // 成绩
} stu1, stu2;

// 可以通过 =.= 获取和操作单个结构体成员
stu1.name = "Tom";
str1.age = 18;
stu1.score = 99.5;

printf("%s的分数是: %d\n", stu1.name, stu1.score);

return 0;
}

8.构造类型 - 共用体(联合体)

联合体定义形式为:

union tag { member-list } variable-list;
// - tag           联合体名(标签)
// - member-list   联合体成员(列表)
// - variable-list 该联合体定义的类型变量

结构体和共用体的区别在于:结构体的各个成员会占用不同的内存,互相之间没有影响;而共用体的所有成员占用同一段内存,修改一个成员会影响其余所有成员。

输入输出

在控制台程序中,输出一般是指将数据(包括数字、字符等)显示在屏幕上,输入一般是指获取用户在键盘上输入的数据。

输出

在 C 语言中,有三个函数可以用来在显示器上输出数据:

  • puts() - 只能输出字符串,并且输出结束后自动换行;
  • putchar() - 只能输出单个字符;
  • printf() - 格式化输出各种类型的数据。

printf() 格式控制符的完整形式如下:

%[flag][width][.precision]type
// - type  表示输出类型
// - width 表示最小输出宽度(不足时左边以空格补齐)
// - .precision 表示输出精度,也就是小数的位数;
//              也可以用于整数和字符串,但是功能相反:
//              - 用于整数时,表示最小输出宽度(不足时左边以 0 补齐)
//              - 用于字符串时,表示最大输出宽度
// - flag 是标志字符:
//        - -    表示左对齐(默认右对齐)
//        - +    表示输出正负号(默认只有负数输出符号)
//        - 空格 输出正时以空格,输出负时以负号
//        - #    输出八进制、十六进制前缀;对于小数表示强迫输出小数点

关于 printf() 不能立即输出的问题

从本质上讲, printf() 执行结束后数据并没有直接输出到显示器上,而是放入了缓冲区,直到遇见换行符 \n 才将缓冲区中的数据输出到显示器上。

输入输出的“命门”就在于缓存。

输入

在 C 语言中,有多个函数可以从键盘获得用户输入:

  • gets() - 获取一行数据,并作为字符串处理(可以读取含有空格的字符串);
  • getchar() - 用于输入单个字符(就是 scanf("%c", c) 的简化版);
  • scanf() - 可以格式化输入多种类型的数据。

对于 scanf() 输入数据的格式要和控制字符串的格式保持一致。

从本质上讲,从键盘输入的数据并没直接交给 scanf() ,而是放了缓冲区中,直到我们按下回车键, scanf() 才到缓冲区中读取数据。

文件操作

C 语言具有操作文件的能力,比如打开文件、读取/追加/插入/删除数据、关闭文件、删除文件等。

C 语言中的文件是什么

文件是数据源的一种,最主要的作用是保存数据。

在操作系统中,为了统一对各种硬件的操作,简化接口,不同的硬件设备也被看成一个文件。对这些文件的操作,等同于对磁盘上普通文件的操作。如:

#+CAPTION: 常见硬件设备所对应的文件

文件 硬件设备
stdin 标准输入文件,一般指键盘; scanf()、getchar() 等函数默认从 stdin 获取输入
stdout 标准输出文件,一般指显示器; printf()、putchar() 等函数默认从 stdout 输出数据
stderr 标准错误文件,一般指显示器; perror() 等函数默认向 stderr 输出数据
stdprn 标准打印文件,一般指打印机

此处不去探讨硬件设备是如何被映射成文件的,只需记住,在 C 语言中硬件设备可以看成文件,有些输入输出函数不需要你指明到底读写哪个文件,系统已经为它们设置了默认的文件(当然你也可以更改,如让 printf 向磁盘上的文件输出数据)。

操作文件的正确流程为:打开文件 → 读写文件 → 关闭文件(使用完毕要记得关闭哦)。

关于文件流

所有的文件都要载入内存才能处理,所有的数据必须写入文件才不会丢失。

数据在文件和内存之间传递的过程叫做 文件流 ,数据从文件复制到内存的过程叫做输入流,从内存保存到文件的过程叫做输出流。

文件是数据源的一种,除了文件,还有数据库、网络、键盘等;数据传递到内存也就是保存到 C 语言的变量(如整数、字符串、数组、缓冲区等)。我们把数据在数据源和程序(内存)之间传递的过程叫做 数据流(Data Stream) ,相应的,数据从数据源到程序(内存)的过程叫做输入流(Input Stream),从程序(内存)到数据源的过程叫做输出流(Output Stream)。

输入输出(Input Outpt, IO)是指程序(内存)与外部设备(键盘、显示器、磁盘、其他计算机等)进行交互的操作。

我们可以说,打开文件就是打开了一个流。

打开/关闭文件

在 C 语言中,操作文件之前必须先打开文件。

所谓 “打开文件” ,就是让程序和文件建立连接的过程,就是获取文件的有关信息,例如文件名、文件状态、当前读写位置等,这些信息会被保存到一个 FILE 类型的结构体变量中; “关闭文件” 就是断开与文件之间的联系,释放结构体变量,同时禁止再对文件进行操作。

标准输入文件 stdin(表示键盘)、标准输出文件 stdout(表示显示器)、标准错误文件 stderr(表示显示器)是由系统打开的,可直接使用。

使用 <stdio.h> 头文件中的 fopen() 函数即可打开文件,它的用法为:

FILE *fopen(char *filename, char *mode);
// - filename  表示文件名称
// - mode      表示打开方式

fopen() 会获取文件信息,包括文件名、文件状态、当前读写位置等,并将这些信息保存到一个 FILE 类型的结构体变量中,然后将该变量的地址返回。

FILE 是 <stdio.h> 头文件中的一个结构体,它专门用来保存文件信息。如果希望接收 fopen() 的返回值,就需要定义一个 FILE 类型的指针。

下面我们来看一段文件操作的规范写法:

FILE *fp;

if ((fp = fopen("D:\\demo.txt", "rb")) == NULL) {
printf("Fail to open file!\n");

exit(0);                      // 结束程序
}

我们在打开文件时 一定要 通过判断 fopen() 的返回值是否和 NULL 相等来判断是否打开失败。

关于文件打开方式 mode

不同的操作需要不同的文件权限(只读、读写等),另外,文件也有不同的类型,按照数据的存储方式可以分为二进制文件和文本文件,它们的操作细节是不同的。

在调用 fopen() 函数时,这些信息都必须提供,称为 文件打开方式 ,具体如下:

打开方式 说明
控制读写权限的字符串 必须指明
———————— ———————————————————————————————————-
“r” (read) 以“只读”方式打开文件。文件必须存在,否则打开失败。
“w” (write) 以“写入”方式打开文件。文件若不存在,新建;若存在,则清空文件内容。
“a” (append) 以“追加”方式打开文件。文件若不存在,新建;若存在,则(保留原有的文件内容)将写入的数据追加到文件的末尾。
“r+” 以“读写”方式打开文件。文件必须存在,否则打开失败。
“w+” 以“写入/更新”方式打开文件。相当于 w 和 r+ ,文件若不存在,新建;若存在,清空。
“a+” 以“追加/更新”方式打开文件。相当于 a 和 r+ ,文件若不存在,新建;若存在,追加。
———————— ———————————————————————————————————-
控制读写方式的字符串 可选
———————— ———————————————————————————————————-
“t” (text) 文本文件(默认)
“b” (binary) 二进制文件

*注意,读写权限和读写方式可以组合使用,但是不能将读写方式放在读写权限的开头(可以放末尾和中间)。

文件一旦使用完毕,应该使用 fclose() 函数把文件关闭,以释放相关资源,避免数据丢失。文件正常关闭时, fclose() 的返回值 为 0 ,如果返回非零值则表示有错误发生。

int fclose(FILE *fp);

读写文件

在 C 语言中,文件有多种读写方式,可以一个字符一个字符地读取,也可以读写一个字符串,还可以读取若干个字节(数据块)。文件的读写位置也非常灵活,可以从文件开头读取,也可以从中间位置读取。

先来个完整的轮廓看看,如下:

// 以字符形式读、写文件
int fgetc(FILE *fp);
int foutc(int ch, FILE *fp);

// 以字符串形式读、写文件
char *fgets(char *str, int n, FILE *fp);
int  fputs (char *str, FILE *fp);

// 以数据块形式读、写文件
size_t fread (void *ptr, size_t size, size_t count, FILE *fp);
size_t fwrite(void *ptr, size_t size, size_t count, FILE *fp);

// 格式化读、写文件
int fscanf (FILE *fp, char * format, ... );
int fprintf(FILE *fp, char * format, ... );

// 随机读、写文件
void rewind(FILE *fp);                        // 用来将位置指针移动到文件开关
int fseek(FILE *fp, long offset, int origin); // 用来将指针移动到任意位置

1.以字符形式读写文件

// int fgetc(FILE *fp);
// - 成功时返回读取到的字符;
// - 读取到文件末尾或读取失败时返回 EOF (一个负数,通常为 -1)

// 从 demo.txt 文件中读取一个字符,并保存到变量 ch 中
char ch;
FILE *fp = fopen("demo.txt", "r+");
ch = fgetc(fp);

关于 EOF (end of line),表示文件末尾,是在 stdio.h 中定义的宏,它的值是一个负数,往往是 -1 。 fgetc() 的返回值类型之所以为 int ,就是为了容纳这个负数(char 不能是负数)。

在文件内部有一个位置指针,用来指向当前读写到的位置,也就是读写到第几个字节。在文件打开时,该指针总是指向文件的第一个字节。使用 fgetc() 函数后,该指针会向后移动一个字节,所以可以连续多次使用 fgetc() 读取多个字符。

*注意:这个文件内部的位置指针与 C 语言中的指针不是一回事。位置指针仅仅是一个标志,表示文件读写到的位置,也就是读写到第几个字节,它不表示地址。文件每读写一次,位置指针就会移动一次,它不需要你在程序中定义和赋值,而是由系统自动设置,对用户是隐藏的。

下面我们来看一个示例,在屏幕上显示 demo.txt 文件的内容。

#include <stdio.h>

int main() {
FILE *fp;
char ch;

// 如果文件不存在,给出提示并退出
if ((fp=fopen("demo.txt", "rt")) == NULL) {
	puts("Fail to open file!");
	exit(0);
}

// 每次读取一个字节,直到读取完毕
while ((ch=fgetc(fp)) != EOF) {
	putchar(ch);
}

putchar('\n');                // 输出换行符

fclose(fp);                   // 关闭文件
return 0;
}

写字符函数 fputc

再看一个写入的示例,从键盘输入一行字符,写入文件。

// int foutc(int ch, FILE *fp);
// - 成功时返回写入的字符
// - 失败时返回 EOF (一个负数)

#include <stdio.h>
int main(){
FILE *fp;
char ch;

// 判断文件是否打开成功
if ((fp=fopen("demo.txt", "wt+")) == NULL) {
	puts("Fail to open file!");
	exit(0);
}

printf("Input a string:\n");
// 每次从键盘读取一个字符并写入文件
while ((ch=getchar()) != '\n') {
	fputc(ch, fp);
}

fclose(fp);
return 0;
}

2.以字符串形式读写文件

fgets() 函数用来从指定的文件中读取一个字符串,并保存到字符数组中,用法如下:

char *fgets(char *str, int n, FILE *fp);
// - str 为字符数组(长度为 n+1 ,不要忘了读取到的字符串会在末尾自动添加 '\0')
// - n   为要读取的字符数目
// - fp  为文件指针

// 读取成功时返回字符数组的首地址,也即 str
// 读取失败时,返回 NULL
// 如果开始读取时,文件内部指针已经指向了文件末尾,将读取不到任何字符,也返回 NULL

来看一个示例:

// 从 demo.txt 中读取 100 个字符,并保存到字符数组 str 中
#define N 101

char str[N];
FILE *fp = fopen("demo.txt", "r");
fgets(str, N, fp);

*需要重点说明的是,在读取 n-1 个字符之前如果出现了换行,或者读到了文件末尾,则读取结束。这就意味着,不管 n 的值多大, fgets() 最多只能读取一行数据,不能跨行。

在 C 语言中,没有按行读取文件的函数,我们可以借助 fgets() ,将 n 的值设置地足够大,每次就可以读取到一行数据了。

再来看一个示例,一行一行地读取文件:

#include <stdio.h>
#include <stdlib.h>
#define N 100

int main() {
FILE *fp;
char str[N+1];

if ((fp=fopen("demo.txt", "rt")) == NULL) {
	puts("Fial to open file!");
	exit(0);
}

while(fgets(str, N, fp) != NULL) {
	printf("%s", str);
}

fclose(fp);
return 0;
}

写字符串函数 fputs

fputs() 函数用来向指定的文件写入一个字符串,它的用法为:

char *fputs(char *str, FILE *fp);
// - str 为要写入的字符串)
// - fp  为文件指针

// 写入成功时返回非负数
// 写入失败时,返回 EOF

来看一个示例,向上例中建立的 demo.txt 文件中追加一个字符串:

#include <stdio.h>
int main() {
FILE *fp;
char str[102] = {0}, strTemp[100];

if ((fp=fopen("demo.txt", "at+")) == NULL) {
	puts("Fail to open file!");
	exit(0);
}

printf("Input a string:");
gets(strTemp);
strcat(str, "\n");
strcat(str, strTemp);
fputs(str, fp);

fclose(fp);
return 0;
}

3.以数据块形式读写文件

// 以数据块形式读、写文件
size_t fread (void *ptr, size_t size, size_t count, FILE *fp);
size_t fwrite(void *ptr, size_t size, size_t count, FILE *fp);

// - ptr 为内存区块的指针,它可以是数组、变量、结构体等
//       - fread() 中的 ptr 用来存放读取到的数据
//       - fwrite()中的 ptr 用来存放要写入的数据
// - size  表示每个数据块的字节数
// - count 表示要读写的数据块的块数
// - fp    表示文件指针

// 理论上,每次读写 size*count 个字节的数据

// 返回成功读写的块数,即 count
// 如果返回值小于 count
// - 对于 fwrite() 来说,不用发生了写入错误,可以用 ferror() 函数检测
// - 对于 fread() 来说,可能读到了文件末尾,可能发生了错误,可以用 ferror() 或 feof() 检测

size_t 是什么呢?

size_t 是在 stdio.hstdlib.h 头文件中使用 typedef 定义的数据类型,表示无符号整数,即非负数,常用来表示数量。

来看一个示例,从键盘输入一个数组,将数组写入文件,再读取出来:

#include <stdio.h>
#define N 5
int main() {
// 从键盘输入的数据放入 a,从文件读取的数据放入 b
int a[N], b[N];
int i, size = sizeof(int);
FILE *fp;

if((fp=open("demo.txt", "rb+")) == NULL) { // 以二进制方式打开
	puts("Fail to pen file!");
	exit(0);
}

// 从键盘输入数据,并保存于数组 a
for (i=0; i<N; i++) {
	scanf("%d", &a[i]);
}

// 将数组 a 的内容写入到文件
fwrite(a, size, N, fp);

// 将文件中的位置指针重新定位到文件开头
rewind(fp);
// 从文件读取内容并保存到数组 b
fread(b, size, N, fp);
// 在屏幕上显示数组 b 的内容
for (i=0; i<N; i++) {
	printf("%d", b[i]);
}

printf("\n");

fclose(fp);
return 0;
}

打开 demo.txt,发现文件内容根本无法阅读。这是因为我们使用 “rb+” 方式打开文件,数组会原封不动地以二进制形式写入文件,一般无法阅读。

再来看一个示例,从键盘输入两个学生数据,写入一个文件中,再读出这两个学生的数据显示到屏幕上:

#include <stdio.h>
#define N 2

struct stu {
char name[10];
int num;
int age;
float score;
} boya[N], boyb[N], *pa, *pb;

int main() {
FILE *fp;
int i;
pa = boya;
pb = boyb;

if ((fp=fopen("demo.txt", "wb+")) == NULL) {
	puts("Fail to pen file!");
	exit(0);
}

// 从键盘输入数据
printf("Input data:\n");
for (i=0; i<N; i++, pa++) {
	scanf("%s %d %d %f", pa->name, &pa->num, &pa->age, &pa->score);
}

// 将数组 boya 的数据写入文件
fwrite(boya, sizeof(struct stu), N, fp);
// 将文件中的位置指针重置到文件开头
rewind(fp);

// 从文件读取数据并保存到数据 boyb
fread(boyb, sizeof(struct stu), N, fp);
// 输出数组 boyb 中的数据
for (i=0; i<N; i++, pb++) {
	printf("%s  %d  %d  %f\n", pb->name, pb->num, pb->age, pb->score);
}

fclose(fp);
return 0;
}

4.格式化读写文件

fscanf()fprintf() 函数与前面使用的 scanf()printf() 功能相似,都是格式化读写函数,两者的区别在于 fscanf()fprintf() 的读写对象不是键盘和显示器,而是磁盘文件。

// 格式化读、写文件
int fscanf (FILE *fp, char * format, ... );
int fprintf(FILE *fp, char * format, ... );

// - fp     为文件指针
// - format 为格式控制字符串
// - ...    表示参数列表

// 成功 返回写入的字符的个数
// 失败 返回负数

来看一个简单的示例:

FILE *fp;
int i, j;
char *str, ch;

fscanf(fp, "%d %s", &i, str);
fprintf(fp, "%d %c", j, ch);

5.随机读写文件

移动文件内部位置指针的函数主要有两个,即 rewind()fseek()

// 随机读、写文件
void rewind(FILE *fp);                          // 用来将位置指针移动到文件开关
int  fseek (FILE *fp, long offset, int origin); // 用来将指针移动到任意位置

// - fp     为文件指针,也就是被移动的文件
// - offset 为偏移量,也就是要移动的字节数,正向后移,负向前移
// - origin 为起始位置,C 语言规定起始位置有三种:
//          - 文件开头, 常量名 SEEK_SET, 值为 0
//          - 当前位置, 常量名 SEEK_CUR, 值为 1
//          - 文件末尾, 常量名 SEEK_END, 值为 2

fseek(fp, 100, 0); 表示把位置指针移动到离文件开头 100 个字节处。