3.8: 系统调用exec的实现分析(Code:exec)

什么是 exec

exec 是一个系统调用,用于将一个进程的用户地址空间替换为从可执行文件(binary 或 executable file)中读取的数据。可执行文件通常是编译器和链接器的输出,包含机器指令和程序数据。

exec 的工作流程

1. 打开文件

  • 调用 nameikernel/exec.c:36)打开指定路径的二进制文件。

  if((ip = namei(path)) == 0){
    end_op();
    return -1;
  }
  • 文件格式为 ELF(Executable and Linkable Format),这是一个被广泛使用的二进制文件格式。

2. 读取 ELF 头

  • ELF 文件结构:

  • ELF 头:struct elfhdr(定义在 kernel/elf.h:6)。


#define ELF_MAGIC 0x464C457FU  // "\x7FELF" in little endian

// File header
struct elfhdr {
  uint magic;  // must equal ELF_MAGIC, four-byte “magic number” 0x7F, ‘E’, ‘L’, ‘F’, 
  uchar elf[12];
  ushort type;
  ushort machine;
  uint version;
  uint64 entry;
  uint64 phoff;
  uint64 shoff;
  uint flags;
  ushort ehsize;
  ushort phentsize;
  ushort phnum;
  ushort shentsize;
  ushort shnum;
  ushort shstrndx;
};

  • 程序段头表:struct proghdr(定义在 kernel/elf.h:25),描述需要加载到内存的程序部分。

// Program section header
struct proghdr {
  uint32 type;
  uint32 flags;
  uint64 off;
  uint64 vaddr;
  uint64 paddr;
  uint64 filesz;
  uint64 memsz;
  uint64 align;
};
  • 通常有两种段:

    • 指令段

    • 数据段

  • 首先检查 ELF 文件是否有效:

    • ELF 文件以 "magic number"(0x7F 'E' 'L' 'F')开头。

3. 分配页表与内存

  • exec 为进程创建新的页表(调用 proc_pagetablekernel/exec.c:49)。

  if((pagetable = proc_pagetable(p)) == 0)
  • 为每个 ELF 段分配内存(调用 uvmallockernel/exec.c:65),并加载到内存中:

    if((sz1 = uvmalloc(pagetable, sz, ph.vaddr + ph.memsz, flags2perm(ph.flags))) == 0)
  • 调用 loadsegkernel/exec.c:10)逐页加载 ELF 文件数据。

      static int loadseg(pde_t *, uint64, struct inode *, uint, uint);
    
    • 使用 walkaddr 查找物理地址。

    • 使用 readi 从文件中读取内容。

4. 加载段

/init 是通过 exec 系统调用创建的第一个用户程序,其程序段头表如下:

# objdump -p user/_init
user/_init: file format elf64-little
Program Header:
0x70000003 off 0x0000000000006bb0 vaddr 0x0000000000000000
paddr 0x0000000000000000 align 2 ** 0
filesz 0x000000000000004a memsz 0x0000000000000000 flags r--
LOAD off 0x0000000000001000 vaddr 0x0000000000000000
paddr 0x0000000000000000 align 2 ** 12
filesz 0x0000000000001000 memsz 0x0000000000001000 flags r-x
LOAD off 0x0000000000002000 vaddr 0x0000000000001000
paddr 0x0000000000001000 align 2 ** 12
filesz 0x0000000000000010 memsz 0x0000000000000030 flags rw-
STACK off 0x0000000000000000 vaddr 0x0000000000000000
paddr 0x0000000000000000 align 2 ** 4
filesz 0x0000000000000000 memsz 0x0000000000000000 flags rw-

关于段的关键字段的解释

字段

含义

off

段在文件中的偏移量。

vaddr

段在内存中的虚拟地址(加载后的起始地址)。

paddr

段在内存中的物理地址(在现代系统中一般与 vaddr 相同,可能被忽略)。

align

段的对齐要求,通常是 2 的幂。

filesz

段在文件中的大小(字节数)。

memsz

段在内存中的大小(字节数)。

flags

段的权限标志:r(读),w(写),x(执行)。

上述的内容中, 0x70000003为自定义段 (Type: 0x70000003),LOAD为可执行段或读写段(数据) 系统调用(例如 exec)会根据 Program Header 中的描述,将 LOAD 段映射到进程的虚拟内存中。特殊段(如 STACK)由内核动态管理,通常不直接对应文件内容。

  • ELF 段头中的 fileszmemsz 指示加载逻辑:

    • filesz < memsz 时,剩余部分用零填充(例如 C 的全局变量)。

    • 示例:/init 的数据段 filesz = 0x10memsz = 0x30,分配 0x30 字节内存,但只从文件中读取 0x10 字节。

5. 设置用户栈

  • 分配一页作为用户栈,并将参数字符串逐一复制到栈顶。

  • 记录参数指针到 ustack,在末尾添加空指针(作为 argv 的结束)。

  • 参数通过系统调用返回路径传递:

    • argc 存储在 a0

    • argv 存储在 a1

  • 栈下方分配一个不可访问页面:

    • 防止程序超限使用多页栈。

    • 如果参数过大,copyout 会检测到目标页不可访问并返回 -1

6. 错误处理

  • 如果 exec 发现错误(如无效段),跳转到 bad 标签:

    • 释放新内存映像,返回 -1

  • 直到确保系统调用成功前,不会释放旧内存映像。

安全性考虑

1. ELF 文件地址的风险

  • ELF 文件可能包含恶意地址:

    • 地址(ph.vaddr)加上大小(ph.memsz)的和可能溢出,导致危险。

    • xv6 使用检查,例如 if(ph.vaddr + ph.memsz < ph.vaddr),防止溢出。

2. 分离的内核地址空间

  • 在 RISC-V 架构的 xv6 中:

    • 内核和用户空间有独立的页表。

    • loadseg 仅加载到用户页表,无法直接写入内核地址空间。

3. 检查缺失的隐患

  • 尽管 xv6 有一些检查,但未涵盖所有用户输入验证:

    • 恶意程序可能利用缺陷绕过隔离。

总结

  1. exec 的作用: 替换当前进程的用户地址空间,加载一个新的程序。

  2. 实现流程: 打开 ELF 文件 -> 解析段头 -> 分配页表和内存 -> 加载段内容 -> 设置用户栈。

  3. 安全设计: 分离内核与用户页表,进行边界检查以防止溢出攻击。

  4. 潜在风险: 检查的遗漏可能被恶意程序利用,造成系统漏洞。