韋東山:Linux驅(qū)動程序基石之mmap(附視頻)

應(yīng)用程序和驅(qū)動程序之間傳遞數(shù)據(jù)時,可以通過read、write函數(shù)進(jìn)行。這涉及在用戶態(tài)buffer和內(nèi)核態(tài)buffer之間傳數(shù)據(jù),如下圖所示:

應(yīng)用程序不能直接讀寫驅(qū)動程序中的buffer,需要在用戶態(tài)buffer和內(nèi)核態(tài)buffer之間進(jìn)行一次數(shù)據(jù)拷貝。這種方式在數(shù)據(jù)量比較小時沒什么問題;但是數(shù)據(jù)量比較大時效率就太低了。比如更新LCD顯示時,如果每次都讓APP傳遞一幀數(shù)據(jù)給內(nèi)核,假設(shè)LCD采用1024*600*32bpp的格式,一幀數(shù)據(jù)就有1024*600*32/8=2.3MB左右,這無法忍受。
改進(jìn)的方法就是讓程序可以直接讀寫驅(qū)動程序中的buffer,這可以通過mmap實(shí)現(xiàn)(memory map),把內(nèi)核的buffer映射到用戶態(tài),讓APP在用戶態(tài)直接讀寫。
1.內(nèi)存映射現(xiàn)象與數(shù)據(jù)結(jié)構(gòu)
假設(shè)有這樣的程序,名為test.c:
#include <stdio.h>
#include <unistd.h>
int a;
int main(int argc, char **argv)
{
? ?printf("enter a's value: \n");
? ?scanf("%d", &a);
? printf("a's address = 0x%x, a's value = %d\n", &a, a);
?while (1)
{
? ? sleep(10);
}
return 0;
}
在PC上如下編譯:
gcc? -o? test? test.c -static
在2個終端中分別執(zhí)行test程序,在第3個終端執(zhí)行ps -a,可以看到這2個程序同時存在,如下圖:



觀察到這些現(xiàn)象:
① 2個程序同時運(yùn)行,它們的變量a的地址都是一樣的:0x6d73c0;
② 2個程序同時運(yùn)行,它們的變量a的值是不一樣的,一個是12,另一個是123。
疑問來了:
① 這2個程序同時在內(nèi)存中運(yùn)行,它們在內(nèi)存中的地址肯定不同,比如變量a的地址肯定不同;
② 但是打印出來的變量a的地址卻是一樣的。
怎么回事?
這里要引入虛擬地址的概念:CPU發(fā)出的地址是虛擬地址,它經(jīng)過MMU(Memory Manage Unit,內(nèi)存管理單元)映射到物理地址上,對于不同進(jìn)程的同一個虛擬地址,MMU會把它們映射到不同的物理地址。如下圖:

當(dāng)前運(yùn)行的是app1時,MMU會把CPU發(fā)出的虛擬地址addr映射為物理地址paddr1,用paddr1去訪問內(nèi)存。
當(dāng)前運(yùn)行的是app2時,MMU會把CPU發(fā)出的虛擬地址addr映射為物理地址paddr2,用paddr2去訪問內(nèi)存。
MMU負(fù)責(zé)把虛擬地址映射為物理地址,虛擬地址映射到哪個物理地址去?映射關(guān)系保存在頁表中:

解析如下:
① 每個APP在內(nèi)核中都有一個task_struct結(jié)構(gòu)體,它用來描述一個進(jìn)程;
② 每個APP都要占據(jù)內(nèi)存,在task_struct中用mm_struct來管理進(jìn)程占用的內(nèi)存;
內(nèi)存在虛擬地址、物理地址,mm_struct中用mmap來描述虛擬地址,用pgd來描述對應(yīng)的物理地址。
注意:pgd,Page Global Directory,頁目錄。
③ 每個APP都有一系列的VMA:virtual memory
比如APP含有代碼段、數(shù)據(jù)段、BSS段、棧等等,還有共享庫。這些單元會保存在內(nèi)存里,它們的地址空間不同,權(quán)限不同(代碼段是只讀的可運(yùn)行的、數(shù)據(jù)段可讀可寫),內(nèi)核用一系列的vm_area_struct來描述它們。
vm_area_struct中的vm_start、vm_end是虛擬地址。
④ vm_area_struct中虛擬地址如何映射到物理地址去?
每一個APP的虛擬地址可能相同,物理地址不相同,這些對應(yīng)關(guān)系保存在pgd中。
2.ARM架構(gòu)內(nèi)存映射簡介
ARM架構(gòu)支持一級頁表映射,也就是說MMU根據(jù)CPU發(fā)來的虛擬地址可以找到第1個頁表,從第1個頁表里就可以知道這個虛擬地址對應(yīng)的物理地址。一級頁表里地址映射的最小單位是1M。
ARM架構(gòu)還支持二級頁表映射,也就是說MMU根據(jù)CPU發(fā)來的虛擬地址先找到第1個頁表,從第1個頁表里就可以知道第2級頁表在哪里;再取出第2級頁表,從第2個頁表里才能確定這個虛擬地址對應(yīng)的物理地址。二級頁表地址旺射的最小單位有4K、1K,Linux使用4K。
一級頁表項里的內(nèi)容,決定了它是指向一塊物理內(nèi)存,還是指問二級頁表,如下圖:

2.1, 一級頁表映射過程
一線頁表中每一個表項用來設(shè)置1M的空間,對于32位的系統(tǒng),虛擬地址空間有4G,4G/1M=4096。所以一級頁表要映射整個4G空間的話,需要4096個頁表項。
第0個頁表項用來表示虛擬地址第0個1M(虛擬地址為0~0x1FFFFF)對應(yīng)哪一塊物理內(nèi)存,并且有一些權(quán)限設(shè)置;
第1個頁表項用來表示虛擬地址第1個1M(虛擬地址為0x100000~0x2FFFFF)對應(yīng)哪一塊物理內(nèi)存,并且有一些權(quán)限設(shè)置;
依次類推。
使用一級頁表時,先在內(nèi)存里設(shè)置好各個頁表項,然后把頁表基地址告訴MMU,就可以加動MMU了。
以下圖為例介紹地址映射過程:
① CPU發(fā)出虛擬地址vaddr,假設(shè)為0x12345678
② MMU根據(jù)vaddr[31:20]找到一級頁表項:
虛擬地址0x12345678是虛擬地址空間里第0x123個1M,所以找到頁表里第0x123項,根據(jù)此項內(nèi)容知道它是一個段頁表項。
段內(nèi)偏移是0x45678。
③ 從這個表項里取出物理基地址:Section Base Address,假設(shè)是0x81000000
④ 物理基地址加上段內(nèi)偏移得到:0x81045678
所以CPU要訪問虛擬地址0x12345678時,實(shí)際上訪問的是0x81045678的物理地址

2.2, 二級頁表映射過程
首先設(shè)置好一級頁表、二級頁表,并且把一級頁表的首地址告訴MMU。
以下圖為例介紹地址映射過程:
① CPU發(fā)出虛擬地址vaddr,假設(shè)為0x12345678
② MMU根據(jù)vaddr[31:20]找到一級頁表項:
虛擬地址0x12345678是虛擬地址空間里第0x123個1M,所以找到頁表里第0x123項。根據(jù)此項內(nèi)容知道它是一個二級頁表項。
③ 從這個表項里取出地址,假設(shè)是address,這表示的是二級頁表項的物理地址;
④ vaddr[19:12]表示的是二級頁表項中的索引index即0x45,在二級頁表項中找到第0x45項;
⑤ 二級頁表項中含有頁基地址page base addr,假設(shè)是0x81889000:
它跟vaddr[11:0]組合得到物理地址:0x81889000 + 0x678 = 0x818678。
所以CPU要訪問虛擬地址0x12345678時,實(shí)際上訪問的是0x81889678的物理地址

3, 怎么給APP新建一塊內(nèi)存映射
3.1, mmap調(diào)用過程
從上面內(nèi)存映射的過程可以知道,要給APP端新開劈一塊虛擬內(nèi)存,并且讓它指向某塊內(nèi)核buffer,我們要做這些事:
① 得到一個vm_area_struct,它表示APP的一塊虛擬內(nèi)存空間;
很幸運(yùn),APP調(diào)用mmap系統(tǒng)函數(shù)時,內(nèi)核就幫我們構(gòu)造了一個vm_area_stuct結(jié)構(gòu)體。里面含有虛擬地址的地址范圍、權(quán)限。
② 確定物理地址:
你想映射某個內(nèi)核buffer,你需要得到它的物理地址,這得由你提供。
③ 給vm_area_struct和物理地址建立映射關(guān)系:
也很幸運(yùn),內(nèi)核提供有相關(guān)函數(shù)。
APP里調(diào)用mmap時,導(dǎo)致的內(nèi)核相關(guān)函數(shù)調(diào)用過程如下:

3.2 cache和buffer
本小節(jié)參考:
ARM的cache和寫緩沖器(write buffer)
ARM的cache和寫緩沖器(write buffer)

使用mmap時,需要有cache、buffer的知識。下圖是CPU和內(nèi)存之間的關(guān)系,有cache、buffer(寫緩沖器)。Cache是一塊高速內(nèi)存;寫緩沖器相當(dāng)于一個FIFO,可以把多個寫操作集合起來一次寫入內(nèi)存。

程序運(yùn)行時有“局部性原理”,這又分為時間局部性、空間局部性。
① 時間局部性:
在某個時間點(diǎn)訪問了存儲器的特定位置,很可能在一小段時間里,會反復(fù)地訪問這個位置。
② 空間局部性:
訪問了存儲器的特定位置,很可能在不久的將來訪問它附近的位置。
而CPU的速度非常快,內(nèi)存的速度相對來說很慢。CPU要讀寫比較慢的內(nèi)存時,怎樣可以加快速度?根據(jù)“局部性原理”,可以引入cache。
① 讀取內(nèi)存addr處的數(shù)據(jù)時:
先看看cache中有沒有addr的數(shù)據(jù),如果有就直接從cache里返回數(shù)據(jù):這被稱為cache命中。
如果cache中沒有addr的數(shù)據(jù),則從內(nèi)存里把數(shù)據(jù)讀入,注意:它不是僅僅讀入一個數(shù)據(jù),而是讀入一行數(shù)據(jù)(cache line)。
而CPU很可能會再次用到這個addr的數(shù)據(jù),或是會用到它附近的數(shù)據(jù),這時就可以快速地從cache中獲得數(shù)據(jù)。
② 寫數(shù)據(jù):
CPU要寫數(shù)據(jù)時,可以直接寫內(nèi)存,這很慢;也可以先把數(shù)據(jù)寫入cache,這很快。
但是cache中的數(shù)據(jù)終究是要寫入內(nèi)存的啊,這有2種寫策略:
a. 寫通(write through):
數(shù)據(jù)要同時寫入cache和內(nèi)存,所以cache和內(nèi)存中的數(shù)據(jù)保持一致,但是它的效率很低。能改進(jìn)嗎?可以!使用“寫緩沖器”:cache大哥,你把數(shù)據(jù)給我就可以了,我來慢慢寫,保證幫你寫完。
有些寫緩沖器有“寫合并”的功能,比如CPU執(zhí)行了4條寫指令:寫第0、1、2、3個字節(jié),每次寫1字節(jié);寫緩沖器會把這4個寫操作合并成一個寫操作:寫word。對于內(nèi)存來說,這沒什么差別,但是對于硬件寄存器,這就有可能導(dǎo)致問題。
所以對于寄存器操作,不會啟動buffer功能;對于內(nèi)存操作,比如LCD的顯存,可以啟用buffer功能。
b. 寫回(write back):
新數(shù)據(jù)只是寫入cache,不會立刻寫入內(nèi)存,cache和內(nèi)存中的數(shù)據(jù)并不一致。
新數(shù)據(jù)寫入cache時,這一行cache被標(biāo)為“臟”(dirty);當(dāng)cache不夠用時,才需要把臟的數(shù)據(jù)寫入內(nèi)存。
使用寫回功能,可以大幅提高效率。但是要注意cache和內(nèi)存中的數(shù)據(jù)很可能不一致。這在很多時間要小心處理:比如CPU產(chǎn)生了新數(shù)據(jù),DMA把數(shù)據(jù)從內(nèi)存搬到網(wǎng)卡,這時候就要CPU執(zhí)行命令先把新數(shù)據(jù)從cache刷到內(nèi)存。反過來也是一樣的,DMA從網(wǎng)卡得過了新數(shù)據(jù)存在內(nèi)存里,CPU讀數(shù)據(jù)之前先把cache中的數(shù)據(jù)丟棄。
是否使用cache、是否使用buffer,就有4種組合(Linux內(nèi)核文件arch\arm\include\asm\pgtable-2level.h):

第1種是不使用cache也不使用buffer,讀寫時都直達(dá)硬件,這適合寄存器的讀寫。
第2種是不使用cache但是使用buffer,寫數(shù)據(jù)時會用buffer進(jìn)行優(yōu)化,可能會有“寫合并”,這適合顯存的操作。因?yàn)閷︼@存很少有讀操作,基本都是寫操作,而寫操作即使被“合并”也沒有關(guān)系。
第3種是使用cache不使用buffer,就是“write through”,適用于只讀設(shè)備:在讀數(shù)據(jù)時用cache加速,基本不需要寫。
第4種是既使用cache又使用buffer,適合一般的內(nèi)存讀寫。
3.3, 驅(qū)動程序要做的事
驅(qū)動程序要做的事情有3點(diǎn):
① 確定物理地址
② 確定屬性:是否使用cache、buffer
③ 建立映射關(guān)系
參考Linux源文件,示例代碼如下:

還有一個更簡單的函數(shù):

9.4 驅(qū)動編程
我們在驅(qū)動程序中申請一個8K的buffer,讓APP通過mmap能直接訪問。
① 使用哪一個函數(shù)分配內(nèi)存?

我們應(yīng)該使用kmalloc或kzalloc,這樣得到的內(nèi)存物理地址是連續(xù)的,在mmap時后APP才可以使用同一個基地址去訪問這塊內(nèi)存。(如果物理地址不連續(xù),就要執(zhí)行多次mmap了)。
關(guān)鍵代碼現(xiàn)場編寫,再完善文檔。
本文配套視頻:
mmap基礎(chǔ)知識:
【韋東山】韋東山升級版全系列嵌入式視頻_快速入門篇_嗶哩嗶哩 (゜-゜)つロ 干杯~-bilibili
mmap驅(qū)動編程:
【韋東山】韋東山升級版全系列嵌入式視頻_快速入門篇_嗶哩嗶哩 (゜-゜)つロ 干杯~-bilibili