1. 背景知识
1.1 Linux进程、线程的内存布局
在各种有栈协程的实现中,不论是独立协程栈还是共享栈,都依托于线程栈的基础,而线程又共享使用进程的地址空间。
为了真正理解协程栈以及在协程切换时的栈保存以及恢复过程,首先需要彻底理解进程的地址空间以及线程栈如何被管理和使用。
这里不讨论进程与线程如何的异同,关注重点在于内存地址空间如何分配使用。
1.1.1 进程的地址空间
对于Linux 64位系统,理论上,64bit内存地址可用空间为0x0000000000000000 ~ 0xFFFFFFFFFFFFFFFF(16位十六进制数),这是个相当庞大的空间,Linux实际上只用了其中一小部分(256T)。
X86_64架构4级页表下(注意在5级页表下虚拟内存地址空间会更加庞大),实际用到的地址空间为0x0000000000000000 ~ 0x00007FFFFFFFFFFF(user space)和0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF(kernel space),其余的都是未被使用的空洞。
也就是说,在64位的巨大地址空间中,仅使用了低地址的128TB用作用户虚拟内存空间,高地址的128TB用作内核虚拟内存空间。就像一个夹心巨厚的三明治,只吃掉了上下两层面包,而中间的夹心扔掉了。
开始地址 | 结束地址 | 空间大小 | 用途 |
---|---|---|---|
0x0000000000000000 | 0x00007FFFFFFFFFFF | 128TB | 用户态虚拟内存空间,每个进程独立 |
0x0000800000000000 | 0xFFFF7FFFFFFFFFFF | 约16M TB | 巨大的空洞,未使用空间 |
0xFFFF800000000000 | 0xFFFFFFFFFFFFFFFF | 128TB | 内核态虚拟内存空间,所有进程共享 |
128TB的内核态虚拟内存空间
划分大致如下:
8TB虚拟化预留空间、64TB直接物理内存映射空间(page_offset_base)、32TB vmalloc/ioremap空间、1TB虚拟内存映射空间、16TB KASAN 镜像空间、以及一些GB或MB单位的小型空间
由此可见,64位Linux能支持的最大物理内存为64TB。-
128TB的用户态虚拟内存空间
借用一张32位的分布图,没有找到合适的64位的图
可见从低地址向高地址,依次有主要的几个Segment:
- Text Segment:代码段,加载保存进程的二进制程序
- Data Segment:数据段, 保存被初始化的静态局部变量或被初始化的全局变量
- BSS Segment: 保存未被初始化的静态局部变量或未被初始化的全局变量
- Heap: 堆空间, 由brk分配的内存,地址向上增长
- Memory Mapping Segment: 由mmap分配的内存,地址向下增长,与Heap的增长方向相反,二者向中间靠拢。二者在编程时通常都是调用库函数malloc分配出来的。文件映射,包括动态库文件的映射,也在此段空间。
- Stack: 栈空间,从高地址向低地址增长,Stack Size受限于系统RLIMIT_STACK,默认为8MB。
下面用一个简单的程序来观察进程的内存分布
test.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
static char *sv_inited1 = "hello"; //用于观察数据段Data Segment
static char *sv_inited2 = "world"; //用于观察数据段Data Segment
static char *sv_uninited1; //用于观察BSS Segment
static char *sv_uninited2; //用于观察BSS Segment
int main() {
int v_stack1 = 1; //用于观察栈 - stack
void *fromBrk1 = malloc(64); //用于观察堆 - heap
void *fromBrk2 = malloc(64); //用于观察堆 - heap
void *from_mmap1 =
malloc(4 * 1024 * 1024); //用于观察mmap位置 - Memory Mapping
void *from_mmap2 = malloc(10 * 1024 * 1024); //用于观察mmap位置
printf("Data Segment: sv_inited1: %p\n", &sv_inited1);
printf("Data Segment: sv_inited2: %p\n", &sv_inited2);
printf("sv_inited1 > sv_inited2 %d\n\n", &sv_inited1 > &sv_inited2);
printf("BSS Segment: sv_uninited1: %p\n", &sv_uninited1);
printf("BSS Segment: sv_uninited2: %p\n", &sv_uninited2);
printf("sv_uninited1 > sv_uninited2 %d\n\n", &sv_uninited1 > &sv_uninited2);
printf("gap between Heap and BSS: %lu \n\n",
(unsigned long)fromBrk1 - (unsigned long)&sv_uninited2);
printf("Heap: fromBrk1: %p\n", fromBrk1);
printf("Heap: fromBrk2: %p\n", fromBrk2);
printf("fromBrk1 > fromBrk2 %d\n\n", fromBrk1 > fromBrk2);
printf("Heap bottom to MMapping top: %lu \n\n",
(unsigned long)from_mmap1 - (unsigned long)fromBrk1);
printf("Memory Mapping: from_mmap1: %p\n", from_mmap1);
printf("Memory Mapping: from_mmap2: %p\n", from_mmap2);
printf("from_mmap1 > from_mmap2 %d, from_mmap1-from_mmap2=%ld\n\n",
from_mmap1 > from_mmap2, from_mmap1 - from_mmap2);
printf("gap between Memory Mapping and Stack: %lu \n\n",
(unsigned long)&v_stack1 - (unsigned long)from_mmap1);
printf("Stack: v_stack1: %p\n", &v_stack1);
getchar();
return 0;
}
使用objdump观察一下段概要信息,省略掉一些Segment
这里只能看到text到bss段,后面的Heap、Memory Mapping、Stack只能在运行时来观察。
可以看出,text、data、bss段的排列紧凑而且都很小,之间没有明显的间隙。
$ objdump -h a.out
a.out: 文件格式 elf64-x86-64
节:
Idx Name Size VMA LMA File off Algn
0 .interp 0000001c 0000000000000238 0000000000000238 00000238 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
... ...
13 .text 000003c2 0000000000000670 0000000000000670 00000670 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
... ...
22 .data 00000020 0000000000202000 0000000000202000 00002000 2**3
CONTENTS, ALLOC, LOAD, DATA
23 .bss 00000018 0000000000202020 0000000000202020 00002020 2**3
ALLOC
... ...
然后结合程序输出和pmap来观察运行时的情况
$ pmap -x 25520
25520: ./a.out
住址 Kbytes RSS Dirty Mode Mapping
00005637323af000 4 4 0 r-x-- a.out
00005637323af000 0 0 0 r-x-- a.out
00005637325b0000 4 4 4 r---- a.out
00005637325b0000 0 0 0 r---- a.out
00005637325b1000 4 4 4 rw--- a.out
00005637325b1000 0 0 0 rw--- a.out
0000563732c23000 132 4 4 rw--- [ anon ]
0000563732c23000 0 0 0 rw--- [ anon ]
00007fdff42dc000 2052 4 4 rw--- [ anon ]
00007fdff42dc000 0 0 0 rw--- [ anon ]
00007fdff44dd000 1948 1156 0 r-x-- libc-2.27.so
00007fdff44dd000 0 0 0 r-x-- libc-2.27.so
00007fdff46c4000 2048 0 0 ----- libc-2.27.so
00007fdff46c4000 0 0 0 ----- libc-2.27.so
00007fdff48c4000 16 16 16 r---- libc-2.27.so
00007fdff48c4000 0 0 0 r---- libc-2.27.so
00007fdff48c8000 8 8 8 rw--- libc-2.27.so
00007fdff48c8000 0 0 0 rw--- libc-2.27.so
00007fdff48ca000 16 12 12 rw--- [ anon ]
00007fdff48ca000 0 0 0 rw--- [ anon ]
00007fdff48ce000 164 164 0 r-x-- ld-2.27.so
00007fdff48ce000 0 0 0 r-x-- ld-2.27.so
00007fdff49ca000 1036 12 12 rw--- [ anon ]
00007fdff49ca000 0 0 0 rw--- [ anon ]
00007fdff4af7000 4 4 4 r---- ld-2.27.so
00007fdff4af7000 0 0 0 r---- ld-2.27.so
00007fdff4af8000 4 4 4 rw--- ld-2.27.so
00007fdff4af8000 0 0 0 rw--- ld-2.27.so
00007fdff4af9000 4 4 4 rw--- [ anon ]
00007fdff4af9000 0 0 0 rw--- [ anon ]
00007ffd8fb29000 132 12 12 rw--- [ stack ]
00007ffd8fb29000 0 0 0 rw--- [ stack ]
00007ffd8fb6f000 12 0 0 r---- [ anon ]
00007ffd8fb6f000 0 0 0 r---- [ anon ]
00007ffd8fb72000 4 4 0 r-x-- [ anon ]
00007ffd8fb72000 0 0 0 r-x-- [ anon ]
ffffffffff600000 4 0 0 --x-- [ anon ]
ffffffffff600000 0 0 0 --x-- [ anon ]
---------------- ------- ------- -------
total kB 7596 1416 88
提取几条关键信息:
4 4 0 r-x-- a.out # text段起始位置
132 4 4 rw--- [ anon ] #大致是Heap起始位置(低地址)
0 0 0 rw--- ld-2.27.so #大致是MMapping结束位置(高地址)
132 12 12 rw--- [ stack ] #stack高地址
再看程序输出:
Data Segment: sv_inited1: 0x5637325b1010
Data Segment: sv_inited2: 0x5637325b1018
sv_inited1 > sv_inited2 0
BSS Segment: sv_uninited1: 0x5637325b1028
BSS Segment: sv_uninited2: 0x5637325b1030
sv_uninited1 > sv_uninited2 0
gap between Heap and BSS: 6758960
Heap: fromBrk1: 0x563732c23260
Heap: fromBrk2: 0x563732c232b0
fromBrk1 > fromBrk2 0
Heap bottom to MMapping top: 45804783562160
Memory Mapping: from_mmap1: 0x7fdff49ca010
Memory Mapping: from_mmap2: 0x7fdff42dc010
from_mmap1 > from_mmap2 1, from_mmap1-from_mmap2=7266304
gap between Memory Mapping and Stack: 127156083924
Stack: v_stack1: 0x7ffd8fb47ce4
从关键提取信息和程序输出来看,二者各段地址大致相当,比较吻合。
从程序输出来看,
Heap bottom to MMapping top: 45804783562160
这是一个非常大的空间,40多TB,这里就是malloc的发挥空间了。
gap between Memory Mapping and Stack: 127156083924
这里有接近120GB的空间,由于栈向低地址增长,所以理论上栈空间可以占用这部分,但是栈大小受到操作系统限制,可以通过ulimit -s来查看,单位是KB,默认是8192 ,8MB。
1.1.2 线程上下文
在多线程程序中,多个线程并发执行,全局变量、Heap上的数据块(指针)、文件映射等可以共享访问,共享同一份程序二进制(代码段)。但是每个线程有其独立的上下文,比如相同的指令执行路径但各自不同的指令执行位置或者完全不同的指令执行路径,不同的局部变量值,不同的状态。那么这些东西是如何为每个线程独立维护的呢?
建议读一读这篇文章,作者分析得很清晰,图文并茂
https://www.51cto.com/article/719916.html
文章中讲解了从父进程fork出子进程的过程,以及关键的内核数据结构struct task_struct
,它在内核中代表了一个进程。
借两张图过来,画得真好,就不自己造轮子了。
在我们的主题中,最为关心的是mm_struct所表示的用户态虚拟内存空间,它在进程和线程之间,有些怎样的共享和独立的关系,这对于未来协程栈的建立非常重要。
看看pthread库中创建一个线程的过程,它与fork一个子进程的差别在哪里呢?
pthread是在glibc中实现的,这里可以找到其源代码,笔者惯用Ubuntu,所以使用了Ubuntu20.04的分支
$ git clone https://git.launchpad.net/ubuntu/+source/glibc
$ git checkout ubuntu/focal-devel
pthread_create的实现函数在nptl/pthread_create.c
中
代码比较长,我么尽可能摘取重要的部分,去掉那些异常分支部分,缩略后的代码分析如下:
int
__pthread_create_2_1 (pthread_t *newthread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg)
{
STACK_VARIABLES; #void *stackaddr = NULL 用来标识栈顶位置
const struct pthread_attr *iattr = (struct pthread_attr *) attr; # 通常这个入参是个NULL
struct pthread_attr default_attr;
bool free_cpuset = false;
bool c11 = (attr == ATTR_C11_THREAD);
if (iattr == NULL || c11)
{
lll_lock (__default_pthread_attr_lock, LLL_PRIVATE);
default_attr = __default_pthread_attr;
... iattr 通常是使用默认的attr ...
iattr = &default_attr;
}
struct pthread *pd = NULL;
int err = ALLOCATE_STACK (iattr, &pd);
int retval = 0;
pd->start_routine = start_routine;
pd->arg = arg;
pd->c11 = c11;
/* Copy the thread attribute flags. */
struct pthread *self = THREAD_SELF;
pd->flags = ((iattr->flags & ~(ATTR_FLAG_SCHED_SET | ATTR_FLAG_POLICY_SET))
| (self->flags & (ATTR_FLAG_SCHED_SET | ATTR_FLAG_POLICY_SET)));
/* Initialize the field for the ID of the thread which is waiting
for us. This is a self-reference in case the thread is created
detached. */
pd->joinid = iattr->flags & ATTR_FLAG_DETACHSTATE pd : NULL;
/* The debug events are inherited from the parent. */
pd->eventbuf = self->eventbuf;
/* Copy the parent's scheduling parameters. The flags will say what
is valid and what is not. */
pd->schedpolicy = self->schedpolicy;
pd->schedparam = self->schedparam;
/* Copy the stack guard canary. */
#ifdef THREAD_COPY_STACK_GUARD
THREAD_COPY_STACK_GUARD (pd);
#endif
/* Copy the pointer guard value. */
#ifdef THREAD_COPY_POINTER_GUARD
THREAD_COPY_POINTER_GUARD (pd);
#endif
/* Setup tcbhead. */
tls_setup_tcbhead (pd);
/* Verify the sysinfo bits were copied in allocate_stack if needed. */
#ifdef NEED_DL_SYSINFO
CHECK_THREAD_SYSINFO (pd);
#endif
/* Inform start_thread (above) about cancellation state that might
translate into inherited signal state. */
pd->parent_cancelhandling = THREAD_GETMEM (THREAD_SELF, cancelhandling);
/* Determine scheduling parameters for the thread. */
if (__builtin_expect ((iattr->flags & ATTR_FLAG_NOTINHERITSCHED) != 0, 0)
&& (iattr->flags & (ATTR_FLAG_SCHED_SET | ATTR_FLAG_POLICY_SET)) != 0)
{
/* Use the scheduling parameters the user provided. */
if (iattr->flags & ATTR_FLAG_POLICY_SET)
{
pd->schedpolicy = iattr->schedpolicy;
pd->flags |= ATTR_FLAG_POLICY_SET;
}
if (iattr->flags & ATTR_FLAG_SCHED_SET)
{
/* The values were validated in pthread_attr_setschedparam. */
pd->schedparam = iattr->schedparam;
pd->flags |= ATTR_FLAG_SCHED_SET;
}
if ((pd->flags & (ATTR_FLAG_SCHED_SET | ATTR_FLAG_POLICY_SET))
!= (ATTR_FLAG_SCHED_SET | ATTR_FLAG_POLICY_SET))
collect_default_sched (pd);
}
if (__glibc_unlikely (__nptl_nthreads == 1))
_IO_enable_locks ();
/* Pass the descriptor to the caller. */
*newthread = (pthread_t) pd;
LIBC_PROBE (pthread_create, 4, newthread, attr, start_routine, arg);
/* One more thread. We cannot have the thread do this itself, since it
might exist but not have been scheduled yet by the time we've returned
and need to check the value to behave correctly. We must do it before
creating the thread, in case it does get scheduled first and then
might mistakenly think it was the only thread. In the failure case,
we momentarily store a false value; this doesn't matter because there
is no kosher thing a signal handler interrupting us right here can do
that cares whether the thread count is correct. */
atomic_increment (&__nptl_nthreads);
/* Our local value of stopped_start and thread_ran can be accessed at
any time. The PD->stopped_start may only be accessed if we have
ownership of PD (see CONCURRENCY NOTES above). */
bool stopped_start = false; bool thread_ran = false;
/* Start the thread. */
if (__glibc_unlikely (report_thread_creation (pd)))
{
stopped_start = true;
/* We always create the thread stopped at startup so we can
notify the debugger. */
retval = create_thread (pd, iattr, &stopped_start,
STACK_VARIABLES_ARGS, &thread_ran);
if (retval == 0)
{
/* We retain ownership of PD until (a) (see CONCURRENCY NOTES
above). */
/* Assert stopped_start is true in both our local copy and the
PD copy. */
assert (stopped_start);
assert (pd->stopped_start);
/* Now fill in the information about the new thread in
the newly created thread's data structure. We cannot let
the new thread do this since we don't know whether it was
already scheduled when we send the event. */
pd->eventbuf.eventnum = TD_CREATE;
pd->eventbuf.eventdata = pd;
/* Enqueue the descriptor. */
do
pd->nextevent = __nptl_last_event;
while (atomic_compare_and_exchange_bool_acq (&__nptl_last_event,
pd, pd->nextevent)
!= 0);
/* Now call the function which signals the event. See
CONCURRENCY NOTES for the nptl_db interface comments. */
__nptl_create_event ();
}
}
else
retval = create_thread (pd, iattr, &stopped_start,
STACK_VARIABLES_ARGS, &thread_ran);
if (__glibc_unlikely (retval != 0))
{
if (thread_ran)
/* State (c) or (d) and we may not have PD ownership (see
CONCURRENCY NOTES above). We can assert that STOPPED_START
must have been true because thread creation didn't fail, but
thread attribute setting did. */
/* See bug 19511 which explains why doing nothing here is a
resource leak for a joinable thread. */
assert (stopped_start);
else
{
/* State (e) and we have ownership of PD (see CONCURRENCY
NOTES above). */
/* Oops, we lied for a second. */
atomic_decrement (&__nptl_nthreads);
/* Perhaps a thread wants to change the IDs and is waiting for this
stillborn thread. */
if (__glibc_unlikely (atomic_exchange_acq (&pd->setxid_futex, 0)
== -2))
futex_wake (&pd->setxid_futex, 1, FUTEX_PRIVATE);
/* Free the resources. */
__deallocate_stack (pd);
}
/* We have to translate error codes. */
if (retval == ENOMEM)
retval = EAGAIN;
}
else
{
/* We don't know if we have PD ownership. Once we check the local
stopped_start we'll know if we're in state (a) or (b) (see
CONCURRENCY NOTES above). */
if (stopped_start)
/* State (a), we own PD. The thread blocked on this lock either
because we're doing TD_CREATE event reporting, or for some
other reason that create_thread chose. Now let it run
free. */
lll_unlock (pd->lock, LLL_PRIVATE);
/* We now have for sure more than one thread. The main thread might
not yet have the flag set. No need to set the global variable
again if this is what we use. */
THREAD_SETMEM (THREAD_SELF, header.multiple_threads, 1);
}
out:
if (__glibc_unlikely (free_cpuset))
free (default_attr.cpuset);
return retval;
}
versioned_symbol (libpthread, __pthread_create_2_1, pthread_create, GLIBC_2_1);
1.1节参考资料
https://www.kernel.org/doc/Documentation/x86/x86_64/mm.txt
https://www.51cto.com/article/719916.html](https://www.51cto.com/article/719916.html
1.2 X86_64寄存器概要
为了理解有栈协程在上下文切换时进行的寄存器保存和恢复过程,需要掌握寄存器的基础知识,重点理解函数调用过程中,涉及的主要寄存器的作用,参数及返回值的传递,以及函数栈切换过程。
寄存器的设计与硬件平台架构紧密相关,下面以X86_64架构为例,其它的架构可能有巨大的差异。
1.2.1 16个通用寄存器
下面8个寄存器,由32位的通用寄存器扩展而来,r开头的表示64位寄存器,e开头等于原先32位的寄存器。
名称 | 0-63位 | 0-31位 | 0-15位 | 0-7位 | 8-15位 |
---|---|---|---|---|---|
栈顶指针寄存器 | rsp | esp | sp | spl | sph |
基址指针寄存器 | rbp | ebp | bp | bpl | bph |
基址寄存器 | rbx | ebx | bx | bl | bh |
目的变址寄存器 | rdi | edi | di | dil | dih |
源变址寄存器 | rsi | esi | si | sil | sih |
数据寄存器 | rdx | edx | dx | dl | dh |
计数寄存器 | rcx | ecx | cx | cl | ch |
累加寄存器 | rax | eax | ax | al | ah |
64位架构新增了8个通用寄存器,r8 - r15, 其32位、16位、8位寄存器分别加上d、w、b后缀
0-63位 | 0-31位 | 0-15位 | 0-7位 |
---|---|---|---|
r8 | r8d | r8w | r8b |
r9 | r9d | r9w | r9b |
r10 | r10d | r10w | r10b |
r11 | r11d | r11w | r11b |
r12 | r12d | r12w | r12b |
r13 | r13d | r13w | r13b |
r14 | r14d | r14w | r14b |
r15 | r15d | r15w | r15b |
-
RSP(ESP) : stack pointer , 栈指针寄存器
rsp寄存器正常情况下,存放的是栈顶地址,若用于其它用途,则使用完以后,应该恢复其原值。 -
RBP(EBP): base pointer,基址寄存器
rbp寄存器正常情况下,存放的是栈底地址,若用于其它用途,则使用完以后,应该恢复其原值。通常使用rbp+偏移量的形式来定位函数存放在栈中的局部变量。 - RAX(EAX): accumulator register,累加寄存器,通常用于存储函数的返回值。它不仅可用于存储函数返回值,也可以用于其它,只是用于存储返回值属于约定俗成的惯例。
下面用一段简单的代码来观察RSP、RBP、RAX(EAX)寄存器是如何被使用的
C代码如下 test.c
:
#include <stdint.h>
uint64_t func1() {
return 0xFFFFFFFFFFFF; // 一个64位以内但超过32位最大值的整数
}
int main() {
int ret = (int)func1();
return ret;
}
将上面的代码编译成汇编代码:
gcc -S -o test.S test.c
.file "test.c"
.text
.globl func1
.type func1, @function
func1:
.LFB0:
.cfi_startproc
pushq %rbp # 压栈,保存rbp寄存器初值
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp # rbp寄存器值修改为rsp寄存器值
.cfi_def_cfa_register 6
movabsq 1474976710655, %rax # 将0xFFFFFFFFFFFF这个值写入rax寄存器,用于函数返回值
# 由于这个数值超出了32位,编译器认为应该使用rax寄存器,而不是eax来返回
popq %rbp # 出栈,恢复rbp寄存器在进入函数func1时的初始值
.cfi_def_cfa 7, 8
ret # func1返回,main函数中就可以通过读取rax寄存器,得到func1的返回值
.cfi_endproc
.LFE0:
.size func1, .-func1
.globl main
.type main, @function
main:
.LFB1:
.cfi_startproc
pushq %rbp # 压栈,保存rbp寄存器初值
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp # rbp寄存器值修改为rsp寄存器值
.cfi_def_cfa_register 6
subq , %rsp # rsp寄存器减16,栈增长16字节,预留了16字节空白内存,注意rbp保存了这16字节的高地址
movl func1( rbp压栈保存 -> rbp赋值为rsp -> 返回值写入rax -> 出栈恢复rbp -> return) , %eax # eax寄存器清零,因为main返回4字节的int,所以eax寄存器就足够了,不需要rax寄存器
call func1 # 调用func1, func1中,将其64位的返回值写入了rax寄存器,自然低32位的eax也被改写了
movl %eax, -4(%rbp) # 读取eax值写入rbp地址-4的位置,这个位置就是变量ret的栈地址
movl -4(%rbp), %eax # 将变量ret地址的值,写入eax寄存器,作为main函数返回值
leave # 关闭栈帧指令,恢复rbp和rsp寄存器,等于 movq %rbp %rsp + popq %rbp
.cfi_def_cfa 7, 8
ret # main函数返回
.cfi_endproc
.LFE1:
.size main, .-main
.ident "GCC: (Ubuntu 9.4.0-1ubuntu1~18.04) 9.4.0"
.section .note.GNU-stack,"",@progbits
回顾一下过程,
main函数开始 -> rbp压栈保存 -> rbp赋值为rsp -> rsp下移16字节开辟栈帧 -> call func1
-
那么,[rsp, rbp) 这个地址区间,即为当前函数的栈帧
-> eax寄存器值写入变量ret地址 -> ret地址值写入eax寄存器 -> 恢复rsp和rbp -> return
总结来看,在调用函数时,调用者将rsp设置为新开辟的栈帧高地址,被调用函数使用此地址作为自己的基址来设置rbp寄存器,而后使用rbp寄存器+偏移的方式来访问函数内的局部变量,函数结束时,返回之前,需要恢复rsp和rbp寄存器,ret之后,调用者回到自己的栈帧,rsp和rbp值恢复到调用前的值。而rax或eax寄存器总是用来传递函数返回值,编译器基于返回值类型决定使用32位还是64位的寄存器。
RDI(EDI)。
继续探究其它的通用寄存器
- RSI(ESI): destination index,目标变址寄存器,字符串运算时常用于目标指针,还用作函数调用时的第1个参数。
- RDX(EDX): source index,源变址寄存器,字符串运算时常应用于源指针,还用作函数调用时的第2个参数。
- RCX(ECX): data register,数据寄存器,I/O操作时提供外部设备接口的端口地址,还用作函数调用时的第3个参数。
- RBX(EBX): counter register,计数寄存器,一般用于循环计数,还用作函数调用时的第4个参数。
-
#include <stdint.h> uint64_t func1(uint64_t u1, unsigned u2, unsigned *p3, uint64_t u4, unsigned u5, unsigned u6, unsigned u7) { return u1 + u2 + *p3 + u4 + u5 + u6 + u7; } int main() { uint64_t a[7] = {1, 2, 3, 4, 5, 6, 7}; int ret = (int)func1(a[0], a[1], (unsigned *)&a[2], a[3], a[4], a[5], a[6]); return ret; }
: base register,基址寄存器,主要用于存储内存中数据存放的基础位置 ,之后只需要知道偏移地址就可以知道内存实际地址。
将之前的text.c的func1改为多参数调用,观察各寄存器如何被使用:
.file "test.c"
.text
.globl func1
.type func1, @function
func1:
.LFB0:
.cfi_startproc
pushq %rbp # rbp入栈保存
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp # 设置rbp
.cfi_def_cfa_register 6
movq %rdi, -8(%rbp) # 读取参数1到临时变量
movl %esi, -12(%rbp) # 读取参数2到临时变量
movq %rdx, -24(%rbp) # 读取参数3到临时变量
movq %rcx, -32(%rbp) # 读取参数4到临时变量
movl %r8d, -16(%rbp) # 读取参数5到临时变量
movl %r9d, -36(%rbp) # 读取参数6到临时变量
movl -12(%rbp), %edx
movq -8(%rbp), %rax
addq %rax, %rdx # 在rdx中累加参数1和2
movq -24(%rbp), %rax
movl (%rax), %eax
movl %eax, %eax
addq %rax, %rdx # 在rdx中累加参数3
movq -32(%rbp), %rax
addq %rax, %rdx # 在rdx中累加参数4
movl -16(%rbp), %eax
addq %rax, %rdx # 在rdx中累加参数5
movl -36(%rbp), %eax
addq %rax, %rdx # 在rdx中累加参数6
movl 16(%rbp), %eax # 注意这里对应参数7,是通过栈地址传递进来的,对应的地址是main栈帧中的参数7的地址
addq %rdx, %rax # 在rax中累加参数7,这里不继续在rdx累加,可以省略rdx向rax再拷贝一次的过程,rax中直接就是返回值了
popq %rbp #恢复rbp,由于rsp并未改变所以不需要恢复
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size func1, .-func1
.globl main
.type main, @function
main:
.LFB1:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq , %rsp
movq %fs:40, %rax
movq %rax, -8(%rbp)
xorl %eax, %eax
movq , -64(%rbp) # a[0] = 1
movq , -56(%rbp) # a[1] = 2
movq , -48(%rbp) # a[2] = 3
movq , -40(%rbp) # a[3] = 4
movq , -32(%rbp) # a[4] = 5
movq , -24(%rbp) # a[5] = 6
movq , -16(%rbp) # a[6] = 7
movq -16(%rbp), %rax
movl %eax, %r8d
movq -24(%rbp), %rax
movl %eax, %r9d # 设置参数6
movq -32(%rbp), %rax
movl %eax, %r10d
movq -40(%rbp), %rdx
movq -56(%rbp), %rax
movl %eax, %edi
movq -64(%rbp), %rax
leaq -64(%rbp), %rcx
leaq 16(%rcx), %rsi
pushq %r8
movl %r10d, %r8d # 设置参数5
movq %rdx, %rcx # 设置参数4
movq %rsi, %rdx # 设置参数3
movl %edi, %esi # 设置参数2
movq %rax, %rdi # 设置参数1
call func1
addq , %rsp
movl %eax, -68(%rbp) # func1返回值在eax中,读取到ret变量地址
movl -68(%rbp), %eax # 准备main函数返回值
movq -8(%rbp), %rdx
xorq %fs:40, %rdx
je .L5
call __stack_chk_fail@PLT
.L5:
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE1:
.size main, .-main
.ident "GCC: (Ubuntu 9.4.0-1ubuntu1~18.04) 9.4.0"
.section .note.GNU-stack,"",@progbits
对应的汇编分析
1.2.2 6个段寄存器
可见,函数调用时,前4个参数分别使用rdi(edi)、rsi(esi)、rdx(edx)、rcx(ecx)传递,第5、6参数用r8(r8d)和r9(r9d)寄存器传递,后续再多余的参数,被调用函数会使用rbp加偏移的方式来访问调用者函数的栈帧中的地址。
rax和rdx都被用于过累加过程,最后func1通过eax寄存器传递返回值。
1.2.3 标志寄存器
段寄存器被用于内存分段寻址,即通过段基址+段内偏移段方式来寻址。那么段基址就是由这些段寄存器进行保存的。
这里借用大佬的一张图来说明:
CS(code segment): 代码段地址寄存器,存放代码段的起始地址
DS(data segment):数据段地址寄存器,存放数据段的起始地址
SS(stack segment):堆栈段地址寄存器,存放堆栈段的起始地址
ES(extra segment):附加段地址寄存器,存放附加段的起始地址
32位架构新增了两个段寄存器:
FS:数据段地址寄存器
GS:数据段地址寄存器
分段寻址过程比较复杂,需要参阅专门的文献。这些寄存器中也未必是直接存放内存的段基址,但从这些寄存器出发,最终可以访问到想要的内存地址。
1.2.4 指令寄存器
标志寄存器:里面有众多标记,每一位代表一个标记,记录了 CPU 执行指令过程中的一系列状态,这些标志大都由 CPU 自动设置和修改,了解即可,仍然借用大佬的一张图
1.2.5 其它寄存器
RIP(EIP)寄存器,它指向了下一条要执行的指令所存放的地址(代码段中指令的偏移地址),CPU的工作其实就是不断取出它指向的指令,然后执行这条指令,同时指令寄存器继续指向下面一条指令,如此不断重复。
RIP寄存器比较特别,它不能在程序中显示的读取、修改,但它会被jmp、call和ret等指令隐式的修改,它一直在改变,永远指向下一条指令。
- 浮点寄存器:32位 CPU 总共有cr0-cr4共5个控制寄存器,64位增加了 cr8。他们各自有不同的功能,但都存储了 CPU 工作时的重要信息。
-
前四个浮点参数在前四个 SSE 寄存器 xmm0-xmm3 中传递,浮点返回值以 xmm0 返回:x64 处理器还提供几组浮点寄存器,八个 80 位 x87 寄存器,八个 64 位 MMX 寄存器,原始的 8 个 128 位 SSE 寄存器集增加到 16 个。
调试寄存器 -
1.2节参考资料
:用于支持软件调试的寄存器,用于调试器设置硬件断点。
https://www.jianshu.com/p/57128e477efb - [猿佑] [寄存器]
https://baijiahao.baidu.com/s?id=1681576659524219730&wfr=spider&for=pc - [轩辕之风O][一口气看完45个寄存器]
https://www.codenong.com/cs109543793 [GCC的内嵌汇编语法]
https://learn.microsoft.com/zh-cn/windows-hardware/drivers/debugger/x64-architecture
https://learn.microsoft.com/zh-cn/cpp/build/stack-usage?source=recommendations&view=msvc-170