# 第?28?章?文件與I/O
**目錄**
+ [1\. 匯編程序的Hello world](ch28s01.html)
+ [2\. C標準I/O庫函數與Unbuffered I/O函數](ch28s02.html)
+ [3\. open/close](ch28s03.html)
+ [4\. read/write](ch28s04.html)
+ [5\. lseek](ch28s05.html)
+ [6\. fcntl](ch28s06.html)
+ [7\. ioctl](ch28s07.html)
+ [8\. mmap](ch28s08.html)
從本章開始學習各種Linux系統函數,這些函數的用法必須結合Linux內核的工作原理來理解,因為系統函數正是內核提供給應用程序的接口,而要理解內核的工作原理,必須熟練掌握C語言,因為內核也是用C語言寫的,我們在描述內核工作原理時必然要用“指針”、“結構體”、“鏈表”這些名詞來組織語言,就像只有掌握了英語才能看懂英文書一樣,只有學好了C語言才能看懂我描述的內核工作原理。讀者看到這里應該已經熟練掌握了C語言了,所以應該有一個很好的起點了。我們在介紹C標準庫時并不試圖把所有庫函數講一遍,而是通過介紹一部分常用函數讓讀者把握庫函數的基本用法,在掌握了方法之后,書上沒講的庫函數讀者應該自己查Man Page學會使用。同樣,本書的第三部分也并不試圖把所有的系統函數講一遍,而是通過介紹一部分系統函數讓讀者理解操作系統各部分的工作原理,在有了這個基礎之后就應該能夠看懂Man Page學習其它系統函數的用法。
讀者可以結合[[APUE2e]](bi01.html#bibli.apue "Advanced Programming in the UNIX Environment")學習本書的第三部分,該書在講解系統函數方面更加全面,但對于內核工作原理涉及得不夠深入,而且假定讀者具有一定的操作系統基礎知識,所以并不適合初學者。該書還有一點非常不適合初學者,作者不辭勞苦,在N多種UNIX系統上做了實驗,分析了它們的內核代碼,把每個系統函數在各種UNIX系統上的不兼容特性總結得非常詳細,很多開發者需要編寫可移植的應用程序,一定愛死他了,但初學者看了大段大段的這種描述(某某函數在4.2BSD上怎么樣,到4.4BSD又改成怎么樣了,在SVR4上怎么樣,到Solaris又改成怎么樣了,現在POSIX標準是怎么統一的,還有哪些系統沒有完全遵守POSIX標準)只會一頭霧水,不看倒還明白,越看越不明白了。也正因為該書要兼顧各種UNIX系統,所以沒法深入講解內核的工作原理,因為每種UNIX系統的內核都不一樣。而本書的側重點則不同,只講Linux平臺的特性,只講Linux內核的工作原理,涉及體系結構時只講x86平臺,對于初學者來說,綁定到一個明確的平臺上學習就不會覺得太抽象了。當然本書的代碼也會盡量兼顧可移植性,避免依賴于Linux平臺特有的一些特性。
## 1.?匯編程序的Hello world
之前我們學習了如何用C標準I/O庫讀寫文件,本章詳細講解這些I/O操作是怎么實現的。所有I/O操作最終都是在內核中做的,以前我們用的C標準I/O庫函數最終也是通過系統調用把I/O操作從用戶空間傳給內核,然后讓內核去做I/O操作,本章和下一章會介紹內核中I/O子系統的工作原理。首先看一個打印Hello world的匯編程序,了解I/O操作是怎樣通過系統調用傳給內核的。
**例?28.1.?匯編程序的Hello world**
```
.data # section declaration
msg:
.ascii "Hello, world!\n" # our dear string
len = . - msg # length of our dear string
.text # section declaration
# we must export the entry point to the ELF linker or
.global _start # loader. They conventionally recognize _start as their
# entry point. Use ld -e foo to override the default.
_start:
# write our string to stdout
movl $len,%edx # third argument: message length
movl $msg,%ecx # second argument: pointer to message to write
movl $1,%ebx # first argument: file handle (stdout)
movl $4,%eax # system call number (sys_write)
int $0x80 # call kernel
# and exit
movl $0,%ebx # first argument: exit code
movl $1,%eax # system call number (sys_exit)
int $0x80 # call kernel
```
像以前一樣,匯編、鏈接、運行:
```
$ as -o hello.o hello.s
$ ld -o hello hello.o
$ ./hello
Hello, world!
```
這段匯編相當于以下C代碼:
```
#include <unistd.h>
char msg[14] = "Hello, world!\n";
#define len 14
int main(void)
{
write(1, msg, len);
_exit(0);
}
```
`.data`段有一個標號`msg`,代表字符串`"Hello, world!\n"`的首地址,相當于C程序的一個全局變量。注意在C語言中字符串的末尾隱含有一個`'\0'`,而匯編指示`.ascii`定義的字符串末尾沒有隱含的`'\0'`。匯編程序中的`len`代表一個常量,它的值由當前地址減去符號`msg`所代表的地址得到,換句話說就是字符串`"Hello, world!\n"`的長度。現在解釋一下這行代碼中的“.”,匯編器總是從前到后把匯編代碼轉換成目標文件,在這個過程中維護一個地址計數器,當處理到每個段的開頭時把地址計數器置成0,然后每處理一條匯編指示或指令就把地址計數器增加相應的字節數,在匯編程序中用“.”可以取出當前地址計數器的值,該值是一個常量。
在`_start`中調了兩個系統調用,第一個是`write`系統調用,第二個是以前講過的`_exit`系統調用。在調`write`系統調用時,`eax`寄存器保存著`write`的系統調用號4,`ebx`、`ecx`、`edx`寄存器分別保存著`write`系統調用需要的三個參數。`ebx`保存著文件描述符,進程中每個打開的文件都用一個編號來標識,稱為文件描述符,文件描述符1表示標準輸出,對應于C標準I/O庫的`stdout`。`ecx`保存著輸出緩沖區的首地址。`edx`保存著輸出的字節數。`write`系統調用把從`msg`開始的`len`個字節寫到標準輸出。
C代碼中的`write`函數是系統調用的包裝函數,其內部實現就是把傳進來的三個參數分別賦給`ebx`、`ecx`、`edx`寄存器,然后執行`movl $4,%eax`和`int $0x80`兩條指令。這個函數不可能完全用C代碼來寫,因為任何C代碼都不會編譯生成`int`指令,所以這個函數有可能是完全用匯編寫的,也可能是用C內聯匯編寫的,甚至可能是一個宏定義(省了參數入棧出棧的步驟)。`_exit`函數也是如此,我們講過這些系統調用的包裝函數位于Man Page的第2個Section。
## 2.?C標準I/O庫函數與Unbuffered I/O函數
現在看看C標準I/O庫函數是如何用系統調用實現的。
`fopen(3)`
調用`open(2)`打開指定的文件,返回一個文件描述符(就是一個`int`類型的編號),分配一個`FILE`結構體,其中包含該文件的描述符、I/O緩沖區和當前讀寫位置等信息,返回這個`FILE`結構體的地址。
`fgetc(3)`
通過傳入的`FILE *`參數找到該文件的描述符、I/O緩沖區和當前讀寫位置,判斷能否從I/O緩沖區中讀到下一個字符,如果能讀到就直接返回該字符,否則調用`read(2)`,把文件描述符傳進去,讓內核讀取該文件的數據到I/O緩沖區,然后返回下一個字符。注意,對于C標準I/O庫來說,打開的文件由`FILE *`指針標識,而對于內核來說,打開的文件由文件描述符標識,文件描述符從`open`系統調用獲得,在使用`read`、`write`、`close`系統調用時都需要傳文件描述符。
`fputc(3)`
判斷該文件的I/O緩沖區是否有空間再存放一個字符,如果有空間則直接保存在I/O緩沖區中并返回,如果I/O緩沖區已滿就調用`write(2)`,讓內核把I/O緩沖區的內容寫回文件。
`fclose(3)`
如果I/O緩沖區中還有數據沒寫回文件,就調用`write(2)`寫回文件,然后調用`close(2)`關閉文件,釋放`FILE`結構體和I/O緩沖區。
以寫文件為例,C標準I/O庫函數(`printf(3)`、`putchar(3)`、`fputs(3)`)與系統調用`write(2)`的關系如下圖所示。
**圖?28.1.?庫函數與系統調用的層次關系**

`open`、`read`、`write`、`close`等系統函數稱為無緩沖I/O(Unbuffered I/O)函數,因為它們位于C標準庫的I/O緩沖區的底層<sup>[[36](#ftn.id2850829)]</sup>。用戶程序在讀寫文件時既可以調用C標準I/O庫函數,也可以直接調用底層的Unbuffered I/O函數,那么用哪一組函數好呢?
* 用Unbuffered I/O函數每次讀寫都要進內核,調一個系統調用比調一個用戶空間的函數要慢很多,所以在用戶空間開辟I/O緩沖區還是必要的,用C標準I/O庫函數就比較方便,省去了自己管理I/O緩沖區的麻煩。
* 用C標準I/O庫函數要時刻注意I/O緩沖區和實際文件有可能不一致,在必要時需調用`fflush(3)`。
* 我們知道UNIX的傳統是Everything is a file,I/O函數不僅用于讀寫常規文件,也用于讀寫設備,比如終端或網絡設備。在讀寫設備時通常是不希望有緩沖的,例如向代表網絡設備的文件寫數據就是希望數據通過網絡設備發送出去,而不希望只寫到緩沖區里就算完事兒了,當網絡設備接收到數據時應用程序也希望第一時間被通知到,所以網絡編程通常直接調用Unbuffered I/O函數。
C標準庫函數是C標準的一部分,而Unbuffered I/O函數是UNIX標準的一部分,在所有支持C語言的平臺上應該都可以用C標準庫函數(除了有些平臺的C編譯器沒有完全符合C標準之外),而只有在UNIX平臺上才能使用Unbuffered I/O函數,所以C標準I/O庫函數在頭文件`stdio.h`中聲明,而`read`、`write`等函數在頭文件`unistd.h`中聲明。在支持C語言的非UNIX操作系統上,標準I/O庫的底層可能由另外一組系統函數支持,例如Windows系統的底層是Win32 API,其中讀寫文件的系統函數是`ReadFile`、`WriteFile`。
### 關于UNIX標準
POSIX(Portable Operating System Interface)是由IEEE制定的標準,致力于統一各種UNIX系統的接口,促進各種UNIX系統向互相兼容的發向發展。IEEE 1003.1(也稱為POSIX.1)定義了UNIX系統的函數接口,既包括C標準庫函數,也包括系統調用和其它UNIX庫函數。POSIX.1只定義接口而不定義實現,所以并不區分一個函數是庫函數還是系統調用,至于哪些函數在用戶空間實現,哪些函數在內核中實現,由操作系統的開發者決定,各種UNIX系統都不太一樣。IEEE 1003.2定義了Shell的語法和各種基本命令的選項等。本書的第三部分不僅講解基本的系統函數接口,也順帶講解Shell、基本命令、帳號和權限以及系統管理的基礎知識,這些內容合在一起定義了UNIX系統的基本特性。
在UNIX的發展歷史上主要分成BSD和SYSV兩個派系,各自實現了很多不同的接口,比如BSD的網絡編程接口是socket,而SYSV的網絡編程接口是基于STREAMS的TLI。POSIX在統一接口的過程中,有些接口借鑒BSD的,有些接口借鑒SYSV的,還有些接口既不是來自BSD也不是來自SYSV,而是憑空發明出來的(例如本書要講的pthread庫就屬于這種情況),通過Man Page的_COMFORMING TO_部分可以看出來一個函數接口屬于哪種情況。Linux的源代碼是完全從頭編寫的,并不繼承BSD或SYSV的源代碼,沒有歷史的包袱,所以能比較好地遵照POSIX標準實現,既有BSD的特性也有SYSV的特性,此外還有一些Linux特有的特性,比如`epoll(7)`,依賴于這些接口的應用程序是不可移植的,但在Linux系統上運行效率很高。
POSIX定義的接口有些規定是必須實現的,而另外一些是可以選擇實現的。有些非UNIX系統也實現了POSIX中必須實現的部分,那么也可以聲稱自己是POSIX兼容的,然而要想聲稱自己是UNIX,還必須要實現一部分在POSIX中規定為可選實現的接口,這由另外一個標準SUS(Single UNIX Specification)規定。SUS是POSIX的超集,一部分在POSIX中規定為可選實現的接口在SUS中規定為必須實現,完整實現了這些接口的系統稱為XSI(X/Open System Interface)兼容的。SUS標準由The Open Group維護,該組織擁有UNIX的注冊商標([http://www.unix.org/](http://www.unix.org/)),XSI兼容的系統可以從該組織獲得授權使用UNIX這個商標。
現在該說說文件描述符了。每個進程在Linux內核中都有一個`task_struct`結構體來維護進程相關的信息,稱為進程描述符(Process Descriptor),而在操作系統理論中稱為進程控制塊(PCB,Process Control Block)。`task_struct`中有一個指針指向`files_struct`結構體,稱為文件描述符表,其中每個表項包含一個指向已打開的文件的指針,如下圖所示。
**圖?28.2.?文件描述符表**

至于已打開的文件在內核中用什么結構體表示,我們將在下一章詳細介紹,目前我們在畫圖時用一個圈表示。用戶程序不能直接訪問內核中的文件描述符表,而只能使用文件描述符表的索引(即0、1、2、3這些數字),這些索引就稱為文件描述符(File Descriptor),用`int`型變量保存。當調用`open`打開一個文件或創建一個新文件時,內核分配一個文件描述符并返回給用戶程序,該文件描述符表項中的指針指向新打開的文件。當讀寫文件時,用戶程序把文件描述符傳給`read`或`write`,內核根據文件描述符找到相應的表項,再通過表項中的指針找到相應的文件。
我們知道,程序啟動時會自動打開三個文件:標準輸入、標準輸出和標準錯誤輸出。在C標準庫中分別用`FILE *`指針`stdin`、`stdout`和`stderr`表示。這三個文件的描述符分別是0、1、2,保存在相應的`FILE`結構體中。頭文件`unistd.h`中有如下的宏定義來表示這三個文件描述符:
```
#define STDIN_FILENO 0
#define STDOUT_FILENO 1
#define STDERR_FILENO 2
```
* * *
<sup>[[36](#id2850829)]</sup> 事實上Unbuffered I/O這個名詞是有些誤導的,雖然`write`系統調用位于C標準庫I/O緩沖區的底層,但在`write`的底層也可以分配一個內核I/O緩沖區,所以`write`也不一定是直接寫到文件的,也可能寫到內核I/O緩沖區中,至于究竟寫到了文件中還是內核緩沖區中對于進程來說是沒有差別的,如果進程A和進程B打開同一文件,進程A寫到內核I/O緩沖區中的數據從進程B也能讀到,而C標準庫的I/O緩沖區則不具有這一特性(想一想為什么)。
## 3.?open/close
`open`函數可以打開或創建一個文件。
```
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
返回值:成功返回新分配的文件描述符,出錯返回-1并設置errno
```
在Man Page中`open`函數有兩種形式,一種帶兩個參數,一種帶三個參數,其實在C代碼中`open`函數是這樣聲明的:
```
int open(const char *pathname, int flags, ...);
```
最后的可變參數可以是0個或1個,由`flags`參數中的標志位決定,見下面的詳細說明。
`pathname`參數是要打開或創建的文件名,和`fopen`一樣,`pathname`既可以是相對路徑也可以是絕對路徑。`flags`參數有一系列常數值可供選擇,可以同時選擇多個常數用按位或運算符連接起來,所以這些常數的宏定義都以`O_`開頭,表示or。
必選項:以下三個常數中必須指定一個,且僅允許指定一個。
* `O_RDONLY` 只讀打開
* `O_WRONLY` 只寫打開
* `O_RDWR` 可讀可寫打開
以下可選項可以同時指定0個或多個,和必選項按位或起來作為`flags`參數。可選項有很多,這里只介紹一部分,其它選項可參考`open(2)`的Man Page:
* `O_APPEND` 表示追加。如果文件已有內容,這次打開文件所寫的數據附加到文件的末尾而不覆蓋原來的內容。
* `O_CREAT` 若此文件不存在則創建它。使用此選項時需要提供第三個參數`mode`,表示該文件的訪問權限。
* `O_EXCL` 如果同時指定了`O_CREAT`,并且文件已存在,則出錯返回。
* `O_TRUNC` 如果文件已存在,并且以只寫或可讀可寫方式打開,則將其長度截斷(Truncate)為0字節。
* `O_NONBLOCK` 對于設備文件,以`O_NONBLOCK`方式打開可以做非阻塞I/O(Nonblock I/O),非阻塞I/O在下一節詳細講解。
注意`open`函數與C標準I/O庫的`fopen`函數有些細微的區別:
* 以可寫的方式`fopen`一個文件時,如果文件不存在會自動創建,而`open`一個文件時必須明確指定`O_CREAT`才會創建文件,否則文件不存在就出錯返回。
* 以`w`或`w+`方式`fopen`一個文件時,如果文件已存在就截斷為0字節,而`open`一個文件時必須明確指定`O_TRUNC`才會截斷文件,否則直接在原來的數據上改寫。
第三個參數`mode`指定文件權限,可以用八進制數表示,比如0644表示`-rw-r--r--`,也可以用`S_IRUSR`、`S_IWUSR`等宏定義按位或起來表示,詳見`open(2)`的Man Page。要注意的是,文件權限由`open`的`mode`參數和當前進程的`umask`掩碼共同決定。
補充說明一下Shell的`umask`命令。Shell進程的`umask`掩碼可以用`umask`命令查看:
```
$ umask
0022
```
用`touch`命令創建一個文件時,創建權限是0666,而`touch`進程繼承了Shell進程的`umask`掩碼,所以最終的文件權限是0666&~022=0644。
```
$ touch file123
$ ls -l file123
-rw-r--r-- 1 akaedu akaedu 0 2009-03-08 15:07 file123
```
同樣道理,用`gcc`編譯生成一個可執行文件時,創建權限是0777,而最終的文件權限是0777&~022=0755。
```
$ gcc main.c
$ ls -l a.out
-rwxr-xr-x 1 akaedu akaedu 6483 2009-03-08 15:07 a.out
```
我們看到的都是被`umask`掩碼修改之后的權限,那么如何證明`touch`或`gcc`創建文件的權限本來應該是0666和0777呢?我們可以把Shell進程的`umask`改成0,再重復上述實驗:
```
$ umask 0
$ touch file123
$ rm file123 a.out
$ touch file123
$ ls -l file123
-rw-rw-rw- 1 akaedu akaedu 0 2009-03-08 15:09 file123
$ gcc main.c
$ ls -l a.out
-rwxrwxrwx 1 akaedu akaedu 6483 2009-03-08 15:09 a.out
```
現在我們自己寫一個程序,在其中調用`open("somefile", O_WRONLY|O_CREAT, 0664);`創建文件,然后在Shell中運行并查看結果:
```
$ umask 022
$ ./a.out
$ ls -l somefile
-rw-r--r-- 1 akaedu akaedu 6483 2009-03-08 15:11 somefile
```
不出所料,文件`somefile`的權限是0664&~022=0644。有幾個問題現在我沒有解釋:為什么被Shell啟動的進程可以繼承Shell進程的`umask`掩碼?為什么`umask`命令可以讀寫Shell進程的`umask`掩碼?這些問題將在[第?1?節 “引言”](ch30s01.html#process.intro)解釋。
`close`函數關閉一個已打開的文件:
```
#include <unistd.h>
int close(int fd);
返回值:成功返回0,出錯返回-1并設置errno
```
參數`fd`是要關閉的文件描述符。需要說明的是,當一個進程終止時,內核對該進程所有尚未關閉的文件描述符調用`close`關閉,所以即使用戶程序不調用`close`,在終止時內核也會自動關閉它打開的所有文件。但是對于一個長年累月運行的程序(比如網絡服務器),打開的文件描述符一定要記得關閉,否則隨著打開的文件越來越多,會占用大量文件描述符和系統資源。
由`open`返回的文件描述符一定是該進程尚未使用的最小描述符。由于程序啟動時自動打開文件描述符0、1、2,因此第一次調用`open`打開文件通常會返回描述符3,再調用`open`就會返回4。可以利用這一點在標準輸入、標準輸出或標準錯誤輸出上打開一個新文件,實現重定向的功能。例如,首先調用`close`關閉文件描述符1,然后調用`open`打開一個常規文件,則一定會返回文件描述符1,這時候標準輸出就不再是終端,而是一個常規文件了,再調用`printf`就不會打印到屏幕上,而是寫到這個文件中了。后面要講的`dup2`函數提供了另外一種辦法在指定的文件描述符上打開文件。
### 習題
1、在系統頭文件中查找`flags`和`mode`參數用到的這些宏定義的值是多少。把這些宏定義按位或起來是什么效果?為什么必選項只能選一個而可選項可以選多個?
2、請按照下述要求分別寫出相應的`open`調用。
* 打開文件`/home/akae.txt`用于寫操作,以追加方式打開
* 打開文件`/home/akae.txt`用于寫操作,如果該文件不存在則創建它
* 打開文件`/home/akae.txt`用于寫操作,如果該文件已存在則截斷為0字節,如果該文件不存在則創建它
* 打開文件`/home/akae.txt`用于寫操作,如果該文件已存在則報錯退出,如果該文件不存在則創建它
## 4.?read/write
`read`函數從打開的設備或文件中讀取數據。
```
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
返回值:成功返回讀取的字節數,出錯返回-1并設置errno,如果在調read之前已到達文件末尾,則這次read返回0
```
參數`count`是請求讀取的字節數,讀上來的數據保存在緩沖區`buf`中,同時文件的當前讀寫位置向后移。注意這個讀寫位置和使用C標準I/O庫時的讀寫位置有可能不同,這個讀寫位置是記在內核中的,而使用C標準I/O庫時的讀寫位置是用戶空間I/O緩沖區中的位置。比如用`fgetc`讀一個字節,`fgetc`有可能從內核中預讀1024個字節到I/O緩沖區中,再返回第一個字節,這時該文件在內核中記錄的讀寫位置是1024,而在`FILE`結構體中記錄的讀寫位置是1。注意返回值類型是`ssize_t`,表示有符號的`size_t`,這樣既可以返回正的字節數、0(表示到達文件末尾)也可以返回負值-1(表示出錯)。`read`函數返回時,返回值說明了`buf`中前多少個字節是剛讀上來的。有些情況下,實際讀到的字節數(返回值)會小于請求讀的字節數`count`,例如:
* 讀常規文件時,在讀到`count`個字節之前已到達文件末尾。例如,距文件末尾還有30個字節而請求讀100個字節,則`read`返回30,下次`read`將返回0。
* 從終端設備讀,通常以行為單位,讀到換行符就返回了。
* 從網絡讀,根據不同的傳輸層協議和內核緩存機制,返回值可能小于請求的字節數,后面socket編程部分會詳細講解。
`write`函數向打開的設備或文件中寫數據。
```
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
返回值:成功返回寫入的字節數,出錯返回-1并設置errno
```
寫常規文件時,`write`的返回值通常等于請求寫的字節數`count`,而向終端設備或網絡寫則不一定。
讀常規文件是不會阻塞的,不管讀多少字節,`read`一定會在有限的時間內返回。從終端設備或網絡讀則不一定,如果從終端輸入的數據沒有換行符,調用`read`讀終端設備就會阻塞,如果網絡上沒有接收到數據包,調用`read`從網絡讀就會阻塞,至于會阻塞多長時間也是不確定的,如果一直沒有數據到達就一直阻塞在那里。同樣,寫常規文件是不會阻塞的,而向終端設備或網絡寫則不一定。
現在明確一下阻塞(Block)這個概念。當進程調用一個阻塞的系統函數時,該進程被置于睡眠(Sleep)狀態,這時內核調度其它進程運行,直到該進程等待的事件發生了(比如網絡上接收到數據包,或者調用`sleep`指定的睡眠時間到了)它才有可能繼續運行。與睡眠狀態相對的是運行(Running)狀態,在Linux內核中,處于運行狀態的進程分為兩種情況:
* 正在被調度執行。CPU處于該進程的上下文環境中,程序計數器(`eip`)里保存著該進程的指令地址,通用寄存器里保存著該進程運算過程的中間結果,正在執行該進程的指令,正在讀寫該進程的地址空間。
* 就緒狀態。該進程不需要等待什么事件發生,隨時都可以執行,但CPU暫時還在執行另一個進程,所以該進程在一個就緒隊列中等待被內核調度。系統中可能同時有多個就緒的進程,那么該調度誰執行呢?內核的調度算法是基于優先級和時間片的,而且會根據每個進程的運行情況動態調整它的優先級和時間片,讓每個進程都能比較公平地得到機會執行,同時要兼顧用戶體驗,不能讓和用戶交互的進程響應太慢。
下面這個小程序從終端讀數據再寫回終端。
**例?28.2.?阻塞讀終端**
```
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
char buf[10];
int n;
n = read(STDIN_FILENO, buf, 10);
if (n < 0) {
perror("read STDIN_FILENO");
exit(1);
}
write(STDOUT_FILENO, buf, n);
return 0;
}
```
執行結果如下:
```
$ ./a.out
hello(回車)
hello
$ ./a.out
hello world(回車)
hello worl$ d
bash: d: command not found
```
第一次執行`a.out`的結果很正常,而第二次執行的過程有點特殊,現在分析一下:
1. Shell進程創建`a.out`進程,`a.out`進程開始執行,而Shell進程睡眠等待`a.out`進程退出。
2. `a.out`調用`read`時睡眠等待,直到終端設備輸入了換行符才從`read`返回,`read`只讀走10個字符,剩下的字符仍然保存在內核的終端設備輸入緩沖區中。
3. `a.out`進程打印并退出,這時Shell進程恢復運行,Shell繼續從終端讀取用戶輸入的命令,于是讀走了終端設備輸入緩沖區中剩下的字符d和換行符,把它當成一條命令解釋執行,結果發現執行不了,沒有d這個命令。
如果在`open`一個設備時指定了`O_NONBLOCK`標志,`read`/`write`就不會阻塞。以`read`為例,如果設備暫時沒有數據可讀就返回-1,同時置`errno`為`EWOULDBLOCK`(或者`EAGAIN`,這兩個宏定義的值相同),表示本來應該阻塞在這里(would block,虛擬語氣),事實上并沒有阻塞而是直接返回錯誤,調用者應該試著再讀一次(again)。這種行為方式稱為輪詢(Poll),調用者只是查詢一下,而不是阻塞在這里死等,這樣可以同時監視多個設備:
```
while(1) {
非阻塞read(設備1);
if(設備1有數據到達)
處理數據;
非阻塞read(設備2);
if(設備2有數據到達)
處理數據;
...
}
```
如果`read(設備1)`是阻塞的,那么只要設備1沒有數據到達就會一直阻塞在設備1的`read`調用上,即使設備2有數據到達也不能處理,使用非阻塞I/O就可以避免設備2得不到及時處理。
非阻塞I/O有一個缺點,如果所有設備都一直沒有數據到達,調用者需要反復查詢做無用功,如果阻塞在那里,操作系統可以調度別的進程執行,就不會做無用功了。在使用非阻塞I/O時,通常不會在一個`while`循環中一直不停地查詢(這稱為Tight Loop),而是每延遲等待一會兒來查詢一下,以免做太多無用功,在延遲等待的時候可以調度其它進程執行。
```
while(1) {
非阻塞read(設備1);
if(設備1有數據到達)
處理數據;
非阻塞read(設備2);
if(設備2有數據到達)
處理數據;
...
sleep(n);
}
```
這樣做的問題是,設備1有數據到達時可能不能及時處理,最長需延遲n秒才能處理,而且反復查詢還是做了很多無用功。以后要學習的`select(2)`函數可以阻塞地同時監視多個設備,還可以設定阻塞等待的超時時間,從而圓滿地解決了這個問題。
以下是一個非阻塞I/O的例子。目前我們學過的可能引起阻塞的設備只有終端,所以我們用終端來做這個實驗。程序開始執行時在0、1、2文件描述符上自動打開的文件就是終端,但是沒有`O_NONBLOCK`標志。所以就像[例?28.2 “阻塞讀終端”](ch28s04.html#io.blockread)一樣,讀標準輸入是阻塞的。我們可以重新打開一遍設備文件`/dev/tty`(表示當前終端),在打開時指定`O_NONBLOCK`標志。
**例?28.3.?非阻塞讀終端**
```
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#define MSG_TRY "try again\n"
int main(void)
{
char buf[10];
int fd, n;
fd = open("/dev/tty", O_RDONLY|O_NONBLOCK);
if(fd<0) {
perror("open /dev/tty");
exit(1);
}
tryagain:
n = read(fd, buf, 10);
if (n < 0) {
if (errno == EAGAIN) {
sleep(1);
write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY));
goto tryagain;
}
perror("read /dev/tty");
exit(1);
}
write(STDOUT_FILENO, buf, n);
close(fd);
return 0;
}
```
以下是用非阻塞I/O實現等待超時的例子。既保證了超時退出的邏輯又保證了有數據到達時處理延遲較小。
**例?28.4.?非阻塞讀終端和等待超時**
```
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#define MSG_TRY "try again\n"
#define MSG_TIMEOUT "timeout\n"
int main(void)
{
char buf[10];
int fd, n, i;
fd = open("/dev/tty", O_RDONLY|O_NONBLOCK);
if(fd<0) {
perror("open /dev/tty");
exit(1);
}
for(i=0; i<5; i++) {
n = read(fd, buf, 10);
if(n>=0)
break;
if(errno!=EAGAIN) {
perror("read /dev/tty");
exit(1);
}
sleep(1);
write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY));
}
if(i==5)
write(STDOUT_FILENO, MSG_TIMEOUT, strlen(MSG_TIMEOUT));
else
write(STDOUT_FILENO, buf, n);
close(fd);
return 0;
}
```
## 5.?lseek
每個打開的文件都記錄著當前讀寫位置,打開文件時讀寫位置是0,表示文件開頭,通常讀寫多少個字節就會將讀寫位置往后移多少個字節。但是有一個例外,如果以`O_APPEND`方式打開,每次寫操作都會在文件末尾追加數據,然后將讀寫位置移到新的文件末尾。`lseek`和標準I/O庫的`fseek`函數類似,可以移動當前讀寫位置(或者叫偏移量)。
```
#include <sys/types.h>
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
```
參數`offset`和`whence`的含義和`fseek`函數完全相同。只不過第一個參數換成了文件描述符。和`fseek`一樣,偏移量允許超過文件末尾,這種情況下對該文件的下一次寫操作將延長文件,中間空洞的部分讀出來都是0。
若`lseek`成功執行,則返回新的偏移量,因此可用以下方法確定一個打開文件的當前偏移量:
```
off_t currpos;
currpos = lseek(fd, 0, SEEK_CUR);
```
這種方法也可用來確定文件或設備是否可以設置偏移量,常規文件都可以設置偏移量,而設備一般是不可以設置偏移量的。如果設備不支持`lseek`,則`lseek`返回-1,并將`errno`設置為`ESPIPE`。注意`fseek`和`lseek`在返回值上有細微的差別,`fseek`成功時返回0失敗時返回-1,要返回當前偏移量需調用`ftell`,而`lseek`成功時返回當前偏移量失敗時返回-1。
## 6.?fcntl
先前我們以`read`終端設備為例介紹了非阻塞I/O,為什么我們不直接對`STDIN_FILENO`做非阻塞`read`,而要重新`open`一遍`/dev/tty`呢?因為`STDIN_FILENO`在程序啟動時已經被自動打開了,而我們需要在調用`open`時指定`O_NONBLOCK`標志。這里介紹另外一種辦法,可以用`fcntl`函數改變一個已打開的文件的屬性,可以重新設置讀、寫、追加、非阻塞等標志(這些標志稱為File Status Flag),而不必重新`open`文件。
```
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd);
int fcntl(int fd, int cmd, long arg);
int fcntl(int fd, int cmd, struct flock *lock);
```
這個函數和`open`一樣,也是用可變參數實現的,可變參數的類型和個數取決于前面的`cmd`參數。下面的例子使用`F_GETFL`和`F_SETFL`這兩種`fcntl`命令改變`STDIN_FILENO`的屬性,加上`O_NONBLOCK`選項,實現和[例?28.3 “非阻塞讀終端”](ch28s04.html#io.nonblockread)同樣的功能。
**例?28.5.?用fcntl改變File Status Flag**
```
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#define MSG_TRY "try again\n"
int main(void)
{
char buf[10];
int n;
int flags;
flags = fcntl(STDIN_FILENO, F_GETFL);
flags |= O_NONBLOCK;
if (fcntl(STDIN_FILENO, F_SETFL, flags) == -1) {
perror("fcntl");
exit(1);
}
tryagain:
n = read(STDIN_FILENO, buf, 10);
if (n < 0) {
if (errno == EAGAIN) {
sleep(1);
write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY));
goto tryagain;
}
perror("read stdin");
exit(1);
}
write(STDOUT_FILENO, buf, n);
return 0;
}
```
以下程序通過命令行的第一個參數指定一個文件描述符,同時利用Shell的重定向功能在該描述符上打開文件,然后用`fcntl`的`F_GETFL`命令取出File Status Flag并打印。
```
#include <sys/types.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
int val;
if (argc != 2) {
fputs("usage: a.out <descriptor#>\n", stderr);
exit(1);
}
if ((val = fcntl(atoi(argv[1]), F_GETFL)) < 0) {
printf("fcntl error for fd %d\n", atoi(argv[1]));
exit(1);
}
switch(val & O_ACCMODE) {
case O_RDONLY:
printf("read only");
break;
case O_WRONLY:
printf("write only");
break;
case O_RDWR:
printf("read write");
break;
default:
fputs("invalid access mode\n", stderr);
exit(1);
}
if (val & O_APPEND)
printf(", append");
if (val & O_NONBLOCK)
printf(", nonblocking");
putchar('\n');
return 0;
}
```
運行該程序的幾種情況解釋如下。
```
$ ./a.out 0 < /dev/tty
read only
```
Shell在執行`a.out`時將它的標準輸入重定向到`/dev/tty`,并且是只讀的。`argv[1]`是0,因此取出文件描述符0(也就是標準輸入)的File Status Flag,用掩碼`O_ACCMODE`取出它的讀寫位,結果是`O_RDONLY`。注意,Shell的重定向語法不屬于程序的命令行參數,這個命行只有兩個參數,`argv[0]`是"./a.out",`argv[1]`是"0",重定向由Shell解釋,在啟動程序時已經生效,程序在運行時并不知道標準輸入被重定向了。
```
$ ./a.out 1 > temp.foo
$ cat temp.foo
write only
```
Shell在執行`a.out`時將它的標準輸出重定向到文件`temp.foo`,并且是只寫的。程序取出文件描述符1的File Status Flag,發現是只寫的,于是打印`write only`,但是打印不到屏幕上而是打印到`temp.foo`這個文件中了。
```
$ ./a.out 2 2>>temp.foo
write only, append
```
Shell在執行`a.out`時將它的標準錯誤輸出重定向到文件`temp.foo`,并且是只寫和追加方式。程序取出文件描述符2的File Status Flag,發現是只寫和追加方式的。
```
$ ./a.out 5 5<>temp.foo
read write
```
Shell在執行`a.out`時在它的文件描述符5上打開文件`temp.foo`,并且是可讀可寫的。程序取出文件描述符5的File Status Flag,發現是可讀可寫的。
我們看到一種新的Shell重定向語法,如果在<、>、>>、<>前面添一個數字,該數字就表示在哪個文件描述符上打開文件,例如2>>temp.foo表示將標準錯誤輸出重定向到文件temp.foo并且以追加方式寫入文件,注意2和>>之間不能有空格,否則2就被解釋成命令行參數了。文件描述符數字還可以出現在重定向符號右邊,例如:
```
$ command > /dev/null 2>&1
```
首先將某個命令command的標準輸出重定向到`/dev/null`,然后將該命令可能產生的錯誤信息(標準錯誤輸出)也重定向到和標準輸出(用&1標識)相同的文件,即`/dev/null`,如下圖所示。
**圖?28.3.?重定向之后的文件描述符表**

`/dev/null`設備文件只有一個作用,往它里面寫任何數據都被直接丟棄。因此保證了該命令執行時屏幕上沒有任何輸出,既不打印正常信息也不打印錯誤信息,讓命令安靜地執行,這種寫法在Shell腳本中很常見。注意,文件描述符數字寫在重定向符號右邊需要加&號,否則就被解釋成文件名了,2>&1其中的>左右兩邊都不能有空格。
除了`F_GETFL`和`F_SETFL`命令之外,`fcntl`還有很多命令做其它操作,例如設置文件記錄鎖等。可以通過`fcntl`設置的都是當前進程如何訪問設備或文件的訪問控制屬性,例如讀、寫、追加、非阻塞、加鎖等,但并不設置文件或設備本身的屬性,例如文件的讀寫權限、串口波特率等。下一節要介紹的`ioctl`函數用于設置某些設備本身的屬性,例如串口波特率、終端窗口大小,注意區分這兩個函數的作用。
## 7.?ioctl
`ioctl`用于向設備發控制和配置命令,有些命令也需要讀寫一些數據,但這些數據是不能用`read`/`write`讀寫的,稱為Out-of-band數據。也就是說,`read`/`write`讀寫的數據是in-band數據,是I/O操作的主體,而`ioctl`命令傳送的是控制信息,其中的數據是輔助的數據。例如,在串口線上收發數據通過`read`/`write`操作,而串口的波特率、校驗位、停止位通過`ioctl`設置,A/D轉換的結果通過`read`讀取,而A/D轉換的精度和工作頻率通過`ioctl`設置。
```
#include <sys/ioctl.h>
int ioctl(int d, int request, ...);
```
`d`是某個設備的文件描述符。`request`是`ioctl`的命令,可變參數取決于`request`,通常是一個指向變量或結構體的指針。若出錯則返回-1,若成功則返回其他值,返回值也是取決于`request`。
以下程序使用`TIOCGWINSZ`命令獲得終端設備的窗口大小。
```
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/ioctl.h>
int main(void)
{
struct winsize size;
if (isatty(STDOUT_FILENO) == 0)
exit(1);
if(ioctl(STDOUT_FILENO, TIOCGWINSZ, &size)<0) {
perror("ioctl TIOCGWINSZ error");
exit(1);
}
printf("%d rows, %d columns\n", size.ws_row, size.ws_col);
return 0;
}
```
在圖形界面的終端里多次改變終端窗口的大小并運行該程序,觀察結果。
## 8.?mmap
`mmap`可以把磁盤文件的一部分直接映射到內存,這樣文件中的位置直接就有對應的內存地址,對文件的讀寫可以直接用指針來做而不需要`read`/`write`函數。
```
#include <sys/mman.h>
void *mmap(void *addr, size_t len, int prot, int flag, int filedes, off_t off);
int munmap(void *addr, size_t len);
```
該函數各參數的作用圖示如下:
**圖?28.4.?mmap函數**

如果`addr`參數為`NULL`,內核會自己在進程地址空間中選擇合適的地址建立映射。如果`addr`不是`NULL`,則給內核一個提示,應該從什么地址開始映射,內核會選擇`addr`之上的某個合適的地址開始映射。建立映射后,真正的映射首地址通過返回值可以得到。`len`參數是需要映射的那一部分文件的長度。`off`參數是從文件的什么位置開始映射,必須是頁大小的整數倍(在32位體系統結構上通常是4K)。`filedes`是代表該文件的描述符。
`prot`參數有四種取值:
* PROT_EXEC表示映射的這一段可執行,例如映射共享庫
* PROT_READ表示映射的這一段可讀
* PROT_WRITE表示映射的這一段可寫
* PROT_NONE表示映射的這一段不可訪問
`flag`參數有很多種取值,這里只講兩種,其它取值可查看`mmap(2)`
* MAP_SHARED多個進程對同一個文件的映射是共享的,一個進程對映射的內存做了修改,另一個進程也會看到這種變化。
* MAP_PRIVATE多個進程對同一個文件的映射不是共享的,一個進程對映射的內存做了修改,另一個進程并不會看到這種變化,也不會真的寫到文件中去。
如果`mmap`成功則返回映射首地址,如果出錯則返回常數`MAP_FAILED`。當進程終止時,該進程的映射內存會自動解除,也可以調用`munmap`解除映射。`munmap`成功返回0,出錯返回-1。
下面做一個簡單的實驗。
```
$ vi hello
(編輯該文件的內容為“hello”)
$ od -tx1 -tc hello
0000000 68 65 6c 6c 6f 0a
h e l l o \n
0000006
```
現在用如下程序操作這個文件(注意,把`fd`關掉并不影響該文件已建立的映射,仍然可以對文件進行讀寫)。
```
#include <stdlib.h>
#include <sys/mman.h>
#include <fcntl.h>
int main(void)
{
int *p;
int fd = open("hello", O_RDWR);
if (fd < 0) {
perror("open hello");
exit(1);
}
p = mmap(NULL, 6, PROT_WRITE, MAP_SHARED, fd, 0);
if (p == MAP_FAILED) {
perror("mmap");
exit(1);
}
close(fd);
p[0] = 0x30313233;
munmap(p, 6);
return 0;
}
```
然后再查看這個文件的內容:
```
$ od -tx1 -tc hello
0000000 33 32 31 30 6f 0a
3 2 1 0 o \n
0000006
```
請讀者自己分析一下實驗結果。
`mmap`函數的底層也是一個系統調用,在執行程序時經常要用到這個系統調用來映射共享庫到該進程的地址空間。例如一個很簡單的hello world程序:
```
#include <stdio.h>
int main(void)
{
printf("hello world\n");
return 0;
}
```
用`strace`命令執行該程序,跟蹤該程序執行過程中用到的所有系統調用的參數及返回值:
```
$ strace ./a.out
execve("./a.out", ["./a.out"], [/* 38 vars */]) = 0
brk(0) = 0x804a000
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
mmap2(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb7fca000
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
open("/etc/ld.so.cache", O_RDONLY) = 3
fstat64(3, {st_mode=S_IFREG|0644, st_size=63628, ...}) = 0
mmap2(NULL, 63628, PROT_READ, MAP_PRIVATE, 3, 0) = 0xb7fba000
close(3) = 0
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
open("/lib/tls/i686/cmov/libc.so.6", O_RDONLY) = 3
read(3, "\177ELF\1\1\1\0\0\0\0\0\0\0\0\0\3\0\3\0\1\0\0\0\260a\1"..., 512) = 512
fstat64(3, {st_mode=S_IFREG|0644, st_size=1339816, ...}) = 0
mmap2(NULL, 1349136, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0xb7e70000
mmap2(0xb7fb4000, 12288, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x143) = 0xb7fb4000
mmap2(0xb7fb7000, 9744, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0xb7fb7000
close(3) = 0
mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb7e6f000
set_thread_area({entry_number:-1 -> 6, base_addr:0xb7e6f6b0, limit:1048575, seg_32bit:1, contents:0, read_exec_only:0, limit_in_pages:1, seg_not_present:0, useable:1}) = 0
mprotect(0xb7fb4000, 4096, PROT_READ) = 0
munmap(0xb7fba000, 63628) = 0
fstat64(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 1), ...}) = 0
mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb7fc9000
write(1, "hello world\n", 12hello world
) = 12
exit_group(0) = ?
Process 8572 detached
```
可以看到,執行這個程序要映射共享庫`/lib/tls/i686/cmov/libc.so.6`到進程地址空間。也可以看到,`printf`函數的底層確實是調用`write`。
- Linux C編程一站式學習
- 歷史
- 前言
- 部分?I.?C語言入門
- 第?1?章?程序的基本概念
- 第?2?章?常量、變量和表達式
- 第?3?章?簡單函數
- 第?4?章?分支語句
- 第?5?章?深入理解函數
- 第?6?章?循環語句
- 第?7?章?結構體
- 第?8?章?數組
- 第?9?章?編碼風格
- 第?10?章?gdb
- 第?11?章?排序與查找
- 第?12?章?棧與隊列
- 第?13?章?本階段總結
- 部分?II.?C語言本質
- 第?14?章?計算機中數的表示
- 第?15?章?數據類型詳解
- 第?16?章?運算符詳解
- 第?17?章?計算機體系結構基礎
- 第?18?章?x86匯編程序基礎
- 第?19?章?匯編與C之間的關系
- 第?20?章?鏈接詳解
- 第?21?章?預處理
- 第?22?章?Makefile基礎
- 第?23?章?指針
- 第?24?章?函數接口
- 第?25?章?C標準庫
- 第?26?章?鏈表、二叉樹和哈希表
- 第?27?章?本階段總結
- 部分?III.?Linux系統編程
- 第?28?章?文件與I/O
- 第?29?章?文件系統
- 第?30?章?進程
- 第?31?章?Shell腳本
- 第?32?章?正則表達式
- 第?33?章?信號
- 第?34?章?終端、作業控制與守護進程
- 第?35?章?線程
- 第?36?章?TCP/IP協議基礎
- 第?37?章?socket編程
- 附錄?A.?字符編碼
- 附錄?B.?GNU Free Documentation License Version 1.3, 3 November 2008
- 參考書目
- 索引