当前位置: 首页>后端>正文

协程基础及背景知识

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

    协程基础及背景知识,第1张

    可见从低地址向高地址,依次有主要的几个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},第2张 4 4 0 r-x-- a.out # text段起始位置
协程基础及背景知识,\color{#ea4335}{0000563732c23000},第3张 132 4 4 rw--- [ anon ] #大致是Heap起始位置(低地址)
协程基础及背景知识,\color{#ea4335}{00007fdff4af8000 },第4张 0 0 0 rw--- ld-2.27.so #大致是MMapping结束位置(高地址)
协程基础及背景知识,\color{#ea4335}{00007ffd8fb29000},第5张 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,它在内核中代表了一个进程。
借两张图过来,画得真好,就不自己造轮子了。

协程基础及背景知识,第6张
struct task_struct

协程基础及背景知识,第7张
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 标志寄存器

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


    协程基础及背景知识,第8张
    段寄存器示意图

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

    1.2.4 指令寄存器

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


    协程基础及背景知识,第9张
    标志寄存器

    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


    https://www.xamrdz.com/backend/3s81938378.html

    相关文章: