1. 程式人生 > >Linux虛擬地址空間布局以及進程棧和線程棧總結(轉)

Linux虛擬地址空間布局以及進程棧和線程棧總結(轉)

開始 系統初始 後來 文本 lov fault 和數 ps命令 變量大小

一:Linux虛擬地址空間布局

(轉自:Linux虛擬地址空間布局)

在多任務操作系統中,每個進程都運行在屬於自己的內存沙盤中。這個沙盤就是虛擬地址空間(Virtual Address Space),在32位模式下它是一個4GB的內存地址塊。在Linux系統中, 內核進程和用戶進程所占的虛擬內存比例是1:3,而Windows系統為2:2(通過設置Large-Address-Aware Executables標誌也可為1:3)。這並不意味著內核使用那麽多物理內存,僅表示它可支配這部分地址空間,根據需要將其映射到物理內存。

虛擬地址通過頁表(Page Table)映射到物理內存,頁表由操作系統維護並被處理器引用。內核空間在頁表中擁有較高特權級,因此用戶態程序試圖訪問這些頁時會導致一個頁錯誤(page fault)。在Linux中,內核空間是持續存在的,並且在所有進程中都映射到同樣的物理內存。內核代碼和數據總是可尋址,隨時準備處理中斷和系統調用。與此相反,用戶模式地址空間的映射隨進程切換的發生而不斷變化。

Linux進程在虛擬內存中的標準內存段布局如下圖所示:

技術分享圖片

其中,用戶地址空間中的藍色條帶對應於映射到物理內存的不同內存段,灰白區域表示未映射的部分。這些段只是簡單的內存地址範圍,與Intel處理器的段沒有關系。

上圖中Random stack offset和Random mmap offset等隨機值意在防止惡意程序。Linux通過對棧、內存映射段、堆的起始地址加上隨機偏移量來打亂布局,以免惡意程序通過計算訪問棧、庫函數等地址。execve(2)負責為進程代碼段和數據段建立映射,真正將代碼段和數據段的內容讀入內存是由系統的缺頁異常處理程序按需完成的。另外,execve(2)還會將BSS段清零。

用戶進程部分分段存儲內容如下表所示(按地址遞減順序):

名稱

存儲內容

局部變量、函數參數、返回地址等

動態分配的內存

BSS段

未初始化或初值為0的全局變量和靜態局部變量

數據段

已初始化且初值非0的全局變量和靜態局部變量

代碼段

可執行代碼、字符串字面值、只讀變量

在將應用程序加載到內存空間執行時,操作系統負責代碼段、數據段和BSS段的加載,並在內存中為這些段分配空間。棧也由操作系統分配和管理;堆由程序員自己管理,即顯式地申請和釋放空間。

BSS段、數據段和代碼段是可執行程序編譯時的分段,運行時還需要棧和堆。

以下詳細介紹各個分段的含義。

1 內核空間

內核總是駐留在內存中,是操作系統的一部分。內核空間為內核保留,不允許應用程序讀寫該區域的內容或直接調用內核代碼定義的函數。

2 棧(stack)

棧又稱堆棧,由編譯器自動分配釋放,行為類似數據結構中的棧(先進後出)。堆棧主要有三個用途:

  • 為函數內部聲明的非靜態局部變量(C語言中稱“自動變量”)提供存儲空間。
  • 記錄函數調用過程相關的維護性信息,稱為棧幀(Stack Frame)或過程活動記錄(Procedure Activation Record)。它包括函數返回地址,不適合裝入寄存器的函數參數及一些寄存器值的保存。除遞歸調用外,堆棧並非必需。因為編譯時可獲知局部變量,參數和返回地址所需空間,並將其分配於BSS段。
  • 臨時存儲區,用於暫存長算術表達式部分計算結果或alloca()函數分配的棧內內存。

持續地重用棧空間有助於使活躍的棧內存保持在CPU緩存中,從而加速訪問。進程中的每個線程都有屬於自己的棧。向棧中不斷壓入數據時,若超出其容量就會耗盡棧對應的內存區域,從而觸發一個頁錯誤。此時若棧的大小低於堆棧最大值RLIMIT_STACK(通常是8M),則棧會動態增長,程序繼續運行。映射的棧區擴展到所需大小後,不再收縮。

Linux中ulimit -s命令可查看和設置堆棧最大值,當程序使用的堆棧超過該值時, 發生棧溢出(Stack Overflow),程序收到一個段錯誤(Segmentation Fault)。註意,調高堆棧容量可能會增加內存開銷和啟動時間。

堆棧既可向下增長(向內存低地址)也可向上增長, 這依賴於具體的實現。本文所述堆棧向下增長。

堆棧的大小在運行時由內核動態調整。

3 內存映射段(mmap)

此處,內核將硬盤文件的內容直接映射到內存, 任何應用程序都可通過Linux的mmap()系統調用或Windows的CreateFileMapping()/MapViewOfFile()請求這種映射。內存映射是一種方便高效的文件I/O方式, 因而被用於裝載動態共享庫。用戶也可創建匿名內存映射,該映射沒有對應的文件, 可用於存放程序數據。在 Linux中,若通過malloc()請求一大塊內存,C運行庫將創建一個匿名內存映射,而不使用堆內存。”大塊” 意味著比閾值 MMAP_THRESHOLD還大,缺省為128KB,可通過mallopt()調整。

該區域用於映射可執行文件用到的動態鏈接庫。在Linux 2.4版本中,若可執行文件依賴共享庫,則系統會為這些動態庫在從0x40000000開始的地址分配相應空間,並在程序裝載時將其載入到該空間。在Linux 2.6內核中,共享庫的起始地址被往上移動至更靠近棧區的位置。

從進程地址空間的布局可以看到,在有共享庫的情況下,留給堆的可用空間還有兩處:一處是從.bss段到0x40000000,約不到1GB的空間;另一處是從共享庫到棧之間的空間,約不到2GB。這兩塊空間大小取決於棧、共享庫的大小和數量。這樣來看,是否應用程序可申請的最大堆空間只有2GB?事實上,這與Linux內核版本有關。在上面給出的進程地址空間經典布局圖中,共享庫的裝載地址為0x40000000,這實際上是Linux kernel 2.6版本之前的情況了,在2.6版本裏,共享庫的裝載地址已經被挪到靠近棧的位置,即位於0xBFxxxxxx附近,因此,此時的堆範圍就不會被共享庫分割成2個“碎片”,故kernel 2.6的32位Linux系統中,malloc申請的最大內存理論值在2.9GB左右。

4 堆(heap)

堆用於存放進程運行時動態分配的內存段,可動態擴張或縮減。堆中內容是匿名的,不能按名字直接訪問,只能通過指針間接訪問。當進程調用malloc(C)/new(C++)等函數分配內存時,新分配的內存動態添加到堆上(擴張);當調用free(C)/delete(C++)等函數釋放內存時,被釋放的內存從堆中剔除(縮減) 。

分配的堆內存是經過字節對齊的空間,以適合原子操作。堆管理器通過鏈表管理每個申請的內存,由於堆申請和釋放是無序的,最終會產生內存碎片。堆內存一般由應用程序分配釋放,回收的內存可供重新使用。若程序員不釋放,程序結束時操作系統可能會自動回收。

堆的末端由break指針標識,當堆管理器需要更多內存時,可通過系統調用brk()和sbrk()來移動break指針以擴張堆,一般由系統自動調用。

使用堆時經常出現兩種問題:1) 釋放或改寫仍在使用的內存(“內存破壞”);2)未釋放不再使用的內存(“內存泄漏”)。當釋放次數少於申請次數時,可能已造成內存泄漏。泄漏的內存往往比忘記釋放的數據結構更大,因為所分配的內存通常會圓整為下個大於申請數量的2的冪次(如申請212B,會圓整為256B)。

註意,堆不同於數據結構中的”堆”,其行為類似鏈表。

【擴展閱讀】棧和堆的區別

①管理方式:棧由編譯器自動管理;堆由程序員控制,使用方便,但易產生內存泄露。

②生長方向:棧向低地址擴展(即”向下生長”),是連續的內存區域;堆向高地址擴展(即”向上生長”),是不連續的內存區域。這是由於系統用鏈表來存儲空閑內存地址,自然不連續,而鏈表從低地址向高地址遍歷。

③空間大小:棧頂地址和棧的最大容量由系統預先規定(通常默認2M或10M);堆的大小則受限於計算機系統中有效的虛擬內存,32位Linux系統中堆內存可達2.9G空間。

④存儲內容:棧在函數調用時,首先壓入主調函數中下條指令(函數調用語句的下條可執行語句)的地址,然後是函數實參,然後是被調函數的局部變量。本次調用結束後,局部變量先出棧,然後是參數,最後棧頂指針指向最開始存的指令地址,程序由該點繼續運行下條可執行語句。堆通常在頭部用一個字節存放其大小,堆用於存儲生存期與函數調用無關的數據,具體內容由程序員安排。

⑤分配方式:棧可靜態分配或動態分配。靜態分配由編譯器完成,如局部變量的分配。動態分配由alloca函數在棧上申請空間,用完後自動釋放。堆只能動態分配且手工釋放。

⑥分配效率:棧由計算機底層提供支持:分配專門的寄存器存放棧地址,壓棧出棧由專門的指令執行,因此效率較高。堆由函數庫提供,機制復雜,效率比棧低得多。Windows系統中VirtualAlloc可直接在進程地址空間中分配一塊內存,快速且靈活。

⑦分配後系統響應:只要棧剩余空間大於所申請空間,系統將為程序提供內存,否則報告異常提示棧溢出。

操作系統為堆維護一個記錄空閑內存地址的鏈表。當系統收到程序的內存分配申請時,會遍歷該鏈表尋找第一個空間大於所申請空間的堆結點,然後將該結點從空閑結點鏈表中刪除,並將該結點空間分配給程序。若無足夠大小的空間(可能由於內存碎片太多),有可能調用系統功能去增加程序數據段的內存空間,以便有機會分到足夠大小的內存,然後進行返回。,大多數系統會在該內存空間首地址處記錄本次分配的內存大小,供後續的釋放函數(如free/delete)正確釋放本內存空間。

此外,由於找到的堆結點大小不一定正好等於申請的大小,系統會自動將多余的部分重新放入空閑鏈表中。

⑧碎片問題:棧不會存在碎片問題,因為棧是先進後出的隊列,內存塊彈出棧之前,在其上面的後進的棧內容已彈出。而頻繁申請釋放操作會造成堆內存空間的不連續,從而造成大量碎片,使程序效率降低。

可見,堆容易造成內存碎片;由於沒有專門的系統支持,效率很低;由於可能引發用戶態和內核態切換,內存申請的代價更為昂貴。所以棧在程序中應用最廣泛,函數調用也利用棧來完成,調用過程中的參數、返回地址、棧基指針和局部變量等都采用棧的方式存放。所以,建議盡量使用棧,僅在分配大量或大塊內存空間時使用堆。

使用棧和堆時應避免越界發生,否則可能程序崩潰或破壞程序堆、棧結構,產生意想不到的後果。

5 BSS段

BSS(Block Started by Symbol)段中通常存放程序中以下符號:

  • 未初始化的全局變量和靜態局部變量
  • 初始值為0的全局變量和靜態局部變量(依賴於編譯器實現)
  • 未定義且初值不為0的符號(該初值即common block的大小)

C語言中,未顯式初始化的靜態分配變量被初始化為0(算術類型)或空指針(指針類型)。由於程序加載時,BSS會被操作系統清零,所以未賦初值或初值為0的全局變量都在BSS中。BSS段僅為未初始化的靜態分配變量預留位置,在目標文件中並不占據空間,這樣可減少目標文件體積。但程序運行時需為變量分配內存空間,故目標文件必須記錄所有未初始化的靜態分配變量大小總和(通過start_bss和end_bss地址寫入機器代碼)。當加載器(loader)加載程序時,將為BSS段分配的內存初始化為0。在嵌入式軟件中,進入main()函數之前BSS段被C運行時系統映射到初始化為全零的內存(效率較高)。

註意,盡管均放置於BSS段,但初值為0的全局變量是強符號,而未初始化的全局變量是弱符號。若其他地方已定義同名的強符號(初值可能非0),則弱符號與之鏈接時不會引起重定義錯誤,但運行時的初值可能並非期望值(會被強符號覆蓋)。因此,定義全局變量時,若只有本文件使用,則盡量使用static關鍵字修飾;否則需要為全局變量定義賦初值(哪怕0值),保證該變量為強符號,以便鏈接時發現變量名沖突,而不是被未知值覆蓋。

某些編譯器將未初始化的全局變量保存在common段,鏈接時再將其放入BSS段。在編譯階段可通過-fno-common選項來禁止將未初始化的全局變量放入common段。

此外,由於目標文件不含BSS段,故程序燒入存儲器(Flash)後BSS段地址空間內容未知。U-Boot啟動過程中,將U-Boot的Stage2代碼(通常位於lib_xxxx/board.c文件)搬遷(拷貝)到SDRAM空間後必須人為添加清零BSS段的代碼,而不可依賴於Stage2代碼中變量定義時賦0值。

【擴展閱讀】BSS歷史

BSS(Block Started by Symbol,以符號開始的塊)一詞最初是UA-SAP匯編器(United Aircraft Symbolic Assembly Program)中的偽指令,用於為符號預留一塊內存空間。該匯編器由美國聯合航空公司於20世紀50年代中期為IBM 704大型機所開發。

後來該詞被作為關鍵字引入到了IBM 709和7090/94機型上的標準匯編器FAP(Fortran Assembly Program),用於定義符號並且為該符號預留指定字數的未初始化空間塊。

在采用段式內存管理的架構中(如Intel 80x86系統),BSS段通常指用來存放程序中未初始化全局變量的一塊內存區域,該段變量只有名稱和大小卻沒有值。程序開始時由系統初始化清零。

BSS段不包含數據,僅維護開始和結束地址,以便內存能在運行時被有效地清零。BSS所需的運行時空間由目標文件記錄,但BSS並不占用目標文件內的實際空間,即BSS節段應用程序的二進制映象文件中並不存在。

6 數據段(Data)

數據段通常用於存放程序中已初始化且初值不為0的全局變量和靜態局部變量。數據段屬於靜態內存分配(靜態存儲區),可讀可寫。

數據段保存在目標文件中(在嵌入式系統裏一般固化在鏡像文件中),其內容由程序初始化。例如,對於全局變量int gVar = 10,必須在目標文件數據段中保存10這個數據,然後在程序加載時復制到相應的內存。

數據段與BSS段的區別如下:

1) BSS段不占用物理文件尺寸,但占用內存空間;數據段占用物理文件,也占用內存空間。

對於大型數組如int ar0[10000] = {1, 2, 3, ...}和int ar1[10000],ar1放在BSS段,只記錄共有10000*4個字節需要初始化為0,而不是像ar0那樣記錄每個數據1、2、3...,此時BSS為目標文件所節省的磁盤空間相當可觀。

2) 當程序讀取數據段的數據時,系統會出發缺頁故障,從而分配相應的物理內存;當程序讀取BSS段的數據時,內核會將其轉到一個全零頁面,不會發生缺頁故障,也不會為其分配相應的物理內存。

運行時數據段和BSS段的整個區段通常稱為數據區。某些資料中“數據段”指代數據段 + BSS段 + 堆。

7 代碼段(text)

代碼段也稱正文段或文本段,通常用於存放程序執行代碼(即CPU執行的機器指令)。一般C語言執行語句都編譯成機器代碼保存在代碼段。通常代碼段是可共享的,因此頻繁執行的程序只需要在內存中擁有一份拷貝即可。代碼段通常屬於只讀,以防止其他程序意外地修改其指令(對該段的寫操作將導致段錯誤)。某些架構也允許代碼段為可寫,即允許修改程序。

代碼段指令根據程序設計流程依次執行,對於順序指令,只會執行一次(每個進程);若有反復,則需使用跳轉指令;若進行遞歸,則需要借助棧來實現。

代碼段指令中包括操作碼和操作對象(或對象地址引用)。若操作對象是立即數(具體數值),將直接包含在代碼中;若是局部數據,將在棧區分配空間,然後引用該數據地址;若位於BSS段和數據段,同樣引用該數據地址。

代碼段最容易受優化措施影響。

8 保留區

位於虛擬地址空間的最低部分,未賦予物理地址。任何對它的引用都是非法的,用於捕捉使用空指針和小整型值指針引用內存的異常情況。

它並不是一個單一的內存區域,而是對地址空間中受到操作系統保護而禁止用戶進程訪問的地址區域的總稱。大多數操作系統中,極小的地址通常都是不允許訪問的,如NULL。C語言將無效指針賦值為0也是出於這種考慮,因為0地址上正常情況下不會存放有效的可訪問數據。

在32位X86架構的Linux系統中,用戶進程可執行程序一般從虛擬地址空間0x08048000開始加載。該加載地址由ELF文件頭決定,可通過自定義鏈接器腳本覆蓋鏈接器默認配置,進而修改加載地址。0x08048000以下的地址空間通常由C動態鏈接庫、動態加載器ld.so和內核VDSO(內核提供的虛擬共享庫)等占用。通過使用mmap系統調用,可訪問0x08048000以下的地址空間。

通過cat /proc/self/maps命令查看加載表如下:

技術分享圖片

【擴展閱讀】分段的好處

進程運行過程中,代碼指令根據流程依次執行,只需訪問一次(當然跳轉和遞歸可能使代碼執行多次);而數據(數據段和BSS段)通常需要訪問多次,因此單獨開辟空間以方便訪問和節約空間。具體解釋如下:

當程序被裝載後,數據和指令分別映射到兩個虛存區域。數據區對於進程而言可讀寫,而指令區對於進程只讀。兩區的權限可分別設置為可讀寫和只讀。以防止程序指令被有意或無意地改寫。

現代CPU具有極為強大的緩存(Cache)體系,程序必須盡量提高緩存命中率。指令區和數據區的分離有利於提高程序的局部性。現代CPU一般數據緩存和指令緩存分離,故程序的指令和數據分開存放有利於提高CPU緩存命中率。

當系統中運行多個該程序的副本時,其指令相同,故內存中只須保存一份該程序的指令部分。若系統中運行數百進程,通過共享指令將節省大量空間(尤其對於有動態鏈接的系統)。其他只讀數據如程序裏的圖標、圖片、文本等資源也可共享。而每個副本進程的數據區域不同,它們是進程私有的。

此外,臨時數據及需要再次使用的代碼在運行時放入棧區中,生命周期短。全局數據和靜態數據可能在整個程序執行過程中都需要訪問,因此單獨存儲管理。堆區由用戶自由分配,以便管理。



二:Linux 中的各種棧:進程棧 線程棧 內核棧 中斷棧

(轉自:Linux 中的各種棧:進程棧 線程棧 內核棧 中斷棧,不過我只轉了他的部分內容,感興趣可以去看)

Linux 中有幾種棧?各種棧的內存位置?

介紹完棧的工作原理和用途作用後,我們回歸到 Linux 內核上來。內核將棧分成四種:

  • 進程棧
  • 線程棧
  • 內核棧
  • 中斷棧

一、進程棧

進程棧是屬於用戶態棧,和進程 虛擬地址空間 (Virtual Address Space) 密切相關。那我們先了解下什麽是虛擬地址空間:在 32 位機器下,虛擬地址空間大小為 4G。這些虛擬地址通過頁表 (Page Table) 映射到物理內存,頁表由操作系統維護,並被處理器的內存管理單元 (MMU) 硬件引用。每個進程都擁有一套屬於它自己的頁表,因此對於每個進程而言都好像獨享了整個虛擬地址空間。

Linux 內核將這 4G 字節的空間分為兩部分,將最高的 1G 字節(0xC0000000-0xFFFFFFFF)供內核使用,稱為內核空間。而將較低的3G字節(0x00000000-0xBFFFFFFF)供各個進程使用,稱為 用戶空間。每個進程可以通過系統調用陷入內核態,因此內核空間是由所有進程共享的。雖然說內核和用戶態進程占用了這麽大地址空間,但是並不意味它們使用了這麽多物理內存,僅表示它可以支配這麽大的地址空間。它們是根據需要,將物理內存映射到虛擬地址空間中使用。

技術分享圖片

Linux 對進程地址空間有個標準布局,地址空間中由各個不同的內存段組成 (Memory Segment),主要的內存段如下:
- 程序段 (Text Segment):可執行文件代碼的內存映射
- 數據段 (Data Segment):可執行文件的已初始化全局變量的內存映射
- BSS段 (BSS Segment):未初始化的全局變量或者靜態變量(用零頁初始化)
- 堆區 (Heap) : 存儲動態內存分配,匿名的內存映射
- 棧區 (Stack) : 進程用戶空間棧,由編譯器自動分配釋放,存放函數的參數值、局部變量的值等
- 映射段(Memory Mapping Segment):任何內存映射文件

技術分享圖片

而上面進程虛擬地址空間中的棧區,正指的是我們所說的進程棧。進程棧的初始化大小是由編譯器和鏈接器計算出來的,但是棧的實時大小並不是固定的,Linux 內核會根據入棧情況對棧區進行動態增長(其實也就是添加新的頁表)。但是並不是說棧區可以無限增長,它也有最大限制 RLIMIT_STACK (一般為 8M),我們可以通過 ulimit 來查看或更改 RLIMIT_STACK 的值。

【擴展閱讀】:如何確認進程棧的大小

我們要知道棧的大小,那必須得知道棧的起始地址和結束地址。棧起始地址 獲取很簡單,只需要嵌入匯編指令獲取棧指針 esp 地址即可。棧結束地址 的獲取有點麻煩,我們需要先利用遞歸函數把棧搞溢出了,然後再 GDB 中把棧溢出的時候把棧指針 esp 打印出來即可。代碼如下:

/* file name: stacksize.c */

void *orig_stack_pointer;

void blow_stack() {
    blow_stack();
}

int main() {
    __asm__("movl %esp, orig_stack_pointer");

    blow_stack();
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
$ g++ -g stacksize.c -o ./stacksize
$ gdb ./stacksize
(gdb) r
Starting program: /home/home/misc-code/setrlimit

Program received signal SIGSEGV, Segmentation fault.
blow_stack () at setrlimit.c:4
4       blow_stack();
(gdb) print (void *)$esp
$1 = (void *) 0xffffffffff7ff000
(gdb) print (void *)orig_stack_pointer
$2 = (void *) 0xffffc800
(gdb) print 0xffffc800-0xff7ff000
$3 = 8378368    // Current Process Stack Size is 8M
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

上面對進程的地址空間有個比較全局的介紹,那我們看下 Linux 內核中是怎麽體現上面內存布局的。內核使用內存描述符來表示進程的地址空間,該描述符表示著進程所有地址空間的信息。內存描述符由 mm_struct 結構體表示,下面給出內存描述符結構中各個域的描述,請大家結合前面的 進程內存段布局 圖一起看:

struct mm_struct {
    struct vm_area_struct *mmap;           /* 內存區域鏈表 */
    struct rb_root mm_rb;                  /* VMA 形成的紅黑樹 */
    ...
    struct list_head mmlist;               /* 所有 mm_struct 形成的鏈表 */
    ...
    unsigned long total_vm;                /* 全部頁面數目 */
    unsigned long locked_vm;               /* 上鎖的頁面數據 */
    unsigned long pinned_vm;               /* Refcount permanently increased */
    unsigned long shared_vm;               /* 共享頁面數目 Shared pages (files) */
    unsigned long exec_vm;                 /* 可執行頁面數目 VM_EXEC & ~VM_WRITE */
    unsigned long stack_vm;                /* 棧區頁面數目 VM_GROWSUP/DOWN */
    unsigned long def_flags;
    unsigned long start_code, end_code, start_data, end_data;    /* 代碼段、數據段 起始地址和結束地址 */
    unsigned long start_brk, brk, start_stack;                   /* 棧區 的起始地址,堆區 起始地址和結束地址 */
    unsigned long arg_start, arg_end, env_start, env_end;        /* 命令行參數 和 環境變量的 起始地址和結束地址 */
    ...
    /* Architecture-specific MM context */
    mm_context_t context;                  /* 體系結構特殊數據 */

    /* Must use atomic bitops to access the bits */
    unsigned long flags;                   /* 狀態標誌位 */
    ...
    /* Coredumping and NUMA and HugePage 相關結構體 */
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

技術分享圖片

【擴展閱讀】:進程棧的動態增長實現

進程在運行的過程中,通過不斷向棧區壓入數據,當超出棧區容量時,就會耗盡棧所對應的內存區域,這將觸發一個 缺頁異常 (page fault)。通過異常陷入內核態後,異常會被內核的 expand_stack() 函數處理,進而調用 acct_stack_growth() 來檢查是否還有合適的地方用於棧的增長。

如果棧的大小低於 RLIMIT_STACK(通常為8MB),那麽一般情況下棧會被加長,程序繼續執行,感覺不到發生了什麽事情,這是一種將棧擴展到所需大小的常規機制。然而,如果達到了最大棧空間的大小,就會發生 棧溢出(stack overflow),進程將會收到內核發出的 段錯誤(segmentation fault) 信號。

動態棧增長是唯一一種訪問未映射內存區域而被允許的情形,其他任何對未映射內存區域的訪問都會觸發頁錯誤,從而導致段錯誤。一些被映射的區域是只讀的,因此企圖寫這些區域也會導致段錯誤。

二、線程棧

從 Linux 內核的角度來說,其實它並沒有線程的概念。Linux 把所有線程都當做進程來實現,它將線程和進程不加區分的統一到了 task_struct 中。線程僅僅被視為一個與其他進程共享某些資源的進程,而是否共享地址空間幾乎是進程和 Linux 中所謂線程的唯一區別。線程創建的時候,加上了 CLONE_VM 標記,這樣 線程的內存描述符 將直接指向 父進程的內存描述符。

  if (clone_flags & CLONE_VM) {
    /*
     * current 是父進程而 tsk 在 fork() 執行期間是共享子進程
     */
    atomic_inc(&current->mm->mm_users);
    tsk->mm = current->mm;
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

雖然線程的地址空間和進程一樣,但是對待其地址空間的 stack 還是有些區別的。對於 Linux 進程或者說主線程,其 stack 是在 fork 的時候生成的,實際上就是復制了父親的 stack 空間地址,然後寫時拷貝 (cow) 以及動態增長。然而對於主線程生成的子線程而言,其 stack 將不再是這樣的了,而是事先固定下來的,使用 mmap 系統調用,它不帶有 VM_STACK_FLAGS 標記。這個可以從 glibc 的nptl/allocatestack.c 中的 allocate_stack() 函數中看到:

mem = mmap (NULL, size, prot,
            MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
  • 1
  • 2
  • 1
  • 2

由於線程的 mm->start_stack 棧地址和所屬進程相同,所以線程棧的起始地址並沒有存放在 task_struct 中,應該是使用 pthread_attr_t 中的 stackaddr 來初始化 task_struct->thread->sp(sp 指向 struct pt_regs 對象,該結構體用於保存用戶進程或者線程的寄存器現場)。這些都不重要,重要的是,線程棧不能動態增長,一旦用盡就沒了,這是和生成進程的 fork 不同的地方。由於線程棧是從進程的地址空間中 map 出來的一塊內存區域,原則上是線程私有的。但是同一個進程的所有線程生成的時候淺拷貝生成者的 task_struct 的很多字段,其中包括所有的vma,如果願意,其它線程也還是可以訪問到的,於是一定要註意。

三、進程內核棧

在每一個進程的生命周期中,必然會通過到系統調用陷入內核。在執行系統調用陷入內核之後,這些內核代碼所使用的棧並不是原先進程用戶空間中的棧,而是一個單獨內核空間的棧,這個稱作進程內核棧。進程內核棧在進程創建的時候,通過 slab 分配器從 thread_info_cache 緩存池中分配出來,其大小為 THREAD_SIZE,一般來說是一個頁大小 4K;

union thread_union {                                   
        struct thread_info thread_info;                
        unsigned long stack[THREAD_SIZE/sizeof(long)];
};                                                     
  • 1
  • 2
  • 3
  • 4
  • 1
  • 2
  • 3
  • 4

thread_union 進程內核棧 和 task_struct 進程描述符有著緊密的聯系。由於內核經常要訪問 task_struct,高效獲取當前進程的描述符是一件非常重要的事情。因此內核將進程內核棧的頭部一段空間,用於存放 thread_info 結構體,而此結構體中則記錄了對應進程的描述符,兩者關系如下圖(對應內核函數為 dup_task_struct()):

技術分享圖片

有了上述關聯結構後,內核可以先獲取到棧頂指針 esp,然後通過 esp 來獲取 thread_info。這裏有一個小技巧,直接將 esp 的地址與上 ~(THREAD_SIZE - 1) 後即可直接獲得 thread_info 的地址。由於 thread_union 結構體是從thread_info_cache 的 Slab 緩存池中申請出來的,而 thread_info_cachekmem_cache_create 創建的時候,保證了地址是 THREAD_SIZE 對齊的。因此只需要對棧指針進行 THREAD_SIZE 對齊,即可獲得 thread_union 的地址,也就獲得了 thread_union 的地址。成功獲取到 thread_info 後,直接取出它的 task 成員就成功得到了task_struct。其實上面這段描述,也就是 current 宏的實現方法:

register unsigned long current_stack_pointer asm ("sp");

static inline struct thread_info *current_thread_info(void)  
{                                                            
        return (struct thread_info *)                        
                (current_stack_pointer & ~(THREAD_SIZE - 1));
}                                                            

#define get_current() (current_thread_info()->task)

#define current get_current()                       
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

四、中斷棧

進程陷入內核態的時候,需要內核棧來支持內核函數調用。中斷也是如此,當系統收到中斷事件後,進行中斷處理的時候,也需要中斷棧來支持函數調用。由於系統中斷的時候,系統當然是處於內核態的,所以中斷棧是可以和內核棧共享的。但是具體是否共享,這和具體處理架構密切相關。

X86 上中斷棧就是獨立於內核棧的;獨立的中斷棧所在內存空間的分配發生在 arch/x86/kernel/irq_32.cirq_ctx_init() 函數中(如果是多處理器系統,那麽每個處理器都會有一個獨立的中斷棧),函數使用 __alloc_pages在低端內存區分配 2個物理頁面,也就是8KB大小的空間。有趣的是,這個函數還會為 softirq 分配一個同樣大小的獨立堆棧。如此說來,softirq 將不會在 hardirq 的中斷棧上執行,而是在自己的上下文中執行。

技術分享圖片

而 ARM 上中斷棧和內核棧則是共享的;中斷棧和內核棧共享有一個負面因素,如果中斷發生嵌套,可能會造成棧溢出,從而可能會破壞到內核棧的一些重要數據,所以棧空間有時候難免會捉襟見肘。


Linux 為什麽需要區分這些棧?

為什麽需要區分這些棧,其實都是設計上的問題。這裏就我看到過的一些觀點進行匯總,供大家討論:

  1. 為什麽需要單獨的進程內核棧?

    • 所有進程運行的時候,都可能通過系統調用陷入內核態繼續執行。假設第一個進程 A 陷入內核態執行的時候,需要等待讀取網卡的數據,主動調用 schedule() 讓出 CPU;此時調度器喚醒了另一個進程 B,碰巧進程 B 也需要系統調用進入內核態。那問題就來了,如果內核棧只有一個,那進程 B 進入內核態的時候產生的壓棧操作,必然會破壞掉進程 A 已有的內核棧數據;一但進程 A 的內核棧數據被破壞,很可能導致進程 A 的內核態無法正確返回到對應的用戶態了;
  2. 為什麽需要單獨的線程棧?

    • Linux 調度程序中並沒有區分線程和進程,當調度程序需要喚醒”進程”的時候,必然需要恢復進程的上下文環境,也就是進程棧;但是線程和父進程完全共享一份地址空間,如果棧也用同一個那就會遇到以下問題。假如進程的棧指針初始值為 0x7ffc80000000;父進程 A 先執行,調用了一些函數後棧指針 esp 為 0x7ffc8000FF00,此時父進程主動休眠了;接著調度器喚醒子線程 A1:
      • 此時 A1 的棧指針 esp 如果為初始值 0x7ffc80000000,則線程 A1 一但出現函數調用,必然會破壞父進程 A 已入棧的數據。
      • 如果此時線程 A1 的棧指針和父進程最後更新的值一致,esp 為 0x7ffc8000FF00,那線程 A1 進行一些函數調用後,棧指針 esp 增加到 0x7ffc8000FFFF,然後線程 A1 休眠;調度器再次換成父進程 A 執行,那這個時候父進程的棧指針是應該為 0x7ffc8000FF00 還是 0x7ffc8000FFFF 呢?無論棧指針被設置到哪個值,都會有問題不是嗎?
  3. 進程和線程是否共享一個內核棧?

    • No,線程和進程創建的時候都調用 dup_task_struct 來創建 task 相關結構體,而內核棧也是在此函數中 alloc_thread_info_node 出來的。因此雖然線程和進程共享一個地址空間 mm_struct,但是並不共享一個內核棧。
  4. 為什麽需要單獨中斷棧?

    • 這個問題其實不對,ARM 架構就沒有獨立的中斷棧。

三:自己的總結

上面的圖都很好,但我覺得這張圖更形象,32位進程棧大小是8M,理論上堆區最大大小約為2.9G,所以還是蠻大的。

從上面兩篇文章,我知道的線程棧是使用mmap系統調用分配的空間,但是mmap分配的系統空間是什麽呢?也就是上圖中的mmap區域或者說共享的內存映射區域是什麽呢?它的方向是向上生長還是向下生長的?

下面兩幅圖給出了答案:

圖一:

技術分享圖片

圖二:

技術分享圖片

所以,mmap其實和堆一樣,實際上可以說他們都是動態內存分配,但是嚴格來說mmap區域並不屬於堆區,反而和堆區會爭用虛擬地址空間。

這裏要提到一個很重要的概念,內存的延遲分配,只有在真正訪問一個地址的時候才建立這個地址的物理映射,這是Linux內存管理的基本思想。Linux內核在用戶申請內存的時候,只是給它分配了一個線性區(也就是虛擬內存),並沒有分配實際物理內存;只有當用戶使用這塊內存的時候,內核才會分配具體的物理頁面給用戶,這時候才占用寶貴的物理內存。內核釋放物理頁面是通過釋放先行區,找到其對應的物理頁面,將其全部釋放的過程。

這篇文章關於mmap生長方向說的也挺詳細的: 進程地址空間的布局(整理)

最後還有一個mmap機制的源代碼分析博客,我水平暫時不夠,只能看懂意思,待日後閱讀內核源碼再來回顧一遍:Linux用戶空間線程管理介紹之二:創建線程堆棧。

from: https://www.cnblogs.com/xzzzh/p/6596982.html

Linux虛擬地址空間布局以及進程棧和線程棧總結(轉)