系统编程:从write()和fwrite()谈开来

发布时间 2023-10-29 21:23:29作者: Changry
 

   在介绍write()和fwrite()的区别和联系之前,先介绍什么是系统调用,系统调用执行期间所经历的每个步骤,以及为了提升系统性能而设置的缓冲机制。最后,给出write()和fwrite()的区别于联系。

系统调用

  系统调用时受控的内核入口,借助于这一机制,进程可以请求内核以自己的名义去执行某些动作。以应用程序编程接口(API)的形式,内核提供一系列服务供程序访问。

  在深入系统调用的运作方式之前,务必关注以下几点:

  • 系统调用将处理器从用户态切换到内核态,以便CPU访问受保护的内核内存。
  • 系统调用的组成是固定的,每个系统调用都有一个唯一的数字编号。(程序通过名称来标识系统调用,对这一编号往往一无所知。)
  • 每个系统调用有其确定的参数,对用户空间(亦即进程的虚拟地址空间)与内核空间之间(相互)传递的信息加以规范。

  从编程的角度来看,系统调用与C语言函数的调用很相似。但是,在执行系统调用时,幕后会经历诸多步骤,下面以一个具体的硬件平台——x86-32为例,按事件发生的顺序对这些步骤加以分析。

  1. 应用程序通过调用C语言函数库中的外壳(wrapper)函数,来发起系统调用。
  2. 对系统调用中断处理程序来说,外壳函数必须保证所有的系统调用参数可用。外壳函数将堆栈参数复制到寄存器。
  3. 由于所有系统调用进入内核的方式相同,内核需要设法区分每个系统调用。为此,外壳函数会将系统调用编号复制到一个特殊的CPU寄存器(%eax)中。
  4. 外壳函数执行一条中断机器指令(init 0x80),引发处理器从用户态切换到内核态,并执行系统中断0x80(十进制数128)的中断矢量所指向的代码1
  5. 为响应中断0x80,内核调用system_call()例程(位于汇编文件arch/i386/entry.S中)来处理这次中断,具体如下:
    1. 在内核栈中保存寄存器值。
    2. 审核系统调用编号的有效性。
    3. 以系统调用编号对存放所有调用服务例程的列表(内核变量sys_call_table)进行索引,发现并调用相应的系统调用服务例程。若服务例程带有参数,那么将首先检查参数的有效性。例如,检查地址指向用户空间的内存位置是否有效。随后,该服务例程会执行必要的任务,这可能涉及对特定参数中指定地址处的值进行修改,以及用户内存和内核内存间传递数据。最后,该服务例程会将结果状态返回给system_call()例程。
    4. 从内核栈中恢复各寄存器值,并将系统调用返回值置于栈中。
    5. 返回至外壳函数,同时将处理器切换回用户态。
  6. 若系统调用服务例程的返回值表明调用有误,外壳函数会使用该值来设置全局变量errno2。然后,外壳函数会返回到调用程序,并同时返回一个整数值,以表明系统调用是否成功。

1. 较新的x86-32硬件平台实现了sysenter指令,较之传统的init 0x80中断指令,sysenter指令进入内核的速度更快。2.6内核已经glibc 2.3.2以后的版本都支持sysenter指令。

2. 在Linux上,系统调用服务例程遵循的惯例是调用成功则返回非负值。发生错误时,例程会对相应errno常量取反,返回一个负值。C语言函数库的外壳函数随即对其再次取反,将结果拷贝至errno,同时以-1作为外壳函数的返回值返回,向调用程序表明有错误发生。

库函数

C语言标准库函数由许多库函数组成,其用途多种多样,可以用来执行以下任务:打开文件、将时间转换为可读格式,以及进行字符串比较等等。许多库函数不会使用任何系统调用(如字符串相关操作),在用户态即可完成相关逻辑,但另外一些库函数,在建立在系统调用之上,是系统调用在用户态的封装。之所以设计库函数,是为了提供比直接调用库函数更为灵活方便的接口。当然,有时为了更进一步,希望能够比直接调用系统调用付出更小的开销。

标准C语言函数库的实现随UNIX的实现而异。GNU C语言函数库(glibc)是Linux上最常用的实现。

write()

在介绍了系统调用和库函数之后,回到本文的主题:write()函数和fwrite()函数。简单来说,write()函数是系统调用,是文件通用I/O API,fwrite()是glibc库函数,是系统调用write()在用户态的封装。所谓通用I/O模型,是因为在Linux系统中,所有设备都抽象为文件,那么,open()、read()、write()和close()可以对所有类型的文件执行I/O操作。

所有执行I/O操作的系统调用都以文件描述符,一个非负整数(通常是小整数)来指向打开的文件。write()系统调用将数据写入一个已打开的文件中。

#include <unistd.h>

// Returns number of bytes written, or -1 on error
ssize_t write(int fd, void *buffer, size_t count);

buffer参数为要写入文件中的数据的内存地址,count参数为要写入文件的字符数,fd参数为目的文件的描述符。如果调用成功,将返回实际写入文件的字节数,该返回值可能小于count值,这称为“部分写”。对磁盘文件来说,造成“部分写”的原因可能是磁盘已满,或者是因为进程资源对文件大小的限制。

内核缓冲

出于速度和效率考虑,系统I/O调用(即内核)和标准C语言库I/O函数,在操作磁盘文件时,会对数据进行缓冲。

write()系统调用在操作磁盘文件时不会直接发起磁盘访问,而是仅仅在用户空间缓冲区与内核缓冲区高速缓存(kernel buffer cache)之间复制数据。例如,如下调用将3个字节的数据从用户空间内存传递到内核空间的缓冲区中,write()随即返回:

write(fd, "abc", 3);

在后续某个时间,内核会将其从缓冲区写入(刷新)到磁盘。(因此,可以说系统调用与磁盘操作并不同步。)如果在此期间,另一进程试图读取该文件的这几个字节,那么内核将自动从缓冲高速缓存中提供这些数据,而不是从文件中(读取过期的内容)。通过缓存,减少了内核必须执行磁盘操作传输数据的次数,提升了性能。

Linux内核对缓冲区高速缓存的大小没有固定上限。内核会分配尽可能多的缓冲区高速缓存页,而仅受限于两个因素:可用的物理内存总量,以及出于其他目的对物理内存的需求(例如,需要将正在运行进程的文本和数据页保留在物理内存中)。若可用内存不足,则内核会将一些修改过的缓冲区高速缓存页内容刷新到磁盘,并释放其供系统使用。

更确切地说,从内核2.4开始,Linux不再维护一个单独的缓冲区高速缓存。相反,会将文件I/O缓冲区置于页面高速缓存中,其中还含有诸如内存映射文件的页面。然而,依然沿用了“缓冲区高速缓存(buffer cache)”这一术语。

fwrite()

正如上文所述,fwrite()是glibc库函数,是系统调用write()的封装。

#include <stdio.h>

size_t fwrite(const void* buffer, size_t size, size_t count, FILE* stream);

buffer指向要写入的缓存数组中第一个对象,size为每个对象的大小,count表示写入对象的数量,stream指向输出流。

如果调用成功,函数返回写入的对象的数量;如果有错误发生,函数返回值可能小于count值。如果size或者count为0,函数返回0并且不会有任何操作。

 库缓冲

当操作磁盘文件时,库函数将大块数据缓冲在用户空间以减少系统调用,如fprintf()、fscanf()、fgets()、fputc()、fgetc()、fputs()。因此,stdio库可以是编程者免于自行处理对数据的缓冲。

当然,stdio库提供函数允许设置缓冲模式。

#include <stdio.h>

// Returns 0 on success, or nonzero on error
int setvbuf(FILE *stream, char *buf, int mode, size_t size);

参数stream标识将要修改哪个文件流的缓冲。打开流后,必须在调用任何其他stdio函数之前先调用setvbuf()。setvbuf()调用将影响后续在指定流上进行的所有stdio操作。

参数buf和size则针对参数stream要使用的缓冲区,有两种方式:

  • buf不为NULL,那么其指向size大小的内存块以作为stream的缓冲区,该缓冲区应该以动态或者静态在堆中为该缓冲区分配一块空间(类似malloc()函数),而不是分配在栈上的本地变量。否则,函数返回时将销毁其栈帧,从而导致混乱。
  • buf为NULL,stdio库则会为stream自动分配一块缓冲区(除非是非缓冲的IO)。glibc在此场景下自动忽略size参数。

参数mode指定了缓冲类型,并具有下列值之一:

_IONBF

不对IO进行缓冲。每个stdio库函数将立即调用write()或者read(),并且忽略buf和size参数,可以分别指定两个参数为NULL和0。stderr默认属于这一类型,从而保证错误能立即输出。

_IOLBF

采用行缓冲IO。指代终端设备的流默认属于这一类型。对于输出流,在输出一个换行符(除非缓冲区已经填满)前将缓冲数据,对于输入流,每次读取一行数据。

_IOFBF

采用全缓冲IO。单次读、写数据(通过read()或者write()系统调用)的大小与缓冲区相同,指代磁盘的流默认采用此模式。

setbuf()和setbuffer()和setvbuf()类似。

无论当前采用何种缓冲模式,在任何时候,都可以使用fflush()库函数强制将stdio输出流中的数据(即通过write())刷新到内核缓冲区中。此函数会刷新指定stream的输出缓冲区。

总结

write()和fwrite()到底有什么区别和联系呢?

  1. fwrite()是glibc库函数,write()是系统调用,是系统内核为外部访问提供的服务访问接口。
  2. fwrite()是系统调用write()的库函数再封装。
  3. fwrite()在用户空间会对数据提供可自定义的缓冲空间,write()会在内核态提供内核缓冲空间。(对于硬盘,内核空间缓存转移到硬盘中的缓存。)数据写入缓冲区后即可返回。

 

参考

1. Linux系统编程手册,[德]Michael Kerrisk