1. 程式人生 > >Linux 中的各種棧:程序棧 執行緒棧 核心棧 中斷棧

Linux 中的各種棧:程序棧 執行緒棧 核心棧 中斷棧

棧是什麼?棧有什麼作用?

首先,棧 (stack) 是一種串列形式的 資料結構。這種資料結構的特點是 後入先出 (LIFO, Last In First Out),資料只能在串列的一端 (稱為:棧頂 top) 進行 推入 (push) 和 彈出 (pop) 操作。根據棧的特點,很容易的想到可以利用陣列,來實現這種資料結構。但是本文要討論的並不是軟體層面的棧,而是硬體層面的棧。

棧結構

大多數的處理器架構,都有實現硬體棧。有專門的棧指標暫存器,以及特定的硬體指令來完成 入棧/出棧 的操作。例如在 ARM 架構上,R13 (SP) 指標是堆疊指標暫存器,而 PUSH 是用於壓棧的彙編指令,POP 則是出棧的彙編指令。

ARM 處理器擁有 37 個暫存器。 這些暫存器按部分重疊組方式加以排列。 每個處理器模式都有一個不同的暫存器組。 編組的暫存器為處理處理器異常和特權操作提供了快速的上下文切換。

提供了下列暫存器: 
- 三十個 32 位通用暫存器: 
- 存在十五個通用暫存器,它們分別是 r0-r12、sp、lr 
- sp (r13) 是堆疊指標。C/C++ 編譯器始終將 sp 用作堆疊指標 
- lr (r14) 用於儲存呼叫子例程時的返回地址。如果返回地址儲存在堆疊上,則可將 lr 用作通用暫存器 
- 程式計數器 (pc):指令暫存器 
- 應用程式狀態暫存器 (APSR):存放算術邏輯單元 (ALU) 狀態標記的副本 
- 當前程式狀態暫存器 (CPSR):存放 APSR 標記,當前處理器模式,中斷禁用標記等 
- 儲存的程式狀態暫存器 (SPSR):當發生異常時,使用 SPSR 來儲存 CPSR

上面是棧的原理和實現,下面我們來看看棧有什麼作用。棧作用可以從兩個方面體現:函式呼叫 和 多工支援 。

一、函式呼叫

我們知道一個函式呼叫有以下三個基本過程: 
- 呼叫引數的傳入 
- 區域性變數的空間管理 
- 函式返回

函式的呼叫必須是高效的,而資料存放在 CPU通用暫存器 或者 RAM 記憶體 中無疑是最好的選擇。以傳遞呼叫引數為例,我們可以選擇使用 CPU通用暫存器 來存放參數。但是通用暫存器的數目都是有限的,當出現函式巢狀呼叫時,子函式再次使用原有的通用暫存器必然會導致衝突。因此如果想用它來傳遞引數,那在呼叫子函式前,就必須先 儲存原有暫存器的值,然後當子函式退出的時候再 恢復原有暫存器的值

 。

函式的呼叫引數數目一般都相對少,因此通用暫存器是可以滿足一定需求的。但是區域性變數的數目和佔用空間都是比較大的,再依賴有限的通用暫存器未免強人所難,因此我們可以採用某些 RAM 記憶體區域來儲存區域性變數。但是儲存在哪裡合適?既不能讓函式巢狀呼叫的時候有衝突,又要注重效率。

這種情況下,棧無疑提供很好的解決辦法。一、對於通用暫存器傳參的衝突,我們可以再呼叫子函式前,將通用暫存器臨時壓入棧中;在子函式呼叫完畢後,在將已儲存的暫存器再彈出恢復回來。二、而區域性變數的空間申請,也只需要向下移動下棧頂指標;將棧頂指標向回移動,即可就可完成區域性變數的空間釋放;三、對於函式的返回,也只需要在呼叫子函式前,將返回地址壓入棧中,待子函式呼叫結束後,將函式返回地址彈出給 PC 指標,即完成了函式呼叫的返回;

於是上述函式呼叫的三個基本過程,就演變記錄一個棧指標的過程。每次函式呼叫的時候,都配套一個棧指標。即使迴圈巢狀呼叫函式,只要對應函式棧指標是不同的,也不會出現衝突。

函式棧結構

函式呼叫經常是巢狀的,在同一時刻,棧中會有多個函式的資訊。每個未完成執行的函式佔用一個獨立的連續區域,稱作棧幀(Stack Frame)。棧幀存放著函式引數,區域性變數及恢復前一棧幀所需要的資料等,函式呼叫時入棧的順序為:

實參N~1 → 主調函式返回地址 → 主調函式幀基指標EBP → 被調函式區域性變數1~N

棧幀的邊界由 棧幀基地址指標 EBP 和 棧指標 ESP 界定,EBP 指向當前棧幀底部(高地址),在當前棧幀內位置固定;ESP指向當前棧幀頂部(低地址),當程式執行時ESP會隨著資料的入棧和出棧而移動。因此函式中對大部分資料的訪問都基於EBP進行。函式呼叫棧的典型記憶體佈局如下圖所示:

函式呼叫棧的典型記憶體佈局

二、多工支援

然而棧的意義還不只是函式呼叫,有了它的存在,才能構建出作業系統的多工模式。我們以 main 函式呼叫為例,main 函式包含一個無限迴圈體,迴圈體中先呼叫 A 函式,再呼叫 B 函式。

func B():
  return;

func A():
  B();

func main():
  while (1)
    A();
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

試想在單處理器情況下,程式將永遠停留在此 main 函式中。即使有另外一個任務在等待狀態,程式是沒法從此 main 函式裡面跳轉到另一個任務。因為如果是函式呼叫關係,本質上還是屬於 main 函式的任務中,不能算多工切換。此刻的 main 函式任務本身其實和它的棧繫結在了一起,無論如何巢狀呼叫函式,棧指標都在本棧範圍內移動。

由此可以看出一個任務可以利用以下資訊來表徵: 
1. main 函式體程式碼 
2. main 函式棧指標 
3. 當前 CPU 暫存器資訊

假如我們可以儲存以上資訊,則完全可以強制讓出 CPU 去處理其他任務。只要將來想繼續執行此 main 任務的時候,把上面的資訊恢復回去即可。有了這樣的先決條件,多工就有了存在的基礎,也可以看出棧存在的另一個意義。在多工模式下,當排程程式認為有必要進行任務切換的話,只需儲存任務的資訊(即上面說的三個內容)。恢復另一個任務的狀態,然後跳轉到上次執行的位置,就可以恢復運行了。

可見每個任務都有自己的棧空間,正是有了獨立的棧空間,為了程式碼重用,不同的任務甚至可以混用任務的函式體本身,例如可以一個main函式有兩個任務例項。至此之後的作業系統的框架也形成了,譬如任務在呼叫 sleep() 等待的時候,可以主動讓出 CPU 給別的任務使用,或者分時作業系統任務在時間片用完是也會被迫的讓出 CPU。不論是哪種方法,只要想辦法切換任務的上下文空間,切換棧即可。

多工模型

【擴充套件閱讀】:任務、執行緒、程序 三者關係

任務是一個抽象的概念,即指軟體完成的一個活動;而執行緒則是完成任務所需的動作;程序則指的是完成此動作所需資源的統稱;關於三者的關係,有一個形象的比喻: 
- 任務 = 送貨 
- 執行緒 = 開送貨車 
- 系統排程 = 決定合適開哪部送貨車 
- 程序 = 道路 + 加油站 + 送貨車 + 修車廠

Linux 中有幾種棧?各種棧的記憶體位置?

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

  • 程序棧
  • 執行緒棧
  • 核心棧
  • 中斷棧

一、程序棧

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

Linux 核心將這 4G 位元組的空間分為兩部分,將最高的 1G 位元組(0xC0000000-0xFFFFFFFF)供核心使用,稱為 核心空間。而將較低的3G位元組(0x00000000-0xBFFFFFFF)供各個程序使用,稱為 使用者空間。每個程序可以通過系統呼叫陷入核心態,因此核心空間是由所有程序共享的。雖然說核心和使用者態程序佔用了這麼大地址空間,但是並不意味它們使用了這麼多實體記憶體,僅表示它可以支配這麼大的地址空間。它們是根據需要,將實體記憶體對映到虛擬地址空間中使用。

Linux虛擬地址空間

Linux 對程序地址空間有個標準佈局,地址空間中由各個不同的記憶體段組成 (Memory Segment),主要的記憶體段如下: 
- 程式段 (Text Segment):可執行檔案程式碼的記憶體對映 
- 資料段 (Data Segment):可執行檔案的已初始化全域性變數的記憶體對映 
- BSS段 (BSS Segment):未初始化的全域性變數或者靜態變數(用零頁初始化) 
- 堆區 (Heap) : 儲存動態記憶體分配,匿名的記憶體對映 
- 棧區 (Stack) : 程序使用者空間棧,由編譯器自動分配釋放,存放函式的引數值、區域性變數的值等 
- 對映段(Memory Mapping Segment):任何記憶體對映檔案

Linux標準程序記憶體段佈局

而上面程序虛擬地址空間中的棧區,正指的是我們所說的程序棧。程序棧的初始化大小是由編譯器和連結器計算出來的,但是棧的實時大小並不是固定的,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
$ 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

上面對程序的地址空間有個比較全域性的介紹,那我們看下 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

mm_struct 記憶體段

【擴充套件閱讀】:程序棧的動態增長實現

程序在執行的過程中,通過不斷向棧區壓入資料,當超出棧區容量時,就會耗盡棧所對應的記憶體區域,這將觸發一個 缺頁異常 (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

雖然執行緒的地址空間和程序一樣,但是對待其地址空間的 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

由於執行緒的 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

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_cache 在 kmem_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

四、中斷棧

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

X86 上中斷棧就是獨立於核心棧的;獨立的中斷棧所在記憶體空間的分配發生在 arch/x86/kernel/irq_32.c 的 irq_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 架構就沒有獨立的中斷棧。

大家還有什麼觀點,可以在留言下來 :-D