Unix编程入门——从一道简单的实验讲文件读写
CS631-HW1:bare-bones copyafile(Solution)
作者: withm
日期: 2024年3月28日
适合读者: 本文面向没有任何Unix编程经验但对C和linux操作有一定了解的读者
实验介绍
请大家浏览 CS631 HW1 - Unix系统编程
(这可能是你RTFM的第一步?)
阅读须知
这是我个人写的第一篇博客,但是我涉猎系统编程已经有一定时间了。(虽然竟然还是在复制文件这种不算困难的任务debug了半天)我深感学艺不精,打算写下这篇博客来帮助想要接触Unix编程而不知道如何入手的初学者们。
如果你没有听说过这门课程却碰巧看到了这篇博客,那我会强烈推荐这门课给你。这门课的教材是被誉为Unix编程者的圣经的APUE。课程围绕教材展开,有相当多的实战内容(这意味着你会看相当多的代码),非常合适想要训练自己C编程能力的朋友。
ps:我希望您在开始编程之前至少较为熟悉C编程
要求并不高,会用API就行
和shell,
会用 cd ls mkdir就算会
因为这门课要在Linux系统
下进行。仅针对此次实验,我用的是ubuntu 22.04
。
本文你可以看成是一个基础内容大杂烩,内容并不单单涉及实验,而是一个笼统的介绍和简单的代码展示,实验只是作为一个展示的大纲,额外会有很多很有用又很简单的补充内容(仅针对初学者)。
收获
通过这次实验,我希望您和我能够:
- 了解
Unix系统编程
的概念 - 熟悉
系统API
的使用(在很多时候和普通的函数调用没有什么区别) - 能够编写简单的Unix系统程序,并理解其运行原理和机制(该实验实现了一个仅针对文件的
cp
)。
什么是Unix系统编程?
以下是gpt
的回答
Unix系统编程是指编写针对Unix操作系统的应用程序或系统软件的过程。Unix是一种类Unix操作系统的家族,包括Linux、macOS等,它们共享类似的设计理念和系统接口。Unix系统编程涉及使用Unix系统提供的API(应用程序编程接口)、工具和库来实现各种功能,例如文件操作、进程管理、网络通信等。
Unix系统编程通常使用C语言作为主要开发语言,因为Unix操作系统本身就是用C语言编写的。通过Unix系统编程,开发人员可以利用Unix操作系统的强大功能和灵活性来构建高效、可靠的软件系统。Unix系统编程的内容广泛,涵盖了从基本的系统调用到高级的多线程编程、网络编程等各个方面。
其实对于本实验来说,你只需要知道:
Unix系统编程就是用你不太熟悉的API写代码。
大部分初学者在学了C语言之后都会有一些疑惑
除了会写杨辉三角和九九乘法表我还能干什么?
我能通过C语言改变什么?
新手学的C语言在很大程度上像一个封闭的圈,除了能在那个黑框框里输出不同的东西什么也做不了。而本次实验会逐渐解答这样的问题。我们并不需要花很大的精力就可以突破这个圈。
实验内容
Write a simple program to copy a file following the specification
Write a simple program to copy a file following the specification provided on this page. Your program will:
follow the general homework guidelines
be written in C (not C++ or anything else)
not generate any output other than error messages when appropriate
be accompanied by a Makefile, README, and checklist
首先浏览需要我们实现的程序的手册,我们可以知道我们的目标是编写一个可执行程序,这个程序接受两个参数。第一个参数是已经存在的文件,第二个参数是目标文件(也可能是目录)
如果第二个参数是目录,我们要cp原文件到该目录下
如果第二个参数是已经存在的文件名,我们要把原文件的内容读到目标文件里
如果第二个参数不存在,我们以该参数为名字创建文件并将原文件内容读到目标文件里
该实验提供了一个测试脚本来检验供本地检验答案正确性。
testscript
思路
开始之前
在本次实验正式开始之前我希望你能够为这次实验分配一个单独的文件夹。这会为后期的整理提供不少方便。我想这不会是你的最后一个Unix编程实验(我希望不是~)
main函数传参
我们不再使用初学者常用的
int main(void)
而是使用
int main(int argc, char *argv[])
这有什么用呢?
考虑你在shell里输入的一个指令
-> xiongzile: ~/workspace/APUE/cs631/File_IO_Sharing/HW1 sleep 10
你会看到命令行停顿了数秒,所以我们就不难理解为什么有的程序需要传入参数。如果没有传参的话,我们想要使用sleep
这个命令,我们怎么能知道具体要sleep多久呢?
而在这个举例里面,argc = 2, argv = ["sleep", "10"]
,所以大家能理解argc
和argv
的含义了吧。其实这两个变量名都是可以改的,你要把他们取名为a
和b
也可以,但约定俗成之下都是argc
和 argv
。
Question:我们本次实验的argc为多少?
如果你能脱口而出是argc = 3
说明你已经完全理解了以上内容了,让我们继续吧!
不过话又说回来了,既然我们知道argc = 3
, 那我们是不是要处理argc
不为3的情况?真实生活中你的客户并不总是按照你的预期输入,就像你告诉他你写了一个判断数的大小的程序而他要输入a
和b
一样。如果你不做任何处理,你的程序就会把他当成整数来处理,会有什么后果呢,咱也不知道。(当然如果你对ASCII码
比较了解的话你会知道这个举例中a < b
,但有的不合法的输入可能导致更严重的后果,比如程序终止。甚至黑客们会利用这一点操控你的机器,偷窃甚至修改你的数据)所以我们要把这种可能扼杀在摇篮里,禁止不符合预期的输入。
所以我们要这样做
if (argc != 3) {
perror("input format error");
exit(EXIT_FAILURE);
}
标准库函数和宏定义
这里的perror()
是一个C标准库函数,用于打印出最近一次系统调用的错误信息。通常情况下,当一个系统调用发生错误时,可以使用 perror() 函数打印出相应的错误信息,帮助你快速定位问题所在。
EXIT_TAILURE
是stdlib.h
里定义的宏,其实是常数
#define EXIT_FAILURE 1 /* Failing exit status. */
#define EXIT_SUCCESS 0 /* Successful exit status. */
那么我们接下来要开始读文件了。C语言提供了一些读文件的方式,这里介绍两种常用的:
read()
是一个系统调用,提供了对文件描述符的直接读取功能。它属于 POSIX 标准,用于Unix-like操作系统(如Linux
、macOS
等)中。read()
函数的原型在 <unistd.h>
头文件中声明。
fread()
是C标准库函数,用于从文件流中读取数据。它属于C标准库(libc)的一部分,可用于所有符合C语言标准的系统中。fread()
函数的原型在 <stdio.h>
头文件中声明。
区别?
以下是它们各自的定义:
ssize_t read(int fd, void *buf, size_t count)
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream)
这里可能会有一些你从来没有见过的变量类型size_t
ssize_t
FILE *
以及你见过但你可能不知道它有什么用的void *
这其实没有什么神秘的,他们都是通过typedef
实现的,比如size_t
就是typedef unsigned long size_t
得到的。这方面更详细的内容可以google
一下,应该有很多人介绍。
这种写法具体的作用和适用场景随着代码量的增大,工程的复杂度提高会慢慢理解。对于本次实验这些并不是重点。
因为我的编程环境是linux,所以我用的是read()
,我接下来会比较详细的讲解一下这个系统调用。你可能会注意到我没有再用函数描述它了,因为这是我们介绍它的第一步:它是一个系统调用而不是一个函数。
什么是系统调用?
还是看看gpt
的解释:
系统调用(System Call)是操作系统提供给用户程序使用的一种服务机制。它允许用户程序访问操作系统内核提供的底层功能和资源,例如文件操作、进程管理、网络通信等。系统调用允许用户程序以更高级别的抽象来操作底层硬件和资源,而不需要直接与底层硬件进行交互。
当用户程序需要执行某些特权操作时(例如访问硬件设备或执行特权指令),它必须通过系统调用来请求操作系统内核执行这些操作。用户程序通过特定的系统调用接口(通常是通过编程语言的库函数或系统调用表)来调用系统调用。操作系统内核在接收到系统调用请求后,会执行相应的操作,并将结果返回给用户程序。
系统调用是操作系统提供的接口之一,它为用户程序提供了访问操作系统功能的途径,同时也是操作系统和用户程序之间的接口。通过系统调用,用户程序可以利用操作系统提供的功能来执行各种任务,从而实现更加复杂和强大的功能。
客观的讲确实是这样,但这样的解读是不太好理解的,对我们来说系统调用就是函数,而且是一类比较特别的函数。
什么叫特别?
回想一下我们自己写的函数,比如你会写这样的东西:
int max(int x, int y)
{
return x > y ? x : y;
}
int main() {
max(1, 5);
return 0;
}
你会发现似乎少了一点什么,这段程序是完整的但是并没有包含头文件
。
你发现没了printf
你好像什么都表达不了,不调用标准库函数
不进行系统调用
,我们的程序只能完成最基本的运算操作。
所以你明白了printf
的作用。这是一个标准库函数
,它本身调用了一个能显示数据
的系统调用
。
大致流程是这样的 user->library function->system call->operating system->machine
(原谅我第一次写博客没有学会嵌入精美的图片),而system call
你也见到了,就是刚才的read()
,实际上你只需要掌握这两部分而已,甚至从编码角度来讲,这两部分很大程度上是类似的。现在你还会觉得系统编程遥不可及吗?
相关的介绍就到这里,我的介绍肯定是不全面的,但是我也不需要写的很全面。全面和精准是manual的任务。但有了上面的解释相比理解这个实验就不难了,所以接下来是正式的实验完成部分。
读文件
int fd1;
if ((fd1 = open(argv[1], O_RDONLY)) < 0) {
perror("open file1: error");
exit(EXIT_FAILURE);
}
struct stat fd1_st;
if (fstat(fd1, &fd1_st) < 0) {
perror("fstat file1: error\n");
exit(EXIT_FAILURE);
}
if ((fd1_st.st_mode & S_IFMT) == S_IFDIR) {
perror("file1 is a directory\n");
exit(EXIT_FAILURE);
}
这里请让我详细的介绍一下这段代码里提到的库函数
,系统调用
以及结构体
库函数包括:perror()
exit()
系统调用包括: open()
fstat()
结构体包括: struct stat
perror()
功能: 打印系统调用错误信息
原型:
void perror(const char *s);
exit()
功能: 终止调用进程
原型:
void exit(int status);
这里简单说一下它和return的区别,return是从某个函数返回的,而exit作为一个库函数,内部会调用一条系统调用来把进程彻底终结。 也就是说如果你在某个函数里用exit()他会直接结束,而不会返回到调用它的函数里。
open()
功能: 打开一个文件
原型:
int open(const char *pathname, int flags, mode_t mode);
const char *pathname
是文件地址的字符串 如 /home/user/input.txt
你希望打开文件的方式(只读,只写,可读可写,可执行...)第三个参数可选,不一定要用,展示代码的时候会讲到。
返回值是文件描述符,是一个整数。这个整数唯一标识某个文件(pathname指向的文件)
这意味着接下来需要做的事情都是对open的返回值做的。(是不是很奇妙很简单?)
int fd1;
if ((fd1 = open(argv[1], O_RDONLY)) < 0) {
perror("open file1: error");
exit(EXIT_FAILURE);
}
接下来你就可以把fd1看着一个文件了,而他本质是一个整数。
fstat()
功能: 获取有关打开文件的信息,把信息存储在一个struct stat结构体里
原型:
int fstat(int fd, struct stat *buf);
看到这个fd
没有,没错,这就对应我们刚才open
得到的文件描述符
,我们只需要用着这个就能所引导真正的文件。而fstat
可以把文件的相关信息存储到struct stat
结构体里。
struct stat fd1_st;
if (fstat(fd1, &fd1_st) < 0) {
perror("fstat file1: error\n");
exit(EXIT_FAILURE);
}
如果返回值小于0意味着这个系统调用发生了错误,我们就要直接退出程序啦,这里是一个简单的错误处理。
到此我们就完全得到了某个文件在C语言里的表示,是什么呢?
文件描述符
和对应的stat结构体
所以我们接下来的所有操作对象只包括这两个东西,是不是一切都明朗了起来?
现在我们提出了这样的问题:
文件内容是什么?怎么读取?怎么写入?
我们已经知道了文件是什么了,即文件标识符,那读取还会困难吗?一定有系统调用或者库函数提供了从文件描述符读取内容的功能。
read()
功能: 用于从打开的文件中读取数据。它会将读取到的数据填充到指定的缓冲区
中。
原型:
ssize_t read(int fd, void *buf, size_t count);
所谓的缓冲区,在C里怎么表示呢,怎么指定呢?
答案是用字符串数组表示,用名字索引。
fd
: 要读取的文件描述符。文件描述符是用于引用打开文件的唯一标识符,由 open
返回。
buf
: 指向缓冲区的指针。缓冲区用于存储读取到的数据。
count
: 要读取的字节数。
char filebuf[512];
memset(filebuf, 0, 512) //不要忘记将未初始化的数组清0
while ((byte_read = read(fd1, filebuf, CHUNK_SIZE)) > 0) { //这里的CHUNK_SIZE是我用宏定义的常量。表示的意思是单次读取的最大字节数
...
}
write()
功能: 用于向打开的文件中写入数据。它会将要写入的数据从指定的缓冲区
中复制到文件。
原型:
ssize_t write(int fd, const void *buf, size_t count);
fd
: 要写入的文件描述符。文件描述符是用于引用打开文件的唯一标识符,由 open
返回。
buf
: 指向缓冲区的指针。缓冲区包含要写入的数据。
count
: 要写入的字节数
char filebuf[512];
memset(filebuf, 0, 512) //不要忘记将未初始化的数组清0
while ((byte_read = read(fd1, filebuf, CHUNK_SIZE)) > 0) {
if(write(fd2, filebuf, byte_read) < 0) {
perror("write to file error");
exit(EXIT_FAILURE);
}
}
好了,到此为止你已经学会了创建文件,读取文件,写入文件。此时再来看看我们的实验要求,是不是觉得一切都豁然开朗呢?
当然我有相当多的细节没有提到。 这些细节也是为什么我为了完成一个似乎这么简单的任务(似乎只涉及到文件读写?),还debug了一下午。
这些细节在你发现你的代码出现了奇特的bug的时候你会知道是什么的。为了完成这个实验你或许不得不看看testscript
的源代码testscript。这是用sh写的脚本,一共有接近四百行,想必对初学者会是很难忘的体验的。
除此之外open
的参数并不像我刚才提到的那样简单,因为为了完成实验或者为了迎合你的个性化需求,你需要调整参数。
考虑这样的问题:
如果一个文件存在,你从哪里开始写入?
如果一个文件不存在,你的open能正常运行吗?
细节远不止这些,你最后大概率会卡在几个测试点上不能成功。这时候你需要打开脚本文件看一下它究竟是怎么测试的,以此为你提供一些灵感。
写给看到这里的人的话
既然你能看到这里,那我猜你是一个对编程充满热情的新手。因为我在这篇博客里真的很啰嗦,如果你有了相关的编程经验一定会很快就被劝退的,因为我甚至连文件描述符都讲了半天。
但我真的是从一个没有任何Unix编程经验,只会一些C语法的初学者的角度来讲的。
我认为对一个这样的人来说,就需要我这样去讲。当然你看到了这里证明我是成功的。对此我非常欣慰,因为正如你刚开始接触Unix编程一般,这也是我第一次写博客。
我的email是xiongzile99@gmail.com
,有任何关于此文的问题欢迎提问!
感谢阅读我的博客,希望本文能够对你有所启发和帮助!