堆棧
參考文章:X86-64和ARM64用戶棧的結構 (2) ---進程用戶棧的初始化-大企鵝-51CTO博客
之前對於函數棧空間的理解就是棧空間由系統自動分配自動釋放,並且局部變量等數據是存放在棧幀中,但是棧空間何時分配,棧空間大小等細節還是沒有過多了解。下文將給大家詳細介紹下堆棧。
棧在計算機中就是一塊連續的存儲區域(至少虛擬地址是連續的),只不過在這塊連續的存儲區域寫入和刪除數據按照先進后出的規則進行,在計算機中使用兩個指針就可以完全描述一個棧,bp(base pointer)指向棧底,sp(stack pointer)指向棧頂。
棧的生命周期
棧的生命周期是和進程的生命周期保持一致的,進程在則棧在,進程亡則棧亡。因此,不妨從進程的生命周期探討棧的生命周期。一個用戶進程從無到開始運行,需要經過幾個重要的步驟:
- Linux首先創建一個task_struct用於管理進程的方方面面。這里只是有了進程的“草圖”,進程還沒有被創建。
- 建立進程的虛擬地址空間,也即建立頁表,建立虛擬地址到物理地址的映射,到這時一個用戶進程所需的基本元素已經具備,一個進程被創建完成,在創建進程的過程中,進程的內核棧也被創建,內核棧不在本文的說明范圍內。
- 接下來就需要可執行文件本身的參與,讀取可執行文件頭,解析文件頭,文件頭的前幾個字節會指出當前文件是何種類型,如果是#!/bin/sh或 #!/bin/python 則該文件是腳本文件,有負責腳本文件的加載程序,本文只關注可執行文件。建立虛擬地址和可執行文件之間的映射。
- 初始化進程環境,其中比較重要的一項便是初始化用戶棧
- 跳轉到可執行文件的入口,執行可執行文件,運行到用戶程序main函數,這其中主要右libc對棧進行管理。
- main()函數通過切換棧幀調用其它子函數,子函數也能通過切換棧幀調用其子函數。
- mian()函數返回,整個進程結束,釋放棧占的內存,棧消失。
結合上面所述以及下圖所示,棧的生命周期可以分為4個部分:
- Linux Kernel創建用戶棧,為棧分配內存空間,處理傳遞給用戶的參數,將參數壓入棧中,壓入指向參數的argv,計算出argc並將其壓棧。
- libc的
_start
函數將 Linux Kernel創建的棧和libc庫函數接上頭,由體系結構相關的匯編語言編寫,核心作用是將棧頂地址賦值給SP,還將Linux設置的棧傳遞、參數傳遞以及一些庫函數的函數指針傳遞給C語言編寫的函數__libc_start_mian
。_start
函數只是起到一個過渡作用,根據CPU的體系結構將Linux Kernel初始化好的棧傳遞給后續的C語言編寫的函數。 - libc的
__libc_start_mian
函數是一個C語言寫的函數,運行到該函數時用戶棧的結構已經是編譯器設計的了,同時由於_start
函數已經設置好了SP的值,各種壓棧、出找操作都在不斷調整SP的值。該函數的功能主要有,main調用前的初始化工作;調用main;main函數返回后的清尾工作。 - 編譯器設計main函數及其調用的子函數的棧。
用戶棧在系統中的位置
對於Linux內核而言,將整個內存空間划分為兩個部分,Kernel Space 和User Space,前者用於支撐Linux Kenrel本身的運行所需空間,后者就是用於支持用戶程序所需的運行空間。用戶棧就是位於用戶空間,一般位於用戶空間的最高部分,向低地址處增長。
用戶進程棧的初始化
在進程剛開始運行的時候,需要知道運行的環境和用戶傳遞給進程的參數,因此Linux在用戶進程運行前,將系統的環境變量和用戶給的參數保存到用戶虛擬地址空間的棧中,從棧基地址處開始存放。若排除棧基地址隨機化的影響,在Linux64bit系統上用戶棧的基地址是固定的:
在x86_64一般設置為0x0000_7FFF_FFFF_F000:
#define STACK_TOP_MAX TASK_SIZE_MAX
#define TASK_SIZE_MAX ((1UL << __VIRTUAL_MASK_SHIFT) - PAGE_SIZE)
#define __VIRTUAL_MASK_SHIFT 47
在ARM64上是可以配置的,可以通過配置CONFIG_ARM64_VA_BITS的值決定棧的基地址:
#define STACK_TOP_MAX TASK_SIZE_64
#define TASK_SIZE_64 (UL(1) << VA_BITS)
#define VA_BITS (CONFIG_ARM64_VA_BITS)
為了防止利用緩沖區溢出,Linux會對棧的基地址做隨機化處理,在開啟地址空間布局隨機化(Address Space Layout Randomization,ASLR)后, 棧的基地址不是一個固定值。
在介紹Linux如何初始化用戶程序棧之前有必要介紹一下虛擬內存區域(Virtual Memory Area, VMA)(還有一篇不錯的中文博客), 因為棧也是通過vma管理的,在初始化棧之前會初始化一個用於管理棧的vma,在Linux上,vma用struct vm_area_struct描述,它描述的是一段連續的、具有相同訪問屬性的虛存空間,該虛存空間的大小為物理內存頁面的整數倍, vm_area_struct 中比較重要的成員是vm_start和vm_end,它們分別保存了該虛存空間的首地址和末地址后第一個字節的地址,以字節為單位,所以虛存空間范圍可以用[vm_start, vm_end)表示。
由於不同虛擬內存區域的屬性不一樣,所以一個進程的虛存空間需要多個vm_area_struct結構來描述。在vm_area_struct結構的數目較少的時候,各個vm_area_struct按照升序排序,以單鏈表的形式組織數據(通過vm_next指針指向下一個vm_area_struct結構)。但是當vm_area_struct結構的數據較多的時候,仍然采用鏈表組織的化,勢必會影響到它的搜索速度。針對這個問題,Linux還使用了紅黑樹組織vm_area_struct,以提高其搜索速度。
Linux 對棧的初始化在系統調用execve中完成,其主要目的有兩個:
- 初始化用戶棧
-
將傳遞給main()函數的參數壓棧
用戶棧的建立是伴隨着可執行文件的加載建立的,Linux內核中使用linux_binprm管理加載的可執行文件,其定義如下:struct linux_binprm { char buf[BINPRM_BUF_SIZE];/*文件的頭128字節,文件頭*/ struct vm_area_struct *vma;/*用於存儲環境變量和參數的空間*/ unsigned long vma_pages;/*vma中page的個數*/ struct mm_struct *mm; unsigned long p; /* current top of mem,vma管理的內存的頂端 */ unsigned int recursion_depth; /* only for search_binary_handler() */ struct file * file; struct cred *cred; /* new credentials */ int unsafe; /* how unsafe this exec is (mask of LSM_UNSAFE_*) */ unsigned int per_clear; /* bits to clear in current->personality */ int argc, envc; /*參數的數目和環境變量的數目*/ const char * filename; /* Name of binary as seen by procps */ const char * interp; /* Name of the binary really executed. Most of the time same as filename, but could be different for binfmt_{misc,script} */ unsigned interp_flags; unsigned interp_data; unsigned long loader, exec; struct rlimit rlim_stack; /* Saved RLIMIT_STACK used during exec. */ } __randomize_layout;
SYSCALL_DEFINE3(execve,
const char __user *, filename, //可執行文件
const char __user *const __user *, argv,//命令行的參數
const char __user *const __user *, envp)//環境變量
{
return do_execve(getname(filename), argv, envp);
}
int do_execve(struct filename *filename, const char __user *const __user *__argv, const char __user *const __user *__envp) {
struct user_arg_ptr argv = { .ptr.native = __argv };
struct user_arg_ptr envp = { .ptr.native = __envp };
return do_execveat_common(AT_FDCWD, filename, argv, envp, 0);
}
static int do_execveat_common(int fd, struct filename *filename, struct user_arg_ptr argv, struct user_arg_ptr envp, int flags) {
char *pathbuf = NULL;
struct linux_binprm *bprm;
struct file *file;
struct files_struct *displaced;
int retval;
bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);
bprm->interp = bprm->filename;
retval = bprm_mm_init(bprm); //建立棧的vma
bprm->argc = count(argv, MAX_ARG_STRINGS);//傳給main()函數的argc
if ((retval = bprm->argc) < 0)
goto out;
bprm->envc = count(envp, MAX_ARG_STRINGS); //envc
if ((retval = bprm->envc) < 0)
goto out;
retval = prepare_binprm(bprm);
if (retval < 0)
goto out;
retval = copy_strings_kernel(1, &bprm->filename, bprm);//復制文件名到vma
if (retval < 0)
goto out;
bprm->exec = bprm->p;
retval = copy_strings(bprm->envc, envp, bprm);//復制環境變量到vma
if (retval < 0)
goto out;
retval = copy_strings(bprm->argc, argv, bprm);//復制參數到vma
if (retval < 0)
goto out;
would_dump(bprm, bprm->file);
retval = exec_binprm(bprm); //執行可執行文件
}
通過對Linux代碼的研究,用戶進程棧的不是一步完成的,大致可以分為三步,一是需要linux建立一個vma用於管理用戶棧,vma的建立主要是在bprm_mm_init中完成的,vma->vm_end設置為STACK_TOP_MAX,這時並沒有棧隨機化的參與,大小為一個PAGE_SIZE。
接着通過以下三個函數的調用分別把文件名,環境變量、參數復制到棧vma中,
retval = copy_strings_kernel(1, &bprm->filename, bprm);
if (retval < 0)
goto out;
bprm->exec = bprm->p;
retval = copy_strings(bprm->envc, envp, bprm);
if (retval < 0)
goto out;
retval = copy_strings(bprm->argc, argv, bprm);
if (retval < 0)
goto out;
第三步主要是在exec_binprm->search_binary_handler->load_elf_binary->setup_arg_pages中完成的。這一步會對棧的基地址做隨機化,並把已經建立起來vma棧復制到基地址隨機化后的棧。
第四步 在函數create_elf_tables中完成,則是分別把argc,指向參數的指針,指向環境變量的指針,elf_info壓棧。
比較重要的一步是start_thread(regs, elf_entry, bprm->p);啟動用戶進程,regs是當前CPU中寄存器的值,elf_entry是用戶程序的進入點, bprm->p是用戶程序的棧指針,根據這3個參數就可以運行一個新的用戶進程了。
start_thread的實現是體系結構相關的,在x86-64上:
static void start_thread_common(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp, unsigned int _cs, unsigned int _ss, unsigned int _ds) {
WARN_ON_ONCE(regs != current_pt_regs());
if (static_cpu_has(X86_BUG_NULL_SEG)) {
/* Loading zero below won't clear the base. */
loadsegment(fs, __USER_DS);
load_gs_index(__USER_DS);
}
loadsegment(fs, 0);
loadsegment(es, _ds);
loadsegment(ds, _ds);
load_gs_index(0);
regs->ip = new_ip;
regs->sp = new_sp;
regs->cs = _cs;
regs->ss = _ss;
regs->flags = X86_EFLAGS_IF;
force_iret();
}
void start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp) {
start_thread_common(regs, new_ip, new_sp,
__USER_CS, __USER_DS, 0);
}
在ARM64上:
static inline void start_thread_common(struct pt_regs *regs, unsigned long pc) {
memset(regs, 0, sizeof(*regs));
forget_syscall(regs);
regs->pc = pc;
}
static inline void start_thread(struct pt_regs *regs, unsigned long pc, unsigned long sp) {
start_thread_common(regs, pc);
regs->pstate = PSR_MODE_EL0t;
regs->sp = sp;
}
不管是ARM64還是X86-64,都是將新的PC和SP復制給當前的current,然后一路路返回到do_execveat_common,從系統調用中斷返回,因為current進程的pc和sp都已經被改變了,會從新的程序入口點elf_entry開始執行,棧也會從bprm->p開始,進程的全新的起點就開始了。新的起點一般不是我們常寫的main函數,而是__start,__start就是elf_entry,其會執行一些初始化工作,最后才調用到main()函數。
每個函數都有屬於自己的一個函數棧幀,假設函數調用關系為:main->func1->func2,那么在執行到func2的時候,該進程的堆棧空間如下所示:
main棧幀 |
func1棧幀 |
func2棧幀 |
棧幀一般包含如下信息:
- 函數的實參和局部變量
- 函數調用鏈接信息-調用函數時要保存某些CPU寄存器的值,如PC,以便返回時能繼續執行下一條指令
下面我們通過匯編函數來簡單的分析一下棧幀的內容以及棧幀是如何分配和回收的。
首先我們寫一段簡單的函數調用C代碼,並將其編譯成匯編文件,亦可通過objdump -dS 命令將可執行文件反匯編得到匯編指令
#include <stdio.h> int foo(int a, int b) { char x =1; int c = 0; c = a + b + x; return c; } int main() { int ret = 0; ret = foo(2, 3); return 0; }
foo: .LFB0: .file 1 "call_no_stack.c" .loc 1 4 0 .cfi_startproc pushq %rbp //rbp入棧 (rsp-8) .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp //rsp 賦值給 rbp,這里rsp並沒有移動,可能是因為這里是最后一個函數調用,所以不需要移動rsp .cfi_def_cfa_register 6 movl %edi, -20(%rbp) //這里通過rbp來訪問棧,將main函數中的實參2放入rbp-20內存 movl %esi, -24(%rbp) //這里表示棧空間分配了24字節,猜測:函數中的參數值從棧頂開始存儲 .loc 1 5 0 movb $1, -5(%rbp) //局部變量x入棧,x占用1個字節,相當於x后入棧:棧的地址是向下減少的 .loc 1 6 0 movl $0, -4(%rbp) //局部變量c入棧,放在rbp-4處 .loc 1 7 0 movl -20(%rbp), %edx movl -24(%rbp), %eax addl %eax, %edx //相加操作 movsbl -5(%rbp), %eax addl %edx, %eax movl %eax, -4(%rbp) .loc 1 8 0 movl -4(%rbp), %eax //將c變量的結果保存到eax寄存器,以便函數返回 .loc 1 9 0 popq %rbp //將堆棧pop,此時棧頂保存着調用函數的rbp值,將棧頂元素賦予rbp寄存器(恢復rbp寄存器) .cfi_def_cfa 7, 8 ret //跳轉回上一層處繼續執行 .cfi_endproc .LFE0: .size foo, .-foo .globl main .type main, @function main: .LFB1: .loc 1 12 0 .cfi_startproc pushq %rbp //rbp:64位寄存器——指向棧底,將rbp寄存器內的值入棧-pushq操作會改變rsp的值 .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp //rsp:64位堆棧指針寄存器——指向棧頂,將rsp值存入rbp寄存器內 .cfi_def_cfa_register 6 subq $16, %rsp //rsp-16,這里講棧頂指針向下移動16字節,相當於為main函數預留了16字節的棧空間-保存局部變量包括實參 .loc 1 13 0 movl $0, -4(%rbp) //對應局部變量ret = 0 .loc 1 14 0 movl $3, %esi //這里直接將實參存入esi寄存器而不是放入堆棧,可加快訪問速度 movl $2, %edi call foo //調用foo函數:call指令有另個作用:1,將call指令的下一條指令入棧-並改變rsp 2,修改程序計數器eip
,跳轉到foo
函數的開頭執行 movl %eax, -4(%rbp) //eax寄存器保存着返回值,這里將eax賦值給rbp-4的位置,也就是ret .loc 1 15 0 movl $0, %eax .loc 1 16 0 leave //leave指令是函數開頭的pushq %rbp
和movq %rsp,%rbp
的逆操作,
//有兩個作用:1,把rbp賦值給rsp 2,然后把該函數棧棧頂保存的rbp值恢復到rbp寄存器中,同時rsp+4(第二部的操作相當於pop棧頂元素) .cfi_def_cfa 7, 8 ret //現在棧頂元素保存的是下一條執行的指令,ret的作用就是pop棧頂元素,並將棧頂元素賦值給程序計數器bip,然后程序跳轉回bip所在地址繼續執行 .cfi_endproc .LFE1: .size main, .-main
上述匯編代碼可以用下圖較為直觀的展示:
可以看出:編譯器生成匯編代碼時,在當前函數開頭,添加對應的對sp/esp/rsp(對應16/32/64位堆棧指針寄存器)的值減去所需堆棧內存大小,即對該函數分配(其實是預留)了堆棧內存。另外需要注意的是,在調用鏈的最后一層,即后續沒有調用其他函數,那么堆棧指針是不會移動(估計和編譯器實現有關)。上述main函數棧幀中,有這么一句:subq $16, %rsp,這個操作直接將棧指針往下移了16個字節,這就是在為堆棧分配空間以保存局部變量和實參。大家可以試一下在函數內分配一個數組,看看生成的匯編有什么變化。
棧空間對齊
這一部分引用相關文章:https://www.cnblogs.com/reload/p/3159053.html https://www.cnblogs.com/tcctw/p/11333743.html
棧的字節對齊,實際是指棧頂指針必須須是16字節的整數倍。棧對齊幫助在盡可能少的內存訪問周期內讀取數據,不對齊堆棧指針可能導致嚴重的性能下降。
上文我們說,即使數據沒有對齊,我們的程序也是可以執行的,只是效率有點低而已,但是某些型號的Intel和AMD處理器對於有些實現多媒體操作的SSE指令,如果數據沒有對齊的話,就無法正確執行。這些指令對16字節內存進行操作,在SSE單元和內存之間傳送數據的指令要求內存地址必須是16的倍數。
因此,任何針對x86_64處理器的編譯器和運行時系統都必須保證分配用來保存可能會被SSE寄存器讀或寫的數據結構的內存,都必須是16字節對齊的,這就形成了一種標准:
- 任何內存分配函數(alloca, malloc, calloc或realloc)生成的塊起始地址都必須是16的倍數。
- 大多數函數的棧幀的邊界都必須是16直接的倍數。
如上,在運行時棧中,不僅傳遞的參數和局部變量要滿足字節對齊,我們的棧指針(%rsp)也必須是16的倍數