协程基础及背景知识

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位的图

    image.png

    可见从低地址向高地址,依次有主要的几个Segment:

  1. Text Segment:代码段,加载保存进程的二进制程序
  2. Data Segment:数据段, 保存被初始化的静态局部变量或被初始化的全局变量
  3. BSS Segment: 保存被初始化的静态局部变量或被初始化的全局变量
  4. Heap: 堆空间, 由brk分配的内存,地址向上增长
  5. Memory Mapping Segment: 由mmap分配的内存,地址向下增长,与Heap的增长方向相反,二者向中间靠拢。二者在编程时通常都是调用库函数malloc分配出来的。文件映射,包括动态库文件的映射,也在此段空间。
  6. 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

提取几条关键信息:
\color{#ea4335}{00005637323af000} 4 4 0 r-x-- a.out # text段起始位置
\color{#ea4335}{0000563732c23000} 132 4 4 rw--- [ anon ] #大致是Heap起始位置(低地址)
\color{#ea4335}{00007fdff4af8000 } 0 0 0 rw--- ld-2.27.so #大致是MMapping结束位置(高地址)
\color{#ea4335}{00007ffd8fb29000} 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,它在内核中代表了一个进程。
借两张图过来,画得真好,就不自己造轮子了。

struct task_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 $281474976710655, %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    $16, %rsp # rsp寄存器减16,栈增长16字节,预留了16字节空白内存,注意rbp保存了这16字节的高地址
    movl    $0, %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

  • func1( rbp压栈保存 -> rbp赋值为rsp -> 返回值写入rax -> 出栈恢复rbp -> return)

-> eax寄存器值写入变量ret地址 -> ret地址值写入eax寄存器 -> 恢复rsp和rbp -> return
总结来看,在调用函数时,调用者将rsp设置为新开辟的栈帧高地址,被调用函数使用此地址作为自己的基址来设置rbp寄存器,而后使用rbp寄存器+偏移的方式来访问函数内的局部变量,函数结束时,返回之前,需要恢复rsp和rbp寄存器,ret之后,调用者回到自己的栈帧,rsp和rbp值恢复到调用前的值。而rax或eax寄存器总是用来传递函数返回值,编译器基于返回值类型决定使用32位还是64位的寄存器。
那么,[rsp, rbp) 这个地址区间,即为当前函数的栈帧

继续探究其它的通用寄存器

  • RDI(EDI): destination index,目标变址寄存器,字符串运算时常用于目标指针,还用作函数调用时的第1个参数。
  • RSI(ESI): source index,源变址寄存器,字符串运算时常应用于源指针,还用作函数调用时的第2个参数。
  • RDX(EDX): data register,数据寄存器,I/O操作时提供外部设备接口的端口地址,还用作函数调用时的第3个参数。
  • RCX(ECX): counter register,计数寄存器,一般用于循环计数,还用作函数调用时的第4个参数。
  • RBX(EBX): base register,基址寄存器,主要用于存储内存中数据存放的基础位置 ,之后只需要知道偏移地址就可以知道内存实际地址。
    将之前的text.c的func1改为多参数调用,观察各寄存器如何被使用:
#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;
}

对应的汇编分析

    .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    $80, %rsp
    movq    %fs:40, %rax
    movq    %rax, -8(%rbp)
    xorl    %eax, %eax
    movq    $1, -64(%rbp) # a[0] = 1
    movq    $2, -56(%rbp) # a[1] = 2
    movq    $3, -48(%rbp) # a[2] = 3
    movq    $4, -40(%rbp) # a[3] = 4
    movq    $5, -32(%rbp) # a[4] = 5
    movq    $6, -24(%rbp) # a[5] = 6
    movq    $7, -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    $8, %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

可见,函数调用时,前4个参数分别使用rdi(edi)、rsi(esi)、rdx(edx)、rcx(ecx)传递,第5、6参数用r8(r8d)和r9(r9d)寄存器传递,后续再多余的参数,被调用函数会使用rbp加偏移的方式来访问调用者函数的栈帧中的地址。
rax和rdx都被用于过累加过程,最后func1通过eax寄存器传递返回值。

1.2.2 6个段寄存器

段寄存器被用于内存分段寻址,即通过段基址+段内偏移段方式来寻址。那么段基址就是由这些段寄存器进行保存的。
这里借用大佬的一张图来说明:


段寄存器示意图

CS(code segment): 代码段地址寄存器,存放代码段的起始地址
DS(data segment):数据段地址寄存器,存放数据段的起始地址
SS(stack segment):堆栈段地址寄存器,存放堆栈段的起始地址
ES(extra segment):附加段地址寄存器,存放附加段的起始地址
32位架构新增了两个段寄存器:
FS:数据段地址寄存器
GS:数据段地址寄存器
分段寻址过程比较复杂,需要参阅专门的文献。这些寄存器中也未必是直接存放内存的段基址,但从这些寄存器出发,最终可以访问到想要的内存地址。

1.2.3 标志寄存器

标志寄存器:里面有众多标记,每一位代表一个标记,记录了 CPU 执行指令过程中的一系列状态,这些标志大都由 CPU 自动设置和修改,了解即可,仍然借用大佬的一张图


标志寄存器

1.2.4 指令寄存器

RIP(EIP)寄存器,它指向了下一条要执行的指令所存放的地址(代码段中指令的偏移地址),CPU的工作其实就是不断取出它指向的指令,然后执行这条指令,同时指令寄存器继续指向下面一条指令,如此不断重复。
RIP寄存器比较特别,它不能在程序中显示的读取、修改,但它会被jmp、call和ret等指令隐式的修改,它一直在改变,永远指向下一条指令。

1.2.5 其它寄存器

  • 控制寄存器:32位 CPU 总共有cr0-cr4共5个控制寄存器,64位增加了 cr8。他们各自有不同的功能,但都存储了 CPU 工作时的重要信息。
  • 浮点寄存器:x64 处理器还提供几组浮点寄存器,八个 80 位 x87 寄存器,八个 64 位 MMX 寄存器,原始的 8 个 128 位 SSE 寄存器集增加到 16 个。
    前四个浮点参数在前四个 SSE 寄存器 xmm0-xmm3 中传递,浮点返回值以 xmm0 返回
  • 调试寄存器:用于支持软件调试的寄存器,用于调试器设置硬件断点。

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,658评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,482评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,213评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,395评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,487评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,523评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,525评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,300评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,753评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,048评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,223评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,905评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,541评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,168评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,417评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,094评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,088评论 2 352

推荐阅读更多精彩内容

  • 这篇文章主要介绍些汇编和函数调用栈的变化过程以及x86-64体系结构下各寄存器的作用,为后面两篇博客分析协程库(L...
    fooboo阅读 1,081评论 0 0
  • 本人有若干成套学习视频, 可试看! 可试看! 可试看, 重要的事情说三遍 包含Java, 数据结构与算法, iOS...
    小冰山口阅读 1,009评论 0 1
  • 工作中经常会遇到需要使用汇编知识来解决的问题,比如查找崩溃堆栈定位在一些未提供源码的第三方库的崩溃原因等,但是由于...
    离原春草阅读 601评论 0 5
  • 1、基础知识 1、API & ABI API(Application Programming Interface)...
    Abner_XuanYuan阅读 204评论 0 0
  • 通常,cpu 会先将内存中的数据存储到寄存器中,然后在对寄存器中的数据进行运算。假设内存中有快红色内存空间的值是3...
    一粒咸瓜子阅读 941评论 0 1