2026年1月3日发表的
C语言6行代码实现文件复制(copy)
引起围观和热议,6行代码如下:
上图代码以其简洁漂亮形式,突出以下要点:
- 在程序内侧(程序员所见语义),无论何种外部执行环境(由操作系统和CPU类型决定),文件的恒不变实质内容及其可变环境属性信息(如文本行结束标记、文件创建、修改与访问时间等)由标准输入/输出库函数封装、隐藏并向程序呈现一个无差别字节流或文本字符流(环境信息被忽略、行结束标记被转换),而这里的被复制文件呈现为字节流。如同内存字节可解作char、int、double等类型一样,这里往自(from/to)外存文件的字节被视为unsigned char类型,即当作非负小整数(字节通常窄至8位)看待。输入时,fgetc将字节从unsigned char转换成int再交由程序处理;输出时,fputc将字节由int转换成unsigned char再写入文件。程序利用内存中的int变量与外存中的unsigned char字节进行对接。顺便指出,因操作系统而异的文本行结束标记(EOL)属于准实质内容(可被复制但解作文本字符流时对程序又是透明的),而文件结束标记(EOF)则属于环境信息(由文件系统维持)。
- 在C程序中,处于存储状态的静态字节(如内存的字符串、外存文件的字节序列)取作char、unsigned char类型,而处在加工处理中的动态字节,通常会提升转换成更宽的int类型。这是为什么呢?这种习惯做法有多种理由,本程序体现其中之一。当fgetc读取文件实质内容字节时,从unsigned char转换成int得到一个非负小整数并返回,而当到达文件尾再无字节可读时,fgetc则返回一个负值作为信号来报告这一情况,该负值作为宏EOF,被预先定义在<stdio.h>中,通常取-1,因此选用int变量c来接收fgetc的返回值以确保容纳这个负值。
- 除字节流外,文件还可呈现为文本字符流,这由文件打开方式选定。如果文件内容是可阅读的文本,多数情况下,视为文本字符流处理;少数情况下,也可当作字节流对待。如果文件内容不是可阅读的文本,则统一当成字节流处理,对于具有数据结构的内容,多数情况下,按其数据结构分块读写并处理;少数情况下,也可以不考虑其结构。例如,这里的文件复制应用,不必管它是什么内容,一律视为字节流,既可按字节读写,也可分块读写。
- (c = fgetc(fp1)) != EOF中的括号决不能省略,否则导致语义错误,语义将变成c = (fgetc(fp1) != EOF),因为赋值号=的优先级低于!=。该表达式还充分体现了C语言的简洁经济特点,早已形成定式,模仿运用即可。
- C程序通过main函数的参数接收来自执行环境的命令行参数,参数传递在程序启动之前自行完成,相当于带初值的局部变量,放心引用,参数结构如下图所示:
具体解释参见:
C语言6行代码实现文件复制(copy)
最好点开看看,关于上图结构以及命令行参数的解释都包括在里面,否则还会影响理解下文。
关于上述6行代码,网友反馈最多的问题是,运行速度慢、没有处理可能的出错情况。下面进行具体考察并加以改进,特拟若干实验版本如下:
mycopy2.c,在上述6行原始代码基础上,添加计时代码以了解其执行时间:
mycopy4.c,在mycopy2.c基础上,放弃fgetc/fputc的按字节读写,改用fread/fwrite按块直接读写,缓冲区设为512字节,由<stdio.h>中的预定义宏BUFSIZ扩展而来:
mycopy5.c,在mycopy4.c基础上,只把缓冲区增大为512*2字节:
mycopy6.c,在mycopy5.c基础上,仅将缓冲区增至512*3字节:
mycopy3.c,直接调用Windows自带控制台应用cmd.exe的内部命令copy:
在Win10 x64平台,2014年出厂的台式机,机械硬盘剩余空间40GB,分别运行上述5个程序,完成6.14GB文件win10.iso(如下图)的复制,
各自运行时间如下:
整理列表如下:
程序 | I/O库函数 | 耗时(秒) | 缓冲区 |
mycopy2 | fgetc/fputc | 393 | 无 |
mycopy4 | fread/fwrite | 143 | 512字节 |
mycopy5 | fread/fwrite | 145 | 512*2字节 |
mycopy6 | fread/fwrite | 148 | 512*3字节 |
copy命令 | 未知 | 94 | 未知 |
之后,又在固态硬盘剩余空间32GB(其它软硬配置同上)条件下,分别运行3个程序,整理列表如下:
程序 | I/O库函数 | 耗时(秒) | 缓冲区 |
mycopy4 | fread/fwrite | 89 | 512字节 |
mycopy8 | fread/fwrite | 97 | 512*8字节 |
copy命令 | 未知 | 39 | 未知 |
上述两次实验结果表明,同fgetc/fputc读写方式相比,512字节缓冲区的fread/fwrite读写方式能显著提高时间效率约2.5倍,但当缓冲区增大2倍、3倍乃至8倍时,运行时间不再缩短,因此并非缓冲区越大越好,反而白白浪费内存开销。BUFSIZ(512)字节缓冲区是适合Windows x64执行环境的最佳取值,是实现者预先根据执行环境具体情况而设定的,这里的实验证实了这一点。
其次,Windows的cmd内部命令copy的时间效率明显更高,而且还复制了文件的"修改时间"信息,而本文C程序只复制文件内容,其它由执行环境的文件系统所维持的诸如"修改时间"等环境相关信息都是由本地文件系统默认设置的。
最后,程序运行时间长短与其当时执行环境的操作系统、CPU类型及型号、内存大小、硬盘类型及剩余空间大小(含碎片化程度)、CPU利用率、内存使用量、硬盘传输速率等等因素均有直接关系,因此上述实验结果属于定性结论。
本文研磨文件复制程序的目标是,弄懂C语言文件操作的基本方法,而不是取代系统内置的copy命令。实际工作中必须依靠成熟高效的copy命令,它一定是采用了基于本地系统环境的更底层技术,而且比C的输入/输出库函数所采用的技术更有效。
下面以512字节缓冲区的mycopy4.c为基础进行改进,尽量考虑诸如fopen返回指针判空、命令行参数格式异常等可能出现的种种情况,以避免程序静默失败。
mycopy4.c的改进版mycopy.c正式亮相如下:
mycopy.c的复制粘贴版如下,代码同上图,另附有详细注释:
#define _CRT_SECURE_NO_WARNINGS //禁用vs针对fopen的不安全报错#include <stdio.h> //若不用VS编译生成,则不必写#define这一行#include <stdlib.h> //包含exit函数原型#include <string.h> //包含strcmp和memcmp函数原型int copy(const char *s, const char *d); //声明自定义函数原型int verify(const char *s, const char *d); //const参数不允许修改int main(int argc, char *argv[]) //argc,argv用于接收命令行参数{ if (argc < 3 || argc > 4) { //粗略判别,点到为止,实用须再完善 fprintf(stderr, "命令格式错\n"); //stderr是在<stdio.h>中预声明的标准错误流,程序执行前自动打开 exit(11); //正常结束程序,控制返回执行环境并回送结束状态码于环境变量中 } //唯独main中的return k与exit(k)等价 switch (copy(argv[1], argv[2])){ //调用copy并依其返回值分支 case 0: printf("复制完成\n"); //等价于fprintf(stdout, "复制完成\n"); break; //stdout是在<stdio.h>中预声明的标准输出流,程序执行前自动打开 case 1: //stdout和stderr通常与屏幕关联,而stdin通常与键盘关联,三者类型均为FILE* fprintf(stderr, "无法打开源文件\n"); exit(1); case 2: fprintf(stderr, "无法打开目的文件\n"); exit(2); case 3: fprintf(stderr, "读源文件时出错\n"); exit(3); case 4: fprintf(stderr, "写目的文件时出错\n"); exit(4); } if (argc == 4 && (strcmp(argv[3], "/v") == 0 \ //反斜线后按回车键(行结束标记)表示续行 || strcmp(argv[3], "/V") == 0)) //字符串比较,相等则返回0 switch (verify(*(argv+1), *(argv+2))) { //*(argv+1)等价于argv[1],C语言用指针访问数组 case 0: //调用verify校验目的文件是否正确 printf("校验通过\n"); exit(5); case 1: fprintf(stderr, "校验时无法打开源文件\n"); exit(6); case 2: fprintf(stderr, "校验时无法打开目的文件\n"); exit(7); case 3: fprintf(stderr, "校验读源文件时出错\n"); exit(8); case 4: fprintf(stderr, "校验读目的文件时出错\n"); exit(9); case 5: fprintf(stderr, "校验失败\n"); exit(10); } else exit(0);}int copy(const char *s, const char *d){ size_t n; char buf[BUFSIZ]; //<stdio.h>中的宏BUFSIZ扩展为512,适合本地执行环境的最佳取值 FILE *fp1, *fp2; if ((fp1 = fopen(s, "rb")) == NULL) //<stdio.h>中的宏NULL扩展为空指针值,通常取0 return 1; else if ((fp2 = fopen(d, "wb")) == NULL) return 2; else { while ((n = fread(buf, 1, BUFSIZ, fp1)) > 0) //按块读写,实现复制 fwrite(buf, 1, n, fp2); //fread遇文件尾或出错时,返回0 fclose(fp2); fclose(fp1); if (ferror(fp1)) //测试读取输入流fp1时是否发生错误 return 3; else if (ferror(fp2)) //测试写入输出流fp2时是否发生错误 return 4; else return 0; }}int verify(const char *s, const char *d){ size_t n1, n2; char buf1[BUFSIZ]; char buf2[BUFSIZ]; FILE *fp1, *fp2; if ((fp1 = fopen(s, "rb")) == NULL) return 1; else if ((fp2 = fopen(d, "rb")) == NULL) return 2; else { while ((n1 = fread(buf1, 1, BUFSIZ, fp1)) > 0 \ //反斜线后接行结束标记表示续行 && (n2 = fread(buf2, 1, BUFSIZ, fp2)) > 0 \ && n1 == n2 && memcmp(buf1, buf2, n1) == 0) //视为unsigned char字节串比较,相等则返回0 ; //分号单占一行为空语句,充当while循环体,因全部工作已在条件计值时完成 fclose(fp2); fclose(fp1); if (ferror(fp1)) return 3; else if (ferror(fp2)) return 4; else if (n1 == 0) //校验通过的条件 return 0; else return 5; }}算法要点:
文件按块复制:
fread按块读出源文件(fp1)字节内容并将字节当作unsigned char类型写入缓冲区buf,块大小为1字节,一次最多读出BUFSIZ(512)块,但最后一次可能不足512块,fread返回实际读出的块数(即字节数)于变量n;fwrite随即将buf中的字节内容当作unsigned char类型按块写入目的文件(fp2),块大小亦为1字节,一次写出n块(即n字节),如此反复读写直到fread抵达源文件尾,这时fread返回0而迫使while循环退出,复制完成。
因为块大小为1字节,所以能适配任意文件长度,否则必须考虑块大小与文件长度的匹配问题以确保读尽源文件。
注意:这里以char(buf)内存字节经fread/fwrite参数中介void指针对接unsigned char外存文件字节,即在不同场合将实体字节视为不同类型,但经过从外存文件出发,到内存数组逗留,再回到外存文件的旅行之后,字节依旧是那个字节,一个比特都没变。
"校验通过"条件的解释:
先从源文件读取n1字节,若读取成功(n1>0),再从目的文件读取n2字节,若读取成功(n2>0),再比较两字节块大小是否相等,若相等(n1==n2),再用memcmp按字节比较,若两字节块相同,则进入下一轮循环(比较下一对字节块),如此反复……
按照&&求值规则,对while循环条件表达式,从左向右逐项求值,"真"则继续、"假"则停止。一旦停止,则整个条件为"假"已明(不必亦不再继续求值),立即退出循环;若各项均为"真"(即整个条件为"真"),则继续循环。顺便指出,各项先后排列次序需要仔细斟酌,通常不能随意安排。
当while循环退出之后(当然原因多种多样),特按以下逻辑链步步推进:先看读取源文件是否发生错误,若未出错,再看读取目的文件是否发生错误,若也未出错,再看从源文件读出的字节数n1,若n1==0,则表明对于源文件按块读取的进程已正常抵达文件尾而再无字节可读,同时意味着此前反复进行的多轮循环的条件都是成立的,也就是说,此前对于源、目的文件从头到尾的同步按块比较都是对应相同的,于是断定"校验通过";反之一律认定"校验失败"。
试想,如果目的文件只是比源文件长(chang)出一些字节,那么必定会以同样原因退出循环,考虑到目的文件是从源文件按块复制而来的,却意外地在目的文件预期结束点之后又凭空追加了一些字节,这种事件发生的概率几乎为0,因此上述判定校验通过的条件是令人信服、可以接受的。
为修补这个微小漏洞,只需在verify函数中添加两行代码,如下图红线所标:
这样,当while循环退出后,再读一次目的文件以刷新n2并达成与n1同步,若此时n2==0,则表明目的文件与源文件字节数恰好相等、不存在多余字节。
另外,while循环条件中比较n1==n2的必要性在于,正常情况下,n1和n2均会读够512,只是最后那一块可能不足512但也会相等,可是一旦发生读取错误就没准了,n1或n2有可能不够512且极有可能出现不相等,这时应当退出循环。
copy与verify返回值的意义如下表:
返回值 | copy | verify |
0 | 复制完成 | 校验通过 |
1 | 无法打开源文件 | 校验时无法打开源文件 |
2 | 无法打开目的文件 | 校验时无法打开目的文件 |
3 | 读源文件时出错 | 校验时读源文件出错 |
4 | 写目的文件时出错 | 校验时读目的文件出错 |
5 | 校验失败 |
请自行分析mycopy.exe结束(exit)状态码的意义。
简单执行结果:
各种情况下的执行结果:
分析与讨论:
- 运行效率提高是因为缓冲区buf大大减少了fread/fwrite的调用次数。有条件的读者,可在Unix/linux/mac OS执行环境下测试比较,看效果究竟如何?
- 按固态硬盘6.14GB文件耗时89秒计算,复制速率约为70MB/s,也就是说,小于70MB的文件复制用不了1秒,因此换用fgetc/fputc实现复制,也就2~3秒,并非完全不可行。
- 不能简单认定fgetc/fputc无缓冲机制、读写效率低,其背后实现一定有缓冲技术支持,而且还不止一级。在I/O中断服务层应该有缓冲,在操作系统API层也应该有缓冲。
- fread/fwrite适合按数据结构读写数据,块与数据结构一一对应,而fgetc/fputc更加通用。
- stdout与stderr都是在<stdio.h>中预先定义的输出流指针,指向FILE结构体,程序启动时自行打开,程序结束时自行关闭,通常均与屏幕相关联。但stdout用于输出正常结果且会因重定向而输出到文本文件中,而stderr用于输出错误信息且不受重定向指令的影响,具体示例如下图:(重定向字符串不算作命令行参数,仅供cmd解释执行)
- 可以看到,原本写入stdout应出现在屏幕上的"复制完成""校验通过"被重定向写入output.txt,而原本写入stderr的"命令格式错"却不受重定向影响,依旧出现在屏幕上,output2.txt因没有进入stdout的正常输出而为空。
- printf("复制完成\n");
- 等价于
- fprintf(stdout, "复制完成\n");
- 校验写入目的文件的正确性,仅仅是选项,因为外存写入出错概率是非常小的。
- mycopy.c完全基于C标准库函数编写,放之四海而皆准,也就是说,凡是支持ISO 9899 C标准的实现(即编译生成系统)所生成的可执行程序均具有相同的行为表现,即运行结果一模一样,但有可能因为int、double等类型在不同执行环境具有不同表示范围而有所不同,这种情况通常容易避免。
- 继续改进mycopy.c的一个方向是包含面向特定执行环境的扩展头文件,例如,包含<winbase.h>以调用Windows base APIs,但这样的应用程序只能在Windows环境执行而不再具有环境通用性,其优点是能利用本地扩展技术来获得更高执行效率。
- 继续改进mycopy.c的另一方向是要充分考虑程序面临的各种可能情形以提高健壮性,不妨参照如下Windows控制台copy命令的用法:
明显感到需要考虑的问题实在是太多了。
- 继续改进mycopy.c的第三方向是copy与verify部分代码外移以减少返回值个数,可考虑改为从传递文件流指针开始:
- void copy(FILE *s, FILE *d);
- void verify(FILE *s, FILE *d);
- 复制粘贴版源码中的注释含有不容错过的信息,例如,指针与数组的密切关系,断行/续行标记等等。
附加信息:
文件的可变环境属性是指那些因文件驻留环境不同而不同的信息。在Windows平台,鼠标右击文件名->属性,在属性页上的信息,有些是由环境维持的信息,如文件的创建、修改和访问日期等,有些则是嵌入文件内容的信息,如兼容性。顺便指出,如同copy命令一样,下载是远程文件复制。例如,在Win 10平台下载Windows应用程序,修改时间亦保持不变,创建时间就是下载时间,兼容性信息由文件内容携带,且仅当扩展名为.exe时可见,如下图所示:
有关库函数的最权威资料(from C23):
获取当前时间并返回,若获而不得则返回(time_t)(-1)。若timer不是空指针NULL,还将所获时间赋于timer所指变量中,例如:
#include <time.h>time_t time1, time2;time(&time1); //time1为当前时间,简单、易读、常用time1 = time(NULL); //time1亦为当前时间time1 = time(&time2); //time1与time2相同,都是当前时间计算两个时间差并返回,单位为秒,double类型,例如:
#include <time.h>time_t time1, time2;double second;second = difftime(time1, time2); 从输入流stream以(数组)元素块为单位读取字节(也称字符,此处二者同义)并写入void指针ptr所指的内存数组中,size为元素块的字节数,至多读取nmemb个元素块,并返回成功读取到的元素块数。若读取出错或到达文件尾,则返回值可能小于nmemb。
若未发生读取错误且返回值为0,则表明已到达文件尾。
在语义上,读取一个元素块等价于调用size次fgetc并将元素块视为unsigned char数组进行写入,其效果等同于拿stream中的一段等长字节序列整体覆盖数组元素块且保持次序不变。
其目的就是以适当的固定长度的内存数组为缓冲区或工作区,把任意长度的外存文件分批次搬进来进行处理。
示例参见mycopy.c。另一种可能是利用结构体数组对接文件,这样一个字节块对应一个结构体,应当是先写而后读,哪怕是别人写出的文件。
从void指针ptr所指的内存数组以元素块为单位读取字节(也称字符,此处二者同义)并写入输出流stream中,size为元素块的字节数,至多写出nmemb个元素块,并返回成功写出的元素块数。仅当写出出错时,返回值才可能小于nmemb。
在语义上,写出一个元素块等价于调用size次fputc并将元素块视为unsigned char数组进行读取而后写入输出流stream中,其效果等同于拿数组元素块整体覆盖stream中的一段等长字节序列且保持次序不变。
其目的就是把适当的固定长度的内存数组中的程序加工成果分批次写入外存文件以便保存。
检测出错指示量,无错返回0,出错返回非0值。
