分析過程基於Linux kernel 3.18.120

內核棧

Linux上進程的相關屬性在內核中表示為task_struct,該結構體中stack成員指向進程內核棧的棧底:

struct task_struct {
...
void *stack;
...
}

我們知道Linux的子進程創建都是通過復制父進程的task_struct來進行的,所以可以從系統的0號進程着手分析進程內核棧的大小;0號進程為init_task

struct task_struct init_task = INIT_TASK(init_task);

來看看init_taskstack字段的值:

#define INIT_TASK(tsk) \
{ \
...
.stack = &init_thread_info, \
...
}
...
#define init_thread_info (init_thread_union.thread_info)
...
union thread_union init_thread_union;

init_taskstack字段實際上指向thread_union聯合體中的thread_info,再來看一下thread_union的結構:

union thread_union {
struct thread_info thread_info;
unsigned long stack[THREAD_SIZE/sizeof(long)];
};

所以init_task進程的內核棧就是init_thread_union.stack,而thread_info位於內核棧的棧底;內核棧聲明為unsigned long類型的數組,其實際大小與平台相關,即為THREAD_SIZE的定義;對於arm32平台,它的定義為:

/* arch/arm/include/asm/thread_info.h */

#define THREAD_SIZE_ORDER 1
#define THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER)

PAGE_SIZE的定義為

/* arch/arm/include/asm/page.h */

#define PAGE_SHIFT 12
#define PAGE_SIZE (_AC(1,UL) << PAGE_SHIFT)

所以對於arm32平台,PAGE_SIZE大小為4kTHREAD_SIZE大小為8k;此時可以確定 init_task的內核棧大小為8k

前面提到進程的創建是在內核中拷貝父進程的task_struct,來看一下這部分代碼:

static struct task_struct *dup_task_struct(struct task_struct *orig)
{
struct task_struct *tsk;
struct thread_info *ti;
int node = tsk_fork_get_node(orig);
int err;

tsk = alloc_task_struct_node(node);
...
ti = alloc_thread_info_node(tsk, node);
...
err = arch_dup_task_struct(tsk, orig);
...
tsk->stack = ti;
...
setup_thread_stack(tsk, orig);
...
}

在復制task_struct的時候,新的task_struct->stack通過alloc_thread_info_node來分配:

static struct thread_info *alloc_thread_info_node(struct task_struct *tsk,
int node)
{
struct page *page = alloc_kmem_pages_node(node, THREADINFO_GFP,
THREAD_SIZE_ORDER);

return page ? page_address(page) : NULL;
}

這里THREAD_SIZE_ORDER1,所以分配了2page,所以我們可以確定,進程的內核棧大小為8k

用戶棧大小

用戶棧虛擬地址空間最大值

通過ulimit命令可以查看當前系統的進程用戶棧的虛擬地址空間上限,單位為kB

~ # ulimit -s
8192

即當前系統中,用戶棧的虛擬地址空間上限為8M;為了確認這個值的出處,使用strace,確認ulimit執行過程中,使用了哪些系統調用:

-> % strace sh -c "ulimit -s"
...
prlimit64(0, RLIMIT_STACK, NULL, {rlim_cur=8192*1024, rlim_max=RLIM64_INFINITY}) = 0
...

接着到內核中查找該系統調用的實現,函數名為SYSCALL_DEFINE4(prlimit64, .......)

/* kernel/sys.c */

SYSCALL_DEFINE4(prlimit64, pid_t, pid, unsigned int, resource,
const struct rlimit64 __user *, new_rlim,
struct rlimit64 __user *, old_rlim)
{
...
tsk = pid ? find_task_by_vpid(pid) : current;
...
ret = do_prlimit(tsk, resource, new_rlim ? &new : NULL,
old_rlim ? &old : NULL);
...
}

函數的第一個參數為pid,第二個參數為資源的索引;這里可以理解為查找pid0的進程中,RLIMIT_STACK的值;函數查找到pid對應的task_struct,然后調用do_prlimit

/* kernel/sys.c */

int do_prlimit(struct task_struct *tsk, unsigned int resource,
struct rlimit *new_rlim, struct rlimit *old_rlim)
{
struct rlimit *rlim;
...
rlim = tsk->signal->rlim + resource;
...
}

do_prlimit的實現為我們指明了到何處去查找RLIMIT_STACK的值,即tsk->signal->rlim + resource;我們知道0號進程為init_task,所以找到init_task->signal->rlim進行確認

/* include/linux/init_task.h */

#define INIT_TASK(tsk) \
{
...
.signal = &init_signals, \
...
}

...

#define INIT_SIGNALS(sig) { \
...
.rlim = INIT_RLIMITS, \
...
}

接着找到INIT_RLIMITS宏的定義

/* include/asm-generic/resource.h */

#define INIT_RLIMITS \
{ \
...
[RLIMIT_STACK] = { _STK_LIM, RLIM_INFINITY }, \
...
}

_STK_LIM即為當前系統中,進程用戶棧的虛擬地址空間上限:

/* include/uapi/linux/resource.h */

#define _STK_LIM (8*1024*1024)

當前用戶棧虛擬地址空間大小

可以從proc文件系統中,查看進程的虛擬地址空間分布;以init進程為例,其pid為1,可以通過以下命令查看init進程的虛擬地址空間分布,在arm32平台,內核版本3.18.120init進程的用戶棧空間大小為132kB

~ # cat /proc/1/smaps
...
beec2000-beee3000 rw-p 00000000 00:00 0 [stack]
Size: 132 kB
...

仔細觀察會發現,任意進程在啟動后,其棧空間大小基本都是132kB;在分析原因之前,我們先來看一下進程的虛擬地址空間分布:

進程虛擬地址空間-進程虛擬地址空間.png

進程的虛擬地址空間大小為4GB,其中內核空間1GB,用戶空間3GB,在arm32平台上,二者之間存在一個大小為16M的空隙;用戶空間的准確大小為TASK_SIZE

/* arch/arm/include/asm/memory.h */

#define TASK_SIZE (UL(CONFIG_PAGE_OFFSET) - UL(SZ_16M))

即用戶空間的地址范圍為0x00000000~0xBEFFFFFF

上圖左側為用戶空間內的虛擬空間分布,分別為:用戶棧(向下增長),內存映射段(向下增長),堆(向上增長)以及BSSDataText;我們關注的重點在用戶空間中的棧空間。

在Linux系統中,運行二進制需要通過exec族系統調用進行,例如execveexeclexecv等,而這些函數最終都會切換到kernel space,調用do_execve_common(),我們從這個函數開始分析:

static int do_execve_common(struct filename *filename,
struct user_arg_ptr argv,
struct user_arg_ptr envp)
{
...
file = do_open_exec(filename); // 在內核中打開可執行文件
...
retval = bprm_mm_init(bprm); // 初始化進程內存空間描述符
...
/* 拷貝文件名、環境變量和執行參數到bprm */
retval = copy_strings_kernel(1, &bprm->filename, bprm);
...
retval = copy_strings(bprm->envc, envp, bprm);
...
retval = copy_strings(bprm->argc, argv, bprm);
...
retval = exec_binprm(bprm); // 處理bprm
...
}

函數中的bprm是類型為struct linux_binprm的結構體,主要用來存儲運行可執行文件時所需要的參數,如虛擬內存空間vma、內存描述符mm、還有文件名和環境變量等信息:

struct linux_binprm {
...
struct vm_area_struct *vma;
...
struct mm_struct *mm;
unsigned long p; /* current top of mem */
...
int argc, envc;
const char * filename; /* Name of binary as seen by procps */
...
};

接着回到do_execve_common函數,在調用bprm_mm_init初始化內存空間描述符時,第一次為進程的棧空間分配了一個頁:

/*
* 文件:fs/exec.c
* 函數調用關系:do_execve_common()->bprm_mm_init()->__bprm_mm_init()
*/

static int __bprm_mm_init(struct linux_binprm *bprm)
{
...
vma->vm_end = STACK_TOP_MAX;
vma->vm_start = vma->vm_end - PAGE_SIZE;
...
}

這里的vma就是進程的棧虛擬地址空間,這段vma區域的結束地址設置為STACK_TOP_MAX,大小為PAGE_SIZE;這兩個宏的定義如下:

/* arch/arm/include/asm/processor.h */
#define STACK_TOP_MAX TASK_SIZE

/* arch/arm/include/asm/memory.h */
#define TASK_SIZE (UL(CONFIG_PAGE_OFFSET) - UL(SZ_16M)) // CONFIG_PAGE_OFFSET定義為0xC0000000

/* arch/arm/include/asm/page.h */
#define PAGE_SHIFT 12
#define PAGE_SIZE (_AC(1,UL) << PAGE_SHIFT)

此時,進程的棧空間如下圖所示:

進程虛擬地址空間-bprm_mm_init.png

繼續回到do_execve_common()函數,到目前為止,內核還沒有識別到可執行文件的格式,也沒有解析可執行文件中各個段的數據;在exec_binprm()中,會遍歷在內核中注冊支持的可執行文件格式,並調用該格式的load_binary方法來處理對應格式的二進制文件:

/*
* 文件:fs/exec.c
* 函數調用關系:do_execve_common()->exec_binprm()->search_binary_handler()
*/

int search_binary_handler(struct linux_binprm *bprm)
{
struct linux_binfmt *fmt;
...
list_for_each_entry(fmt, &formats, lh) {
...
retval = fmt->load_binary(bprm);
...
}
...
}

search_binary_handler()會依次調用系統中注冊的可執行文件格式load_binary()方法;load_binary()方法中會自行識別當前二進制格式是否支持;以ELF格式為例,其注冊的load_binary方法為load_elf_binary()

/* fs/binfmt_elf.c */

static int load_elf_binary(struct linux_binprm *bprm)
{
...
for (i = 0; i < loc->elf_ex.e_phnum; i++) {
...
retval = kernel_read(bprm->file, elf_ppnt->p_offset, // 讀取ELF中的各個段
elf_interpreter,
elf_ppnt->p_filesz);
...
}
...
retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
executable_stack);
...
current->mm->start_stack = bprm->p;
...
}

該函數的實現比較復雜,這里我們重點關注setup_arg_pages()函數。

int setup_arg_pages(struct linux_binprm *bprm,
unsigned long stack_top,
int executable_stack)
{
...
stack_top = arch_align_stack(stack_top);
stack_top = PAGE_ALIGN(stack_top);
...
stack_shift = vma->vm_end - stack_top;
...
/* Move stack pages down in memory. */
if (stack_shift) {
ret = shift_arg_pages(vma, stack_shift); // 移動arg pages
...
}
...
stack_expand = 131072UL; /* randomly 32*4k (or 2*64k) pages */
...
if (stack_size + stack_expand > rlim_stack)
stack_base = vma->vm_end - rlim_stack;
else
stack_base = vma->vm_start - stack_expand;
...
ret = expand_stack(vma, stack_base);
...
}

前面我們已經初始化了一個頁的棧空間,用來存放二進制文件名、參數和環境變量等;在setup_arg_pages()中,我們把前面這一個頁的棧空間移動到stack_top的位置;在調用函數時,stack_top的值是randomize_stack_top(STACK_TOP),即一個隨機地址,這里是為了安全性而實現的棧地址隨機化;函數通過shift_arg_pages()將頁移動到新的地址,移動后的棧如下圖所示:

進程虛擬地址空間-shift_arg_pages.png

接着回到setup_arg_pages()函數,關注如下代碼:

stack_expand = 131072UL; /* randomly 32*4k (or 2*64k) pages */
...
if (stack_size + stack_expand > rlim_stack)
stack_base = vma->vm_end - rlim_stack;
else
stack_base = vma->vm_start - stack_expand;
...
ret = expand_stack(vma, stack_base);

expand_stack()函數用來擴展棧虛擬地址空間的大小,stack_base是新的棧基地址,這里的stack_expand是一個固定值,大小為128k,即此處將棧空間擴展128k的大小,擴展后棧空間如下:

進程虛擬地址空間-expand_stack.png

所以擴展后的棧虛擬地址空間為4kB+128kB,剛剛好132kB.

棧頂地址隨機化

前面介紹setup_arg_pages()函數移動棧頂的時候提到,出於安全原因,會將棧頂移動到一個隨機的地址:

/*
* 文件:fs/binfmt_elf.c
* 函數調用關系:load_elf_binary()->setup_arg_pages()
*/

static int load_elf_binary(struct linux_binprm *bprm)
{
...
retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
executable_stack);
...
}

這里randomize_stack_top(STACK_TOP)就是將STACK_TOP進行隨機化處理,在我們的平台上。STACK_TOPSTACK_TOP_MAX的值相同,為0xBF000000;我們來分析一下randomize_stack_top()函數:


/* fs/binfmt_elf.c */

#ifndef STACK_RND_MASK
#define STACK_RND_MASK (0x7ff >> (PAGE_SHIFT - 12)) /* 8MB of VA */
#endif

static unsigned long randomize_stack_top(unsigned long stack_top)
{
unsigned long random_variable = 0;

if ((current->flags & PF_RANDOMIZE) &&
!(current->personality & ADDR_NO_RANDOMIZE)) {
random_variable = (unsigned long) get_random_int();
random_variable &= STACK_RND_MASK;
random_variable <<= PAGE_SHIFT;
}
#ifdef CONFIG_STACK_GROWSUP
return PAGE_ALIGN(stack_top) + random_variable;
#else
return PAGE_ALIGN(stack_top) - random_variable;
#endif
}

函數整體非常好理解,就是獲取一個隨機值,再根據棧向上還是向下增長,將棧頂地址加上或減去這個隨機值;我們重點關注下面兩行:

``` C
random_variable &= STACK_RND_MASK;
random_variable <<= PAGE_SHIFT;

STACK_RND_MASK的值為0x7FFPAGE_SHIFT12;第一行將獲取的隨機值范圍限制在0~0x7FF的范圍內;第二行將該值左移12位,這樣得到的隨機數范圍就變成了0~0x7FF000,可以理解為棧頂地址是在一個8MB的范圍內取一個4kB對齊的隨機值

線程的用戶棧

我們知道在Linux系統上,無論是進程還是線程,都是通過clone系統調用來創建,區別是傳入的參數不同;為了確認創建線程時使用的參數,我准備了一個測試程序,然后使用strace來確認:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

void *function(void *arg) {
printf("function call\n");
}

int main() {
pthread_t thread;
pthread_create(&thread, NULL, function, NULL);
pthread_join(thread,NULL);
return 0;
}

該程序的strace部分輸出(在x86平台上運行):

clone(child_stack=0x7fd2500d0fb0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, parent_tid=[36747], tls=0x7fd2500d1700, child_tidptr=0x7fd2500d19d0) = 36747

我們可以看到調用clone的時候傳入的flags,其中與內存相關最重要的flagsCLONE_VM;接着我們來看內核部分的源碼,仍然從copy_process()函數開始:

/* kernel/fork.c */

static struct task_struct *copy_process(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *child_tidptr,
struct pid *pid,
int trace)
{
...
retval = copy_mm(clone_flags, p);
...
}

...

static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
struct mm_struct *mm, *oldmm;
...
oldmm = current->mm;
...
if (clone_flags & CLONE_VM) {
atomic_inc(&oldmm->mm_users);
mm = oldmm;
goto good_mm;
}
...
}

copy_mm中,檢查了clone_flags,如果設置了CLONE_VM,那么將當前task_struct->mm指針賦值給新的task_struct->mm;所以我們可以得到結論,通過pthread庫創建的線程,其內存是與主線程共享的。