(一) 一起学 Unix 环境高级编程 (APUE) 之 标准IO

时间:2022-05-07 16:09:04

.

.

.

.

.

目录

(一) 一起学 Unix 环境高级编程 (APUE) 之 标准IO

(二) 一起学 Unix 环境高级编程 (APUE) 之 文件 IO

(三) 一起学 Unix 环境高级编程 (APUE) 之 文件和目录

(四) 一起学 Unix 环境高级编程 (APUE) 之 系统数据文件和信息

(五) 一起学 Unix 环境高级编程 (APUE) 之 进程环境

(六) 一起学 Unix 环境高级编程 (APUE) 之 进程控制

(七) 一起学 Unix 环境高级编程 (APUE) 之 进程关系 和 守护进程

(八) 一起学 Unix 环境高级编程 (APUE) 之 信号

(九) 一起学 Unix 环境高级编程 (APUE) 之 线程

(十) 一起学 Unix 环境高级编程 (APUE) 之 线程控制

(十一) 一起学 Unix 环境高级编程 (APUE) 之 高级 IO

(十二) 一起学 Unix 环境高级编程 (APUE) 之 进程间通信(IPC)

(十三) [终篇] 一起学 Unix 环境高级编程 (APUE) 之 网络 IPC:套接字

最近在学习 APUE,所以顺便将每日所学记录下来,一方面为了巩固学习的知识,另一方面也为同样在学习APUE的童鞋们提供一份参考。

本系列博文均根据学习《UNIX环境高级编程》一书总结而来,如有错误请多多指教。

APUE主要讨论了三部分内容:文件IO、并发、进程间通信。

文件IO:

  标准IO:优点是可移植性高,缺点是性能比系统 IO 差,且功能没有系统 IO 丰富。

  系统IO:因为是内核直接提供的系统调用函数,所以性能比标准 IO 高,但是可移植性比标准 IO 差。

并发:

  信号 + 多进程;

  多线程;

进程间通信:

  FIFO:管道;

  System V:又称为 XSI,支持以下三种方式:

    msg:消息队列;

    sem:信号量;

    shm:共享存储;

  Socket:套接字(网络通信);

本系列博文就是围绕着这些内容进行学习和总结出来的,但是APUE一书讲述的主要是 Unix 环境,而 LZ 用的是 Linux 环境,所以本系列博文的所有内容都是基于 Linux 环境的,仅供各位童鞋参考。LZ 尽量多讲原理,少讲函数的具体使用,在使用 LZ 提到的函数的时候,如与各位开发环境的 man 手册冲突,则以 man 手册为准。

大家在学习的过程中一定要使用 Linux 系统,每学习到一个新的函数或知识点的时候要自己运行起来。

从 LZ 的经验来看,仅仅看 LZ 写的文字和你自己手动做实验得出的结果是不同的。只看不练的话跟没看过一样,什么都是看着简单,写起来才知道哪里有坑。

如果你因为工作等关系实在无法在电脑上安装 Linux 系统,那么在虚拟机里安装一个 Linux 也是很必要的。

如果你真的想学好 APUE,那么最好使用真正的 Linux。APUE 就是让你在 Linux 下面写程序用的,只有使用它才能体会到你平时使用的各种程序和命令是如何实现的,只有是使用它你才知道你需要什么程序,你自己可以写什么程序。边使用边学习你才能体会到 Linux 的种种设计思想,才能融入到整个 *nix 大家庭来。

废话先写到这里。预祝大家学习成功,记住,坚持就是胜利。

==============================华丽的分割线==============================

好了,通过简单的介绍相信大家对 APUE 的结构已经有了一个大致的了解了,接下来就开始今天的正题:文件 IO。

前面提到了标准 IO(STDIO)和系统 IO(SYSIO),那么这里整理一下它们的差别。

类型 可移植性 实时性 吞吐量 功能
STDIO 受限
SYSIO *

这里我一个一个的解释表格中的每一项,表格中的每一项都是两者之间相对而言,使用哪种 IO 并没有绝对的好坏之分,要根据实际的需求来决定应该使用哪个。

可移植性:

  标准 IO 是 C89 支持的函数,所以使用了标准 IO 的程序无论在 Linux 平台还是换成了 Windows 平台,不用修改代码是可以直接编译运行的。

  而系统 IO 是由内核直接提供的函数库实现的,不同的操作系统平台上提供的 IO 操作接口是不同的,所以想要移植使用了系统 IO 的程序,必须按照目标平台的 IO 库修改程序并重新调试。

  所以你写的程序将来可能在不同的平台上运行,那么最好使用标准 IO 库;如果你的程序是专门针对于某个平台而开发的,那么使用系统 IO 库能够得到我们下面说的其它优势。

实时性和吞吐量:

  讲这两个概念之前我先给大家看一段代码:

 #include <stdio.h>
#include <unistd.h> int main (void)
{
putchar('a');
write(, "b", ); putchar('a');
write(, "b", ); putchar('a');
write(, "b", ); printf("\n"); return ;
}
 >$ gcc -Wall .c
>$ ./a.out
bbbaaa

  输出的结果为什么不是 ababab 呢,这就是因为标准 IO 具有合并系统调用的功能,putchar(3) 将本应该执行多次的 write(2) 动作合并成了一步来完成,所以 aaa 是作为一个字符串打印的,这一点我们可以通过 strace(1) 命令跟踪系统调用来得出结论(下方第7行)。另外由于 stdout 默认使用的是行缓冲模式(下面会讲缓冲),所以对 putchar(3) 的调用并没有立即打印出来。

 >$ strace ./a.out
# ... 此处省略 n 行不相关内容
mmap(NULL, , PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -, ) = 0x7f077a35f000
write(, "b", 1b) =
write(, "b", 1b) =
write(, "b", 1b) =
write(, "aaa\n", 4aaa
) =
exit_group()
>$

  到这里我们还是没有说明白为什么标准 IO 吞吐量高,而系统 IO 实时性高。我再举个简单的栗子:门卫老大爷负责送信到邮局,他去一次邮局要花费 10 分钟的时间,而每次最多能送 20 封信,每当信件累计到 20 封的时候他就要动身去邮局了。但是当他收到一封加急的邮件时,就会立即去一趟邮局。系统 IO 就好比每收到一封信时都要去一趟邮局,所以实时性高。而标准 IO 就好比要攒够 20 封信才去一趟邮局,所以吞吐量高,因为用户把信件交到老大爷的手上时就会立即返回,响应速度快,用户体验更好。而我们使用 fflush(3) 之类的函数强制刷新缓冲的时候,就相当于是老大爷收到了一封加急信件需要立即去一趟邮局送信。

至于这里所说的标准 IO 功能受限,是因为标准 IO 在各个平台上都是使用系统 IO 封装的,为了使它具有通用性,又要考虑底层操作系统各自平台在实现上的差异,难免在功能上就要作出让步。

今天讲的所有的 IO 操作都是标准 IO,如果是方言我会单独标识出来。

首先要了解的一个概念是文件位置指针。

当我们打开一个文件要对它进行读写的时候,我们怎么能知道要从哪里开始读(写)文件呢?其实标准库准备了一个工具辅助我们读写文件,它就是文件位置指针。当我们使用标准库函数操作文件的时候,它会自动根据文件位置指针找到我们要操作的位置,也会随着我们的读写操作而自动修改指向,而不用我们自己手动记录和修改文件的操作位置。它使用起来非常方便,以至于你完全感觉不到它的存在,但是为了更好的理解文件 IO,你必须知道它的作用。

1.fopen(3)

 fopen - stream open functions

 #include <stdio.h>

 FILE *fopen(const char *path, const char *mode);

这是今天要学习的第一个函数,在操作文件之前,我们需要通过 fopen() 函数将文件打开,通过这个函数我们可以告诉操作系统我们要操作的是哪个文件,以及用什么样的方式操作这个文件。

参数列表:

path:要操作的文件路径。

mode:文件的打开方式,这个打开方式一共分为6种。

  r:以只读的方式打开文件,并且文件位置指针会被定位到文件首。如果要打开的文件不存在则报错。

  r+:以读写的方式打开文件,并且文件位置指针会被定位到文件首。如果要打开的文件不存在则报错。

  w:以只写的方式打开文件,如果文件不存在则创建,如果文件已存在则被截断为 0 字节,并且文件位置指针会被定位到文件首。

  w+:以读写的方式打开文件,如果文件不存在则创建,如果文件已存在则被截断为 0 字节,并且文件位置指针会被定位到文件首。

  a:以追加的方式打开文件,如果文件不存在则创建,且文件位置指针会被定位到文件最后一个有效字符的后面(EOF,end of the file)。

  a+:以读和追加的方式打开文件,如果文件不存在则创建,且读文件位置指针会被初始化到文件首,但是总是写入到最后一个有效字符的后面(EOF,end of the file)。

返回值:

  FILE 是一个由标准库定义的结构体,各位童鞋不要企图通过手动修改结构体里的内容来实现文件的操作,一定要通过标准库函数来操作文件。

  这个函数返回一个 FILE 类型的指针,它作为我们打开文件的凭据,后面所有对这个文件的操作都需要使用这个指针,而且使用之后一定不要忘记调用 fclose(3) 函数释放资源。

  如果该函数返回了一个指向 NULL 的指针,则表示文件打开失败了,可以通过 errno 获取到具体失败的原因。

error 是什么呢?它是标准 C 中定义的一个整形值,用来表示上次发生的错误。大家可以在头文件中看看 errno 都定义了哪些值:

>$ vim /usr/include/asm-generic/errno.h
>$ vim /usr/include/asm-generic/errno-base.h

通常系统调用会给我们我返回一个整形值来表示是否出现了错误,当出现了错误的时候会设置 errno,通过 errno 我们就可以得知出现了什么错误了。

当然,直接给我们一个数字,我们自己再从头文件中查找这个数字表示的意义,然后再打印出来给用户看,似乎态麻烦了,没有什么简便的办法吗?

别担心,其实标准库已经为我们准备好专门的转换函数了:perror(3) 和 strerror(3)

perror(3) 会自动读取 errno 帮我们转换成对应的文字描述,并且将它们输出到标准错误流中。它的参数是一个字符串,用来让我们自定义一些错误消息给用户看,它的输出格式就是 我们给传递的参数:errno 转换的描述文字。

strerror(3) 函数也会将 errno 转换为文字,不过它不会自动读取 errno 当前的值,需要我们把 errno 传递给它。它也不会帮我们输出到标准输出中,而是将转换完的字符串返回给我们。

如果大家是开发一个前台应用,一般可以使用 perror(3) 函数直接将错误输出给用户。

如果大家开发的是后台应用(如守护进程等),那么一般先使用 strerrno(3) 函数将 errno 转换为字符串,然后再把这个字符串传给日志系统记录下来。

大家在使用 errno 这个全局变量的时候要导入 errno.h 头文件:

#include <errno.h>

在使用 strerror(3) 函数时不要忘记导入 string.h 头文件,否则会报段错误!

#include <string.h>

其实现在的很多 *nix 系统中,errno 早已不是全局变量了,为了线程安全它已经变成了一个宏定义,这个我们在后面的博文中介绍线程的时候会讨论它。

2.fclose(3)

 fclose - close a stream

 #include <stdio.h>

 int fclose(FILE *fp);

这个函数是与 fopen(3) 函数对应的,当我们使用完一个文件之后,需要调用 fclose(3) 函数释放相关的资源,否则会造成内存泄漏。当一个 FILE 指针被 fclose(3) 函数成功释放后,这个指针所指向的内容将不能再次被使用,如果需要再次打开文件还需要调用 fopen(3) 函数。

参数列表:

  fp:fopen(3) 函数的返回值作为参数传入即可。

3.fgets(3)

 fgets - input of strings

 #include <stdio.h>

 int fgetc(FILE *stream);

 char *fgets(char *s, int size, FILE *stream);

从输入流 stream 中读取一个字符串回填到 s 所指向的空间。

这里出现了一个 stream 的概念,这个 stream 是什么呢,它被成为“流”,其实就是操作系统对于可以像文件一样操作的东西的一种抽象。它并非像自然界的小河流水一样潺潺细流,而通常是要么没有数据,要么一下子来一坨数据。当然 stream 也未必一定就是文件,比如系统为每个进程默认打开的三个 stream:stdin、stdout、stderr,它们本身就不是文件,就是与文件有着相同的操作方式,所以同样被抽象成了“流”。

这个函数并没有解决 gets(3) 函数可能会导致的数组越界问题,而是通过牺牲了获取数据的正确性来保证程序不会出现数组越界的错误,实际上是掩盖了 gets(3) 的问题。

该函数遇到如下四种情况会返回:

  1.当读入的数据量达到 size - 1 时;

  2.当读取的字符遇到 \n 时;

  3.当读取的字符遇到 EOF 时;

  4.当读取遇到错误时;

并且它会在读取到的数据的最后面添加一个 \0 到 s 中。

返回值:

  成功时返回 s。

  返回 NULL 时表示出现了错误或者读到了 strem 的末尾(EOF)。

4.fread(3)、fwrite(3)

 fread, fwrite - binary stream input/output

 #include <stdio.h>

 size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);

 size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);

这两个函数使用得最频繁,用来读写 stream,通常是用来读写文件。

参数列表:

  ptr:fread(3) 将从 stream 中读取出来的数据回填到 ptr 所指向的位置;fwrite(3) 则将从 ptr 所只想的位置读取数据写入到 stream 中;

  size:要读取的每个对象所占用的字节数;

  nmemb:要读取出多少个对象;

  stream:数据来源或去向;

返回值:

  注意这两个函数的返回值表示的是成功读(写)的对象的个数,而不是字节数!

例如:

  read(buf, 1, 10, fp); // 读取 10 个对象,每个对象 1 个字节

  read(buf, 10, 1, fp); // 读取 1 个对象,每个对象 10 个字节

当数据量充足的时候,这两种方式是没有区别的。

但是!!当数据量少于 size 个字节的整倍数时,第二种方法的的最后一个对象会读取失败。比如数据只有 45 个字节,那么第二种方法的返回值为 4,因为它只能成功读取 4 个对象。

所以通常第一种方式读写数据使用得比较普遍。

5.atoi(3)

 atoi, atol, atoll, atoq - convert a string to an integer

 #include <stdlib.h>

 int atoi(const char *nptr);
long atol(const char *nptr);
long long atoll(const char *nptr);
long long atoq(const char *nptr);

atoi(3) 函数族在这里提一下,主要是为了下面的 printf(3) 函数族做一个铺垫。

这些函数的作用是方便的将一个字符串形式的数字转换为对应的数字类型的数字。

上面这句话可能有点坳口,给你看个例子就懂了,下面是伪代码。

 char *str = "123abc456";
int i = ;
i = atoi(str);

i 的结果会变成 123。这些函数会转换一个字符串中地一个非有效数字前面的数字。如果很不幸这个字符串中的第一个字符就不是一个有效数字时,那么它们会返回 0。

6.printf(3)

 printf,   fprintf,  sprintf,  snprintf - formatted output conversion

 #include <stdio.h>

 int printf(const char *format, ...);
int fprintf(FILE *stream, const char *format, ...);
int sprintf(char *str, const char *format, ...);
int snprintf(char *str, size_t size, const char *format, ...);

printf(3) 函数大家一定不会陌生了,应该从写 Hello World! 的时候就接触到了的吧,所以我也不多介绍了,主要介绍两个内容。

一个是面试常考的一个问题,用了这么久的 printf(3) 函数,大家有没有注意过它的返回值表示什么呢?

printf(3) 的返回值表示成功打印的有效字符数量,不包括 \0。

另一个要说的就是刚才我们提到了 atoi(3) 函数族,它们负责将字符串转换为数字,那么有没有什么函数可以将数字转换为字符串呢,其实通过 sprintf(3) 或 snprintf(3) 就可以了。

有了这两个函数,不仅可以方便的将数字转换为字符串,还可以将多个字符串任意拼接为一个完整的字符串。

这里直接讲解一下 snprintf(3) 函数。

参数列表:

  str:拼接之后的结果会回填到这个指针所指向的位置;

  size:size - 1 为回填到 str 中的最大长度,数据超过这个长度的部分则会被舍弃,然后会在拼接的字符串的尾部追加 \0;

  format:格式化字符串,用法与 printf(3) 相同,这里不再赘述;

  ...:格式化字符串的参数,用法与 printf(3) 相同;

这个函数与 fputs(3) 一样,只是掩盖了 sprintf(3) 可能会导致的数组越界问题,通过牺牲数据的正确性来保证程序不会出现数组越界的错误。

7.scanf(3)

 scanf,  fscanf, sscanf - input format conversion

 #include <stdio.h>

 int scanf(const char *format, ...);
int fscanf(FILE *stream, const char *format, ...);
int sscanf(const char *str, const char *format, ...);

scanf(3) 函数族相信也不用过多的介绍了,这里唯一要强调的就是:scanf(3) 函数支持多种格式化参数,唯独 %s 是不能安全使用的,可能会导致数组越界,所以当需要接收用户输入的时候可以使用 fgets(3) 等函数来替代。

8.fseek(3)

 fgetpos, fseek, fsetpos, ftell, rewind - reposition a stream

 #include <stdio.h>

 int fseek(FILE *stream, long offset, int whence);

 long ftell(FILE *stream);

 void rewind(FILE *stream);

fseek(3) 函数族的函数用来控制和获取文件位置指针所在的位置,从而能够使我们灵活的读写文件。

介绍一下 fseek(3) 函数的参数列表:

  stream:这个已经不需要多介绍了吧,就是准备修改文件位置指针的文件流;

  offset:基于 whence 参数的偏移量;

  whence:相对于文件的哪里;有三个宏定义可以作为它的参数:SEEK_SET(文件首), SEEK_CUR(当前位置), or SEEK_END(文件尾);

返回值:

  成功返回 0;失败返回 -1,并且会设置 errno。

单独看参数列表也许你还有所疑惑,那么我写点简单的伪代码作为例子:

 fseek(fp, -, SEEK_CUR); // 从当前位置向前偏移10个字节。
fseek(fp, 2GB, SEEK_SET); // 可以制造一个空洞文件,如迅雷刚开始下载时产生的文件。

ftell(3) 函数以字节为单位获得文件指针的位置。

fseek(fp, 0, SEEK_END) + ftell(3) 可以计算出文件总字节大小。

还有一个值得大家注意的问题:

fseek(3) 和 ftell(3) 的参数和返回值使用了 long,所以取值范围为 -2GB ~ (2GB-1),而 ftell(3) 只能表示 2G-1 之内的文件大小,所以可以使用 fseeko(3) 和 ftello(3) 函数替代它们,但它们只是方言(SUSv2, POSIX.1-2001.)。

由于这两个函数比较古老,所以设计的时候认为 +-2GB 的取值范围已经足够用了,而没有意识到科技发展如此迅速的今天,2GB 大小的文件已经完全不能满足实际的需求了。

rewind(3) 函数将文件位置指针移动到文件起始位置,相当于:

 (void) fseek(stream, 0L, SEEK_SET)

9.getline(3)

 getline - delimited string input

 #include <stdio.h>

 ssize_t getline(char **lineptr, size_t *n, FILE *stream);

 Feature Test Macro Requirements for glibc (see feature_test_macros()):

 getline():
Since glibc 2.10:
_POSIX_C_SOURCE >= 200809L || _XOPEN_SOURCE >=
Before glibc 2.10:
_GNU_SOURCE

这个函数是一个非常好用的函数,它能帮助我们一次获取一行数据,而无论这个数据有多长。

参数列表:
  lineptr:一个一级指针的地址,它会将读取到的数据填写到一级指针指向的位置,并将>该位置回填到该参数中。指针初始必须置为NULL,该函数会根据指针是否为 NULL 来决定是否需要分配新的内存。
  n:是由该函数回填的申请的内存缓冲区的总大小,长度初始必须置为0。
虽然很好用,但是各位童鞋别高兴得太早了,该函数仅支持 GNU 标准,所以是方言,大家还是自己封装一个备用吧。

另外,想要使用这个函数必须在编译的时候指定 -D_GNU_SOURCE 参数:

 $> gcc -D_GNU_SOURCE

当然如果不想在编译的时候添加参数,也可以在引用头文件之前 #define _GNU_SOURCE,只是比较丑陋而已。

还有一个办法,是在 makefile 中配置 CFLAGS += -D_GNU_SOURCE,这样即省去了编译时手动写参数的麻烦,也避免了代码中的丑陋。

10.fflush(3)

 fflush - flush a stream

 #include <stdio.h>

 int fflush(FILE *stream);

fflush(3) 函数的作用是刷新缓冲区,提到这个函数就要讲讲缓冲区了。

缓冲区的作用是为了合并系统调用,在上面讲 STDIO 与 SYSIO 的区别时大家已经看到什么是合并系统调用了。

Linux 系统中有三种缓冲形式:无缓冲、行缓冲和全缓冲。

无缓冲:需要立刻输出时使用,例如 stderr;

行缓冲:遇到换行符时进行刷新、缓冲区满了的时候刷新、强制刷新(fflush(3));而标准输出(stdout)是行缓冲,因为涉及到终端设备;

全缓冲:只有缓冲区满了的时候和强制刷新(fflush(3))时才会刷新,这是 Linux 默认的缓冲模式,但终端设备除外,终端设备使用行缓冲模式;

当数据被放入缓冲区的时候是不会通过系统调用(read(3)、write(3))送到内核中的,只有缓冲区被刷新的时候数据才会通过系统调用进入内核。而刷新缓冲区就是 fflush(3) 函数的作用。

fflush(3) 的参数是具体要刷新的流,当参数为 NULL 时会刷新所有的输出流。

好了,时间不早了,今天先写到这里。

== 补充 == 2016.02.20 ==============================

有园友提问:函数括号里的数字表示的是什么意思?

其实这个数字表示的是该函数在 man 手册的第几章里可以查询到。

man 手册一共有 8 章,每一章保存不一样的内容,这八章的内容分别是:

第一章:shell 命令。如:ls、vim,查询方法:>$ man ls

第二章:系统调用。如:open、close,查询方法:>$ man 2 open 或 >$ man close。因为第一章也有 open,所以 man 的参数中要加章节号;因为第一章中没有 close,所以查询 close 不需要加章节号。

第三章:库函数。如:printf、fopen,查询方法:>$ man 3 printf 或 >$ man fopen

第四章:/dev 下的文件。如:zero。

第五章:一些配置文件的格式。如:/etc/shadow,查询方法:>$ man shadow

第六章:预留给游戏的,由游戏自己定义。如:sol。

第七章:附件和变量。如 iso-8859-1。

第八章:只能由 root 执行的系统管理命令。如 mkfs。

因为有些函数的名字会在不同的章节出现,这种函数在查询 man 手册的时候如果不加上章节号默认会查询低编号的章节。

比如 printf,如果你输入的查询命令是 >$ man printf,那么打开的并不是 printf 标准库函数的手册,而是 printf shell 命令的手册,想要查询 printf 函数的手册就需要在 man 命令后面加上该函数所在的章节数,正确的命令是 >$ man 3 printf,这样就会打开 man 手册第三章的 printf 函数。

另外,如果想查看每个章节中都有哪些内容,可以使用 >$ man -aw 命令得到 man 手册的安装路径,然后找到对应的 man 章节的目录,再用 zcat 命令查看那些 gz 文件。

以上补充内容参考《linux man 手册各个章节的意义和用法》。