1. 程式人生 > 其它 >【高階程式設計】Linux read系統呼叫

【高階程式設計】Linux read系統呼叫

最近一個專案做了一個模擬u盤的裝置,但是在read虛擬u盤的內容時必須每次都從磁碟內讀取,而不是從系統的cache中讀取,由於這個問題,就查資料看了下read的系統呼叫,以及檔案系統的一些內容。由於檔案系統涉及面較廣,例如虛擬檔案系統(VFS),頁快取,塊快取,資料同步等內容,不可能全部分析到位,這裡只記錄和read有關的兩種使用方式。cached IO和direct IO。

1. 什麼是系統呼叫

首先系統呼叫能做那些事呢?概括來說,大概有下面這些事需要系統呼叫來實現。

  • 控制硬體:系統呼叫往往作為硬體資源和使用者空間的抽象介面,比如讀寫檔案時用到的write/read呼叫。
  • 設定系統狀態或讀取核心資料:
    因為系統呼叫是使用者空間和核心的唯一通訊手段,所以使用者設定系統狀態,比如開/關某項核心服務(設定某個核心變數),或讀取核心資料都必須通過系統呼叫。比如getpgid、getpriority、setpriority、sethostname
  • 程序管理:用來保證系統中程序能以多工在虛擬記憶體環境下得以執行。比如 fork、clone、execve、exit等

那為什麼一定要用系統呼叫來訪問作業系統的內容呢,其實這可以看做對核心的保護,linux分為使用者空間和核心空間,而使用者空間是不允許訪問核心空間的資料的。那麼在使用者空間的程式需要訪問核心空間的資源時就必須通過系統呼叫這個中間人來實現。這樣可以對使用者空間的行為進行限制,只有特定的得到許可的(事先規定的)使用者空間行為才能進入核心空間。一句話,系統呼叫是核心給使用者空間提供的一個可以訪問核心資源的一個介面。

另外多說一句,從使用者程序切換到核心程序只有兩種方式,一種就是系統呼叫,另一種是中斷。

要實現系統呼叫,首先要能從使用者空間切換到核心空間,這個切換在IA-32系統上是用匯編指令int $0x80來引發軟體中斷實現的。這部分內容一般是在C標準庫中實現的。進入核心空間後,系統呼叫中樞處理程式碼(所有的系統呼叫都由一處中樞程式碼處理)根據傳遞的引數(引數是有暫存器傳遞的包括唯一的系統呼叫號)和一個靜態表分別執行不同的函式。例如read系統呼叫,0x80 中斷處理程式接管執行後,先檢查其系統呼叫號,然後根據系統呼叫號查詢系統呼叫表,並從系統呼叫表中得到處理 read 系統呼叫的核心函式 sys_read ,最後傳遞引數並執行 sys_read 函式。至此,核心真正開始處理 read 系統呼叫(sys_read 是 read 系統呼叫的核心入口)。

2. read系統呼叫在核心空間的處理層次模型

如圖所示為read 系統呼叫在核心空間中所要經歷的層次模型。從圖中看出:對於磁碟的一次讀請求,首先經過虛擬檔案系統層(vfs layer),其次是具體的檔案系統層(例如 ext2),接下來是 cache 層(page cache 層)、通用塊層(generic block layer)、IO 排程層(I/O scheduler layer)、塊裝置驅動層(block device driver layer),最後是物理塊裝置層(block device layer)。

  • 虛擬檔案系統層的作用:遮蔽下層具體檔案系統操作的差異,為上層的操作提供一個統一的介面。正是因為有了這個層次,所以可以把裝置抽象成檔案,使得操作裝置就像操作檔案一樣簡單。
  • 在具體的檔案系統層中,不同的檔案系統(例如 ext2 和 NTFS)具體的操作過程也是不同的。每種檔案系統定義了自己的操作集合。關於檔案系統的更多內容,請參見參考資料。
  • 引入 cache 層的目的是為了提高 linux 作業系統對磁碟訪問的效能。 Cache 層在記憶體中快取了磁碟上的部分資料。當資料的請求到達時,如果在 cache 中存在該資料且是最新的,則直接將資料傳遞給使用者程式,免除了對底層磁碟的操作,提高了效能。
  • 通用塊層的主要工作是:接收上層發出的磁碟請求,並最終發出 IO 請求。該層隱藏了底層硬體塊裝置的特性,為塊裝置提供了一個通用的抽象檢視。
  • IO 排程層的功能:接收通用塊層發出的 IO 請求,快取請求並試圖合併相鄰的請求(如果這兩個請求的資料在磁碟上是相鄰的)。並根據設定好的排程演算法,回撥驅動層提供的請求處理函式,以處理具體的 IO 請求。
  • 驅動層中的驅動程式對應具體的物理塊裝置。它從上層中取出 IO 請求,並根據該 IO 請求中指定的資訊,通過向具體塊裝置的裝置控制器傳送命令的方式,來操縱裝置傳輸資料。
  • 裝置層中都是具體的物理裝置。定義了操作具體裝置的規範。

3. 相關的核心資料結構

  • dentry(目錄項) : 聯絡了檔名和檔案的 i 節點
  • inode(索引節點) : 檔案 i 節點,儲存檔案標識、許可權和內容等資訊
  • file : 儲存檔案的相關資訊和各種操作檔案的函式指標集合
  • file_operations :操作檔案的函式介面集合
  • address_space :描述檔案的 page cache 結構以及相關資訊,幷包含有操作 page cache 的函式指標集合
  • address_space_operations :操作 page cache 的函式介面集合
  • bio : IO 請求的描述

以上結構的定義可參考VFS檔案系統以及核心原始碼。

資料結構之間的關係如下圖所示:

上圖展示了上述各個資料結構(除了 bio)之間的關係。可以看出:由 dentry 物件可以找到 inode 物件,從 inode 物件中可以取出 address_space 物件,再由 address_space 物件找到 address_space_operations 物件。File 物件可以根據當前程序描述符中提供的資訊取得,進而可以找到 dentry 物件、 address_space 物件和 file_operations 物件。

4. read系統呼叫的過程

4.1. 前提條件

對於具體的一次 read 呼叫,核心中可能遇到的處理情況很多。這裡舉例其中的一種情況:

  • 要讀取的檔案已經存在
  • 檔案經過 page cache
  • 要讀的是普通檔案
  • 磁碟上檔案系統為 ext2 檔案系統,有關 ext2 檔案系統的相關內容,參見參考資料

4.2. read前的open

open系統呼叫對應的核心函式是sys_open。sys_open呼叫do_sys_open:

long do_sys_open(int dfd, const char __user *filename, int flags, int mode)
{
struct open_flags op;
int lookup = build_open_flags(flags, mode, &op);
char *tmp = getname(filename);
int fd = PTR_ERR(tmp);

if (!IS_ERR(tmp)) {
fd = get_unused_fd_flags(flags);
if (fd >= 0) {
struct file *f = do_filp_open(dfd, tmp, &op, lookup);
if (IS_ERR(f)) {
put_unused_fd(fd);
fd = PTR_ERR(f);
} else {
fsnotify_open(f);
fd_install(fd, f);
}
}
putname(tmp);
}
return fd;
}

其中主要程式碼的解釋:

  • get_unused_fd_flags:取回一個未被使用的檔案描述符(每次都會選取最小的未被使用的檔案描述符)。
  • do_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 等操作作準備。

函式最後返回該檔案的檔案描述符。

4.3. 虛擬檔案系統層的處理

read系統呼叫對應的核心函式是sys_read。實現如下(read_write.c):

SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
 struct file *file;
 ssize_t ret = -EBADF;
 int fput_needed;

file = fget_light(fd, &fput_needed);
 if (file) {
 loff_t pos = file_pos_read(file);
 ret = vfs_read(file, buf, count, &pos);
 file_pos_write(file, pos);
 fput_light(file, fput_needed);
 }

return ret;
}

程式碼解析:

  • fget_light() :根據 fd 指定的索引,從當前程序描述符中取出相應的 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 檔案系統層。

4.4. ext2層及後續的處理

檢視ext2_file_operations的初始化,我們可以看到,ext2的read指向do_sync_read,而在do_sync_read中又呼叫了ext2的aio_read函式,而aio_read指向generic_file_aio_read,所以generic_file_aio_read就是ext2層的入口。

generic_file_aio_read的大致走向(filemap.c):

4.4.1. 檔案的page cache結構

在 Linux 作業系統中,當應用程式需要讀取檔案中的資料時,作業系統先分配一些記憶體,將資料從儲存裝置讀入到這些記憶體中,然後再將資料分發給應用程式;當需要往檔案 中寫資料時,作業系統先分配記憶體接收使用者資料,然後再將資料從記憶體寫到磁碟上。檔案 Cache 管理指的就是對這些由作業系統分配,並用來儲存檔案資料的記憶體的管理。 Cache 管理的優劣通過兩個指標衡量:一是 Cache 命中率,Cache 命中時資料可以直接從記憶體中獲取,不再需要訪問低速外設,因而可以顯著提高效能;二是有效 Cache 的比率,有效 Cache 是指真正會被訪問到的 Cache 項,如果有效 Cache 的比率偏低,則相當部分磁碟頻寬會被浪費到讀取無用 Cache 上,而且無用 Cache 會間接導致系統記憶體緊張,最後可能會嚴重影響效能。

檔案 Cache 是檔案資料在記憶體中的副本,因此檔案 Cache 管理與記憶體管理系統和檔案系統都相關:一方面檔案 Cache 作為實體記憶體的一部分,需要參與實體記憶體的分配回收過程,另一方面檔案 Cache 中的資料來源於儲存裝置上的檔案,需要通過檔案系統與儲存裝置進行讀寫互動。從作業系統的角度考慮,檔案 Cache 可以看做是記憶體管理系統與檔案系統之間的聯絡紐帶。因此,檔案 Cache 管理是作業系統的一個重要組成部分,它的效能直接影響著檔案系統和記憶體管理系統的效能。

Linux核心中檔案預讀演算法的具體過程是這樣的:對於每個檔案的第一個讀請求,系統讀入所請求的頁面並讀入緊隨其後的少數幾個頁面(不少於一 個頁面,通常是三個頁面),這時的預讀稱為同步預讀。對於第二次讀請求,如果所讀頁面不在Cache中,即不在前次預讀的group中,則表明檔案訪問不 是順序訪問,系統繼續採用同步預讀;如果所讀頁面在Cache中,則表明前次預讀命中,作業系統把預讀group擴大一倍,並讓底層檔案系統讀入 group中剩下尚不在Cache中的檔案資料塊,這時的預讀稱為非同步預讀。無論第二次讀請求是否命中,系統都要更新當前預讀group的大小。此外,系 統中定義了一個window,它包括前一次預讀的group和本次預讀的group。任何接下來的讀請求都會處於兩種情況之一:第一種情況是所請求的頁面 處於預讀window中,這時繼續進行非同步預讀並更新相應的window和group;第二種情況是所請求的頁面處於預讀window之外,這時系統就要 進行同步預讀並重置相應的window和group。

檔案被分割為一個個以 page 大小為單元的資料塊,這些資料塊(頁)被組織成一個多叉樹(稱為 radix 樹)。樹中所有葉子節點為一個個頁幀結構(struct page),表示了用於快取該檔案的每一個頁。在葉子層最左端的第一個頁儲存著該檔案的前4096個位元組(如果頁的大小為4096位元組),接下來的頁儲存著檔案第二個4096個位元組,依次類推。樹中的所有中間節點為組織節點,指示某一地址上的資料所在的頁。此樹的層次可以從0層到6層,所支援的檔案大小從0位元組到16 T 個位元組。樹的根節點指標可以從和檔案相關的 address_space 物件(該物件儲存在和檔案關聯的 inode 物件中)中取得。

一個物理頁可能由多個不連續的物理磁碟塊組成。也正是由於頁面中對映的磁碟塊不一定連續,所以在頁快取記憶體中檢測特定資料是否已被快取就變得不那麼容易了。另外linux頁快取記憶體對被快取頁的範圍定義的非常寬。快取的目標是任何基於頁的物件,這包含各種型別的檔案和各種型別的記憶體對映。為了滿足普遍性要求,linux使用定義在linux/fs.h中的結構體address_space結構體描述頁快取記憶體中的頁面。

4.4.2. ext2層的處理

do_generic_file_read做的工作:

  • 根據檔案當前的讀寫位置,在 page cache 中找到快取請求資料的 page
  • 如果該頁已經最新,將請求的資料拷貝到使用者空間
  • 否則, Lock 該頁
  • 呼叫 readpage 函式向磁碟發出添頁請求(當下層完成該 IO 操作時會解鎖該頁),程式碼:error = mapping->a_ops->readpage(filp, page);
  • 再一次 lock 該頁,操作成功時,說明資料已經在 page cache 中了,因為只有 IO 操作完成後才可能解鎖該頁。此處是一個同步點,用於同步資料從磁碟到記憶體的過程。
  • 解鎖該頁
  • 到此為止資料已經在 page cache 中了,再將其拷貝到使用者空間中(之後 read 呼叫可以在使用者空間返回了)

到此,我們知道:當頁上的資料不是最新的時候,該函式呼叫 mapping->a_ops->readpage 所指向的函式(變數 mapping 為 inode 物件中的 address_space 物件),那麼這個函式到底是什麼呢?在Ext2檔案系統中,readpage指向ext2_readpage。

4.4.3. page cache層的處理

從上文得知:ext2_readpage 函式是該層的入口點。該函式呼叫 mpage_readpage 函式,如下mpage_readpage 函式的程式碼。

int mpage_readpage(struct page *page, get_block_t get_block)
{
 struct bio *bio = NULL;
 sector_t last_block_in_bio = 0;
 struct buffer_head map_bh;
 unsigned long first_logical_block = 0;

map_bh.b_state = 0;
 map_bh.b_size = 0;
 bio = do_mpage_readpage(bio, page, 1, &last_block_in_bio,
 &map_bh, &first_logical_block, get_block);
 if (bio)
 mpage_bio_submit(READ, bio);
 return 0;
}

該函式首先呼叫函式 do_mpage_readpage 函式建立了一個 bio 請求,該請求指明瞭要讀取的資料塊所在磁碟的位置、資料塊的數量以及拷貝該資料的目標位置——快取區中 page 的資訊。然後呼叫 mpage_bio_submit 函式處理請求。 mpage_bio_submit 函式則呼叫 submit_bio 函式處理該請求,後者最終將請求傳遞給函式 generic_make_request ,並由 generic_make_request 函式將請求提交給通用塊層處理。

到此為止, page cache 層的處理結束。

4.4.4. 通用塊層的處理

generic_make_request 函式是該層的入口點,該層只有這一個函式處理請求。函式的程式碼參見blk-core.c。

主要操作:

  • 根據 bio 中儲存的塊裝置號取得請求佇列 q
  • 檢測當前 IO 排程器是否可用,如果可用,則繼續,否則等待排程器可用
  • 呼叫 q->make_request_fn 所指向的函式將該請求(bio)加入到請求佇列中
  • 到此為止,通用塊層的操作結束。

4.4.5. IO排程層的處理

對 make_request_fn 函式的呼叫可以認為是 IO 排程層的入口,該函式用於向請求佇列中新增請求。該函式是在建立請求佇列時指定的,程式碼如下(blk_init_queue 函式中):

q->request_fn = rfn; blk_queue_make_request(q, __make_request);

函式 blk_queue_make_request 將函式 __make_request 的地址賦予了請求佇列 q 的 make_request_fn 成員,那麼, __make_request 函式才是 IO 排程層的真實入口。

__make_request 函式的主要工作為:

  1. 檢測請求佇列是否為空,若是,延緩驅動程式處理當前請求(其目的是想積累更多的請求,這樣就有機會對相鄰的請求進行合併,從而提高處理的效能),並跳到3,否則跳到2。
  2. 試圖將當前請求同請求佇列中現有的請求合併,如果合併成功,則函式返回,否則跳到3。
  3. 該請求是一個新請求,建立新的請求描述符,並初始化相應的域,並將該請求描述符加入到請求佇列中,函式返回。

將請求放入到請求佇列中後,何時被處理就由 IO 排程器的排程演算法決定了(有關 IO 排程器的演算法內容請參見參考資料)。一旦該請求能夠被處理,便呼叫請求佇列中成員 request_fn 所指向的函式處理。這個成員的初始化也是在建立請求佇列時設定的:

q->request_fn = rfn; blk_queue_make_request(q, __make_request);

第一行是將請求處理函式 rfn 指標賦給了請求佇列的 request_fn 成員。而 rfn 則是在建立請求佇列時通過引數傳入的。

對請求處理函式 request_fn 的呼叫意味著 IO 排程層的處理結束了。

4.4.6. 塊裝置驅動層的處理

request_fn 函式是塊裝置驅動層的入口。它是在驅動程式建立請求佇列時由驅動程式傳遞給 IO 排程層的。

IO 排程層通過回撥 request_fn 函式的方式,把請求交給了驅動程式。而驅動程式從該函式的引數中獲得上層發出的 IO 請求,並根據請求中指定的資訊操作裝置控制器(這一請求的發出需要依據物理裝置指定的規範進行)。

到此為止,塊裝置驅動層的操作結束。

4.4.7. 塊裝置層的處理

接受來自驅動層的請求,完成實際的資料拷貝工作等等。同時規定了一系列規範,驅動程式必須按照這個規範操作硬體。

4.4.8. 後續工作

當裝置完成了 IO 請求之後,通過中斷的方式通知 cpu ,而中斷處理程式又會呼叫 request_fn 函式進行處理。

當驅動再次處理該請求時,會根據本次資料傳輸的結果通知上層函式本次 IO 操作是否成功,如果成功,上層函式解鎖 IO 操作所涉及的頁面。

該頁被解鎖後, 就可以再次成功獲得該鎖(資料的同步點),並繼續執行程式了。之後,函式 sys_read 可以返回了。最終 read 系統呼叫也可以返回了。

至此, read 系統呼叫從發出到結束的整個處理過程就全部結束了。

本文原創,使用請註明出處:www.coderonline.net