進程
創建
? ? 創建進程用fork()函數。fork()為子進程創建新的地址空間并且拷貝頁表。子進程的虛擬地址空間和父進程是相等的,子進程的物理內存與父進程是共用的。但是,此時物理內存中的數據是只讀的。fork()函數使用寫時拷貝,即一旦有其中一個人去寫數據x,那么發生缺頁異常,系統會新建一個物理頁來存儲獨立的數據x并修改這個人的頁表,指向新的物理頁。這時,父子之間的數據x就不再共享了。
? ? 創建進程還有一個函數,即vfork()。vfork()不拷貝父進程的地址空間和頁表,而是直接在父進程的地址空間里執行。所以,對vfork()中執行的數據進行更改的時候,則會修改父進程的數據,因為父子進程的地址空間是完全共享的。于此同時父進程被堵塞,直到vfork()的_exit()或者exec*()調用之后。
? ??exec*()創建新的地址空間并且把新的程序拷貝進來。
????進程通過exit()函數退出,或者是有特殊情況而被動地終結,無論如何,都要調用do_exit()來執行實際操作。do_exit()釋放大部分資源,執行完do_exit()以后,進程處于ZOMBIE狀態,即成為僵尸進程,且還擁有三項資源:(內核棧、task_struct、thread_info)。父進程調用wait4()掛起并詢問子進程是否死亡,如果有孩子死了,那么調用release_task()回收上述三項資源。如果父進程在子進程死之前死了,那么子進程變成孤兒進程,必須要在當前線程組找一個父親,如果找不到那么用init進程當父親。
????線程共享:(地址空間、files_struct、fs_struct、信號處理函數)。最重要的是,Linux中進程和線程的本質區別就在于是否共享父進程的地址空間。內核線程沒有獨立的地址空間,而是在內核空間里執行。線程共享同一個files_struct,即線程共享進程描述符表。而父子進程不共享進程描述符表,只是父子進程的進程描述符表在fork()之后是一樣的。父子進程共享進程描述符指向的struct file。
表示
? ? 進程用struct task_struct結構體來描述,每一個進程(或線程)對應一個task_struct對象。Linux通過slab來分配task_struct。為了找到task_struct,內核還有一個結構體struct thread_info,thread_info里面有一個指針指向task_struct,同時也含有一些關于進程的信息。thread_info放在內核棧的最高處,由這個特性,我們就可以經由thread_info快速找到進程的task_struct。我們討論在現在的語境下有必要介紹的task_struct的數據成員。task_struct.state存儲進程的五種狀態,分別是:RUNNING、INTERRUPTIBLE、UNINTERRUPTIBLE、TRACED、STOPPED。task_struct.parent指向父進程,task_struct.children是子進程的鏈表。task.cpu_allowed來指定進程特定的cpu,這樣進程可以強制綁定到當前的cpu。
? ? current宏代表當前正在執行的進程,current的存在是必要的,有些硬件體系結構寄存器比較富裕,可以有一個專門的寄存器存放當前的task_struct的首地址,而有的硬件體系結構則只能經由thread_info的指針找到。由軟件工程基本定理知,加入一層抽象可以解決任何問題。所以current宏可以隱藏這種硬件的不一致,讓不同的硬件都發揮相應的效能。當一個進程執行系統調用或者觸發了異常的時候,進程陷入內核空間,內核代替進程執行,此時內核執行于進程上下文中,current指向當前進程,尤為有用。而在中斷時,內核代替硬件執行,與被打斷的進程沒有任何關系,處于中斷上下文中,current沒有用處。
調度
? ? Linux有兩種不同的優先級范圍,一種是nice,-20~19;一種是實時優先級,0~99。任何實時優先級都優先于nice普通優先級。進程的調度通過調度器類,一般進程用CFS(即完全公平調度)調度器類,實時進程用實時調度器類。
? ??CFS之所以叫完全公平調度,是因為它在一定的時間粒度上是完全公平的。在一個調度周期里面,權重比就是運行時間的比,并且每個進程的加權增長速率都是相同的。CFS選取vruntime最小的進程來運行,所以根據vruntime為鍵,內核把進程組織成一個紅黑樹來方便選取這個最小值。其中,分配給進程的運行時間 = 調度周期 * 進程權重 / 所有進程權重之和。vruntime = 實際運行時間 * 1024 / 進程權重。每個CPU的運行隊列cfs_rq都維護一個min_vruntime字段,記錄該運行隊列中所有進程的vruntime最小值,新進程的初始vruntime值就以它所在運行隊列的min_vruntime為基礎來設置,與老進程保持在合理的差距范圍內。內核調用__pick_next_entity()來運行紅黑樹最小的節點。進程調度的函數接口是schedule(),它的調用層次為schedule() -> pick_next_task() -> pick_next_entity()。
????休眠的進程移出紅黑樹,通過add_wait_queue()加入到等待隊列,當wake_up()調用以后,進程調用try_to_wake_up()來嘗試蘇醒,如果蘇醒的進程優先級高于正在運行的,那么設置need_resched標志,指示schedule()重新調度。只要內核返回用戶空間,就需要檢查need_resched標志,如果設置了那么必須調用schedule()進行調度,這叫用戶搶占。而于此同時,只要當前的任務不帶有鎖,即thread_info.preempt_count為0,那么這個任務也可以被內核搶占。注意,need_resched保存在thread_info中,這樣訪問比把need_resched當成全局變量要快。schedule()也負責進程的切換,調用層次是schedule() -> context_switch()。其中context_switch()分別調用switch_mm()切換地址空間,調用switch_to()切換進程的狀態并保存上一個進程的信息。
? ??實時調度有兩種策略,一種是FIFO,高優先級執行完再執行低優先級,同優先級之間保持先進先出;一種是RR,高優先級執行完再執行低優先級,同優先級之間保持時間片輪轉。進程還可以調用sched_yield()來轉讓給其他進程執行,自己移動到過期隊列。如果是實時進程調用sched_yield()的話,則移動到優先級隊列的最后面。
中斷
原理
? ? 中斷是硬件設備產生的一種特殊的電信號,通過總線發送給中斷控制器,如果中斷線是激活的,那么中斷控制器把中斷發送給cpu,如果cpu沒有禁止中斷,那么cpu立刻停止當前正在做的工作,禁止中斷,并且執行中斷處理程序。執行中斷處理程序最后總是要調用do_IRQ(),do_IRQ()首先禁止這個中斷線,然后調用handle_IRQ_event()運行這條中斷線上的所有處理程序。中斷是硬件產生的,是異步的,不考慮系統時鐘,而異常則是cpu自己產生的,和時鐘同步。每一個中斷都有唯一的中斷號,稱為中斷線,中斷線可以共享。一個設備的中斷處理程序是其驅動程序的一部分,中斷處理程序被內核調用來響應中斷。驅動程序通過request_irq()注冊中斷處理程序,卸載中斷處理程序則通過free_irq(),如果中斷線不是共享的,那么刪除處理程序之后禁用中斷線,如果共享的話僅當刪除的是當前中斷線的最后一個處理程序時,才禁用中斷線。中斷處理程序運行在中斷上下文中,代替硬件執行硬件相關的程序,打斷了當前正在執行的進程,所以要盡快且不能睡眠。為了快速從中斷處理程序中退出,Linux把中斷分成了兩部分,一部分是上半部,即中斷處理程序;一部分是下半部。
系統調用
? ? 有一種特殊的中斷是系統調用,它不是硬件產生的,而是進程調用int 0x80觸發的。這條指令產生一個異常,讓系統切換到內核態去執行0x80號(即8 * 16 = 128號)中斷處理函數,這個函數叫做system_call()。所有的系統調用存放在一個表里,叫做sys_call_table,內核通過表的索引值來查找應該調用哪一個系統調用,這個索引就叫做系統調用號。系統調用的調用約定是:系統調用號通過eax寄存器來傳遞。系統調用參數通過五個寄存器,即ebx ecx edx esi edi來傳遞。如果參數多于五個,那么ebx存放所有參數在內存中的起始地址。系統調用的返回值存儲在eax中。注意,系統調用執行在進程上下文中,它代替進程執行,并且寄存器的初始值來源于進程,所以處在進程的環境里。current有效,并且可以休眠,可以被搶占。
中斷棧
? ? 一個進程的所有地址空間可以分為用戶空間(0~3G)和內核空間(3G~4G),這兩個空間都有相應的棧,分別叫用戶棧和內核棧。內核棧一般是兩頁的大小。中斷原來是執行在內核棧里面的,但是現在有一個可選的選項,即把內核棧縮小成一頁,并且存在一個中斷棧,也是一頁,每個處理器一個。
中斷狀態
? ? 想要得知中斷的狀態有三個函數接口,irq_disable()得知本地處理器中斷是否禁止,in_interrupt()得知內核是否處于中斷,in_irq()得知內核是否在執行中斷處理程序。
中斷處理程序
? ? 中斷處理程序(即上半部分)一般只處理必須要在中斷上下文中的、和硬件關系非常緊密的操作,例如對中斷的確認和從硬件拷貝數據。做完就立刻返回,而其他的操作交給下半部分做。
中斷下半部
????下半部分可以有多種實現方法,現存的有:(軟中斷、tasklet、工作隊列)。
? ? 軟中斷是靜態定義的,有32個。實際上軟中斷就是一個struct softirq_action softirq_vec[32]數組,其中softirq_action就是每一個軟中斷,其中有一個函數指針。唯一可以打斷軟中斷執行的就是中斷處理程序。有一個位圖pending來表示軟中斷數組的每一位是否置位。軟中斷的執行的實際操作最后都依賴do_softirq()函數,它遍歷軟中斷,根據pending是否置位來決定執行哪些軟中斷。目前只有兩個系統使用軟中斷,即網絡和SCSI。
????使用軟中斷的話,首先要注冊,即調用open_softirq()函數。啟動軟中斷有兩個時機。一、在中斷處理程序中,可以調用raise_softirq()設置相應的pending位,在硬中斷的中斷處理程序中的do_IRQ()里面會會調用irq_exit(),這個函數會判定是否有pending的某一位置位,如果有那么調用invoke_softirq(),invoke_softirq()調用__invoke_softirq(),__invoke_softirq()調用__do_softirq()遍歷軟中斷數組執行pending置位的位相應的軟中斷。二、在非中斷上下文中調用rasie_softirq_irqoff()函數設置相應的pending位,然后喚醒內核線程ksoftirqd/n()執行軟中斷(和tasklet),ksoftirqd/n優先級最低,為19。
? ? tasklet是動態的,是用軟中斷來實現的。實際上tasklet就是一個struct tasklet_struct鏈表。tasklet用struct tasklet_struct來表示,其中有tasklet_struct.count來代表tasklet是否被禁止。tasklet_struct.func指向處理函數。tasklet_struct.data是傳給處理函數的參數。tasklet_struct.next用來聚合成鏈表。tasklet_schedule()或tasklet_hi_schedule()把tasklet_struct添加到兩個鏈表之一(即tasklet_vec或tasklet_hi_vec)并且分別置位TASKLET_SOFTIRQ(軟中斷號是5)或HI_SOFTIRQ(軟中斷號是0)兩個軟中斷。這兩個軟中斷的處理程序分別是tasklet_action()和tasklet_hi_action(),他們遍歷相應的鏈表,執行鏈表上所有的tasklet。
????使用tasklet的話,可以調用DECLARE_TASKLET或DECLARE_TASKLET_DISABLED靜態創建tasklet,也可以調用tasklet_init()來動態創建tasklet。然后就可以在中斷處理程序中調用tasklet_schedule()或tasklet_hi_schedule()來把tasklet添加到兩個鏈表其中一個并置位軟中斷。
? ? 工作隊列把推后的工作交給內核線程去執行,也就是說,工作隊列是在進程上下文中執行的,可以參與調度,可以睡眠。工作隊列實際上就是內核線程。Linux有一個默認的工作隊列,用struct workqueue_struct來表示,其中有一個struct cpu_workqueue_struct[N_CPU]數組,即每一個CPU都對應一個struct cpu_workqueue,這個結構體有一個worklist鏈表,鏈表的每一個節點都代表一個需要執行的中斷下半部。每一個處理器都有一個默認的工作內核線程叫events/n,來執行worklist中的所有中斷下半部函數。每一個內核線程調用worker_thread()函數,這個函數執行一個死循環并且休眠,如果有新的函數加入到了worklist里面,那么就喚醒執行,沒有操作了就睡眠。
????使用工作隊列的話,可以調用DECLARE_WORK()靜態創建work_struct,也可以用INIT_WORK()動態創建。可以在硬中斷處理程序中調用schedule_work(&work)把工作交給events/n,函數會立馬被執行。也可以通過schedule_delayed_work(&work, delay)來指定delay。flush_scheduled_work()來保證工作隊列的所有對象都被執行完才會返回,可以用于一些同步的情況。
時鐘
時鐘
? ? 全局變量jiffies是記錄自系統啟動以來所產生的節拍總數,是unsigned long volatile類型的變量,volatile指示編譯器每次訪問變量都從內存中獲得,而不是從寄存器中來訪問。jiffies = HZ * seconds。每次執行時鐘中斷處理程序都會增加jiffies。內核有兩個jiffies變量,一個叫jiffies,一個叫jiffies_64,其中jiffies = jiffies_64,即在32位機器,jiffies是jiffies_64的后32位,而64位機器上,這兩個值等價。32位jiffies在1000HZ情況下50天溢出,100HZ情況下500天溢出。而64位不可能溢出。為了解決溢出帶來的問題,內核使用四個宏解決比大小,即:time_after(),time_before(),time_after_eq(),time_before_eq()。
? ? 實時時鐘(RTC)是用來持久存放時間的設備,即使設備關機后仍然可以依靠主板上的電池維持自己。一般RTC和CMOS是集成在一起的。當系統啟動時,內核讀取RTC初始化墻上時間,該墻上時間存儲在xtime中。xtime是struct timespec{ tv_sec, tv_nsec }類型的,tv_sec是從1970.1.1至今的秒數,tv_nsec是ns數。讀寫xtime要用xtime_lock()鎖,這時一個seq鎖(即順序鎖)。用戶取得墻上時間用gettimeofday()。
定時器
? ? 定時器有兩種,一種是系統定時器,一種是動態定時器。
????系統定時器是硬件提供的,以一定的頻率(即節拍率)自行觸發時鐘中斷,然后內核去執行時鐘中斷處理程序。兩次時鐘中斷間隔時間即是節拍,節拍等于節拍率分之一。x86默認時鐘節拍率是100HZ。而在2.5內核中,時鐘節拍率提高到了1000HZ,提高節拍率的好處是更高的頻度和準確度,而壞處是系統負擔變重了,時鐘中斷處理程序頻繁打斷進程并占用處理器,打亂了處理器的高速緩存并且增加了耗電。時鐘處理程序需要做的事情非常多,其中包括:設置各種時鐘值、調用體系結構無關的tick_periodic()。tick_periodic()做了很多,包括:jiffies_64加一、更新各種值、置位軟中斷的pending的第1位(動態定時器)、執行scheduler_tick()、更新xtime墻上時間、計算負載。其中scheduler_tick()減少進程的時間片,并且在時間片用光的時候設置need_resched標志,以及平衡各個處理器上的運行隊列。
????動態定時器不是周期執行的,而是使得任務能夠在指定的時間執行。動態定時器由struct timer_list表示,是一個鏈表。使用動態定時器要先定義并初始化,即:struct timer_list my timer; init_timer(&my_timer)。注意,init_timer()只初始化系統內部的變量,timer_list.expires,timer_list.data,timer_list.function都需要再手動設定。注意,my_timer.expires是超時時刻,是一個時間點,所以一般這樣賦值:my_timer.expires = jiffies + delay。這樣定義和初始化階段就完成了。還需要激活timer,調用add_timer(&my_timer)。還可以用mod_timer(&my_timer)來更改激活或者未激活(如果未激活,mod_timer()會把它激活)的動態定時器。如果要刪除一個定時器要調用del_timer(),注意已經超時的會自行刪除,所以這里有一個競爭條件,即調用del_timer()的時候已經刪除,但是定時器中斷已經在別的處理器上執行了,del_timer()卻直接返回。而del_timer_sync()則等到當前的定時器中斷執行完才返回,這個函數不能在中斷上下文使用。一般情況下應該使用del_timer_sync(),很保險。動態定時器是依靠軟中斷來實現的,軟中斷號是TIMER_SOFTIRQ(即是1)。動態定時器的執行是作為時鐘中斷處理函數的下半部分來執行的。時鐘中斷處理程序會執行update_process_times(),該函數調用run_local_timers(),該函數調用raise_softirq()來設置pending的第1位。TIMER_SOFTIRQ對應的軟中斷處理程序是run_timer_softirq(),這個函數在當前處理器上遍歷timer_list鏈表,運行所有的超時定時器。為了提高效率,timer_list的鏈表分為5組,當超時時間接近時,定時器隨著組一起下移。
延遲執行
? ? 延遲執行除了動態定時器和下半部機制以外(實際上動態定時器就是時鐘中斷處理程序的下半部),還有:忙等待、短延遲、schedule_timeout()。
內存
物理頁
? ? 物理頁(也叫頁框、頁幀、page frame)把內存(DRAM)分為一頁一頁的大小來管理,頁框是內存管理的基本單位。大多數32位機器里面,頁框是4KB,而64位的頁框大多是8KB。內核用struct page來表示物理頁。內核用struct page來管理每一頁框的原因是,內核需要知道每個頁框的詳細信息。內核用alloc_pages()來分配2^n個頁框,返回指向第一個頁框結構體struct page的指針。內核用page_address()把頁框轉換成邏輯地址。分配頁框和釋放頁框還有好多函數,不過都是基于alloc_pages()。
內存分區
? ? 內核把頁框(即內存)分為不同的區,Linux主要有四種內存分區,即:DMA、DMA32、NORMAL、HIGHEM。每一個區域用struct zone來表示。分區的原因是,每個進程有它獨立的地址空間,地址空間在32位機器上是4GB,而內存可以大于4GB,所以地址空間不能和內存進行一一映射。如果要想充分利用內存,就一定要在地址空間和內存兩個集合中分別預留出來一部分來做非永久的映射,這樣地址空間就能訪問到所有內存了。
? ??一般來說,物理內存中0~16M是DMA區域,16M~896M的是NORMAL區域,896M以上的內存就都是HIGHEM的內存區域了。
????而對于x86-64這種64位機器來說,地址空間高達無數,基本上都可以保證內存可以映射到地址空間里面去,所以就不需要分區了。
內存分配
? ? 在內核中,kmalloc()用來分配內存空間,釋放函數是kfree()。kmalloc()函數有一些標志,GFP_KERNEL會睡眠,GFP_ATOMIC不會睡眠,GFP_NOIO不啟動磁盤IO,GFP_NOFS不啟動文件系統,GFP_DMA必須從DMA區分配。kmalloc()保證在地址空間和物理內存空間都是連續分配的,所以,kmalloc()分配的是上述所說的一一映射的區域(DMA和NORMAL,一共896MB)。而vmalloc()分配的內存只是在地址空間連續,而不在物理內存空間連續,所以vmalloc()映射的是HIGHEM區域中vmalloc區的內存。kmalloc()由于是直接一一映射,地址空間和物理內存的轉換極為簡單,只相差一個PAGE_OFFSET(即內核地址空間和用戶地址空間的分界,32位系統即3G)而且連續,所以速度很快;但是vmalloc()就要通過內核頁表的缺頁異常來映射了,動作很慢,而且有可能會睡眠。但是vmalloc()可以分配很大塊的內存。vmalloc()分配的物理內存用vfree()釋放。
? ??地址空間中的內核空間的高端映射區域分為:(vmalloc區、可持久映射區、臨時映射區)。其中vmalloc()映射的是vmalloc區的內存。其中可持久映射區的使用方式是這樣的:先從alloc_pages()返回一頁的指針,然后調用kmap(struct page*)來在可持久映射區映射一頁的內存區域。kmap()可以睡眠,而且應當在不使用時調用kunmap()解除映射。可以調用kmap_atomic()不讓這個函數睡眠。kamp_atomic()函數是映射在內核空間的臨時映射區的。臨時映射區又叫原子映射區。當然解除的時候調用kunmap_atomic()。
? ? 分配和釋放數據結構是使用物理內存的最普遍的操作。所以為了便于頻繁地分配和釋放同一個數據結構,可以采用高速緩存struct kmem_cache。每一個數據結構都對應于一個高速緩存。kmem_cache里面含有一個struct kmem_list3,這其中有3個slab鏈表,分別是:滿的、部分的、空的。每一個slab都含有一個或者多個物理頁。可以用kmem_cache_create()創建新的高速緩存,用kmem_cache_destroy()刪除高速緩存,用kmem_getpages()分配新的slab,用kmem_freepages()釋放slab。用kmem_cache_alloc()分配一個對象,如果高速緩存中對象沒有可用的,那么就先調用kmem_getpages()創建新的slab。實際上,slab就是一個或多個頁框的頭,用來把這一個或多個頁框組成一個整體并且串起來。用kmem_cache_free()把一個對象還給高速緩存。而一般的內存分配則通過伙伴系統來分配,伙伴系統通過每一個struct zone的zone.free_area數組來組織。
頁高速緩存
? ? 在物理頁struct page中,有一個struct address_space *mapping成員,這個成員代表一個IO緩存,即頁高速緩存。頁高速緩存是內存對磁盤的緩存,來減少對磁盤IO的調用。頁高速緩存把磁盤的數據緩存到物理內存中。
? ??當一個文件打開后,內核在物理內存中創建一個inode,其中inode.i_mapping指向的就是這個文件在內存中的頁高速緩存,即struct address_space結構。address_space.host指向對應的inode。一個磁盤文件對應一個struct inode,也對應一個struct address_space,但是對應多個struct file。address.a_ops指向一個struct address_space_operations函數表,里面有具體的回寫,讀入內存數據等函數。
????對于read()調用,進程會先去inode.i_mapping頁高速緩存看看讀的東西是否存在,如果存在直接返回,如果不命中則產生缺頁異常,創建一個頁緩存頁,同時通過inode找到文件該頁的磁盤地址,讀取相應的頁填充該緩存頁。
????如果是write()調用,進程會先去inode.i_mapping頁高速緩存看看讀的東西是否存在,如果存在那么直接修改,如果不命中則產生缺頁異常,創建一個頁緩存頁,同時通過inode找到文件該頁的磁盤地址,讀取相應的頁填充該緩存頁,然后修改。被寫入的頁框標記成臟的并且加入一個臟頁鏈表中,然后在合適的時機,由內核線程來回寫到磁盤,然后刪除臟頁的標志。
? ??回寫的條件是:空閑內存低于某個閾值、臟頁停留時間高于某個閾值、用戶進程調用sync()或fsync()。在2.6中,內核調用一組內核線程flusher來進行回寫。內核當:內存低于閾值、顯式調用sync()或fsync()時,通過函數flusher_threads()調用一個或多個flusher線程,線程調用bdi_writeback_all()開始回寫,直到:指定的頁數寫完了、空閑內存超過閾值、所有臟頁都寫完了時,停止運行。當然,flusher線程群也會周期運行,將駐留時間過長的臟頁寫回。Linux還有一種回寫策略,叫做膝上型計算機模式,以硬盤轉動最小為目標,不會專門為了回寫而主動調用磁盤IO,而且上述的兩個閾值也非常大。多數Linux在電池供電時自動用這個,而交流電供電時用正常的。
虛擬內存
? ? Linux也有虛擬內存。Linux對頁的換出采取了雙鏈策略,即維護兩個鏈表:活躍與非活躍。非活躍的鏈表里面的頁面是可以換出的,兩個鏈表需要維持平衡,這種頁面置換算法叫做LRU/2。
地址空間
分類
? ? 地址空間是邏輯上的,即是虛擬的。所有的進程都有它獨立的地址空間,一般來說32位機器上是4G,其中0G~3G為用戶空間,3G~4G為內核空間。內核空間的3G~3G+16M被內存的DMA區域映射,3G+16M~3G+896M被內存的NORMAL區域映射,3G+896M~4G被內存的HIGHEM區域映射。其中3G+896M~4G還分為:vmalloc區域、可持久映射區域、臨時映射區域。
分區
? ? 地址空間中能被進程訪問的部分叫做內存區域。當一個進程訪問了它不能訪問的,或者是以錯誤的方式訪問的地址空間時,那么內核終止該進程并返回一個段錯誤。內存區域包含:數據段、代碼段、BSS段、用戶空間棧、映射的文件,等等。對于Linux,地址空間是平坦的、不分段的。即所有進程所擁有的地址空間都是一塊大的連續的虛擬空間。虛擬地址是地址空間范圍內的一個值。對于用戶角度,所有的指針的值、變量的地址,只要是用戶看見的,都是虛擬的地址。
表示
? ? 內核使用struct mm_struct來描述一個進程的地址空間。在task_struct中,用mm指針指向這個進程的mm_struct。其中mm_users代表使用計數,mm_count代表主引用數。mmap和mm_rb都表示全部的內存區域,只不過一個用鏈表聚合,一個用紅黑樹聚合。所有的進程的mm_struct通過一個雙向鏈表mmlist相連。鏈表頭是init_mm,是init進程的地址空間。
? ? 內核線程沒有獨立的地址空間,也沒有相關的mm_struct和page table。即,該線程的mm = NULL。但是當內核線程想使用相關數據的時候,就會使用前一個進程的mm_struct。即,當一個內核線程被調度時,內核發現mm = NULL,所以就保留前一個進程的mm_struct,并用內核線程的active_mm指向該mm_struct。
? ? 內存區域用struct vm_area_struct來描述。其中有vm_mm指向了它所在的地址空間mm_struct,有vm_start和vm_end來指明它在這個地址空間里面的范圍。同時還有一個鏈表節點next和紅黑樹節點vm_rb,就是mm_struct里面的鏈表和紅黑樹。vm_area_struct采用了面向對象的設計思路,有一個vm_ops指向了一個struct vm_operations函數表。
創建地址空間
????當我們用fork()創建進程的時候,fork()調用allocate_mm()從slab中分配一個mm_struct,fork()還調用copy_mm()來復制父進程的mm_struct。然而如果在clone中指定了CLONE_VM標志,那么fork()不調用allocate_mm(),而是直接在copy_mm()中讓子進程的mm指針指向父進程的mm_struct。當進程退出時,調用exit_mm()函數,這個函數會掉用mmput()減少mm_users,如果為0那么調用mmdrop()減少mm_count,如果mm_count為0那么調用free_mm(),這個函數調用kmem_cache_free()將mm_歸還給slab。
? ? exec*()也會創建新的地址空間。
創建內存區域
? ? 內核用do_mmap()來為地址空間創建新的區域,這個函數把文件(struct file)和地址空間的區域(struct vm_area_struct)來進行映射。這樣的話,可以根本不用文件的相關的系統調用,就能像訪問內存那樣去讀寫文件,即使文件關閉,照樣可以使用這片映射來讀寫文件。如果創建的區域和現有的相鄰,那么就合并加入到里面,如果不相鄰,那么就從slab中分配一個vm_area_struct,并且調用vma_link()來把vm_area_struct添加到鏈表和紅黑樹中。函數do_mmap()原型是unsigned long do_mmap(file, addr, len, prot, flag, offset)。如果調用的時候file = NULL且offset = 0,這就叫匿名映射。可以創建匿名映射后再調用fork(),這樣父子進程就可以實現對內存的共享。函數do_munmap()從特定的地址空間中刪除一個vm_area_struct。
地址空間與物理內存的轉換
? ? 應用程序操作的都是虛擬的地址空間,而cpu卻操作的都是真實的物理內存,所以需要一個轉換,這個轉換由MMU這個硬件來完成。MMU實現虛擬地址到物理內存地址的轉換,并且檢查訪問權限。
????Linux使用三級頁表(PGD、PMD、PTE),多級列表可以節省很多空間。每一個進程都有它自己的用戶頁表,而內核空間里面也有一個內核頁表,內核頁表也可以產生缺頁異常,因為內核的地址空間和物理內存也有不是一一映射的時候,即3G+896M~4G。
????線程會共享地址空間和頁表。
????其中mm_struct.pgd就指向PGD。為了加快轉化,Linux使用了TLB這個硬件來緩存,當cpu想知道一個虛擬地址空間的地址所對應的物理內存地址時,就先去檢查TLB,如果沒有,再通過頁表一級級索引。
虛擬文件系統
? ? 虛擬文件系統(VFS)是Linux為用戶空間的程序提供了操作文件的相關接口,VFS可以屏蔽掉(一定程度上屏蔽掉)各種類型的文件系統的不一致,并提供了所有文件系統都支持(或者說應該支持)的數據結構和操作。
????例如用戶調用write(),先會進入sys_write()系統調用,然后調用vfs_write(),然后vfs_write()就會去調用f -> f_op -> write(f),即和文件系統相關的,由文件系統完成的write()函數去寫文件。Linux的VFS將文件和文件相關的信息分開存儲。一般來說在磁盤里也是這樣的,如果不是這樣的,也可以使用VFS,但是需要在轉化的過程中付出沉重的開銷。Linux的VFS設計參考了面向對象的一些思維,即把對對象的操作放在函數表中,并用對象的指針指向這個表,例如上面舉的例子f -> f_op -> write(f),還要注意要把自己傳給write()函數,這樣才能操縱f里面的數據成員。這相當于面向對象里面的this指針。
? ??Linux的VFS為了描述一個文件系統,提供了四個結構。分別是:struct super_block代表一個文件系統的信息。struct inode代表一個磁盤里面的文件元信息。struct dentry代表一個目錄項,dentry存儲文件名。struct file代表一個進程打開的文件,struct file里面存儲文件的打開時指定的標志、文件的offset指針。注意這里面的dentry不是一個具體的存在于磁盤里面的結構,它的存在是為了快速的定位一個文件的路徑。dentry存儲在一個目錄項緩存dcache中,VFS會先去目錄項緩存搜索路徑名,如果沒有的話再去文件樹遍歷,然后把相關的目錄加入到dcache中。注意緩存目錄項的同時也會去緩存相應的inode。
? ? 在task_struct中,有一個files指針指向struct files_struct。files_struct里面有一個fd_array指針數組,指向的是struct file,即這個進程打開的文件。fd_array的索引就是fd,即文件描述符。在64位系統中,這個數組大小是64個,如果一個進程打開了超過64個的文件,那么會再為多出來的文件分配一個文件指針數組,這個數組由files_struct的fdt指向。也就是說如果訪問多于64個的文件的話,就要多通過一個指針。
? ??父進程先open得到文件描述符fd之后再fork,子進程擁有父進程打開的文件描述符fd。這時,父子進程的兩個fd指向同一個struct file,也就是操作同一個文件,擁有同樣的文件的打開時指定的標志,擁有同樣的文件的offset指針。
????task_struct的fs指針指向struct fs_struct,這是當前進程的工作目錄和根目錄。task_struct的mmt_namespace指向struct namespace,這是進程所在的命名空間。一般來說,每一個進程都有屬于它自己的,獨立的一個files_struct和fs_struct,但是所有進程都指向同一個namespace。當然,對于特殊的,即調用clone的時候指明CLONE_FILES的進程則和父進程共享files_struct,指明CLONE_FS的進程和父進程共享fs_struct,指明CLONE_NEWS的話,會為這個進程創建一個屬于它的新的命名空間。
? ? 一個inode對應一個磁盤文件。因為硬鏈接,所以一個inode可以對應多個dentry。因為多個進程可以打開同一個目錄項對應的文件,一個進程也可以多次打開同一個目錄項對應的文件,所以一個dentry可以對應多個file。
塊設備
簡述
????Linux一共有兩種硬件存儲設備,即字符設備和塊設備。塊設備是可以隨機訪問的設備,常見的塊設備是磁盤。字符設備是只能按照字符流來有序訪問的硬件設備,例如鍵盤。
? ??對于硬件來說,塊設備最小的可尋址單元是扇區,一般是512B。扇區是一個設備的物理屬性。而最小的邏輯可尋址單元是塊,內核所有執行塊設備的操作都是按照塊來的。塊倍數于扇區,但是要小于頁框。通常塊是:512字節、1KB、4KB。
緩沖區表示
? ? 當塊調入內存時,會有一個緩沖區和它對應,內核用struct buffer_head來表示緩沖區的信息。buffer_head.b_bdev指明對應的塊設備,buffer_head.bblocknr指明塊設備的起始塊號,buffer_head.page指明用于存儲這個塊的頁框,buffer_head.b_data用來指明塊在頁內的起始地址,buffer_head.b_size指明塊的大小。所以塊在buffer_head.page的(b_data, b_data + b_size)區間處。一個buffer_head只能指明一個塊和一個物理頁的對應關系,只能描述一個塊。
塊IO表示
? ? 在2.5中,引入了struct bio結構體來表示一個正在進行的塊IO操作。bio里面有一個struct bio_vec *bi_io_vec動態數組,這個動態數組包含了這個IO操作所需要的所有片段(即塊在內存的緩沖區)。其中struct bio_vec是一個{ page, offset, len }結構,描述一個塊。bio.vcnt表示這個動態數組的長度。bio.bi_idx表示正在操作的IO片段。所有的塊請求保存在一個請求隊列struct request_queue中。每一個請求用struct request表示,一般來說一個bio代表一個請求,但是因為有合并操作,所以一個請求也可以有多個bio。
塊IO調度
????在2.4版本中使用的調度程序是Linus電梯調度,即:1.如果請求隊列中存在前相鄰或者后相鄰的,那么合并。2.如果隊列中存在駐留時間過長的請求,直接加到隊列尾部。3.如果存在一個合適的插入位置,那么插入。4.如果不存在插入位置則加入尾部。在2.6中有新的IO調度算法,即最后期限IO調度,基礎還是Linus電梯,但是擁有三個鏈表,其中多出來的兩個是FIFO的讀和寫鏈表,分別有超時時間(默認為500ms和5s),如果超時了則優先從這兩個鏈表里取請求。預測IO調度和最后期限一樣,只是請求提交完會停留6ms,來給相鄰的請求提交的機會,預測IO會跟蹤并統計每個進程的習慣和行為。完全公平調度是每個提交了IO的進程都有自己的隊列,按照時間片輪轉執行每個隊列的請求。空操作IO調度除了合并以外什么也不干,這個調度程序是給真正可以隨機訪問的設備用的,例如閃存卡。