1. 程式人生 > >裝置IO之一(mmap、直接IO以及非同步IO)

裝置IO之一(mmap、直接IO以及非同步IO)

現在,在linux中經常可以看到在使用者空間編寫的驅動程式,比如X伺服器,一些廠商的私有驅動等等,這就意味著使用者空間取得了對硬體的訪問能力,這通常是通過mmap將裝置記憶體對映到了使用者程序空間,從而使得使用者可以通過讀寫這些記憶體來獲取對硬體的訪問能力。
核心一般會對I/O操作進行緩衝以獲取更好的效能,但是也提供了直接I/O以及非同步I/O的能力。
在和硬體進行資料互動時,有的硬體支援DMA,DMA可以降低處理器負擔;有的硬體的記憶體空間無法直接讀寫,需要使用特殊的指令。

一、虛擬記憶體區

當使用mmap時,需要將核心的地址塊對映到使用者的地址空間,這就涉及到一個很關鍵的資料結構VMA,一個VMA表示在程序的虛擬地址空間中的同一類區域:擁有同樣的許可權並且被同樣的物件(一個檔案或者交換空間)所備份的一個連續的虛擬地址範圍。
可以通過檢視/proc/${pid}/maps來檢視程序的記憶體區域,其格式為:
start-end perm offset major:minor inode image
下邊是一個init程序的maps片段:
00000000-00000000 r-xp 00000000 01:00 149505                             /sbin/init.sysvinit
00000000-00000000 rw-p 00000000 01:00 149505                             /sbin/init.sysvinit
00000000-00000000 ---p 00000000 00:00 0 
00000000-00000000 rw-p 00000000 00:00 0                                  [heap]
00000000-00000000 rw-p 00000000 00:00 0 
00000000-00000000 rw-p 00000000 00:00 0                                  [stack]
其中各部分的含義如下:
  • start end:該片記憶體區的開始和結束虛擬地址.
  • perm:記憶體區的讀,寫和執行許可的位掩碼,perm的最後一個字元要麼是p表示是私有的,要麼是s表示是共享的。
  • offset:記憶體區在對映檔案中的偏移量(記憶體區是被對映到了一個檔案中)
  • major minor:擁有對映檔案的裝置的主次編號。對裝置對映來說,主次裝置號指的是磁碟中代表該裝置的磁碟檔案的主次裝置號,而不是核心分配給該真實裝置的主次裝置號。
  • inode:對映檔案的inode 號.
  • image:對映檔名
VMA對應的資料結構為vm_area_struct,其中包含了如下幾個函式指標:

1.1 open

其原型為:void (*open)(struct vm_area_struct *vma)

核心會在產生對一個VMA的新的引用時,呼叫它,以使得實現該VMA的核心部件有機會做自己的初始化。不過在建立新的VMA時不會呼叫它,而是會呼叫核心部件提供的mmap函式。

1.2 close

其原型為:void (*close)(struct vm_area_struct *vma)

記憶體區被銷燬時被呼叫,VMA沒有引用計數,因而一個程序只能開啟和關閉一個VMA區域一次。

1.3 nopage

 其原型為:struct page *(*nopage)(struct vm_area_struct *vma, unsigned long address, int *type);

當一個程序試圖存取一個合法的VMA頁,但是該頁當前不在記憶體中時,則核心會為該VMA呼叫它的nopage函式。該函式返回指向物理頁的page指標。如果該VMA沒有定義自己的nopage介面,則核心會為它分配一個空頁。

二、mmap

mmap使得可以將裝置記憶體對映到使用者空間,從而使得使用者程式獲得訪問硬體的能力,mmap的動作需要由核心中的驅動來實現。在使用mmap對映後,使用者程式對給定範圍的記憶體的讀寫就變成了對裝置記憶體的讀寫,也就是在訪問裝置了。
並不是所有的硬體都支援mmap,比如串列埠裝置就不支援mmap。mmap存在一個限制,就是它對映的粒度為PAGE_SIZE,因而核心只能在頁表一級對虛擬記憶體地址進行管理,因而使用mmap將裝置記憶體對映到使用者程序的虛擬記憶體空間時必須以頁為單位,並且核心被對映的實體地址也必須起始於PAGE_SIZE的整數倍,即被對映的實體地址的起始地址必須對齊到PAGE_SIZE上。
大多數PCI外設將其控制暫存器對映到了記憶體地址中,對於這類裝置只需要通過將這種記憶體對映到使用者空間就可以獲得對硬體的控制能力,相對於通過常規的ioctl方法,這是非常誘人的。
mmap是file_operations結構的一部分。由於在*nix中,一切皆檔案,因而核心部件很容易藉助該結構來實現自己的mmap。
使用者空間程式通過系統呼叫:
mmap (caddr_t addr, size_t len, int prot, int flags, int fd, off_t offset)
來呼叫fd上的mmap函式。當使用mmap系統呼叫時,核心會在呼叫fd上的mmap之前做一些準備工作。fd上的mmap函式在核心中的原型如下:
int (*mmap) (struct file *filp, struct vm_area_struct *vma);
filp為對映檔案,vma包含了訪問裝置的虛擬地址的資訊。fd上的mmap需要完成的是為vma包含的虛擬地址範圍建立合適的頁表,並且初始化vma中的函式指標,以便後續可以使用適當的函式。

2.1 建立頁表

建立頁表是mmap需要完成的最重要的工作。有兩種方法可以用來建立頁表:
  1. 呼叫remap_pfn_range 函式一次全部建成
  2. 通過nopage函式一次一頁的建立

2.1.1 使用 remap_pfn_range

remap_pfn_range 和 io_remap_page_range負責為一段實體地址建立新的頁表,它們的原型如下:
int remap_pfn_range(struct vm_area_struct *vma, unsigned long addr,
   unsigned long pfn, unsigned long size, pgprot_t prot);
它將從pfn開始的長度為size大小(size會向上對齊到頁PAGE_SIZE)的地址空間對映到vma中從addr開始的位置。因為size可變,因而它可以用於對映整個區域,也可以用於僅對映其中的一部分。
  • vma: 實體地址要對映到的使用者VMA
  • addr: 實體地址對映到使用者VMA地址空間時的使用者空間起始地址(通常為vam->start),但是也可以不為vam->start。
  • pfn: 對映的核心實體地址
  • size: 區域大小
  • prot: 該對映中的頁面的保護模式
int ioremap_page_range(unsigned long addr, unsigned long end,
      phys_addr_t phys_addr, pgprot_t prot);
它將從phys_addr開始的大小為(end-addr+1並且向上對齊到PAGE_SIZE)的I/O記憶體對映到從addr開始的虛擬地址。
  • addr:虛擬地址起始值
  • end:虛擬地址結束值
  • phys_addr:實體地址起始值
  • prot:該區域的保護模式
二者的區別在於,當要對映到使用者空間的地址是真正的RAM時,使用remap_pfn_range,如果要對映到使用者空間的地址是I/O記憶體的時候用ioremap_page_range。需要注意的是如果是I/O記憶體,則核心通常不會對它進行快取。

2.1.2 使用 nopage 對映記憶體

一次性建立好整個頁表在大多數情況下是不錯的選擇,但是在有的情況下時候nopage更合適。因為它更靈活。使用nopage的兩種典型場景如下:
  1. 應用程式呼叫mremap系統呼叫改變對映區域。當這個呼叫導致VMA區域變小時,核心不會通知驅動,而是會將不必要的頁重新整理掉;但是當這個呼叫導致VMA變大時,核心就會呼叫nopage方法來申請新頁。因此從這個意義上來說如果要支援該系統呼叫,就必須實現nopage方法。
  2. 當用戶訪問VMA中的頁,但是該頁又不在記憶體中時,nopage函式會被呼叫。
nopage函式要返回所獲得的page的指標,並增加它的引用計數表明有 人在使用該頁。
如果nopage的引數type不為NULL,則它可用於返回錯誤不同於返回值所返回的,一般為VM_FAULT_MINOR。由於nopage需要返回指向所獲得記憶體的page指標,但是PCI的儲存空間是沒有page指標的,因而nopage方法不適用於PCI地址空間。
當呼叫成功時nopage地返回一個指向 struct page 的指標。否則nopage將返回一個錯誤。如果 nopage 函式為NULL,則負責處理頁錯誤的核心程式碼將零記憶體頁對映到失效的虛擬地址上。零記憶體頁是一個特殊的頁,讀它將返回0,寫它將修改程序的私有拷貝。

2.2 新增VMA 的操作

mmap的另一個重要的動作是更新VMA的函式指標。也就是nopage,open,close等函式指標。

2.3 重新對映 RAM

remap_pfn_range只能用於保留頁以及在實體記憶體頂之上的實體地址,實際上就是不被記憶體管理系統管理的記憶體。也就是說常規的記憶體是不能用它來對映的,包括用__get_free_page獲得的記憶體。因此如果想用用它來對映一片記憶體,就要在系統啟動時將這部分記憶體給預留出來(因為經過remap_pfn_range對映後,程序就可以發起對它的直接讀寫,而由內核心記憶體管理系統管理的記憶體可能會被分配做其它用途,這就存在潛在的衝突)。
雖然無法使用remap_pfn_range來對映RAM到使用者空間,但是有變通的方法,可以使用VMA的nopage方法來將RAM對映到使用者地址空間,也就是一次一頁的向用戶空間對映記憶體。如果一個核心部件想要將RAM地址對映到使用者地址空間,就要實現nopage函式介面,並且在該函式中一次一頁的返回取到的頁。

需要注意的是使用nopage函式來返回page時,需要的是真正的page,因此需要找到真正的page指標,對於常規核心記憶體可以通過virt_to_page來獲取其page,但是對於vmalloc返回的地址,則要通過vmalloc_to_page來獲取page。

三、直接I/O

大部分I/O操作都要經過核心緩衝,這是為了提高I/O的效率,但是有的場景中緩衝並不一定能得到很好的效能。因此核心也為不想使用緩衝的場景提供了API,如果一個外設的驅動不想使用核心的緩衝機制,可以使用如下API: long get_user_pages(struct task_struct *tsk, struct mm_struct *mm,
unsigned long start, unsigned long nr_pages, int write,
int force, struct page **pages, struct vm_area_struct **vmas)
該函式將使用者程序的頁對映到核心的地址空間,然後核心中的程式碼就可以直接訪問這些頁了。其引數的含義:
  • tsk:一個指向進行 I/O 的任務的指標,作用在於告知核心誰該為頁錯誤負責,如果不需要記錄則可以設定為NULL
  • mm:一個記憶體管理結構的指標,描述被對映的地址空間
  • start:使用者空間起始地址
  • nr_pages頁數
  • write:呼叫者是否要往這部分頁中寫入資料whether pages will be written to by the caller
  • force:如果設定了它,則即便使用的是隻讀的使用者對映程序對映區,也會強制進行寫入,通常這不是所想要的效果
  • pages:指向獲得的page的指標陣列,該陣列大小應該至少為nr_pages,或者如果不想獲得這些資訊就設定為NULL。
  • vmas:指向與每個page相對應的vma區域的指標陣列。如果呼叫者不想要這些資訊,則可以為NULL。
由於該函式需要建立頁表進行對映,因而它還是比較耗時的,而且直接I/O忽略了核心的緩衝,由於缺少了核心緩衝,因而使用直接I/O的往往也會同時使用非同步I/O,否則直接IO的使用者為了知道它的操作什麼時候完成了,它什麼時候可以重用它向核心提交資料的快取等資訊就必須等待IO完成,這顯然在大部分情況下都不是使用者所期望的(因為IO本身是比較耗時的,在IO上等待將浪費寶貴的CPU時間)。 事實上對於塊裝置驅動以及網路驅動,相關框架的程式碼已經在合適的時候使用了直接I/O,因而驅動編寫者基本不需要考試直接I/O,而對於字元驅動來說,顯然直接I/O並沒有什麼吸引力(字元流不是以PAGE為單位的)。 特別強調的是該函式必須在mmap_sem被持有的情況下呼叫。 在直接 I/O 操作完成後,這些頁必須被釋放,另外如果這些頁被修改了,則必須呼叫SetPageDirty標記頁為髒的,否則核心會假設頁的內容沒有發生變化,因而不會將它的內容同步到它對應的裝置或者檔案中,這通常是錯誤的。 釋放頁通過函式page_cache_release完成。

四、非同步I/O(AIO)

除了直接I/O外,核心還提供了另外一種I/O特性,非同步I/O。非同步 I/O 允許使用者程式來發起一個或多個I/O操作而不必等待操作的完成,核心提供了一套API來支援使用者程式發起AIO。

4.1 使用者介面

核心提供給使用者空間的API及其介面如下:
  • io_setup:為當前程序建立一個非同步I/O上下文,它有一個引數可以指定該上下文最多可以提交多少個非同步I/O。
  • io_submit:提交一個或多個非同步I/O請求
  • io_getevents:獲取已經提交的非同步I/O請求的完成狀態
  • io_cancel:取消提交的非同步I/O請求
  • io_destroy:清除為本程序建立的非同步I/O上下文
這幾個介面定義在aio.h和aio.c中,都是系統呼叫。這些API的含義很明顯,需要使用非同步I/O的應用需要首先建立一個非同步I/O上下文,然後在該上下文上提交非同步I/O請求,一個程序可以建立多個非同步I/O上下文,這些上下文會儲存在task_struct->mm->ioctx_list中。隨後使用者程序即可在它所提交的上下文上提交非同步/IO請求。如果想獲取非同步I/O的狀態可以用io_getevents來獲取,程序也可以選擇用io_cancel來取消一個已經提交的非同步I/O。在使用完後,可以用io_destroy來清除非同步I/O上下文。

4.2 核心實現

4.2.1 非同步I/O上下文

核心使用kioctx來表示非同步I/O上下文,使用者建立非同步I/O上下文時的資訊都儲存在這裡,在成功建立一個非同步I/O上下文後,核心會返回一個id個使用者程序,隨後使用者程序用該id即可使用這個上下文。在建立非同步I/O上下文時,核心會建立一個AIO ring。 AIO ring對應使用者態程序地址空間的一段記憶體快取區,使用者態程序可以訪問,核心也可訪問。核心的做法是呼叫get_user_pages獲得使用者頁。AIO ring是一個環形緩衝區,核心用它來報告非同步IO的完成情況,使用者態程序也可以直接檢查非同步IO完成情況,從而避免系統呼叫的開銷。

4.2.2 非同步I/O請求

核心使用kiocb來表示一個非同步I/O請求,而使用者程序使用資料結構iocb來表示一個非同步I/O請求,核心會完成二者之間的轉換。 在io_submit時,使用者可以一次提交多個非同步I/O請求,核心會根據請求的模式依次處理每個非同步I/O請求(可以設定),這裡最終會呼叫到檔案操作指標file_operations裡的非同步IO操作函式,如果函式返回了非EIOCBQUEUED的值,則AIO框架和會直接呼叫aio_complete並返回,否則就是一個真正的非同步I/O,file_operations裡的返回EIOCBQUEUED的部件要負責在處理完該I/O請求後呼叫aio_complete來最終完成該非同步I/O。 從核心實現可以看出對於支援非同步I/O的部件來說,它所需要做的就是正確的實現file_operations中的非同步I/O介面(可以通過workqueue等機制來實現自己的非同步I/O),並且在完成非同步I/O後呼叫aio_complete即可。

4.2.3 收集非同步I/O狀態

當用戶程序通過系統呼叫收集非同步I/O狀態時,核心會通過read_events來響應該請求,核心會在相應的上下文的等待佇列上等待,該等待由aio_complete喚醒或者被中斷打斷或者超時結束。

4.2.4 取消一個非同步I/O請求

如果想要支援取消非同步I/O請求,則I/O操作的實現者需要呼叫kiocb_set_cancel_fn設定其取消函式,這樣當用戶發起取消I/O操作的請求時,AIO框架就會呼叫該取消函式來取消指定的非同步I/O請求。

4.2.5 清除非同步I/O上下文

清除一個非同步I/O上下文時,AIO框架會為通過kill_ioctx主動喚醒在該上下文等待的所有程序,然後釋放相關資料結構。