玩转C语言——文件操作、预处理、编译、链接

前言:

经过前面的学习,我们已经对C语言的语法学习完毕了,今天,我们这节内容是为了修炼内功,为以后的学习打下一个坚实基础。话不多说,开始我们今天的学习吧!

一、文件操作

1.⼆进制⽂件和⽂本⽂件

根据数据的组织形式,数据⽂件被称为⽂本⽂件或者⼆进制⽂件。

数据在内存中以⼆进制的形式存储,如果不加转换的输出到外存的⽂件中,就是⼆进制⽂件。

如果要求在外存上以ASCII码的形式存储,则需要在存储前转换。以ASCII字符的形式存储的⽂件就是⽂ 本⽂件。

⼀个数据在⽂件中是怎么存储的呢? 字符⼀律以ASCII形式存储,数值型数据既可以⽤ASCII形式存储,也可以使⽤⼆进制形式存储。 如有整数10000,如果以ASCII码的形式输出到磁盘,则磁盘中占⽤5个字节(每个字符⼀个字节),⽽ ⼆进制形式输出,则在磁盘上只占4个字节。

2.流和标准流

想要理解文件操作就不得不提到流这个概念,那什么是流?当我们写程序时需要从键盘上进行输入输出,那我们想?这些数据怎么从外部获取又怎么输出到外部设备?这种工作全部由流来完成,在这里你可以简单理解为想象成流淌着字符的河。

C程序针对⽂件、画⾯、键盘等的数据输⼊输出操作都是通过流操作的。

⼀般情况下,我们要想向流⾥写数据,或者从流中读取数据,都是要打开流,然后操作。

这时候或许你就要说了,我平时写程序时也没有打开什么流的,那我这算二般情况吗?答案是显而易见的。那这是为什么呢?

经过我不懈的努力以及各位读者的陪伴我得出了如下结论:

那是因为C语⾔程序在启动的时候,默认打开了3个流:

stdin - 标准输⼊流,在⼤多数的环境中从键盘输⼊,scanf函数就是从标准输⼊流中读取数据。

stdout - 标准输出流,⼤多数的环境中输出⾄显⽰器界⾯,printf函数就是将信息输出到标准输出流中。

stderr - 标准错误流,⼤多数环境中输出到显⽰器界⾯。

这是默认打开了这三个流,我们使⽤scanf、printf等函数就可以直接进⾏输⼊输出操作的 。

stdin、stdout、stderr 三个流的类型是: FILE* ,通常称为⽂件指针。

C语⾔中,就是通过 FILE* 的⽂件指针来维护流的各种操作的。

3.文件指针

缓冲⽂件系统中,关键的概念是"⽂件类型指针 ",简称"⽂件指针"。 每个被使⽤的⽂件都在内存中开辟了⼀个相应的⽂件信息区,⽤来存放⽂件的相关信息(如⽂件的名 字,⽂件状态及⽂件当前的位置等)。

这些信息是保存在⼀个结构体变量中的。该结构体类型是由系 统声明的,取名 FILE。以下是VS2022编译环境中stdio.h中头文件文件类型声明:

cpp 复制代码
#ifndef _FILE_DEFINED
    #define _FILE_DEFINED
    typedef struct _iobuf
    {
        void* _Placeholder;
    } FILE;
#endif

_ACRTIMP_ALT FILE* __cdecl __acrt_iob_func(unsigned _Ix);

不同的C编译器的FILE类型包含的内容不完全相同,但是⼤同⼩异。

每当打开⼀个⽂件的时候,系统会根据⽂件的情况⾃动创建⼀个FILE结构的变量,并填充其中的信 息,使⽤者不必关⼼细节。

⼀般都是通过⼀个FILE的指针来维护这个FILE结构的变量,这样使⽤起来更加⽅便。

通过⽂件指针变量能够间接找到与它关联的⽂件。

4.⽂件的打开和关闭

⽂件在读写之前应该先打开⽂件,在使⽤结束之后应该关闭⽂件。和之前使用内存函数一样,要有始有终。

在编写程序的时候,在打开⽂件的同时,都会返回⼀个FILE*的指针变量指向该⽂件,也相当于建⽴了 指针和⽂件的关系。

我们规定使用fopen来打开文件,使用fclose来关闭文件。

cpp 复制代码
//打开⽂件
FILE * fopen ( const char * filename, const char * mode );
//关闭⽂件
int fclose ( FILE * stream );

mode表⽰⽂件的打开模式,下⾯都是⽂件的打开模式:

|-------------|----------------------|-----------|
| ⽂件使⽤⽅式 | 含义 | 如果指定⽂件不存在 |
| "r"(只读) | 为了输⼊数据,打开⼀个已经存在的⽂本⽂件 | 出错 |
| "w"(只写) | 为了输出数据,打开⼀个⽂本⽂件 | 建⽴⼀个新的⽂件 |
| "a"(追加) | 向⽂本⽂件尾添加数据 | 建⽴⼀个新的⽂件 |
| "rb"(只读) | 为了输⼊数据,打开⼀个⼆进制⽂件 | 出错 |
| "wb"(只写) | 为了输出数据,打开⼀个⼆进制⽂件 | 建⽴⼀个新的⽂件 |
| "ab"(追加) | 向⼀个⼆进制⽂件尾添加数据 | 建⽴⼀个新的⽂件 |
| "r+"(读写) | 为了读和写,打开⼀个⽂本⽂件 | 出错 |
| "w+"(读写) | 为了读和写,建立⼀个⽂本⽂件 | 建⽴⼀个新的⽂件 |
| "a+"(读写) | 打开⼀个⽂件,在⽂件尾进⾏读写 | 建⽴⼀个新的⽂件 |
| "rb+"(读写) | 为了读和写打开⼀个⼆进制⽂件 | 出错 |
| "wb+"(读 写) | 为了读和写新建⼀个⼆进制⽂件 | 建⽴⼀个新的⽂件 |
| "ab+"(读 写) | 打开⼀个⼆进制⽂件,在⽂件尾进⾏读和写 | 建⽴⼀个新的⽂件 |

那咱们写一个文件吧:

cpp 复制代码
#include <stdio.h>
int main()
{
	FILE* pf;
	pf = fopen("myfile,txt","w");//注意此处都应该是双引号
	if (pf == NULL)
	{
		strerror(pf);
		return 1;
	}
	fputs("fopen example", pf);
	fclose(pf);
	return 0;
}

那大家这时可能要问了fputs是个啥,别急,先缓一会,欲速则不达。且听我下回分晓:

5.⽂件的顺序读写

以下是函数名:

|---------|---------|-------|
| 函数名 | 功能 | 适⽤于 |
| fgetc | 字符输⼊函数 | 所有输⼊流 |
| fputc | 字符输出函数 | 所有输出流 |
| fgets | ⽂本⾏输⼊函数 | 所有输⼊流 |
| fputs | ⽂本⾏输出函数 | 所有输出流 |
| fscanf | 格式化输⼊函数 | 所有输⼊流 |
| fprintf | 格式化输出函数 | 所有输出流 |
| fread | ⼆进制输⼊ | ⽂件 |
| fwrite | ⼆进制输出 | ⽂件 |

上⾯说的适⽤于所有输⼊流⼀般指适⽤于标准输⼊流和其他输⼊流(如⽂件输⼊流);所有输出流⼀ 般指适⽤于标准输出流和其他输出流(如⽂件输出流)。

其具体使用大家可自行探索。

了解了顺序读写再来了解了解随机读写吧!

6.⽂件的随机读写

一共有三个函数,分别为:fseek ,ftell ,rewind

6.1 fseek函数

根据⽂件指针的位置和偏移量来定位⽂件指针。

cpp 复制代码
int fseek ( FILE * stream, long int offset, int origin );

用法如下:

代码实现:

cpp 复制代码
#include <stdio.h>
int main()
{
	FILE* pf;
	pf = fopen("myfile,txt","wb");
	if (pf == NULL)
	{
		perror(pf);
		return 1;
	}
	fputs("this is an apple", pf);
	fseek(pf , 9 ,SEEK_SET);
	fputs(" sam",pf);
	fclose(pf);
	return 0;
}
6.2 ftell函数

返回⽂件指针相对于起始位置的偏移量。

cpp 复制代码
long int ftell ( FILE * stream );

代码实现:

cpp 复制代码
#include <stdio.h>

int main()
{
	FILE* pFile;
	long size;

	pFile = fopen("myfile.txt", "rb");
	if (pFile == NULL)
	{
		perror("Error opening file");
	}
	else
	{
		fseek(pFile, 0, SEEK_END);   
		size = ftell(pFile);
		fclose(pFile);
		printf("Size of myfile.txt: %ld bytes.\n", size);
	}
	return 0;
}
6.3 rewind函数

让⽂件指针的位置回到⽂件的起始位置。

cpp 复制代码
void rewind ( FILE * stream );

代码实现:

cpp 复制代码
#include <stdio.h>

int main()
{
	int n;
	FILE* pFile;
	char buffer[27];

	pFile = fopen("myfile.txt", "w+");
	for (n = 'A'; n <= 'Z'; n++)
	{
		fputc(n, pFile);
	}
	rewind(pFile);
	fread(buffer, 1, 26, pFile);
	fclose(pFile);
	buffer[26] = '\0';
	puts(buffer);
	return 0;
}

7. ⽂件读取结束的判定

当我们运用函数读取文件时,我们改如何确定文件是正常关闭还是非正常关闭,我们接下来会来介绍文件读取结束判定的函数吗?不,这仅仅是为了feof函数洗清冤屈而已。

各位牢记:不能使用feof函数的返回值来判断文件读取是否结束

feof 的作⽤是:当⽂件读取结束的时候,判断是读取结束的原因是否是:遇到⽂件尾结束。

有感兴趣的可下去自行查询⽂件读取结束的判定的函数。

8.⽂件缓冲区

ANSIC 标准采⽤"缓冲⽂件系统" 处理的数据⽂件的,所谓缓冲⽂件系统是指系统⾃动地在内存中为 程序中每⼀个正在使⽤的⽂件开辟⼀块"⽂件缓冲区"。从内存向磁盘输出数据会先送到内存中的缓冲区,装满缓冲区后才⼀起送到磁盘上。如果从磁盘向计算机读⼊数据,则从磁盘⽂件中读取数据输⼊到内存缓冲区(充满缓冲区),然后再从缓冲区逐个地将数据送到程序数据区(程序变量等)。缓冲区的⼤⼩根据C编译系统决定的。

这⾥可以得出⼀个结论: 因为有缓冲区的存在,C语⾔在操作⽂件的时候,需要做刷新缓冲区或者在⽂件操作结束的时候关闭⽂件。 如果不做,可能导致读写⽂件的问题。

二、预处理

1.预定义符号

C语言设置了一些预定义符号,可以直接使用具体如下:

cpp 复制代码
__FILE__ //进⾏编译的源⽂件
__LINE__ //⽂件当前的⾏号
__DATE__ //⽂件被编译的⽇期
__TIME__ //⽂件被编译的时间
__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义

可采取如下使用:

cpp 复制代码
	printf("file: %s,line: %d,time : %s",__FILE__,__LINE__,__TIME__);

2.#define定义

大家C语言学习到这里相必对于define应该不会太过于陌生,但此处为了课程需要还是要介绍一下:#define可以用来定义常量具体方式如下:

cpp 复制代码
#define MAX 10
#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__ ) 

到这里我们对于#define的讲解就结束了。想必此时有人觉得这句话有点问题,怎么讲?明明目录还有,你却告诉我结束了,这莫过于你在看视频的时候,你才看一半,告诉你结束了,这不纯纯恶心人吗?

那我明明知道为什么还要说?这就是节目效果,也是为了大家稍微放松一下,好我们继续。

上文讲到我们可以使用#define来进行定义,那我们可不可以来替换一个函数呢?不得不说真的是脑洞大开,那答案是什么呢?可以!是不是很惊喜,当然名字也要换一下,名字叫:宏。

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

以下是一个简单的宏:

cpp 复制代码
#define MUL(a,b) a*b

这个宏是什么意思呢?很简单,我们拆分来看:#define是用来替换的对吧?即,用MUL(a,b)来替换 a*b,就是简单实现了一下两数相乘,你可以简单验证验证。

相信经过大家的检验,已经发现了这个问题,若是没发现不妨使用以下代码一试:

cpp 复制代码
#include<stdio.h>

#define MUL(a,b) a*b 
int main()
{
	int a = 3;
	int b = 5;
	int ret = MUL(a+a, b+b);
	printf("%d", ret);
	return 0;
}

按照大家的想法应该是先执行3+3=6,5+5=10,6*10=60,答案应该是60,但很不幸答案是23。

这是为什么呢?大家要紧扣定义MUL只是简单经行宏替换,即:3+3*5+5 这个表达式,那该如何解决呢?很简单,加上括号即可,如下:

cpp 复制代码
#define MUL(a,b) (a)*(b)

这样的话就会按照我们的预想去执行。

所以⽤于对数值表达式进⾏求值的宏定义都应该⽤这种⽅式加上括号,避免在使⽤宏时由于参数中的 操作符或邻近操作符之间不可预料的相互作⽤。

接下来,给大家介绍宏的副作用:

3.宏的副作用

大家观察以下代码并推断其输出结果:

cpp 复制代码
#include<stdio.h>
#define MAX(a, b) ( (a) > (b) ? (a) : (b) )
int main()
{
	int x = 5;
	int y = 8;
	int z = MAX(x++, y++);
	printf("x=%d y=%d z=%d\n", x, y, z);
	return 0;
}

大家想必都有自己的答案了,这里公布一下正确答案:

x=6 y=10 z=9

估计又没有符合大多数人的预期,这是为什么呢?咱们看一下替换后的代码就一目了然:

cpp 复制代码
#define MAX(a, b) ( (x++) > (y++) ? (x++) : (y++) )

这下你该明白了吧,当你觉得不明白时替换看一下便会一目了然。

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

4.宏与函数对比

宏:

  1. 宏是一系列的VBA代码,用于自动化重复性任务或执行复杂的操作。

  2. 可以通过录制宏或手动编写VBA代码来创建宏。

  3. 宏可以执行各种操作,例如格式设置、数据处理、自定义功能等。

  4. 宏可以通过快捷键、按钮或特定事件触发执行。
    函数:

  5. 函数是Excel内置的或自定义的公式,用于执行特定的计算或操作。

  6. 函数通常用于处理数据、进行数学运算、查找特定信息等。

  7. 函数可以嵌套组合,以实现更复杂的计算。

  8. 函数通常返回一个值,可以在单元格中直接使用。
    和函数相⽐宏的劣势:

  9. 每次使⽤宏的时候,⼀份宏定义的代码将插⼊到程序中。除⾮宏⽐较短,否则可能⼤幅度增加程序 的⻓度。

  10. 宏是没法调试的。

  11. 宏由于类型⽆关,也就不够严谨。

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

5.条件编译

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

以下是条件编译的一些指令:

cpp 复制代码
1.
#if 常量表达式
	//...
#endif
	//常量表达式由预处理器求值。
如:
#define __DEBUG__ 1
#if __DEBUG__
	//..
#endif

2.多个分⽀的条件编译
#if 常量表达式
	//...
#elif 常量表达式
	//...
#else
	//...
#endif

3.判断是否被定义
#if defined(symbol)
#ifdef symbol

#if !defined(symbol)
#ifndef symbol

4.嵌套指令
#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

6.头⽂件的包含

本地文件包含:

cpp 复制代码
#include "filename"

查找策略:先在源⽂件所在⽬录下查找,如果该头⽂件未找到,编译器就像查找库函数头⽂件⼀样在 标准位置查找头⽂件。

如果找不到就提⽰编译错误。

库⽂件包含:

cpp 复制代码
#include<stdio.h>

查找头⽂件直接去标准路径下去查找,如果找不到就提⽰编译错误。

这样是不是可以说,对于库⽂件也可以使⽤ " " 的形式包含? 答案是肯定的,可以,但是这样做查找的效率就低些,当然这样也不容易区分是库⽂件还是本地⽂件 了。

嵌套⽂件包含:

我们已经知道, #include 指令可以使另外⼀个⽂件被编译。就像它实际出现于 #include 指令的 地⽅⼀样。

这种替换的⽅式很简单:预处理器先删除这条指令,并⽤包含⽂件的内容替换。

⼀个头⽂件被包含10次,那就实际被编译10次,如果重复包含,对编译的压⼒就⽐较⼤。

如何解决头⽂件被重复引⼊的问题?答案:条件编译。

每个头⽂件的开头写:

cpp 复制代码
#ifndef __TEST_H__
#define __TEST_H__
//头⽂件的内容
#endif //__TEST_H__

或者:

cpp 复制代码
#pragma once

就可以避免头⽂件的重复引⼊。

三、编译和链接

1.预处理(预编译)

经过前面的学习我们已经对编译已经有了初步的了解,接下来咱们一起来学习吧!

编译器在执行程序时会有以下流程:预处理、编译、汇编、链接。

在预处理阶段,编译器会执行以下操作:

  1. 将所有的 #define 删除,并展开所有的宏定义。
  2. 处理所有的条件编译指令,如: #if、#ifdef、#elif、#else、#endif 。
  3. 处理#include 预编译指令,将包含的头⽂件的内容插⼊到该预编译指令的位置。这个过程是递归进 ⾏的,也就是说被包含的头⽂件也可能包含其他⽂件。
  4. 删除所有的注释
  5. 添加⾏号和⽂件名标识,⽅便后续编译器⽣成调试信息等。
  6. 保留所有的#pragma的编译器指令,编译器后续会使⽤。

2.编译

编译过程就是将预处理后的⽂件进⾏⼀系列的:词法分析、语法分析、语义分析及优化,⽣成相应的 汇编代码⽂件。

词法分析:将源代码程序被输⼊扫描器,扫描器的任务就是简单的进⾏词法分析,把代码中的字符分割成⼀系列 的记号(关键字、标识符、字⾯量、特殊字符等)。

语法分析:将对扫描产⽣的记号进⾏语法分析,从⽽产⽣语法树。这些语法树是以表达式为 节点的树。

语义分析:由语义分析器来完成语义分析,即对表达式的语法层⾯分析。编译器所能做的分析是语义的静态分 析。静态语义分析通常包括声明和类型的匹配,类型的转换等。这个阶段会报告错误的语法信息。

3.汇编

汇编器是将汇编代码转转变成机器可执⾏的指令,每⼀个汇编语句⼏乎都对应⼀条机器指令。就是根 据汇编指令和机器指令的对照表⼀⼀的进⾏翻译,也不做指令优化。

4.链接

链接是⼀个复杂的过程,链接的时候需要把⼀堆⽂件链接在⼀起才⽣成可执⾏程序。

链接过程主要包括:地址和空间分配,符号决议和重定位等这些步骤。

链接解决的是⼀个项⽬中多⽂件、多模块之间互相调⽤的问题。

前⾯我们⾮常简洁的讲解了⼀个C的程序是如何编译和链接,到最终⽣成可执⾏程序的过程,其实很多 内部的细节⽆法展开讲解。⽐如:⽬标⽂件的格式elf,链接底层实现中的空间与地址分配,符号解析 和重定位等,如果你有兴趣,可以看**《程序的⾃我修养》**⼀书来详细了解。

5.运⾏环境

1.程序必须载⼊内存中。在有操作系统的环境中:⼀般这个由操作系统完成。在独⽴的环境中,程序 的载⼊必须由⼿⼯安排,也可能是通过可执⾏代码置⼊只读内存来完成。

  1. 程序的执⾏便开始。接着便调⽤main函数。

  2. 开始执⾏程序代码。这个时候程序将使⽤⼀个运⾏时堆栈(stack),存储函数的局部变量和返回 地址。程序同时也可以使⽤静态(static)内存,存储于静态内存中的变量在程序的整个执⾏过程 ⼀直保留他们的值。

  3. 终⽌程序。正常终⽌main函数;也有可能是意外终⽌。

完!

相关推荐
不会C语言的男孩16 分钟前
C++ Primer 第3章:字符串、向量和数组
开发语言·c++
兰令水18 分钟前
leecodecode【反前后指针】【2026.5.31打卡-java版本】
java·开发语言
Dovis(誓平步青云)1 小时前
《QT学习第四篇:常见事件与UDP、TCP、文件系统、(锁、信号量、条件变量》
c语言·开发语言·汇编·qt
isyangli_blog10 小时前
OpenDayLight (Carbon 版本) 启动与组件安装
开发语言·php
vb20081110 小时前
FastAPI APIRouter
开发语言·python
Benszen10 小时前
KVM虚拟化解决方案
开发语言·perl
会编程的土豆10 小时前
Go 语言反射(Reflection)详解
开发语言·后端·golang
東雪木10 小时前
多线程与并发编程 专属复习笔记
java·开发语言·笔记·java面试
杨充10 小时前
1.3 浮点型数据设计灵魂
开发语言·python·算法
噜噜噜阿鲁~10 小时前
python学习笔记 | 11.3、面向对象高级编程-多重继承
java·开发语言