这篇文章讲解如何将程序加载到虚拟内存中?文件是以 ELF 格式组织起来的,接下来先讲解 ELF 格式。

ELF 文件格式

这段代码是关于 ELF (Executable and Linkable Format) 文件格式的定义。ELF 是一种常见的二进制文件格式,用于存储程序或库。在这个文件中,定义了一些结构体和常量,用于解析和处理 ELF 文件。

  1. struct Elf:这个结构体代表了 ELF 文件的头部信息。包括魔数、类型、机器类型、版本、入口点地址、程序头部偏移、节头部偏移、标志、头部大小等信息。

  2. struct Proghdr:这个结构体代表了 ELF 文件的程序头部信息。包括类型、偏移、虚拟地址、物理地址、文件大小、内存大小、标志、对齐等信息。

  3. struct Secthdr:这个结构体代表了 ELF 文件的节头部信息。包括名称、类型、标志、地址、偏移、大小、链接、信息、地址对齐、条目大小等信息。

  4. ELF_PROG_LOADELF_PROG_FLAG_EXECELF_PROG_FLAG_WRITEELF_PROG_FLAG_READ 等常量:这些常量用于解析和处理 ELF 文件的程序头部信息。

  5. ELF_SHT_NULLELF_SHT_PROGBITSELF_SHT_SYMTABELF_SHT_STRTAB 等常量:这些常量用于解析和处理 ELF 文件的节头部信息。

这些定义是操作系统加载和运行程序的基础,通过这些定义,操作系统可以正确地解析 ELF 文件,将程序加载到内存中,并执行。

ELF 文件在内存中的表示

在内存中,ELF 文件的加载会将其各个部分放置到相应的地址空间中。以下是一个简化的 ELF 文件在内存中的文本图形化表示:

+-------------------------+
|      ELF File Layout    |
+-------------------------+
|                         |
|   ELF Header            |
|                         |
+-------------------------+ <-- 0x08048000 (ELF默认起始地址)
|   Program Headers       |
|                         |
+-------------------------+
|   Section Headers       |
|                         |
+-------------------------+
|   Section 1 (.text)     |
|                         |
+-------------------------+
|   Section 2 (.data)     |
|                         |
+-------------------------+
|   Section 3 (.bss)      |
|                         |
+-------------------------+
|   Section 4 (.rodata)   |
|                         |
+-------------------------+
|   Dynamic Section       |
|                         |
+-------------------------+
|   String Table          |
|                         |
+-------------------------+
|   Symbol Table          |
|                         |
+-------------------------+
|   Relocation Table      |
|                         |
+-------------------------+
|   Stack (grows down)    |
|                         |
+-------------------------+ <-- Bottom of Memory

解释:

  • ELF Header 包含有关整个 ELF 文件的信息,例如入口点地址、程序头和节头的偏移量等。ELF Header 在内存中的地址通常是 ELF 文件默认起始地址(0x08048000)。

  • Program Headers 包含指向各个段的信息,如代码段、数据段等。每个 Program Header 指定了在内存中的加载位置、大小等信息。

  • Section Headers 包含有关各个节的信息,如代码节、数据节等。每个 Section Header 指定了在内存中的加载位置、大小等信息。

  • 各个 Section 包括代码、数据、bss(未初始化数据)、只读数据等。

  • Dynamic Section 包含动态链接信息,用于运行时动态链接和共享库加载。

  • String Table 包含节名、符号名等字符串。

  • Symbol Table 包含符号信息,如函数名、变量名等。

  • Relocation Table 包含需要在加载时进行重定位的信息,以修正代码和数据的引用地址。

  • Stack 是程序运行时使用的堆栈,通常从内存底部开始向上增长。

请注意,实际情况可能更加复杂,具体取决于 ELF 文件的结构和加载方式。

加载 ELF 到进程中

load_icode函数是用于加载 ELF 二进制文件到新创建的进程中的函数。它接受两个参数:一个是新创建的进程的指针,另一个是指向 ELF 二进制文件的指针。

  1. 首先,函数会检查 ELF 文件的魔数是否正确。如果不正确,函数会触发 panic。

  2. 然后,函数会获取 ELF 文件的程序头部信息。程序头部信息描述了如何将 ELF 文件加载到内存中。

  3. 接着,函数会遍历所有的程序头部信息。对于每一个类型为ELF_PROG_LOAD的程序头部,函数会分配相应的内存,并将 ELF 文件的相应部分加载到内存中。如果程序头部的内存大小大于文件大小,函数会将多余的部分清零。

  4. 加载完所有的程序段后,函数会设置进程的入口点地址。这个地址是程序开始执行的地方。

  5. 最后,函数会为程序的初始堆栈分配一个页面。这个页面位于虚拟地址USTACKTOP - PGSIZE

这个函数是操作系统加载和运行程序的关键步骤,它将一个程序加载到新的进程中,并设置好进程的状态,使得操作系统可以开始运行这个程序。

程序头部组成

下面是程序头部信息的文本图形化表示:

+-----------------------+
|   Program Header 0    |   <- ph
+-----------------------+
| p_type                |
| p_offset              |
| p_va                  |
| p_pa                  |
| p_filesz              |
| p_memsz               |
| p_flags               |
| p_align               |
+-----------------------+
|   Program Header 1    |
+-----------------------+
| p_type                |
| p_offset              |
| p_va                  |
| p_pa                  |
| p_filesz              |
| p_memsz               |
| p_flags               |
| p_align               |
+-----------------------+
|          ...          |
+-----------------------+
|   Program Header n    |   <- eph
+-----------------------+
| p_type                |
| p_offset              |
| p_va                  |
| p_pa                  |
| p_filesz              |
| p_memsz               |
| p_flags               |
| p_align               |
+-----------------------+

上述表示形式中,pheph 之间的每一项代表一个程序头部。每个程序头部包含了描述如何将 ELF 文件加载到内存中的信息。根据实际情况,n 的值可能会不同,具体取决于 ELF 文件中的程序头部数量。

加载程序段

下面这段代码是从 ELF (Executable and Linkable Format) 文件中加载程序段的部分。它遍历 ELF 文件的所有程序头部,只加载类型为 ELF_PROG_LOAD 的段。

	struct Proghdr *ph, *eph;
	ph = (struct Proghdr *)(binary + elf_hdr->e_phoff);
	eph = ph + elf_hdr->e_phnum;

	// Load each program segment
	for (; ph < eph; ph++) {
		if (ph->p_type != ELF_PROG_LOAD) {
			continue;
		}

		// Allocate memory for the segment
		region_alloc(e, (void *)ph->p_va, ph->p_memsz);

		// Load the segment
		memcpy((void *)ph->p_va, binary + ph->p_offset, ph->p_filesz);

		// Zero the remaining part
		if (ph->p_memsz > ph->p_filesz) {
			memset((void *)(ph->p_va + ph->p_filesz), 0, ph->p_memsz - ph->p_filesz);
		}
	}
  1. region_alloc(e, (void *)ph->p_va, ph->p_memsz);:这行代码为程序段分配内存。ph->p_va 是程序段应该被加载到的虚拟地址,ph->p_memsz 是程序段在内存中的大小。

  2. memcpy((void *)ph->p_va, binary + ph->p_offset, ph->p_filesz);:这行代码将程序段加载到内存中。binary + ph->p_offset 是程序段在 ELF 文件中的位置,ph->p_filesz 是程序段在 ELF 文件中的大小。

  3. memset((void *)(ph->p_va + ph->p_filesz), 0, ph->p_memsz - ph->p_filesz);:如果程序段的内存大小大于文件大小,这行代码将多余的部分清零。

总的来说,这段代码将 ELF 文件的每个程序段加载到指定的虚拟地址中,并将未使用的部分清零。

为进程分配物理内存

这段代码的功能是为进程分配一定长度的物理内存,并将其映射到进程的虚拟地址空间中。这段代码并不会对映射的页面进行零初始化或其他初始化操作。页面应该可以被用户和内核写入。如果任何分配尝试失败,代码将会触发 panic。

这段代码的主要步骤如下:

  1. 计算需要分配的页面数量,这是通过将请求的长度(len)除以页面大小(PGSIZE)并向上取整得到的。

  2. 对于每个需要分配的页面,执行以下操作:

    • 使用 page_alloc 函数分配一个新的页面。如果分配失败,触发 panic。
    • 使用 page_insert 函数将新分配的页面映射到进程的虚拟地址空间中。映射的虚拟地址由参数 va 指定,每个新页面的虚拟地址比前一个页面的虚拟地址高一个页面大小(PGSIZE)。如果映射失败,触发 panic。

接下来实现如何为进程的虚拟地址空间分配并映射新的物理内存。这在进程需要更多内存来存储数据或代码时非常有用。例如,当进程调用 malloc 函数来请求更多堆内存时,或者当进程需要加载新的代码段时,就可能需要调用这段代码。

static void
region_alloc(struct Env *e, void *va, size_t len)
{
	uintptr_t start = ROUNDDOWN((uintptr_t)va, PGSIZE);
	uintptr_t end = ROUNDUP((uintptr_t)va + len, PGSIZE);
	uintptr_t i;
	struct PageInfo *pp;

	for (i = start; i < end; i += PGSIZE) {
		if (!(pp = page_alloc(0))) {
			panic("region_alloc: out of memory");
		}
		if (page_insert(e->env_pgdir, pp, (void *)i, PTE_W|PTE_U) < 0) {
			panic("region_alloc: page_insert failed");
		}
	}
}

启动进程

下面这段代码是在操作系统中执行上下文切换的函数,从当前环境 curenv 切换到环境 e。如果这是对 env_run 的第一次调用,curenvNULL

void
env_run(struct Env *e)
{
	// 如果这是一个上下文切换(即正在运行一个新的环境)
	if (curenv && curenv->env_status == ENV_RUNNING) {
		// 将当前环境(如果有)的状态设置回ENV_RUNNABLE,如果它当前的状态是ENV_RUNNING
		curenv->env_status = ENV_RUNNABLE;
	}

	// 将'curenv'设置为新的环境
	curenv = e;

	// 将新环境的状态设置为ENV_RUNNING
	curenv->env_status = ENV_RUNNING;

	// 更新新环境的'env_runs'计数器
	curenv->env_runs++;

	// 使用lcr3()切换到新环境的地址空间
	lcr3(PADDR(curenv->env_pgdir));

	// 使用env_pop_tf()恢复环境的寄存器并在环境中进入用户模式
	env_pop_tf(&curenv->env_tf);
}

步骤 1:如果这是一个上下文切换(即正在运行一个新的环境):

  1. 如果当前环境存在且其状态为 ENV_RUNNING,则将其状态设置回 ENV_RUNNABLE
  2. curenv 设置为新的环境。
  3. 将新环境的状态设置为 ENV_RUNNING
  4. 更新新环境的 env_runs 计数器。
  5. 使用 lcr3() 切换到新环境的地址空间。

步骤 2:使用 env_pop_tf() 恢复环境的寄存器并在环境中进入用户模式。

提示:此函数从 e->env_tf 加载新环境的状态。回顾你之前写的代码,确保你已经将 e->env_tf 的相关部分设置为合理的值。

这行代码的作用是切换到新环境的地址空间。lcr3() 是一个用于加载页目录基址寄存器(CR3)的函数,它接受一个物理地址作为参数。在 x86 架构中,CR3 寄存器存储了当前活动页目录的物理地址。当 CR3 的值改变时,处理器会清空或部分清空其转换缓冲区(TLB),这是一种硬件缓存,用于加速虚拟地址到物理地址的转换。

在这个上下文中,PADDR(curenv->env_pgdir) 计算出新环境的页目录的物理地址,然后 lcr3() 将这个地址加载到 CR3 寄存器中。这样,处理器就会开始使用新环境的页目录,从而实现了到新环境的地址空间的切换。这是在操作系统中进行进程或线程上下文切换的关键步骤之一。

总结

这篇文章主要讲解了如何将程序加载到虚拟内存中。首先介绍了 ELF 文件格式,包括其头部信息、程序头部信息和节头部信息等。然后详细解释了如何将 ELF 文件的各个部分加载到内存中,包括为进程分配物理内存、加载程序段和设置进程的入口点等步骤。最后讲解了如何在操作系统中执行上下文切换,从当前环境切换到新的环境。