00_前言

接下来需要理解的源码文件是kernel/exec.c。在xv6中,exec是一个系统调用,它用于加载并运行一个新的程序。在调用exec之后,当前进程的内存空间将被新的程序代码和数据覆盖,旧的程序将不再执行。所以exec会用一个新程序替换当前进程的内容,但是进程ID保持不变

1.exec.c文件的主要逻辑

kernel/exec.c中,实现的主要逻辑是:

  • 如何加载可执行文件
  • 如何设置新的内存布局
  • 如何配置栈和寄存器
2.exec系统调用的作用

注意:exec系统调用是创建一个地址空间的用户部分,一个用户进程的地址空间除了用户部分之外,还需要有内核栈等位于内核的部分。所以exec不负责初始化地址空间的内核部分

3.用ELF文件初始化地址空间的用户部分

exec系统调用用存储在文件系统里的ELF格式的文件来初始化地址空间的用户部分

一般来说ELF文件会有多个program header,它们每一个指向必须读入内存的一个程序段,xv6中为了简单起见,只有一个program header,ELF格式的定义全部在kernel/elf.h

01_loadseg函数

1.函数的作用

seg是程序段的意思,这个函数 loadseg 的主要作用是在操作系统加载一个可执行文件时,将该文件的一个段(如代码段、数据段等)从存储设备(如磁盘)读取到内存中,并将其放置在指定的虚拟地址空间内。

2.代码
// Load a program segment into pagetable at virtual address va.
// va must be page-aligned
// and the pages from va to va+sz must already be mapped.
// Returns 0 on success, -1 on failure.
// 译:将一个程序段读入页表pagetable的虚拟地址va处
// va必须是页对齐的,va到va + sz范围内的页必须已经被映射好
// 返回0表示成功,-1表示失败
static int
loadseg(pagetable_t pagetable, uint64 va, struct inode *ip, uint offset, uint sz)
{
  uint i, n;
  uint64 pa;

  // 迭代地复制程序段,每次复制一个页面
  for(i = 0; i < sz; i += PGSIZE){
    // 使用walkaddr找到对应的物理地址 
    pa = walkaddr(pagetable, va + i);

    // pa返回0表示walkaddr调用失败,对应的虚拟地址可能:
    // 1.PTE不存在
    // 2.没有建立映射关系
    // 3.用户无权访问
    if(pa == 0)
      panic("loadseg: address should exist");

    // 这里是防止最后一页要复制的字节不满一页而导致的复制溢出
    if(sz - i < PGSIZE)
      n = sz - i;
    else
      n = PGSIZE;

    // readi系统调用是从索引节点对应的文件数据缓冲区中读取数据的函数
    // 下面的调用从索引节点ip指向的文件的off偏移处读取n个字节放入物理地址pa
    // 第二个参数为0表示处于内核地址空间
    // 再次地,我们用到了内核地址空间的直接映射(direct-mapping)
    // 就算pa是某个用户页表翻译出来的物理地址,在内核地址空间中也会被译为同等的地址
    if(readi(ip, 0, (uint64)pa, offset+i, n) != n)
      return -1;
  }

  return 0;
}
3.代码逻辑

假设我们一个简单的可执行文件,其代码段大小为 8192 字节,并且该段存储在硬盘的某个位置。现在我们需要将这个代码段加载到某个进程的内存空间中。这个进程的虚拟地址空间已经分配好了虚拟内存页面,并且这些页面已经映射到实际的物理内存中。

  • 确定加载位置
    • 假设虚拟地址 va = 0x4000(即虚拟地址 16KB 处)是页对齐的,并且已经映射到物理内存。
    • 文件在磁盘的偏移量 offset = 0 处。
    • 段的大小 sz = 8192 字节。
  • 迭代加载
    • 程序按页面大小(通常是 4096 字节,也即 PGSIZE = 4096)分块加载这个段。因此,需要两次迭代,每次加载一个页面。
    • 第一次迭代
    • i = 0va + i = 0x4000
    • 使用 walkaddr 查找 0x4000 处的物理地址 pa,假设返回物理地址为 0x10000
    • 检查 sz - i = 8192 - 0 = 8192,大于 PGSIZE,所以 n = PGSIZE = 4096
    • 从文件的 offset + i = 0 处读取 4096 字节到物理地址 pa = 0x10000 处。
    • 第二次迭代
    • i = 4096va + i = 0x5000
    • 使用 walkaddr 查找 0x5000 处的物理地址 pa,假设返回物理地址为 0x11000
    • 检查 sz - i = 8192 - 4096 = 4096,刚好等于 PGSIZE,所以 n = PGSIZE = 4096
    • 从文件的 offset + i = 4096 处读取 4096 字节到物理地址 pa = 0x11000 处。
  • 加载完成
    • 两次迭代后,文件的这个代码段已经被加载到了内存中的两个连续的物理页面中,分别位于 0x40000x5000 虚拟地址对应的物理内存位置。

02_exec函数

1.函数的作用

exec函数的作用是在操作系统中替换当前进程的地址空间,使其执行一个新的程序。它会加载一个指定的可执行文件到当前进程的内存中,设置好新的程序入口点和栈,然后开始执行这个程序。

2.代码
int
exec(char *path, char **argv)
{
  char *s, *last;
  int i, off;
  uint64 argc, sz = 0, sp, ustack[MAXARG+1], stackbase;
  struct elfhdr elf;
  struct inode *ip;
  struct proghdr ph;
  pagetable_t pagetable = 0, oldpagetable;
  struct proc *p = myproc();

  begin_op();

  if((ip = namei(path)) == 0){
    end_op();
    return -1;
  }
  ilock(ip);

  // Check ELF header
  if(readi(ip, 0, (uint64)&elf, 0, sizeof(elf)) != sizeof(elf))
    goto bad;
  if(elf.magic != ELF_MAGIC)
    goto bad;

  if((pagetable = proc_pagetable(p)) == 0)
    goto bad;

  // Load program into memory.
  for(i=0, off=elf.phoff; i<elf.phnum; i++, off+=sizeof(ph)){
    if(readi(ip, 0, (uint64)&ph, off, sizeof(ph)) != sizeof(ph))
      goto bad;
    if(ph.type != ELF_PROG_LOAD)
      continue;
    if(ph.memsz < ph.filesz)
      goto bad;
    if(ph.vaddr + ph.memsz < ph.vaddr)
      goto bad;
    uint64 sz1;
    if((sz1 = uvmalloc(pagetable, sz, ph.vaddr + ph.memsz)) == 0)
      goto bad;
    sz = sz1;
    if(ph.vaddr % PGSIZE != 0)
      goto bad;
    if(loadseg(pagetable, ph.vaddr, ip, ph.off, ph.filesz) < 0)
      goto bad;
  }
  iunlockput(ip);
  end_op();
  ip = 0;

  p = myproc();
  uint64 oldsz = p->sz;

  // Allocate two pages at the next page boundary.
  // Use the second as the user stack.
  sz = PGROUNDUP(sz);
  uint64 sz1;
  if((sz1 = uvmalloc(pagetable, sz, sz + 2*PGSIZE)) == 0)
    goto bad;
  sz = sz1;
  uvmclear(pagetable, sz-2*PGSIZE);
  sp = sz;
  stackbase = sp - PGSIZE;

  // Push argument strings, prepare rest of stack in ustack.
  for(argc = 0; argv[argc]; argc++) {
    if(argc >= MAXARG)
      goto bad;
    sp -= strlen(argv[argc]) + 1;
    sp -= sp % 16; // riscv sp must be 16-byte aligned
    if(sp < stackbase)
      goto bad;
    if(copyout(pagetable, sp, argv[argc], strlen(argv[argc]) + 1) < 0)
      goto bad;
    ustack[argc] = sp;
  }
  ustack[argc] = 0;

  // push the array of argv[] pointers.
  sp -= (argc+1) * sizeof(uint64);
  sp -= sp % 16;
  if(sp < stackbase)
    goto bad;
  if(copyout(pagetable, sp, (char *)ustack, (argc+1)*sizeof(uint64)) < 0)
    goto bad;

  // arguments to user main(argc, argv)
  // argc is returned via the system call return
  // value, which goes in a0.
  p->trapframe->a1 = sp;

  // Save program name for debugging.
  for(last=s=path; *s; s++)
    if(*s == '/')
      last = s+1;
  safestrcpy(p->name, last, sizeof(p->name));

  // Commit to the user image.
  oldpagetable = p->pagetable;
  p->pagetable = pagetable;
  p->sz = sz;
  p->trapframe->epc = elf.entry;  // initial program counter = main
  p->trapframe->sp = sp; // initial stack pointer
  proc_freepagetable(oldpagetable, oldsz);

  return argc; // this ends up in a0, the first argument to main(argc, argv)

 bad:
  if(pagetable)
    proc_freepagetable(pagetable, sz);
  if(ip){
    iunlockput(ip);
    end_op();
  }
  return -1;
}
3.代码逻辑

例如,当前在xv6中有一个shell进程,该进程正在运行并接受用户输入。现在用户在shell中输入了命令ls,以列出当前目录中的文件。shell进程通过调用exec函数来执行ls程序。

  • 进程初始化

    • 当前shell进程通过输入的ls命令决定要执行ls程序
    • shell进程通过exec("ls",argv)语句来调用exec函数
  • 查找可执行文件

    • exec 函数通过 namei 系统调用找到 ls 程序的文件路径并返回对应的索引节点(inode)。这个索引节点包含了 ls 程序的元数据和内容。
      // namei同样是一个文件系统操作,它返回对应路径文件的索引节点(index node)的内存拷贝
      // 索引节点中记录着文件的一些元数据(meta data)
      // 如果出错就使用end_op结束当前调用的日志功能
      if((ip = namei(path)) == 0){
        end_op();
        return -1;
      }
    
      // 给索引节点加锁,防止访问冲突
      ilock(ip);
  • 验证 ELF 文件

    • exec 函数读取 ls 文件的 ELF 头部信息,并检查它是否是一个合法的 ELF 可执行文件
      if(readi(ip, 0, (uint64)&elf, 0, sizeof(elf)) != sizeof(elf))
        goto bad;
      if(elf.magic != ELF_MAGIC)
        goto bad;
  • 创建新地址空间

    • exec 函数调用 proc_pagetable 创建一个新的页表,用来为 ls 程序的执行创建虚拟地址空间。
      // 创建一个用户页表,将trampoline页面和trapframe页面映射进去
      // 保持用户内存部分留白
      if((pagetable = proc_pagetable(p)) == 0)
        goto bad;
  • 加载程序段

    • exec 函数遍历 ELF 文件中的 Program Header(程序头),并逐个将 ls 程序的代码段和数据段加载到新创建的虚拟地址空间中。

    • 比如,ls 程序的代码段被加载到进程的虚拟地址 0x400000,数据段被加载到 0x600000

  • 设置用户栈

    • exec 函数分配了新的栈空间,并将 argv 参数数组的内容拷贝到栈中,更新栈指针 sp。这是为了让 ls 程序在启动时能够接收到正确的命令行参数。
  • 更新进程信息

    • exec 函数用新加载的 ls 程序的入口地址 elf.entry 更新进程的 epc(程序计数器)。
    • 更新进程的栈指针 sp 以指向新的用户栈。
    • 同时,进程的名称被更新为 "ls",用于调试和管理。
  • 切换到新程序

    • 旧的页表和内存空间被释放。

    • exec 函数返回 argc,表示参数个数,这个值会被传递给 ls 程序的 main(argc, argv) 函数,进程开始执行 ls 程序的代码。

  • 最终效果

    • exec 函数执行完毕后,原本是 shell 的进程现在被替换成了 ls 程序的进程。它的内存空间、代码段、数据段、栈等都被 ls 程序的内容覆盖。当进程继续执行时,实际执行的是 ls 程序中的代码,而不是原来的 shell 代码。

踏上取经路,比抵达灵山更重要