read 系統呼叫剖析
Read 系統呼叫在使用者空間中的處理過程
Linux 系統呼叫(SCI,system call interface)的實現機制實際上是一個多路匯聚以及分解的過程,該匯聚點就是 0x80 中斷這個入口點(X86 系統結構)。也就是說,所有系統呼叫都從使用者空間中匯聚到 0x80 中斷點,同時儲存具體的系統呼叫號。當 0x80 中斷處理程式執行時,將根據系統呼叫號對不同的系統呼叫分別處理(呼叫不同的核心函式處理)。系統呼叫的更多內容,請參見參考資料。
Read 系統呼叫也不例外,當呼叫發生時,庫函式在儲存 read 系統呼叫號以及引數後,陷入 0x80 中斷。這時庫函式工作結束。Read 系統呼叫在使用者空間中的處理也就完成了。
Read 系統呼叫在核心空間中的處理過程
0x80 中斷處理程式接管執行後,先檢察其系統呼叫號,然後根據系統呼叫號查詢系統呼叫表,並從系統呼叫表中得到處理 read 系統呼叫的核心函式 sys_read ,最後傳遞引數並執行 sys_read 函式。至此,核心真正開始處理 read 系統呼叫(sys_read 是 read 系統呼叫的核心入口)。
在講解 read 系統呼叫在核心空間中的處理部分中,首先介紹了核心處理磁碟請求的層次模型,然後再按該層次模型從上到下的順序依次介紹磁碟讀請求在各層的處理過程。
Read 系統呼叫在核心空間中處理的層次模型
圖1顯示了 read 系統呼叫在核心空間中所要經歷的層次模型。從圖中看出:對於磁碟的一次讀請求,首先經過虛擬檔案系統層(vfs layer),其次是具體的檔案系統層(例如 ext2),接下來是 cache 層(page cache 層)、通用塊層(generic block layer)、IO 排程層(I/O scheduler layer)、塊裝置驅動層(block device driver layer),最後是物理塊裝置層(block device layer)
圖1 read 系統呼叫在核心空間中的處理層次
- 虛擬檔案系統層的作用:遮蔽下層具體檔案系統操作的差異,為上層的操作提供一個統一的介面。正是因為有了這個層次,所以可以把裝置抽象成檔案,使得操作裝置就像操作檔案一樣簡單。
- 在具體的檔案系統層中,不同的檔案系統(例如 ext2 和 NTFS)具體的操作過程也是不同的。每種檔案系統定義了自己的操作集合。關於檔案系統的更多內容,請參見參考資料。
- 引入 cache 層的目的是為了提高 linux 作業系統對磁碟訪問的效能。 Cache 層在記憶體中快取了磁碟上的部分資料。當資料的請求到達時,如果在 cache 中存在該資料且是最新的,則直接將資料傳遞給使用者程式,免除了對底層磁碟的操作,提高了效能。
- 通用塊層的主要工作是:接收上層發出的磁碟請求,並最終發出 IO 請求。該層隱藏了底層硬體塊裝置的特性,為塊裝置提供了一個通用的抽象檢視。
- IO 排程層的功能:接收通用塊層發出的 IO 請求,快取請求並試圖合併相鄰的請求(如果這兩個請求的資料在磁碟上是相鄰的)。並根據設定好的排程演算法,回撥驅動層提供的請求處理函式,以處理具體的 IO 請求。
- 驅動層中的驅動程式對應具體的物理塊裝置。它從上層中取出 IO 請求,並根據該 IO 請求中指定的資訊,通過向具體塊裝置的裝置控制器傳送命令的方式,來操縱裝置傳輸資料。
- 裝置層中都是具體的物理裝置。定義了操作具體裝置的規範。
相關的核心資料結構:
- Dentry : 聯絡了檔名和檔案的 i 節點
- inode : 檔案 i 節點,儲存檔案標識、許可權和內容等資訊
- file : 儲存檔案的相關資訊和各種操作檔案的函式指標集合
- file_operations :操作檔案的函式介面集合
- address_space :描述檔案的 page cache 結構以及相關資訊,幷包含有操作 page cache 的函式指標集合
- address_space_operations :操作 page cache 的函式介面集合
- bio : IO 請求的描述
資料結構之間的關係:
圖2示意性地展示了上述各個資料結構(除了 bio)之間的關係。可以看出:由 dentry 物件可以找到 inode 物件,從 inode 物件中可以取出 address_space 物件,再由 address_space 物件找到 address_space_operations 物件。
File 物件可以根據當前程序描述符中提供的資訊取得,進而可以找到 dentry 物件、 address_space 物件和 file_operations 物件。
圖2 資料結構關係圖:
前提條件:
對於具體的一次 read 呼叫,核心中可能遇到的處理情況很多。這裡舉例其中的一種情況:
- 要讀取的檔案已經存在
- 檔案經過 page cache
- 要讀的是普通檔案
- 磁碟上檔案系統為 ext2 檔案系統,有關 ext2 檔案系統的相關內容,參見參考資料
準備:
注:所有清單中程式碼均來自 linux2.6.11 核心原始碼
讀資料之前,必須先開啟檔案。處理 open 系統呼叫的核心函式為 sys_open 。
所以我們先來看一下該函式都作了哪些事。清單1顯示了 sys_open 的程式碼(省略了部分內容,以後的程式清單同樣方式處理)
清單1 sys_open 函式程式碼
1 2 3 4 5 6 7 8 9 10 11 12 |
|
程式碼解釋:
- get_unuesed_fd() :取回一個未被使用的檔案描述符(每次都會選取最小的未被使用的檔案描述符)。
- filp_open() :呼叫 open_namei() 函式取出和該檔案相關的 dentry 和 inode (因為前提指明瞭檔案已經存在,所以 dentry 和 inode 能夠查詢到,不用建立),然後呼叫 dentry_open() 函式建立新的 file 物件,並用 dentry 和 inode 中的資訊初始化 file 物件(檔案當前的讀寫位置在 file 物件中儲存)。注意到 dentry_open() 中有一條語句:
f->f_op = fops_get(inode->i_fop);
這個賦值語句把和具體檔案系統相關的,操作檔案的函式指標集合賦給了 file 物件的 f _op 變數(這個指標集合是儲存在 inode 物件中的),在接下來的 sys_read 函式中將會呼叫 file->f_op 中的成員 read 。
- fd_install() :以檔案描述符為索引,關聯當前程序描述符和上述的 file 物件,為之後的 read 和 write 等操作作準備。
- 函式最後返回該檔案描述符。
圖3顯示了 sys_open 函式返回後, file 物件和當前程序描述符之間的關聯關係,以及 file 物件中操作檔案的函式指標集合的來源(inode 物件中的成員 i_fop)。
圖3 file 物件和當前程序描述符之間的關係
到此為止,所有的準備工作已經全部結束了,下面開始介紹 read 系統呼叫在圖1所示的各個層次中的處理過程。
虛擬檔案系統層的處理:
核心函式 sys_read() 是 read 系統呼叫在該層的入口點,清單2顯示了該函式的程式碼。
清單2 sys_read 函式的程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
程式碼解析:
- fget_light() :根據 fd 指定的索引,從當前程序描述符中取出相應的 file 物件(見圖3)。
- 如果沒找到指定的 file 物件,則返回錯誤
- 如果找到了指定的 file 物件:
- 呼叫 file_pos_read() 函式取出此次讀寫檔案的當前位置。
- 呼叫 vfs_read() 執行檔案讀取操作,而這個函式最終呼叫 file->f_op.read() 指向的函式,程式碼如下:
if (file->f_op->read)
ret = file->f_op->read(file, buf, count, pos);
- 呼叫 file_pos_write() 更新檔案的當前讀寫位置。
- 呼叫 fput_light() 更新檔案的引用計數。
- 最後返回讀取資料的位元組數。
到此,虛擬檔案系統層所做的處理就完成了,控制權交給了 ext2 檔案系統層。
在解析 ext2 檔案系統層的操作之前,先讓我們看一下 file 物件中 read 指標來源。
File 物件中 read 函式指標的來源:
從前面對 sys_open 核心函式的分析來看, file->f_op 來自於 inode->i_fop 。那麼 inode->i_fop 來自於哪裡呢?在初始化 inode 物件時賦予的。見清單3。
清單3 ext2_read_inode() 函式部分程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
從程式碼中可以看出,如果該 inode 所關聯的檔案是普通檔案,則將變數 ext2_file_operations 的地址賦予 inode 物件的 i_fop 成員。所以可以知道: inode->i_fop.read 函式指標所指向的函式為 ext2_file_operations 變數的成員 read 所指向的函式。下面來看一下 ext2_file_operations 變數的初始化過程,如清單4。
清單4 ext2_file_operations 的初始化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
該成員 read 指向函式 generic_file_read 。所以, inode->i_fop.read 指向 generic_file_read 函式,進而 file->f_op.read 指向 generic_file_read 函式。最終得出結論: generic_file_read 函式才是 ext2 層的真實入口。
Ext2 檔案系統層的處理
圖4 read 系統呼叫在 ext2 層中處理時函式呼叫關係
由圖 4 可知,該層入口函式 generic_file_read 呼叫函式 __generic_file_aio_read ,後者判斷本次讀請求的訪問方式,如果是直接 io (filp->f_flags 被設定了 O_DIRECT 標誌,即不經過 cache)的方式,則呼叫 generic_file_direct_IO 函式;如果是 page cache 的方式,則呼叫 do_generic_file_read 函式。函式 do_generic_file_read 僅僅是一個包裝函式,它又呼叫 do_generic_mapping_read 函式。
在講解 do_generic_mapping_read 函式都作了哪些工作之前,我們再來看一下檔案在記憶體中的快取區域是被怎麼組織起來的。
檔案的 page cache 結構
圖5顯示了一個檔案的 page cache 結構。檔案被分割為一個個以 page 大小為單元的資料塊,這些資料塊(頁)被組織成一個多叉樹(稱為 radix 樹)。樹中所有葉子節點為一個個頁幀結構(struct page),表示了用於快取該檔案的每一個頁。在葉子層最左端的第一個頁儲存著該檔案的前4096個位元組(如果頁的大小為4096位元組),接下來的頁儲存著檔案第二個4096個位元組,依次類推。樹中的所有中間節點為組織節點,指示某一地址上的資料所在的頁。此樹的層次可以從0層到6層,所支援的檔案大小從0位元組到16 T 個位元組。樹的根節點指標可以從和檔案相關的 address_space 物件(該物件儲存在和檔案關聯的 inode 物件中)中取得(更多關於 page cache 的結構內容請參見參考資料)。
圖5 檔案的 page cache 結構
現在,我們來看看函式 do_generic_mapping_read 都作了哪些工作, do_generic_mapping_read 函式程式碼較長,本文簡要介紹下它的主要流程:
- 根據檔案當前的讀寫位置,在 page cache 中找到快取請求資料的 page
- 如果該頁已經最新,將請求的資料拷貝到使用者空間
- 否則, Lock 該頁
- 呼叫 readpage 函式向磁碟發出添頁請求(當下層完成該 IO 操作時會解鎖該頁),程式碼:
1 |
|
- 再一次 lock 該頁,操作成功時,說明資料已經在 page cache 中了,因為只有 IO 操作完成後才可能解鎖該頁。此處是一個同步點,用於同步資料從磁碟到記憶體的過程。
- 解鎖該頁
- 到此為止資料已經在 page cache 中了,再將其拷貝到使用者空間中(之後 read 呼叫可以在使用者空間返回了)
到此,我們知道:當頁上的資料不是最新的時候,該函式呼叫 mapping->a_ops->readpage 所指向的函式(變數 mapping 為 inode 物件中的 address_space 物件),那麼這個函式到底是什麼呢?
Readpage 函式的由來
address_space 物件是嵌入在 inode 物件之中的,那麼不難想象: address_space 物件成員 a_ops 的初始化工作將會在初始化 inode 物件時進行。如清單3中後半部所顯示。
1 2 3 4 |
|
可以知道 address_space 物件的成員 a_ops 指向變數 ext2_aops 或者變數 ext2_nobh_aops 。這兩個變數的初始化如清單5所示。
清單5 變數 ext2_aops 和變數 ext2_nobh_aops 的初始化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
從上述程式碼中可以看出,不論是哪個變數,其中的 readpage 成員都指向函式 ext2_readpage 。所以可以斷定:函式 do_generic_mapping_read 最終呼叫 ext2_readpage 函式處理讀資料請求。
到此為止, ext2 檔案系統層的工作結束。
Page cache 層的處理
從上文得知:ext2_readpage 函式是該層的入口點。該函式呼叫 mpage_readpage 函式,清單6顯示了 mpage_readpage 函式的程式碼。
清單6 mpage_readpage 函式的程式碼
1 2 3 4 5 6 7 8 9 10 11 |
|
該函式首先呼叫函式 do_mpage_readpage 函式建立了一個 bio 請求,該請求指明瞭要讀取的資料塊所在磁碟的位置、資料塊的數量以及拷貝該資料的目標位置——快取區中 page 的資訊。然後呼叫 mpage_bio_submit 函式處理請求。 mpage_bio_submit 函式則呼叫 submit_bio 函式處理該請求,後者最終將請求傳遞給函式 generic_make_request ,並由 generic_make_request 函式將請求提交給通用塊層處理。
到此為止, page cache 層的處理結束。
通用塊層的處理
generic_make_request 函式是該層的入口點,該層只有這一個函式處理請求。清單7顯示了函式的部分程式碼
清單7 generic_make_request 函式部分程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
主要操作:
- 根據 bio 中儲存的塊裝置號取得請求佇列 q
- 檢測當前 IO 排程器是否可用,如果可用,則繼續;否則等待排程器可用
- 呼叫 q->make_request_fn 所指向的函式將該請求(bio)加入到請求佇列中
到此為止,通用塊層的操作結束。
IO 排程層的處理
對 make_request_fn 函式的呼叫可以認為是 IO 排程層的入口,該函式用於向請求佇列中新增請求。該函式是在建立請求佇列時指定的,程式碼如下(blk_init_queue 函式中):
1 2 |
|
函式 blk_queue_make_request 將函式 __make_request 的地址賦予了請求佇列 q 的 make_request_fn 成員,那麼, __make_request 函式才是 IO 排程層的真實入口。
__make_request 函式的主要工作為:
- 檢測請求佇列是否為空,若是,延緩驅動程式處理當前請求(其目的是想積累更多的請求,這樣就有機會對相鄰的請求進行合併,從而提高處理的效能),並跳到3,否則跳到2
- 試圖將當前請求同請求佇列中現有的請求合併,如果合併成功,則函式返回,否則跳到3
- 該請求是一個新請求,建立新的請求描述符,並初始化相應的域,並將該請求描述符加入到請求佇列中,函式返回
將請求放入到請求佇列中後,何時被處理就由 IO 排程器的排程演算法決定了(有關 IO 排程器的演算法內容請參見參考資料)。一旦該請求能夠被處理,便呼叫請求佇列中成員 request_fn 所指向的函式處理。這個成員的初始化也是在建立請求佇列時設定的:
1 2 |
|
第一行是將請求處理函式 rfn 指標賦給了請求佇列的 request_fn 成員。而 rfn 則是在建立請求佇列時通過引數傳入的。
對請求處理函式 request_fn 的呼叫意味著 IO 排程層的處理結束了。
塊裝置驅動層的處理
request_fn 函式是塊裝置驅動層的入口。它是在驅動程式建立請求佇列時由驅動程式傳遞給 IO 排程層的。
IO 排程層通過回撥 request_fn 函式的方式,把請求交給了驅動程式。而驅動程式從該函式的引數中獲得上層發出的 IO 請求,並根據請求中指定的資訊操作裝置控制器(這一請求的發出需要依據物理裝置指定的規範進行)。
到此為止,塊裝置驅動層的操作結束。
塊裝置層的處理
接受來自驅動層的請求,完成實際的資料拷貝工作等等。同時規定了一系列規範,驅動程式必須按照這個規範操作硬體。
後續工作
當裝置完成了 IO 請求之後,通過中斷的方式通知 cpu ,而中斷處理程式又會呼叫 request_fn 函式進行處理。
當驅動再次處理該請求時,會根據本次資料傳輸的結果通知上層函式本次 IO 操作是否成功,如果成功,上層函式解鎖 IO 操作所涉及的頁面(在 do_generic_mapping_read 函式中加的鎖)。
該頁被解鎖後, do_generic_mapping_read() 函式就可以再次成功獲得該鎖(資料的同步點),並繼續執行程式了。之後,函式 sys_read 可以返回了。最終 read 系統呼叫也可以返回了。
至此, read 系統呼叫從發出到結束的整個處理過程就全部結束了。
總結
本文介紹了 linux 系統呼叫 read 的處理全過程。該過程分為兩個部分:使用者空間的處理和核心空間的處理。在使用者空間中通過 0x80 中斷的方式將控制權交給核心處理,核心接管後,經過6個層次的處理最後將請求交給磁碟,由磁碟完成最終的資料拷貝操作。在這個過程中,呼叫了一系列的核心函式。如圖 6
圖6 read 系統呼叫在核心中所經歷的函式呼叫層次