所有高级语言的运行时(runtime)都提供了执行I/O功能的机制。
例如,C语言中提供了包含像printf()和scanf()等这样的标准I/O库函数, C++语言中提供了如 <<和>>这样的重载操作符。
从高级语言程序中通过I/O函数或I/O操作符提出I/O请求,到设备响应并完成I/O请求,涉及到多层次I/O软件和I/O硬件的协作。
I/O子系统和计算机系统一样也采用层次结构:封装+抽象+提供简单接口给上层使用。
I/O软件被组织成从高到低的四个层次,层次越低,则越接近设备而越远离用户程序。这四个层次依次为:
(1) 用户层I/O软件(I/O函数调用系统调用)
(2) 与设备无关的操作系统I/O软件 此层开始属于OS,OS在I/O系统中极其重要
(3) 设备驱动程序
(4) I/O中断处理程序
大部分I/O软件都属于操作系统内核态程序,最初的I/O请求在用户程序中提出。从用户I/O软件切换到内核I/O软件的唯一办法是异常机制——系统调用(自陷)
最下面的两个层次才会和硬件直接打交道。
OS在I/O子系统中的重要性由I/O系统以下三个特性决定:
(1)共享性:I/O系统被多个程序共享,须由OS对I/O资源统一调度管理,以保证用户程序只能访问自己有权访问的那部分I/O设备,并使系统的吞吐率达到最佳。
(2)复杂性:I/O设备控制细节复杂,需OS提供专门的驱动程序进行控制,这样可对用户程序屏蔽设备控制的细节。
(3)异步性:不同设备之间速度相差较大,因而,I/O设备与主机之间的信息交换使用异步的中断I/O方式,中断导致从用户态向内核态转移,因此必须由OS提供中断服务程序来处理。
因此,各类用户的I/O请求需要通过某种方式传给OS:
- 最终用户:键盘、鼠标通过操作界面传递给OS
- 用户程序:通过函数(高级语言)转换为系统调用传递给OS
系统调用(陷阱)是特殊异常事件,是OS为用户程序提供服务的手段。
OS提供一组系统调用,为用户进程的I/O请求进行具体的I/O操作:
用户软件可用以下两种方式提出I/O请求,最终都是调用系统调用。
(1)使用高级语言提供的标准I/O库函数。例如,在C语言程序中可以直接使用像fopen、fread、fwrite和fclose等文件操作函数,或printf、putc、scanf和getc等控制台I/O函数。 程序移植性很好。但是,使用标准I/O库函数有以下几个方面的不足:
- 标准I/O库函数不能保证文件的安全性(无加/解锁机制)
- 所有I/O都是同步的,只能串行执行,程序必须等待I/O操作完成后才能继续执行
- 有些I/O功能不适合甚至无法使用标准I/O库函数实现,如,不提供读取文件元数据(文件大小和文件创建时间等)的函数
- 用它进行网络编程会造成易于出现缓冲区溢出等风险
(2)使用OS提供的API函数或系统调用。例如,在Windows中直接使用像CreateFile、ReadFile、WriteFile、CloseHandle等文件操作API函数,或ReadConsole、WriteConsole等控制台I/O的API函数;对于Unix或Linux用户程序,则直接使用像open、read、write(printf最终也是调用了write)、close等系统调用封装函数(系统级I/O函数)。
注意点:C标准库中提供的函数并没有涵盖所有底层操作系统提供的功能;不同的C标准库函数可能调用相同的系统调用;此外,C标准I/O库函数、UNIX/Linux 和Windows的API函数所提供的I/O操作功能并不是一一对应的。
例如,它们的参数中对文件的标识方式不同:函数read() 和write() 的参数中指定的文件用一个整数类型的文件描述符来标识;而C 标准库函数fread()和fwrite() 的参数中指定的文件用一个指向特定结构的指针类型来标识。
API与系统调用有什么区别?
应用编程接口(API)与系统调用两者在概念上不完全相同,它们都是系统提供给用户程序使用的编程接口,但前者指的是功能更广泛、抽象程度更高的函数,后者仅指通过软中断(自陷)指令向内核态发出特定服务请求的函数。
系统调用封装函数是 API 函数中的一种。
API 函数最终通过调用系统调用实现 I/O。一个API 可能调用多个系统调用,不同 API 可能会调用同一个系统调用。但是,并不是所有API 都需要调用系统调用。
从编程者来看,API和系统调用之间没有什么差别。
从内核设计者来看,API 和系统调用差别很大:API 在用户态执行, 系统调用封装函数也在用户态执行,但具体服务例程在内核态执行。
用户程序总是通过某种I/O函数或I/O操作符请求I/O操作。
例如,用户进程读一个磁盘文件记录时,可调用C标准I/O库函数fread(),或Windows API函数ReadFile,或Unix/Linux的系统调用封装函数read()来提出I/O请求。不管是C库函数、API函数还是系统调用封装函数,最终都通过操作系统内核提供的系统调用来实现I/O。
即,用户程序中涉及I/O操作的函数最终会被转换为一组与具体机器架构相关的指令序列,这里我们将其称为I/O请求指令序列。
每个指令系统中一定有一类陷阱指令(有些机器也称为软中断指令或系统调用指令),主要功能是为操作系统提供灵活的系统调用机制。
在I/O请求指令序列中,具体I/O请求被转换为这条陷阱指令,在陷阱指令前面则是相应的系统调用参数的设置指令。
当CPU 执行到系统这条陷阱指令时, 会从用户态陷入到内核态;转到内核态执行后, CPU 根据陷阱指令执行时EAX 寄存器中的系统调用号,选择执行一个相应的系统调用服务例程;
在系统调用服务例程的执行过程中可能需要调用具体设备的驱动程序;在设备驱动程座执行过程中启动外设工作,外设准备好后发出中断请求,CPU响应中断后,就调出中断服务程序执行,在中断服务程序中控制主机与设备进行具体的数据交换。
标准I/O库函数比系统调用封装函数抽象层次高,后者属于系统级I/O函数。与系统提供的API函数一样,前者是基于后者实现的。
两者关系如图所示:
printf()函数的调用过程如下:
以下是write封装函数的原型:
ssize_t write(int fd, const void * buf, size_t n);
//fd是文件描述符,每个文件用一个int型的文件描述符来标示文件
//buf是要写的字符串的首地址。buf是void指针,可以通过强制类型转换变成任何类型的指针
//n是要写的字符个数,size_t是unsigned int
//返回值是真正写的字符个数,ssize_t是int,因为返回值可能为-1
write:
pushl %ebx //将EBX入栈(EBX为被调用者保存寄存器)
movl $, %eax //将系统调用号4送EAX
movl (%esp), %ebx //将第一个参数-文件描述符fd送EBX
movl (%esp), %ecx //将第二个参数-所写字符串首址buf送ECX
movl (%esp), %edx //将第三个参数-所写字符个数n送EDX
int $0x80 //进入系统调用处理程序system_call执行
cmpl $-, %eax //检查返回值,假定最大出错码为131(所有正数都小于FFFFFF83H)
jbe .L1 //若无错误,则跳转至.L1(按无符号数比)
negl %eax //将返回值取负送EAX
movl %eax, error //将EAX的值送error
movl $-, %eax //将write函数返回值置-1
.L1:
popl %ebx
ret
Linux 中有一个系统调用的统一人口,即系统调用处理程序system_call()。CPU 执行陷阱指令后,便转到system_call()的第一条指令执行。
进入system_call后根据调用号是4跳转到sys_read服务例程,然后在Linux内核中单向调用20次以上。
内核执行write的结果在EAX中返回,正确时为所写字符数(最高位为0),出错时为错误码的负数(最高位为1)
某函数调用了printf(),执行到调用printf()语句时,便会转到C语言I/O标准库函数printf()去执行;
printf()通过一系列函数调用,最终会调用函数write();
调用write()时,便会通过一系列步骤在内核空间中找到write对应的系统调用服务例程sys_write来执行。
在system_call中根据系统调用号知道要转到sys_write执行。