Linux核心筆記005 - 越界訪問記憶體,Linux核心處理過程

jmpcall發表於2020-06-06
1. 幾個重要的資料結構和函式
  • 記憶體管理本質
        在Linux核心筆記004中,已經引出了"分配"的概念,它本質上就是在保護模式下,做兩件事:
        ① 隔離同一程式已使用和未使用虛擬記憶體空間,以及整個系統的已使用和未使用實體記憶體空間
            具體實現:記錄已每個程式已使用的虛擬地址,和整個系統已使用的實體地址,分配時使用未使用的。
        ② 隔離不同程式的使用者空間
            具體實現:為每個程式0-3G範圍的虛擬地址,建立獨立的對映,對映不同的實體地址(記憶體共享除外)。
        現實生活中,所有的管理都依賴一道"關卡",同樣的,軟體層"記憶體管理"的實現,依賴CPU硬體層的"地址對映"特性。比如,在真實模式狀態下,每個程式,都可以用指令中的地址,直接訪問到實體地址(比如Linux核心筆記004中記錄的"ljmp 0x100000"跳轉指令),就沒有這道"關卡"。

  • 實體地址管理
        之前的筆記,已經詳細描述了對映過程,包括CPU內部執行"段式/頁式地址對映"的過程,以及Linux核心為"0xC0000000 - 0xC0000000+8MB"這塊虛擬空間建立對映的過程,緊接著的學習,就是關於核心對"已使用/未使用"地址的管理,以下為實體地址管理相關的結構:
        ① struct page
typedef struct page {
	struct list_head list;
	struct address_space *mapping;
	unsigned long index;
	struct page *next_hash;
	atomic_t count;
	unsigned long flags;	/* atomic flags, some possibly updated asynchronously */
	struct list_head lru;
	unsigned long age;
	wait_queue_head_t wait;
	struct page **pprev_hash;
	struct buffer_head * buffers;
	void *virtual; /* non-NULL if kmapped */
	struct zone_struct *zone;
} mem_map_t;

        這個結構,跟記錄"已使用/未使用"有什麼關係呢?
        Linux核心,利用的是80386的頁式管理,所以分配釋放的最小單位必須是"頁",每個4K倍數的地址,都是一頁記憶體的開始,如果只是記錄"已使用/未使用",使用一個點陣圖即可,每個位的0/1值代表相應頁的"已使用/未使用"狀態,然而,記憶體管理還需要記錄其它很多資訊,從而定義了以上結構,裡面的成員,隨著後續的學習,都會接觸到,暫時不用關心。
        此外,核心在啟動時,根據實際記憶體的大小,建立了一個mem_map[]陣列,陣列的每個成員都是一個struct page"物件",0下標對應"0地址頁"資訊,1下標對應"4K地址頁"資訊..,所以struct page中沒有表示頁地址的成員,另外,"已使用/未使用"狀態,是透過list成員掛在"已使用區/未使用區"區分。
start_kernel()
 |- setup_arch()
     |- paging_init()
         |- free_area_init()
             |- free_area_init_core()
                 |  // 根據實際的實體記憶體大小,建立mem_map[]陣列(*gmap == mem_map)
                 |- *gmap = alloc_bootmem_node()
        一定要注意,struct page"物件",跟頁面本身不是同一個東西,它只是用於記錄一塊4K大小的實體記憶體的使用情況而已,它們在位置上也沒有任何聯絡。

        ② struct zone_struct
            Linux核心將所有物理頁面,劃分成三個管理區:ZONE_DMA、ZONE_NORMAL、ZONE_HIGHMEM。
            為什麼要劃分一個ZONE_DMA管理區?
            DMA設計的用意是:磁碟資料往記憶體的讀寫,不用CPU參與。那麼,DMA本身就有訪問記憶體的能力,同真實模式/保護模式類似,DMA訪問的地址,可以直接是實體地址,也可以是需要經過對映的虛擬地址,取決於記憶體管理單元(MMU)是單獨實現,還是整合在CPU內部實現。80386就沒有設計單獨的MMU,所在DMA直接訪問實體記憶體。
            首先,由於有些外設,不能訪問過高的地址,所以要在低地址區,劃分一塊ZONE_DMA管理區,專門用於DMA,避免被其它程式佔用。
            其次,如果某個外設希望透過DMA訪問連續8K的記憶體,那就需要兩個連續的物理頁面,而不能像經過MMU那樣,只要保證虛擬地址連續即可,前4K對映到一個物理頁面,後4K對映到另一個相隔很遠的物理頁面都沒關係,所以也要單獨劃分一塊ZONE_DMA管理區,方便保證這一點。
            最後,整個記憶體也有被用完的時候,單獨劃分一塊ZONE_DMA管理區,也能保證DMA始終有記憶體可用。
            除了ZONE_DMA管理區,在實際記憶體大於1G時,還會劃分一個ZONE_HIGHMEM管理區(暫不關心,後期學習),其餘部分則為ZONE_NORMAL管理區。
            每個管理區資訊對應的結構如下:
typedef struct zone_struct {
	/*
	 * Commonly accessed fields:
	 */
	spinlock_t		lock;
	unsigned long		offset;
	unsigned long		free_pages;
	unsigned long		inactive_clean_pages;
	unsigned long		inactive_dirty_pages;
	unsigned long		pages_min, pages_low, pages_high;

	/*
	 * free areas of different sizes
	 */
	struct list_head	inactive_clean_list;
	free_area_t		free_area[MAX_ORDER];

	/*
	 * rarely used fields:
	 */
	char			*name;
	unsigned long		size;
	/*
	 * Discontig memory support fields.
	 */
	struct pglist_data	*zone_pgdat;
	unsigned long		zone_start_paddr;
	unsigned long		zone_start_mapnr;
	struct page		*zone_mem_map;
} zone_t;

        ③ struct pglist_data
            如下圖所示,CPU訪問不同記憶體條,代價是不一樣的,有的需要跨匯流排,有的不需要,這種情況叫做"非均勻儲存結構(NUMA)"。
            Linux核心筆記005 - 越界訪問記憶體,Linux核心處理過程
            比如下面是一臺機器的CPU資訊,"Socket(s)"表示實際插在主機板上的CPU個數為2,"Core(s) per socket"表示每個CPU上的物理核數為8,"Thread(s) per core"表示每個物理核開了2個超執行緒,所以總共有32個核,這些核就分別靠近兩個不同的NUMA節點。
            Linux核心筆記005 - 越界訪問記憶體,Linux核心處理過程
            Linux核心,會對不同NUMA節點中的記憶體,進行獨立管理。假設有2塊屬於不同NUMA節點的記憶體條,大小都是2G,將整個4G記憶體看作一個整體也是沒問題的,但是Linux核心是將這2個2G記憶體,分別劃分成3個管理區,從而在程式碼中定義了struct pglist_data結構,用於記錄一個NUMA節點的資訊。
typedef struct pglist_data {
	zone_t node_zones[MAX_NR_ZONES];
	zonelist_t node_zonelists[NR_GFPINDEX];
	struct page *node_mem_map;
	unsigned long *valid_addr_bitmap;
	struct bootmem_data *bdata;
	unsigned long node_start_paddr;
	unsigned long node_start_mapnr;
	unsigned long node_size;
	int node_id;
	struct pglist_data *node_next;
} pg_data_t;
            到此為止,可以得出一個結論:利用struct pglist_data結構,可以表達系統中有多少個NUMA節點,利用struct zone_struct結構,可以表達每個NUMA節點中有哪些管理區,利用struct page結構,可以表達每個管理區包含的物理頁面,最終相當於描述了一個物理頁面"倉庫",物理頁面的管理,也正是對這個"倉庫"的管理。

        ④ 夥伴演算法
            struct zone_struct有一個陣列成員:
free_area_t		free_area[MAX_ORDER];    // MAX_ORDER為10
            陣列的每個成員,又是一個struct free_area_struct"物件":
typedef struct free_area_struct {
	struct list_head	free_list;
	unsigned int		*map;
} free_area_t;
            每個struct free_area_struct"物件"包含一個連結串列頭,用於掛接連續的"空閒頁面塊"(透過頁面塊中首個頁面對應的struct page"物件"的list成員),並且free_area[0]、free_area[1]..、free_area[9]的free_list,分別用於掛接大小為2、4..、1024(2^10)的連續頁面塊(所以可以分配的最大連續頁面塊為1024*4K=4M)。
            雖然保護模式下,程式使用的是虛擬地址,即使分配超大塊的記憶體時,只要保證虛擬地址是連續的即可,對映到的物理頁面是不是連續,對於程式是"透明"的,但是"分配"發生頻率超高,並且經常需要對映到多個物理頁面,那麼付出少量的代價,維持儘量多的連續物理頁面,能在"分配"這一面獲取非常大的效率收益。
            夥伴演算法就是用於快速將小頁面塊,合併為大頁面塊:
        Linux核心筆記005 - 越界訪問記憶體,Linux核心處理過程
            上圖是我絞盡腦汁想到一種理解的方法:假設實際記憶體最開始16個頁面的狀態如上圖,藍色表示已分配、白色表示未分配,然後先別管抽象,無腦的跟著以下步驟去做:
            將第一行(2n, 2n+1)下標處的內容(即(0,1)、(2,3)..夥伴演算法不會將(1,2)、(3,4)..當成夥伴),兩兩圈在一起,它們各自屬於一對夥伴。夥伴都忙(藍色數字),表示其中一個釋放,不可以合併成塊,在下一行中用藍色的0表示;夥伴都閒(黑色數字),表示已經成塊,不需要再次合併,在下一行中用黑色的0表示;否則等正在忙的那個夥伴被釋放時,就可以合併成塊,在下一行中用藍色的1表示(總之下一行的值,是由上一行中兩個夥伴的顏色決定,而不是值)。
            然後依次對第二行、第三行..做同樣的操作,直到只剩一個數字,無法組成夥伴的那一行。此時再去驗證每一個0、1,比如:
            order(0)中的第3個1:page4已分配,page5空閒,當page5釋放後就可以和page4合併;
            order(1)中的第2個0:page4-7有3個已分配頁面,只釋放其中一個,不能進行合併;
            order(2)中的第2個1:page8-15,只有page11已分配,當它釋放後,就可以合併成一個8頁面的空閒塊;
            ...
            然後,是不是就理解夥伴演算法了?
            夥伴演算法用於釋放時,將小塊合併成大塊,並將其移動到更高下標的free_area[]中,直到最大支援的1024個。而分配時,可能會將大塊切分成小塊:比如分配連續2個物理頁面,程式會優先到free_area[0]中找,如果為空,就到free_area[1]中找,假設還為空,就到free_area[2]中找,假設不再為空了,此時就找到一個8頁面空閒塊,程式會將它切分成兩個4頁面塊,其中一個掛接到free_area[1],另一個繼續切分成2個2頁面塊,其中一個掛接到free_area[0],剩餘一個就可以作為分配到的2個連續物理頁面了。

        ⑤ 頁表項(PTE)低12位
            頁表項用於指向最終對映到的物理頁面,而物理頁面的地址,都是按4K對齊(因為第一個頁面的地址為0,每個頁面的大小為4K),所以CPU只把PTE的高20位當作地址(左移12位即可),低12位用於PTE所指頁面的屬性,在後續的換入換出管理中,就可以看到對這些屬性的利用。
            另外,每個目錄項指向的也是頁面,並且,它指向的頁面都被核心當作頁表使用。

  • 虛擬地址管理
        虛擬地址也是資源,每個程式可以獨立使用的有隻有3G個地址,即使在64位系統中,數量就目前來講已經不成問題了,那也得避免邏輯上的歧義,不能用同一個邏輯地址指向兩個不同"物件",所以至少要對正在使用的虛擬地址,做記錄管理,以下為虛擬地址管理相關的結構和函式:
        ① struct vm_area_struct
            虛擬地址的分配,依賴程式本身,每個程式編譯後,就有一部分要使用的虛擬地址是確定的了,比如程式碼段、資料段佔用的虛擬地址,對於動態分配,要分配的連續大小也是由程式指定的,根據這樣理解,虛擬地址管理的邏輯,本身就已經很固定了,所以整個邏輯,比對物理頁面的管理要簡單的多,struct vm_area_struct結構,就是程式指定要分配多大的記憶體時,核心分配這樣一個"物件",將該虛擬地址區域記錄下來,各個成員的含義在書中有詳細的說明,筆記中不再重複。
struct vm_area_struct {
	struct mm_struct * vm_mm;	/* VM area parameters */
	unsigned long vm_start;
	unsigned long vm_end;

	/* linked list of VM areas per task, sorted by address */
	struct vm_area_struct *vm_next;

	pgprot_t vm_page_prot;
	unsigned long vm_flags;

	/* AVL tree of VM areas per task, sorted by address */
	short vm_avl_height;
	struct vm_area_struct * vm_avl_left;
	struct vm_area_struct * vm_avl_right;

	/* For areas with an address space and backing store,
	 * one of the address_space->i_mmap{,shared} lists,
	 * for shm areas, the list of attaches, otherwise unused.
	 */
	struct vm_area_struct *vm_next_share;
	struct vm_area_struct **vm_pprev_share;

	struct vm_operations_struct * vm_ops;
	unsigned long vm_pgoff;		/* offset in PAGE_SIZE units, *not* PAGE_CACHE_SIZE */
	struct file * vm_file;
	unsigned long vm_raend;
	void * vm_private_data;		/* was vm_pte (shared mem) */
};

        ② find_vma()函式
            查詢某個虛擬地址所在的虛擬區間。

2. 越界訪問堆區
    接下來,書中設定了一個情景:某個應用程式存在bug,它執行時,先透過mmap()函式獲取一塊記憶體,但會在munmap()之後,繼續訪問這塊記憶體中的地址,從而發生"段錯誤"。
    剛開始學習核心的時候,我始終想不明白核心為什麼比應用程式"有權"(Linus寫的程式碼可以執行CPU特權指令,我寫的卻不可以),以及它是如何將"權力"控制在自己手裡。其實之前的筆記已經舉了一些例子,用於感性的體會,這裡再相對完整的理一遍:
    核心對"權力"的把握,其實就是對CPU狀態核心空間控制權的把握:
    ① 開機時,CPU執行的入口是核心程式碼,所以說"權力"一開始就給了核心;
    ② 核心在為應用程式的執行,創造好準備條件之後,是先將CPU切換到低許可權狀態,才跳轉,所以使用者態的程式碼就不能執行特權指令;
    ③ 應用程式的程式碼在低許可權狀態執行,如果希望CPU回到高許可權狀態,必須穿過一道核心設定的"門"(詳細內容在書中第三章——中斷、異常和系統呼叫),許可權提高的同時,指令也必須回到核心程式碼執行;
    ④ 應用程式的程式碼在低許可權狀態執行,就無法利用特權指令,建立/修改虛擬地址到實體地址的對映狀態,再加上虛擬地址到實體地址對映的過程,CPU會進行許可權檢查,從而核心又掌握了對核心空間的"控制權",雖然每個程式都可以訪問核心空間,但同樣必須是穿過一道"門"進入核心態,呼叫核心的介面訪問;
    ⑤ 核心除了將核心空間設定為"高許可權才能訪問"外,同時也保證將每一個核心空間中的虛擬地址,對映到的相同的實體地址,讓核心空間成為所有程式的"公共空間"。
    綜上五點,最終的效果就是,任何程式都必須穿過"門",回到核心態,呼叫核心介面,進入到公共的核心空間,才能修改/獲取整個系統中某個全域性的管理資訊,應用程式如果有bug,也只能影響到自己使用者空間對映的那塊獨立的實體記憶體。
    Linux核心筆記005 - 越界訪問記憶體,Linux核心處理過程

    關於"門",仍然可以先感性的體會一下:程式如何才可以進入"門"?
  • 主動進入
#include <stdio.h>

int main()
{
        printf("hello\n");
        return 0;
}
        這個程式中呼叫的printf()函式,叫libc函式,libc庫封裝了很多"系統呼叫"函式(核心提供給應用程式的介面),比如執行"gcc test.c -g -Wall"編譯上面的程式,然後執行"strace ./a.out",就可以列出來printf()內部呼叫了哪些"系統呼叫"函式,其中就有一個是write()函式,它在執行的過程中,就會切換到核心態,write()函式也可以不經過libc的封裝直接呼叫,不管哪種方式,都屬於應用程式主動進入核心態。

  • 被動進入
        如果只依賴應用程式主動進入核心態,那麼,當應用程式執行到while(1){}迴圈裡面的時候,CPU就只能永遠為這個程式執行指令了,因為其它程式的資訊都在核心空間,無法回到核心態,當然也就沒有機會切換到其它程式。
        所以硬體設計了一個"時鐘中斷"的功能,每過一段時間,不管CPU目前在幹什麼,都把當前的狀態儲存下來,進入"核心態"執行一個"定時函式",這個函式是由核心設定。
        除了"時鐘中斷",如果應用程式訪問一個還未對映到實體地址的虛擬地址,或者執行"除以0"這些異常操作,CPU也會自動切換到核心態,並且執行核心事先設定的"異常/陷阱"函式,這些雖然觸發的源頭是應用程式,但往往是"不經意"的,比如"/0"一切是程式有bug,然後由於"段錯誤"而被迫停止執行。

    到這裡,就可以回到書中設定的情景了,由於地址的對映關係,已經被munmap()函式撤消了,再次訪問時,CPU在執行對映過程中,必定會遇到值為0的目錄項或頁表項,這種情況下,CPU就會按照上面描述的,儲存當前狀態,切換到核心態,並且跳轉到核心的do_page_fault()函式執行。
    跳轉到do_page_fault(),完整和正規的描述,書中第三章有詳細的介紹,目前直接分析do_page_fault()的執行過程,在分析之前,可以先看一下Linux核心對程式虛擬空間的佈局(按照書中對程式碼邏輯的解釋,在Linux-2.4.0版本的時候,應該是如下左圖):
    Linux核心筆記005 - 越界訪問記憶體,Linux核心處理過程

    do_page_fault()主要流程:
// 出現上述情景,CPU隱式跳轉到do_page_fault()函式執行,並且自動把當時處於使用者態/核心態、讀/寫訪問資訊,壓棧作為error_code引數
do_page_fault()
 |  // CPU也會自動把訪問出錯的虛擬地址,存入CR2暫存器,這裡取出,方便在C函式中使用
 |- __asm__("movl %%cr2,%0":"=r" (address))
 |- 獲取核心為當前程式設定的程式管理結構,然後從該結構中獲取虛擬地址管理結構
 |  // 暫時將上述隱式跳轉到的函式,理解為中斷函式,那麼do_page_fault()本身就是中斷函式,in_interrupt()表示跳轉過來前,也是在中斷函式
 |  // 所有應用程式,都有自己獨立的虛擬空間,所以mm一定不為空,上述情景不滿足以下判斷條件
 |- if (in_interrupt() || !mm)
 |  // 當前程式一定能在已使用的使用者空間中,找出結束地址大於出錯地址的第一個區間,因為至少也有個棧區在最上方
 |- find_vma()
 |  // 當前情景找到的區間,會是從堆中動態分配的,並且起始地址也大於出錯地址,因為出錯地址所在的區間已經被munmap()函式撤消了
 |  // 從堆中分配的區間,增長方向向上,所以符合以下判斷條件,goto到bad_area處執行
 |- if (!(vma->vm_flags & VM_GROWSDOWN))
 |  // 以下判斷出錯時是否在使用者態,上述情景符合判斷
 |- if (error_code & 4)
     |  // 設定軟中斷,讓程式coredump(詳細內容在書中第三章——中斷、異常和系統呼叫)
     |- info.si_signo = SIGSEGV

    根據do_page_fault()的邏輯可以看出,訪問沒有對映的地址,必然會coredump,但按照平時的實際經驗,又會發現,應用程式中越界訪問堆區,有時候並不會coredump。那是因為應用程式使用的是libc中的malloc()/free(),呼叫free(),libc層並不一定立即呼叫核心的brk()函式,而是等到一定程度,才會真正撤消對映。

3. 越界訪問棧區

    上述說明了核心對堆區越界訪問(分配範圍以外)的處理過程,書中接著又設定了一個對棧區越界訪問的情景:
    Linux核心建立新程式時,會為新程式分配一塊初始大小的棧區,當程式執行到某個狀態,需要向棧中壓入很多區域性變數和函式引數時,初始分配的棧可能就不夠用了,比如如同下圖的狀態,再向棧區存入一個變數,esp暫存器就要指向未分配區 了:
  Linux核心筆記005 - 越界訪問記憶體,Linux核心處理過程
    需要注意的是,這種情景跟安全領域中的"棧溢位"不是一個概念。這個情景針對的是分配,影響的是esp的指向,並且程式也不一定存在bug,而"棧溢位"針對的是對變數的寫操作,影響的是已分配棧空間的內容,並且程式一定是存在bug,比如:
#include <stdio.h>
#include <string.h>

int main()
{
    char buf[10] = { 0 };

    // 會在buf[10]處寫一個'\0'字元,超出了陣列的範圍
    strcpy(buf, "0123456789");
    printf("%s\n", buf);

    return 0;
}
 
   回到書中設定的情景,do_page_fault()主要執行流程如下: 
do_page_fault()
 |  // 到達這個判斷條件之前,和munmap()的情景一樣,但這時找到的虛擬區間,就會是棧區了
 |- if (!(vma->vm_flags & VM_GROWSDOWN))
 |  // 這個情景也是在使用者態訪問地址的
 |- if (error_code & 4)
 |   |  // 壓棧資料最多的是pusha指令,可以一次性壓入32位元組
 |   |  // 核心無法完全判斷應用程式的邏輯錯誤,比如*(&區域性變數-xx),也有可能訪問到這個範圍,那樣程式一定會出現別的異常現象
 |   |- if (address + 32 < regs->esp)
 |  // 棧是向下增長,正常的壓棧操作,不符合以上判斷,執行到這裡對棧空間的大小進行擴充套件
 |- expand_stack()
 |   |  // Linux對每個程式的棧空間,限制了大小,執行"ulimit -s"可以檢視,超過就不能繼續擴充套件了
 |   |- if (RLIMIT_STACK檢查)
 |   |- 擴充套件虛擬區間大小
 |- 進入讀寫操作檢查,產生異常時是寫操作,而棧區肯定是可寫的,所以透過檢查
 |  // 為虛擬區間中剛才擴充套件的部分,分配實體地址並建立對映
 |- handle_mm_fault()
     |  // .org 0x1000
	 |  // ENTRY(swapper_pg_dir)
	 |  // 整個系統只需要一個目錄項,在系統初始化階段就確定位置了(見"Linux核心筆記004")
     |- pgd_offset()
	 |  // 32位CPU上,不使用中間目錄,所以還是返回目錄頁的地址,不影響對映過程
	 |- pmd_alloc()
	 |  // 一個頁表可以容納1024個頁表項,第一個需要這個頁表的分配操作,分配這個頁表,其它的直接使用
	 |- pte_alloc()
	 |  // 以上獲取的pte,可能是正在使用中的,它們它的低12位可以知道物理頁面是否換出到交換分割槽了
	 |  // 本次設定情景,由於訪問的是之前沒有分配過的記憶體,所以pte也一定是0
	 |- handle_pte_fault()
	     |- if (!pte_present(entry))
		     |- if (pte_none(entry))
			 |   |  // 本次情景會執行到這裡
			 |   |- do_no_page()
			 |       |  // mmap()、交換分割槽,都需要虛擬空間和檔案有聯絡,vma->vm_ops就包含一些檔案操作的函式指標
			 |       |- if (!vma->vm_ops || !vma->vm_ops->nopage)
			 |       |   |  // 棧空間跟檔案沒有聯絡,所以本次情景會執行這裡
			 |       |   |- do_anonymous_page()
			 |       |       |- if (write_access)
			 |       |       |   |  // 分配物理頁面,如果是讀訪問,先對映到ZERO_PAGE,寫的時候再分配(COW)
			 |       |       |   |- alloc_page()
			 |       |       |   |- pte_mkwrite()
			 |       |       |  // 讓pte指令新分配的物理頁面
			 |       |       |- set_pte()
			 |       |- vma->vm_ops->nopage()
			 |- do_swap_page()

    核心執行完do_page_fault(),會再回到應用程式中壓棧的那條指令執行,這樣,就是壓棧的那條指令,在應用程式"感受"不到的情況下,導致程式進入核心走了一圈,擴充套件了棧的大小後,又回到了這條指令,這個過程叫做"缺頁異常"。

    最後順便提一下,在以上的內容中,提到"中斷",異常和中斷,在從核心態返回使用者態時,是有區別的:

    Linux核心筆記005 - 越界訪問記憶體,Linux核心處理過程

相關文章