棧是什么?棧有什么作用,?首先,,棧 (stack) 是一種串列形式的 數(shù)據(jù)結構。這種數(shù)據(jù)結構的特點是 后入先出 (LIFO, Last In First Out),,數(shù)據(jù)只能在串列的一端 (稱為:棧頂 top) 進行 推入 (push) 和 彈出 (pop) 操作,。根據(jù)棧的特點,很容易的想到可以利用數(shù)組,,來實現(xiàn)這種數(shù)據(jù)結構,。但是本文要討論的并不是軟件層面的棧,而是硬件層面的棧,。 大多數(shù)的處理器架構,,都有實現(xiàn)硬件棧,。有專門的棧指針寄存器,以及特定的硬件指令來完成 入棧/出棧 的操作,。例如在 ARM 架構上,,R13 (SP) 指針是堆棧指針寄存器,而 PUSH 是用于壓棧的匯編指令,,POP 則是出棧的匯編指令,。 下面我們來看看棧有什么作用。棧作用可以從兩個方面體現(xiàn):函數(shù)調用 和 多任務支持 ,。 一,、函數(shù)調用我們知道一個函數(shù)調用有以下三個基本過程: - 調用參數(shù)的傳入 - 局部變量的空間管理 - 函數(shù)返回 函數(shù)的調用必須是高效的,而數(shù)據(jù)存放在 CPU通用寄存器 或者 RAM 內存 中無疑是最好的選擇,。以傳遞調用參數(shù)為例,,我們可以選擇使用 CPU通用寄存器 來存放參數(shù)。但是通用寄存器的數(shù)目都是有限的,,當出現(xiàn)函數(shù)嵌套調用時,,子函數(shù)再次使用原有的通用寄存器必然會導致沖突。因此如果想用它來傳遞參數(shù),,那在調用子函數(shù)前,,就必須先 保存原有寄存器的值,然后當子函數(shù)退出的時候再 恢復原有寄存器的值 ,。 函數(shù)的調用參數(shù)數(shù)目一般都相對少,,因此通用寄存器是可以滿足一定需求的。但是局部變量的數(shù)目和占用空間都是比較大的,,再依賴有限的通用寄存器未免強人所難,,因此我們可以采用某些 RAM 內存區(qū)域來存儲局部變量。但是存儲在哪里合適,?既不能讓函數(shù)嵌套調用的時候有沖突,,又要注重效率。 這種情況下,,棧無疑提供很好的解決辦法,。一、對于通用寄存器傳參的沖突,,我們可以再調用子函數(shù)前,,將通用寄存器臨時壓入棧中;在子函數(shù)調用完畢后,,在將已保存的寄存器再彈出恢復回來,。二、而局部變量的空間申請,也只需要向下移動下棧頂指針,;將棧頂指針向回移動,,即可就可完成局部變量的空間釋放;三,、對于函數(shù)的返回,,也只需要在調用子函數(shù)前,將返回地址壓入棧中,,待子函數(shù)調用結束后,將函數(shù)返回地址彈出給 PC 指針,,即完成了函數(shù)調用的返回,; 于是上述函數(shù)調用的三個基本過程,就演變記錄一個棧指針的過程,。每次函數(shù)調用的時候,,都配套一個棧指針。即使循環(huán)嵌套調用函數(shù),,只要對應函數(shù)棧指針是不同的,,也不會出現(xiàn)沖突。 需要C/C++ Linux服務器架構師學習資料私信“資料”(資料包括C/C++,,Linux,,golang技術,Nginx,,ZeroMQ,,MySQL,Redis,,fastdfs,,MongoDB,ZK,,流媒體,,CDN,P2P,,K8S,,Docker,TCP/IP,,協(xié)程,,DPDK,ffmpeg等),免費分享 二,、多任務支持然而棧的意義還不只是函數(shù)調用,,有了它的存在,才能構建出操作系統(tǒng)的多任務模式,。我們以 main 函數(shù)調用為例,,main 函數(shù)包含一個無限循環(huán)體,循環(huán)體中先調用 A 函數(shù),,再調用 B 函數(shù),。 func B(): return;func A(): B();func main(): while (1) A(); 試想在單處理器情況下,程序將永遠停留在此 main 函數(shù)中,。即使有另外一個任務在等待狀態(tài),,程序是沒法從此 main 函數(shù)里面跳轉到另一個任務。因為如果是函數(shù)調用關系,,本質上還是屬于 main 函數(shù)的任務中,,不能算多任務切換。此刻的 main 函數(shù)任務本身其實和它的棧綁定在了一起,,無論如何嵌套調用函數(shù),,棧指針都在本棧范圍內移動。 由此可以看出一個任務可以利用以下信息來表征: 1. main 函數(shù)體代碼 2. main 函數(shù)棧指針 3. 當前 CPU 寄存器信息 假如我們可以保存以上信息,,則完全可以強制讓出 CPU 去處理其他任務,。只要將來想繼續(xù)執(zhí)行此 main 任務的時候,把上面的信息恢復回去即可,。有了這樣的先決條件,,多任務就有了存在的基礎,也可以看出棧存在的另一個意義,。在多任務模式下,,當調度程序認為有必要進行任務切換的話,只需保存任務的信息(即上面說的三個內容),?;謴土硪粋€任務的狀態(tài),然后跳轉到上次運行的位置,,就可以恢復運行了,。 可見每個任務都有自己的棧空間,,正是有了獨立的??臻g,,為了代碼重用,不同的任務甚至可以混用任務的函數(shù)體本身,,例如可以一個main函數(shù)有兩個任務實例,。至此之后的操作系統(tǒng)的框架也形成了,譬如任務在調用 sleep() 等待的時候,,可以主動讓出 CPU 給別的任務使用,,或者分時操作系統(tǒng)任務在時間片用完是也會被迫的讓出 CPU。不論是哪種方法,,只要想辦法切換任務的上下文空間,,切換棧即可。 Linux 中有幾種棧,?各種棧的內存位置?內核將棧分成四種:
一,、進程棧 進程棧是屬于用戶態(tài)棧,,和進程 虛擬地址空間 (Virtual Address Space) 密切相關。那我們先了解下什么是虛擬地址空間:在 32 位機器下,,虛擬地址空間大小為 4G,。這些虛擬地址通過頁表 (Page Table) 映射到物理內存,頁表由操作系統(tǒng)維護,,并被處理器的內存管理單元 (MMU) 硬件引用,。每個進程都擁有一套屬于它自己的頁表,因此對于每個進程而言都好像獨享了整個虛擬地址空間,。 Linux 內核將這 4G 字節(jié)的空間分為兩部分,,將最高的 1G 字節(jié)(0xC0000000-0xFFFFFFFF)供內核使用,稱為 內核空間,。而將較低的3G字節(jié)(0x00000000-0xBFFFFFFF)供各個進程使用,,稱為 用戶空間。每個進程可以通過系統(tǒng)調用陷入內核態(tài),,因此內核空間是由所有進程共享的,。雖然說內核和用戶態(tài)進程占用了這么大地址空間,但是并不意味它們使用了這么多物理內存,,僅表示它可以支配這么大的地址空間,。它們是根據(jù)需要,將物理內存映射到虛擬地址空間中使用,。 Linux 對進程地址空間有個標準布局,,地址空間中由各個不同的內存段組成 (Memory Segment),,主要的內存段如下: - 程序段 (Text Segment):可執(zhí)行文件代碼的內存映射 - 數(shù)據(jù)段 (Data Segment):可執(zhí)行文件的已初始化全局變量的內存映射 - BSS段 (BSS Segment):未初始化的全局變量或者靜態(tài)變量(用零頁初始化) - 堆區(qū) (Heap) : 存儲動態(tài)內存分配,匿名的內存映射 - 棧區(qū) (Stack) : 進程用戶空間棧,,由編譯器自動分配釋放,,存放函數(shù)的參數(shù)值、局部變量的值等 - 映射段(Memory Mapping Segment):任何內存映射文件 而上面進程虛擬地址空間中的棧區(qū),,正指的是我們所說的進程棧,。進程棧的初始化大小是由編譯器和鏈接器計算出來的,但是棧的實時大小并不是固定的,,Linux 內核會根據(jù)入棧情況對棧區(qū)進行動態(tài)增長(其實也就是添加新的頁表),。但是并不是說棧區(qū)可以無限增長,它也有最大限制 RLIMIT_STACK (一般為 8M),,我們可以通過 ulimit 來查看或更改 RLIMIT_STACK的值,。
$ g++ -g stacksize.c -o ./stacksize$ gdb ./stacksize(gdb) rStarting program: /home/home/misc-code/setrlimitProgram received signal SIGSEGV, Segmentation fault.blow_stack () at setrlimit.c:44 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 上面對進程的地址空間有個比較全局的介紹,,那我們看下 Linux 內核中是怎么體現(xiàn)上面內存布局的,。內核使用內存描述符來表示進程的地址空間,該描述符表示著進程所有地址空間的信息,。內存描述符由 mm_struct 結構體表示,,下面給出內存描述符結構中各個域的描述,請大家結合前面的 進程內存段布局 圖一起看:
二,、線程棧 從 Linux 內核的角度來說,其實它并沒有線程的概念,。Linux 把所有線程都當做進程來實現(xiàn),,它將線程和進程不加區(qū)分的統(tǒng)一到了 task_struct 中。線程僅僅被視為一個與其他進程共享某些資源的進程,,而是否共享地址空間幾乎是進程和 Linux 中所謂線程的唯一區(qū)別,。線程創(chuàng)建的時候,加上了 CLONE_VM 標記,,這樣 線程的內存描述符 將直接指向 父進程的內存描述符,。 if (clone_flags & CLONE_VM) { /* * current 是父進程而 tsk 在 fork() 執(zhí)行期間是共享子進程 */ atomic_inc(¤t->mm->mm_users); tsk->mm = current->mm; } 雖然線程的地址空間和進程一樣,但是對待其地址空間的 stack 還是有些區(qū)別的,。對于 Linux 進程或者說主線程,,其 stack 是在 fork 的時候生成的,實際上就是復制了父親的 stack 空間地址,,然后寫時拷貝 (cow) 以及動態(tài)增長,。然而對于主線程生成的子線程而言,其 stack 將不再是這樣的了,,而是事先固定下來的,,使用 mmap 系統(tǒng)調用,,它不帶有 VM_STACK_FLAGS 標記。這個可以從 glibc 的nptl/allocatestack.c 中的 allocate_stack() 函數(shù)中看到:
由于線程的 mm->start_stack 棧地址和所屬進程相同,,所以線程棧的起始地址并沒有存放在 task_struct 中,,應該是使用 pthread_attr_t 中的 stackaddr 來初始化 task_struct->thread->sp(sp 指向 struct pt_regs 對象,該結構體用于保存用戶進程或者線程的寄存器現(xiàn)場),。這些都不重要,,重要的是,線程棧不能動態(tài)增長,,一旦用盡就沒了,,這是和生成進程的 fork 不同的地方。由于線程棧是從進程的地址空間中 map 出來的一塊內存區(qū)域,,原則上是線程私有的,。但是同一個進程的所有線程生成的時候淺拷貝生成者的 task_struct 的很多字段,其中包括所有的 vma,,如果愿意,,其它線程也還是可以訪問到的,于是一定要注意,。 三,、進程內核棧 在每一個進程的生命周期中,必然會通過到系統(tǒng)調用陷入內核,。在執(zhí)行系統(tǒng)調用陷入內核之后,,這些內核代碼所使用的棧并不是原先進程用戶空間中的棧,而是一個單獨內核空間的棧,,這個稱作進程內核棧,。進程內核棧在進程創(chuàng)建的時候,通過 slab 分配器從 thread_info_cache 緩存池中分配出來,,其大小為 THREAD_SIZE,,一般來說是一個頁大小 4K; union thread_union { struct thread_info thread_info; unsigned long stack[THREAD_SIZE/sizeof(long)];}; thread_union 進程內核棧 和 task_struct 進程描述符有著緊密的聯(lián)系,。由于內核經(jīng)常要訪問 task_struct,,高效獲取當前進程的描述符是一件非常重要的事情。因此內核將進程內核棧的頭部一段空間,,用于存放 thread_info 結構體,,而此結構體中則記錄了對應進程的描述符,兩者關系如下圖(對應內核函數(shù)為 dup_task_struct()): 有了上述關聯(lián)結構后,,內核可以先獲取到棧頂指針 esp,,然后通過 esp 來獲取 thread_info,。這里有一個小技巧,直接將 esp 的地址與上 ~(THREAD_SIZE - 1) 后即可直接獲得 thread_info 的地址,。由于 thread_union 結構體是從 thread_info_cache 的 Slab 緩存池中申請出來的,,而 thread_info_cache 在 kmem_cache_create 創(chuàng)建的時候,保證了地址是 THREAD_SIZE 對齊的,。因此只需要對棧指針進行 THREAD_SIZE 對齊,,即可獲得 thread_union 的地址,也就獲得了 thread_union 的地址,。成功獲取到 thread_info 后,,直接取出它的 task 成員就成功得到了 task_struct。其實上面這段描述,,也就是 current 宏的實現(xiàn)方法:
四,、中斷棧 進程陷入內核態(tài)的時候,需要內核棧來支持內核函數(shù)調用,。中斷也是如此,,當系統(tǒng)收到中斷事件后,進行中斷處理的時候,,也需要中斷棧來支持函數(shù)調用,。由于系統(tǒng)中斷的時候,系統(tǒng)當然是處于內核態(tài)的,,所以中斷棧是可以和內核棧共享的,。但是具體是否共享,這和具體處理架構密切相關,。 X86 上中斷棧就是獨立于內核棧的,;獨立的中斷棧所在內存空間的分配發(fā)生在 arch/x86/kernel/irq_32.c 的 irq_ctx_init()函數(shù)中(如果是多處理器系統(tǒng),那么每個處理器都會有一個獨立的中斷棧),,函數(shù)使用 __alloc_pages 在低端內存區(qū)分配 2個物理頁面,也就是8KB大小的空間,。有趣的是,,這個函數(shù)還會為 softirq 分配一個同樣大小的獨立堆棧。如此說來,,softirq 將不會在 hardirq 的中斷棧上執(zhí)行,,而是在自己的上下文中執(zhí)行。 而 ARM 上中斷棧和內核棧則是共享的,;中斷棧和內核棧共享有一個負面因素,,如果中斷發(fā)生嵌套,,可能會造成棧溢出,從而可能會破壞到內核棧的一些重要數(shù)據(jù),,所以??臻g有時候難免會捉襟見肘。 Linux 為什么需要區(qū)分這些棧,?為什么需要區(qū)分這些棧,,其實都是設計上的問題。這里就我看到過的一些觀點進行匯總,,供大家討論:
|
|
來自: 啟云_9137 > 《計算機及軟件應用》