Skip to content

Latest commit

 

History

History
310 lines (166 loc) · 24.3 KB

chapter-24.md

File metadata and controls

310 lines (166 loc) · 24.3 KB

24. C 标准库

总结一下,Linux平台提供的C标准库包括:

  • 头文件

    一组头文件,定义了很多类型和宏,声明了很多库函数和全局变量。

    这些头文件放在哪些目录下取决于不同的Linux发行版和编译器版本,在我的系统上,stdarg.h和stddef.h位于/usr/lib/gcc/i486-linux-gnu/4.4.3/include目录下,stdio.h、stdlib.h、time.h、math.h、assert.h位于/usr/include目录下。

    C99标准定义的头文件有24个,本书只介绍其中最基本、最常用的几个。

  • 库文件

    一组库文件,提供了库函数和全局变量的定义。

    大多数库函数在libc共享库中,有些库函数在另外的共享库中,例如数学函数在libm中。

    在第19.4节讲过,通常libc共享库是/lib/libc.so.6,而我的系统启用了hwcap机制,libc共享库是/lib/tls/i686/cmov/libc.so.6。

24.1 字符串操作函数

  • str 和 mem

    我们可以得出这几个库函数的命名规律,以str开头的函数操作以Null结尾的字符串,而以mem开头的函数则不关心'\0'字符,或者说这些函数只是把参数看作n个字节,并不看作字符串,因此参数的指针类型是void *而非char *

  • 内存区间重叠和 restrict

    我们来看一个和memcpy/memmove类似的问题。下面的函数将两个数组中对应的元素相加,结果保存在第三个数组中。

    void vector_add(const double *x, const double *y, double *result)
    {
        int i;
        for (int i = 0; i < 64; ++i)
            result[i] = x[i] + y[i];
    }

    如果这个函数要在多处理器的计算机上运行,编译器可以做这样的优化:把这一个循环拆成两个循环,一个处理器计算i值从0到31的循环,另一个处理器计算i值从32到63的循环,这样两个处理器可以同时工作,使计算时间缩短一半。

    但是这样的编译优化能保证得出正确结果吗?假如result和x所指的内存区间是重叠的,result[0]其实是x[1],result[i]其实是x[i+1],这两个处理器就不能各干各的事情了,因为第二个处理器的计算过程依赖于第一个处理器的最终计算结果,这种情况下编译优化的结果是错的。这样看来编译器是不敢随便做优化了,那么多处理器提供的并行性就无法利用,岂不可惜?

    为此,C99引入restrict关键字,如果程序员把上面的函数声明为void vector_add(const double *restrict x, const double *restrict y, double *restrict result),就是告诉编译器可以放心地对这个函数做优化,由程序员负责保证这些指针所指的内存区间互不重叠。

    由于restrict是C99引入的新关键字,目前Linux的Man Page还没有更新,所以都没有restrict关键字,本书的函数原型都取自Man Page,所以也都没有restrict关键字。但在C99标准中库函数的原型都在必要的地方加了restrict关键字,在C99中memcpy的原型是void *memcpy(void * restrict s1, const void * restrict s2, size_t n);,就是告诉调用者,这个函数的实现可能会做些优化,编译器也可能会做些优化,传进来的指针不允许指向重叠的内存区间,否则结果可能是错的,而memmove的原型是void *memmove(void *s1, const void *s2, size_t n);,没有restrict关键字,说明传给这个函数的指针允许指向重叠的内存区间。

    在restrict关键字出现之前都是用自然语言描述哪些函数的参数不允许指向重叠的内存区间,例如在C89标准的库函数一章开头提到,本章描述的所有函数,除非特别说明,都不应该接收两个指针参数指向重叠的内存区间,例如调用sprintf时传进来的格式化字符串和结果字符串重叠,诸如此类的调用都是非法的。

    本书也遵循这一惯例,除了像memmove这样的函数特别说明之外,一般都不允许两个指针参数指向重叠的内存区间。

    关于restrict关键字更详细的解释可以查阅参考文献[29]。

  • 可重入性

    刚才提到在strtok函数中应该有一个静态指针变量记住上次处理到字符串的什么位置,所以不必每次调用都把字符串的当前处理位置传给strtok。但在函数中使用静态变量是不好的,这样的函数是不可重入的(可重入性的概念请查阅参考文献[31]的10.6节)。

    所以POSIX标准定义了一个不使用静态变量的strtok_r函数(这个函数不属于C标准),调用者需要自己分配一个指针变量来维护字符串的当前处理位置,每次调用时要传这个指针变量的地址给strtok_r的第三个参数,告诉strtok_r从哪里开始处理,strtok_r返回时再把新的处理位置写回这个指针变量中(这是一个Value-result参数)。strtok_r末尾的r表示可重入(Reentrant)。

24.2 标准I/O库函数

文件的基本概念

  • 文件分类

    文件可分为文本文件(Text File)和二进制文件(Binary File)两种,源文件是文本文件,而目标文件、可执行文件和库文件是二进制文件。

    文本文件是用来保存字符的,文件中的字节都是字符的某种编码(例如ASCII或UTF-8),用cat命令可以查看文本文件的内容,用vi可以编辑文本文件;

    而二进制文件不是用来保存字符的,文件中的字节表示其他含义,例如可执行文件中有些字节表示指令,有些字节表示各Section在文件中的位置,有些字节表示各Segment的加载地址。

  • 末尾换行

    很多程序要求文本文件的每一行末尾都要有换行符,最后一行也不例外,如果一个源文件的最后一行末尾没有换行符,用gcc编译会报错,所以vi要在最后一行末尾加换行符。

  • od

    od命令默认以八进制数显示文件中的字节,左边的文件地址默认也是八进制的,od是octal dump的缩写。

    -tx1选项要求以十六进制数显示文件中的字节,并且一个字节一组,-tc选项要求以字符形式显示文件中的ASCII码,-Ax选项要求以十六进制数显示左边的文件地址。

fopen/fclose

  • 句柄

    FILE是C标准库中定义的结构体类型,其中包含该文件在内核中的标识⑯、用户空间I/O缓冲区和当前读写位置等信息。但调用者不必知道FILE结构体都有哪些成员,调用者只是把文件指针在库函数接口之间传来传去,而文件指针所指的FILE结构体的成员在库函数内部维护,调用者不能直接访问这些成员,这也是封装思想的一种应用。

    FILE *这样的指针称为不透明指针(Opaque Pointer)或句柄(Handle),FILE *指针就像一个把手(Handle),抓住这个把手就可以打开门或抽屉,但用户只能抓这个把手,而不能直接抓门或抽屉。

  • 封装

    现在总结一下我们讲过的封装(Encapsulation):在第4.2节讲过把一组语句封装成一个函数,这是最简单的封装;在第19.2节讲过用static关键字封装模块的内部变量和函数;现在我们讲到用不透明指针封装一个类型的内部表示。

    封装是为了隔离,为了使一个模块的改动不会波及其他模块,从而保证整个系统的复杂性是可以控制的。

  • 文本模式和二进制模式

    为什么打开方式要区分文本模式和二进制模式呢?

    主要是因为换行符的问题,建议读者仔细看看Wikipedia的Newline词条,一个换行符原来可以这么复杂。

    我们知道Windows系统的文本文件的换行符是\r\n(ASCII码0x0d 0x0a),如果以文本模式打开,则从文件中读到的\r\n会自动转换,看起来像是一个\n字符,而写入文件的\n自动转换成\r\n保存,如果以二进制模式打开则不会做这种转换。

    UNIX系统的文本文件的换行符是\n,不管以文本模式还是二进制模式打开都一样,所以在UNIX系统上这两种模式没区别,本书示例代码的mode参数都省略b,对文本模式和二进制模式不加区分,但要注意这样的代码对于非UNIX操作系统是不可移植的。

  • EOF

    EOF在stdio.h中定义:

    它的值是-1。

  • fopen 和 fclose 配对

    fopen调用应该和fclose调用配对,打开文件操作完之后一定要记得关闭。

    如果不调用fclose,在进程退出时内核会自动关闭该进程打开的所有文件,但不能因此就忽略fclose调用,如果写一个长年累月运行而不退出的程序(比如网络服务器程序),打开的文件都不关闭,堆积得越来越多,就会占用越来越多的系统资源。

stdin/stdout/stderr

  • 终端设备

    终端设备和文件一样也需要先打开后操作,终端设备也有对应的路径名,/dev/tty就表示和当前进程相关联的终端设备(称为进程的控制终端),/dev/tty不是一个普通文件,它不表示磁盘上的一组数据,而是表示一个设备。用ls命令查看这个文件:

    开头的c表示文件类型是字符设备。中间的“5, 0”是它的设备号,主设备号5,次设备号0,主设备号标识内核中的一个设备驱动程序,次设备号标识该设备驱动程序管理的一个设备。

    内核通过设备号找到相应的驱动程序,完成对该设备的操作。

    我们知道常规文件的这一列应该显示文件长度,而设备文件的这一列显示设备号,这表明设备文件没有“文件长度”的属性,设备文件在磁盘上不保存数据,对设备文件做读写操作并不是读写磁盘上的数据,而是在读写设备。

  • 新进程会继承已打开的 stdin、stdout、stderr

    我们还没有打开过终端设备,为什么就可以用printf和scanf来读写呢?

    因为程序启动时会自动打开终端设备(更准确地说是这样:在Shell下敲命令启动一个新进程,新进程会继承Shell进程已经打开的文件,所以新进程在启动时就已经打开了终端设备。),并且用三个FILE *指针stdin、stdout和stderr指向这个设备,这三个文件指针是libc中定义的全局变量,在stdio.h中声明,printf向stdout写,而scanf从stdin读,用户程序也可以直接使用这三个文件指针。

    stdin、stdout和stderr的打开方式都是可读可写的,但通常stdin只用于读操作,称为标准输入(Standard Input),stdout只用于写操作,称为标准输出(Standard Output),stderr也只用于写操作,称为标准错误输出(Standard Error),通常程序的运行结果打印到标准输出,而错误提示(例如gcc报的警告和错误)打印到标准错误输出,所以fopen的错误处理写成这样更符合惯例:

    if ((fp = fopen("/tmp/file1", "r")) == NULL) {
        fputs("Error open file /tmp/file1\n", stderr);
        exit(1);
    }
  • 为什么要分成标准输出和标准错误输出

    不管是打印到标准输出还是打印到标准错误输出效果是一样的,都是打印到终端设备(也就是屏幕),那为什么还要分成标准输出和标准错误输出呢?

    我们可以在命令行用重定向把标准输出和标准错误输出分开,例如:

    ./a.out > errlog.txt

    这样把标准输出重定向到一个常规文件,而标准错误输出仍然对应终端设备,就可以把正常的输出结果和错误提示分开,而不是混在一起打印到屏幕。

errno与perror/strerror函数

所以一个系统函数错误返回后应该马上检查errno,在检查errno之前不能再调用其他系统函数。

另外,有时候错误码并不保存在errno中,例如pthread库函数的错误码都是通过返回值返回,不改变errno,显然这种情况用strerror比perror方便。

以字节为单位的I/O函数

  • 读写位置

    系统对于每个打开的文件都记录着当前读写位置(Position Indicator)。当文件打开时,读写位置在文件开头,每调用一次fgetc,读写位置向后移动一个字节,因此可以连续多次调用fgetc函数依次读取多个字节。

  • EOF

    注意,fgetc读到文件末尾时返回EOF,只是用这个返回值表示已读到文件末尾,并不是说每个文件末尾都有一个特殊的字节是EOF(根据上面的分析,EOF并不是一个字节)。

  • 读写位置

    每调用一次fputc,读写位置向后移动一个字节,因此可以连续多次调用fputc函数依次写入多个字节。如果文件是以追加方式打开的,每次调用fputc总是先把读写位置移到文件末尾然后把要写入的字节追加到后面。以后要介绍的I/O函数也都是这样更新读写位置的,不再赘述。

  • 从终端设备读

    从终端设备读有点特殊。当调用getchar()或fgetc(stdin)时,如果用户没有输入字符,getchar函数就阻塞等待。所谓阻塞(Block)是指这个函数调用不返回,也就不能执行后面的代码,这个进程阻塞了,操作系统可以调度别的进程执行。

    从终端设备读还有一个特点,用户输入一般字符并不会使getchar函数返回,仍然阻塞着,只有当用户输入回车或者文件结束标志时getchar才返回(这个特性取决于终端的工作模式,终端可以配置成一次输入一行的模式,也可以配置成一次输入一个字符的模式,默认是一次输入一行的模式(本书的实验都是在这种模式下做的),关于终端的配置请查阅参考文献[31]的第18章。)。

  • 终端输入文件结束的方法

    从终端设备输入时有两种方法表示文件结束,一种方法是在某一行开头输入Ctrl-D(如果光标不在一行开头则需要连续输入两次Ctrl-D),另一种方法是利用Shell的Heredoc语法:

    $ ./a.out <<END
    > hello
    > hey
    > END
    hello
    hey

    <<END表示从下一行开始是标准输入,直到某一行开头出现END时结束。<<后面的结束符可以任意指定,不一定非得是END,只要和输入的内容能区分开就行。

操作读写位置的函数

读写位置可以用一个long型的值表示,这个值可以调用ftell得到,也可以传给fseek,UNIX系统的文件模型比较简单,我们可以认为这个值等同于文件地址,但在其他操作系统上则不一定。我们讲过Windows的文本文件如果以文本模式打开,换行符在磁盘上存的是\r\n,而在程序中看到的却是\n,在磁盘上存的字节数和在程序中看到的不一致,其他操作系统的文件模型也有一些特殊规定,所以C标准关于fseek函数有很多奇怪的规定(至少在UNIX程序员看起来是很奇怪的),如果要编写可移植的代码就得考虑这些问题:

1.对于以文本模式打开的文件,whence参数只有取SEEK_SET是有意义的,并且传给offset参数的值要么是0,要么是先前对同一个文件调用ftell得到的返回值,不能像上面的例子那样任意指定一个10L。

2.对于以二进制模式打开的文件,fseek函数有可能不支持whence参数取SEEK_END的情况。

最后还有一点要注意,常规文件都可以做Seek操作,而设备文件有很多是不支持Seek操作的,只允许顺序读写,比如对终端设备调用fseek会出错返回。

以字符串为单位的 I/O 函数

  • \n 对 fgets 是特别字符,fgets 只适合读文本文件

    注意,对于fgets来说,'\n'是一个特别的字符,而'\0'并无任何特别之处,如果读到'\0'就当做普通字符读入。

    如果文件中存在'\0'字符(或者说字节0),调用fgets之后就无法判断缓冲区中的'\0'究竟是从文件读上来的还是由fgets自动添加的,所以fgets只适合读文本文件而不适合读二进制文件,并且文本文件中的所有字符都应该是可见字符,不能有'\0'。

  • stream

    注意在Man Page的函数原型中FILE *指针参数通常起名叫stream,因为标准I/O库操作的文件有另一个名称叫做流(Stream),现在简单介绍一下这个名词的历史背景。

    最早的操作系统并没有设备抽象机制,磁带驱动器、磁盘驱动器、行式打印机、打孔卡片等设备的操作方式各不相同,每种设备的打开方法都不一样,读写方法也不一样,有的设备中的数据按记录存储,每次只能读写一条记录,有的设备需要一边读写一边发各种控制命令,程序员需要记住所有这些细节。

    而UNIX系统向前迈进了一大步,把所有设备都抽象成“文件”的概念,文件由一串字节组成,用一个路径名标识,不管操作什么设备都用同样的函数打开,用同样的函数读写,并且每次可以读写任意的字节数。比如磁盘上的数据是按扇区(Sector)来组织的,每次操作磁盘驱动器可以读写一个扇区,但是UNIX系统屏蔽了这些底层细节,用户程序把磁盘文件看作数据流,每次可以读写任意的字节数,比如用fgets读一行,这一行可长可短,也可以跨扇区边界,如果用户程序调用一次fgets读三个扇区的数据,UNIX系统底层做三次读操作来完成用户的一次fgets调用。

以记录为单位的I/O函数

nmemb是用户程序请求读或写的记录数,但系统不一定能完成这样的请求,fread/fwrite返回的记录数是实际完成读写的记录数,有可能小于nmemb。例如调用fread时指定nmemb为2,而当前读写位置距文件末尾只有一条记录的长度,这种情况下只读一条记录,返回1。如果当前读写位置已经到达文件末尾,则调用fread返回0。如果在读写文件的过程中出错了,fread/fwrite的返回值也可能小于nmemb指定的值,比如刚写完一条记录,在写下一条记录时出错了,则fwrite返回1。

注意,直接在文件中读写结构体的程序是不可移植的,如果在一个平台上编译运行writebin.c程序,把生成的recfile文件拷贝到另一个平台,然后在另一个平台上编译运行readbin.c程序,则不一定能正确读出文件内容,因为不同平台的大小端可能不同,结构体的填充方式也可能不同,只有当两个平台遵循相同的ABI时才能保证正确读出文件内容。

格式化I/O函数

每个转换说明以%号开头,以转换字符结尾,我们以前用过的转换说明仅包含%号和转换字符,例如%d、%s,其实在这两个字符中间还有一些可选项,如表24.1所示。

从终端设备读的函数都要阻塞等待用户输入,直到敲回车才返回,换行符也是空白字符,每次循环的scanf("%lf", &v)匹配到换行符之前,下次循环的scanf("%lf",&v)从换行符之后的第一个非空白字符开始匹配。

C标准库的I/O缓冲区

  • 库函数调用系统调用

    用户程序调用C标准I/O库函数读写文件或设备,而这些库函数要通过系统调用把读写请求传给内核,最终由内核驱动磁盘或设备完成I/O操作。

    fopen要通过open(2)系统调用打开文件,fgetc/fgets/fread/fscanf等函数要通过read(2)系统调用请求内核读设备,fputc/fputs/fwrite/fprintf等函数要通过write(2)系统调用请求内核写设备,fclose要通过close(2)系统调用关闭文件。

  • 每个打开的文件有用户空间I/O缓冲区,加速读写

    C标准库为每个打开的文件分配一个用户空间I/O缓冲区以加速读写操作,库函数内部通过FILE结构体的成员可以访问到这个缓冲区,用户程序调用读写函数大多数时候都在I/O缓冲区中读写,只有少数时候需要把读写请求传给内核。

  • fgetc/fputc

    以fgetc/fputc为例,当用户程序第一次调用fgetc读一个字节时,fgetc函数可能通过read(2)系统调用进入内核读1K字节到I/O缓冲区,然后返回I/O缓冲区中的第一个字节给用户,把读写位置指向I/O缓冲区中的第二个字节,以后用户程序再调fgetc就直接从I/O缓冲区中读取,而不需要进内核了,当用户程序把这1K字节都读完之后,再次调用fgetc时,fgetc函数会再次进入内核读1K字节到I/O缓冲区。

    在这个场景中,用户程序、C标准库和内核之间的关系就像第16.5节讲过的CPU、Cache和内存之间的关系一样,C标准库之所以会从内核预读一些数据到I/O缓冲区,是希望用户程序稍后要用到这些数据,如果能直接从用户空间I/O缓冲区读数据,就省去了系统调用和模式切换的开销,效率要高得多。

    另一方面,用户程序调用fputc通常只是写到I/O缓冲区中,这样fputc函数可以很快地返回,如果I/O缓冲区写满了,fputc就通过write(2)系统调用把I/O缓冲区中的数据传给内核,内核最终把数据写回磁盘。

  • fflush

    如果某个时刻用户程序希望把I/O缓冲区中的数据立刻写回内核(这称为Flush操作),而不是等I/O缓冲区写满了再写回内核,可以调用库函数fflush。fclose函数在关闭文件之前也会自动做Flush操作。

    作为一个特例,调用fflush(NULL)可以对当前进程所有打开的文件做Flush操作。

    注意,fflush只保证通过write(2)系统调用将数据写回内核,并不保证数据一定写到了设备上,通常内核里也会有一个I/O缓存,如果一定要求内核把数据写到设备上可以调用fsync(2),请查阅参考文献[31]的3.13节。

  • 三类I/O缓冲区

    C标准库的I/O缓冲区有三种类型:全缓冲、行缓冲和无缓冲。

    当用户程序调用库函数做写操作时,不同类型的缓冲区具有不同的特性。

    • 全缓冲

      如果缓冲区写满了就写回内核,常规文件通常是全缓冲的。

    • 行缓冲

      如果用户程序写的数据中有换行符就把这一行写回内核,或者如果缓冲区写满了就写回内核。标准输入和标准输出对应的终端设备通常是行缓冲的。

      在本书的其他例子中,printf打印的字符串末尾都有换行符,以保证字符串在printf调用结束时就写回内核,如果你用printf打印调试信息,保证这一点尤其重要。

      除了写满缓冲区和写入换行符之外,还有两种特殊情况会导致行缓冲的文件被Flush。如果:

      • 用户程序调用库函数从某个无缓冲的文件中读取

      • 或者从某个行缓冲的文件中读取,并且这次读操作会引发read(2)系统调用从内核读取数据

      那么在读取之前会自动Flush所有打开的行缓冲文件。

    • 无缓冲

      用户程序每次调库函数做写操作都要立刻写回内核。标准错误输出通常是无缓冲的,这样用户程序产生的错误信息可以尽快输出到设备。

  • 读写同一个I/O缓冲区

    关于I/O缓冲区还有一点要注意,在图24.2中我们看到,如果一个文件以可读可写的方式打开(fopen的mode参数中包含+号),读和写用的是同一个I/O缓冲区,I/O缓冲区在某个时刻可以用作读缓冲,在另一个时刻可以用作写缓冲,但不能同时支持读写操作,因此C标准对于这种文件的读写操作有一些特别规定。

    1.写操作后面不能紧跟着读操作,在写操作和读操作之间应该做一次Flush,使I/O缓冲的数据写回内核,这样I/O缓冲区可以重新利用做读缓冲。要做Flush操作,除了直接调用fflush之外还可以调用fseek、fsetpos或rewind,Seek操作会使I/O缓冲区中的数据无效,因此在Seek操作之前会自动做Flush。

    2.读操作后面不能紧跟着写操作,在读操作和写操作之间应该调用fseek、fsetpos或rewind做一次Seek操作,声明I/O缓冲区中的数据无效,这样I/O缓冲区才可以重新利用做写缓冲,注意在读操作之后不能调用fflush(C标准规定这种情况是Undefined)。

    3.上一条规则有一个例外,如果读操作遇到了文件末尾,后面允许紧跟着写操作。

24.3 数值字符串转换函数

strtol在出错时可能返回0x7fffffff,在成功调用时也可能返回0x7fffffff,我们如何知道需要读errno呢?最严谨的做法是首先把errno置0,再调用strtol,再查看errno是否变成了错误码。

24.4 分配内存的函数

alloca函数不是在堆上分配空间,而是在调用者函数的栈帧上分配空间,类似于C99的变长数组,当调用者函数返回时自动释放栈帧,所以不需要free。很多UNIX系统都提供了这个函数,但它既不属于C标准也不属于POSIX标准。