这篇文章结合具体的代码讲解什么是进程,在内核中是如何表示一个进程的。

什么是进程?

进程是计算机中的程序关于某个数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位。具体来说,进程是一个具有一定独立功能的程序关于一个数据集合的一次动态执行过程。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。

下面这段代码定义了一个名为Env的结构体,它代表了一个进程(在这个上下文中,可以理解为一个进程)。Env结构体中包含了以下字段:

此外,代码还定义了一个名为EnvType的枚举类型,用于表示特殊的进程类型。目前只定义了一个值ENV_TYPE_USER,表示用户类型的进程。

struct Env {
	struct Trapframe env_tf;	// 保存的寄存器状态
	struct Env *env_link;		// 指向下一个空闲的`Env`的指针
	envid_t env_id;			    // 进程的唯一标识符
	envid_t env_parent_id;		// 父进程的唯一标识符
	enum EnvType env_type;		// 表示特殊系统进程的类型,例如区分用户进程或内核进程
	unsigned env_status;		// 进程的状态
	uint32_t env_runs;		    // 进程运行的次数

	// Address space
	pde_t *env_pgdir;		    // 页目录的内核虚拟地址
};

如何区分不同的进程?

通过进程 id 来区分不同的进程,即envid_t是一个 32 位的整数,用于唯一标识一个进程。envid_t ID 的结构如下:

  • 最高位(第 32 位)是符号位,如果小于零表示错误,大于零表示其他进程,等于零表示当前进程。
  • 中间的 21 位(第 11 位到第 31 位)是唯一标识符(Uniqueifier),用于区分在不同时间创建但共享相同进程索引的进程。
  • 最低的 10 位(第 1 位到第 10 位)是进程索引(Environment Index),等于进程在envs[]数组中的索引。
+1+---------------21-----------------+--------10--------+
|0|          Uniqueifier             |   Environment    |
| |                                  |      Index       |
+------------------------------------+------------------+
                                      \--- ENVX(eid) --/

这种设计使得进程 ID 既包含了进程的唯一标识,又包含了进程在进程数组中的位置,从而方便了进程的管理和查找。

进程状态有哪些?

下面这段代码定义了一个枚举类型,用于表示Env结构体中的env_status字段的状态。env_status字段表示该进程的运行状态。枚举类型中的每个值代表一种可能的状态:

  • ENV_FREE:进程是空闲的,没有运行任何进程。
  • ENV_DYING:进程正在结束,进程正在被销毁。
  • ENV_RUNNABLE:进程中的进程可以运行,但当前没有运行。
  • ENV_RUNNING:进程中的进程正在运行。
  • ENV_NOT_RUNNABLE:进程中的进程不能运行。

这种设计使得操作系统可以通过检查env_status字段来快速确定一个进程的状态,从而决定如何管理和调度进程中的进程。

进程初始化

在 OS 中,每个进程都有自己的地址空间,这个地址空间是由操作系统管理的。OS 会将进程数组 'envs' 映射到 UENVS 处,使得用户程序可以读取但不能写入这个数组。这样做的目的是为了让用户程序能够获取到进程的信息,但是不能修改这些信息,以保证系统的稳定性和安全性。

'envs' 数组存储了系统中所有进程的信息,每个元素是一个 'struct Env' 结构体,包含了进程的状态、进程的页目录、进程的运行时间等信息。通过将 'envs' 数组映射到用户空间,用户程序可以读取这些信息,了解系统的运行状态,例如可以查看其他进程的状态,或者计算自己的 CPU 使用率等。

下面是具体的申请内存,将 'envs' 数组映射到用户空间的具体代码,和此前 Page 的映射过程类似。

// 申请内存
size_t envs_size = ROUNDUP(NENV * sizeof(struct Env), PGSIZE);
envs = (struct Env *) boot_alloc(envs_size);
memset(envs, 0, envs_size);

// ...

// 在虚拟地址 UPAGES 处映射 'pages' 数组
boot_map_region(kern_pgdir, UENVS, envs_size, PADDR(envs), PTE_U | PTE_P);

总的来说,将 'envs' 数组映射到 UENVS 处,是为了让用户程序能够访问到进程的信息,同时防止用户程序修改这些信息,保证系统的稳定性和安全性。

下面是 UENVS 在虚拟内存中的具体位置。

   UVPT      ---->  +------------------------------+ 0xef400000
                    |          RO PAGES            | R-/R-  PTSIZE
   UPAGES    ---->  +------------------------------+ 0xef000000
                    |           RO ENVS            | R-/R-  PTSIZE
UTOP,UENVS ------>  +------------------------------+ 0xeec00000
UXSTACKTOP -/       |     User Exception Stack     | RW/RW  PGSIZE
                    +------------------------------+ 0xeebff000
                    |       Empty Memory (*)       | --/--  PGSIZE
   USTACKTOP  --->  +------------------------------+ 0xeebfe000

UXSTACKTOP:这是用户异常栈的顶部。当用户模式的代码触发异常时,处理器会切换到这个栈。

这些地址的定义对于操作系统来说非常重要,因为它们定义了用户空间的内存布局和权限。例如,UVPTUPAGESUENVS 都是只读的,这意味着用户程序不能修改这些区域的内容,但可以读取它们。这有助于保护操作系统的关键数据结构不被恶意或错误的用户程序修改。

初始化进程

接下来会初始化进程数组 'envs' 中的状态,下面是具体的初始化代码:

void
env_init(void)
{
	int i;
	for (i = NENV - 1; i >= 0; --i) {
		envs[i].env_id = 0;
		envs[i].env_status = ENV_FREE;
		envs[i].env_link = env_free_list;
		env_free_list = &envs[i];
	}

	// Per-CPU part of the initialization
	env_init_percpu();
}

这段代码的功能是初始化进程数组 envs。在操作系统中,这段代码的主要任务是将所有进程标记为自由(即未被使用),并将它们插入到自由进程列表 env_free_list 中。

代码首先定义了一个整数 i,然后使用一个倒序的循环遍历 envs 数组。对于数组中的每个进程,它将进程的 env_id 设置为 0,将进程的状态 env_status 设置为 ENV_FREE,然后将进程的 env_link 设置为当前的 env_free_list。然后,它将 env_free_list 更新为当前进程。这样,env_free_list 就成为了一个链表,链表中的每个元素都是一个自由的进程,链表的顺序与 envs 数组中的顺序相同。

最后,代码调用 env_init_percpu 函数来完成每个 CPU 的进程初始化。这是因为在多处理器系统中,每个处理器都有自己的当前进程。

总的来说,这段代码的目的是为操作系统创建和初始化一个进程池,操作系统可以从这个进程池中分配新的进程来运行新的进程或线程。

进程相关的虚拟内存设置

在操作系统中,每个进程都有自己的虚拟地址空间,这个地址空间被分为用户空间和内核空间两部分。用户空间是每个进程独有的,用于存放进程的代码、数据和堆栈等。而内核空间则是所有进程共享的,主要用于存放内核代码和数据,以及为内核模式下的执行提供运行进程。

内核空间对所有进程是共享的,主要有以下几个原因:

  1. 提高效率:如果每个进程都有自己的内核空间,那么在进程切换时,除了要保存和恢复用户空间的状态,还需要保存和恢复内核空间的状态,这将大大增加进程切换的开销。而如果内核空间是共享的,那么在进程切换时,就无需切换内核空间,从而可以提高效率。

  2. 方便通信:内核空间是所有进程共享的,这意味着一个进程可以通过在内核空间中留下信息,来与其他进程或者内核进行通信。

  3. 简化设计:如果内核空间对每个进程都不同,那么内核就需要处理更多的复杂情况,比如说,当一个进程在用户模式下运行时,它可能需要访问的内核空间是一个版本,而当它在内核模式下运行时,可能需要访问的内核空间又是另一个版本。这将大大增加设计和实现的复杂性。

因此,为了提高效率、方便通信以及简化设计,操作系统通常会选择让所有进程共享内核空间。

下面的代码实现为每个进程申请对应的虚拟内存,并且设置了共享内核空间。

static int
env_setup_vm(struct Env *e)
{
	int i;
	struct PageInfo *p = NULL;

	// Allocate a page for the page directory
	if (!(p = page_alloc(ALLOC_ZERO)))
		return -E_NO_MEM;

	// 现在,设置 e->env_pgdir 并初始化页目录。
	e->env_pgdir = (pde_t *)page2kva(p);

	// 增加页目录的引用计数 p->pp_ref++;
	p->pp_ref++;

	// 复制内核的地址空间
	memcpy(e->env_pgdir, kern_pgdir, PGSIZE);

	// UVPT 将 env 的自己的页表映射为只读。
	// 权限: kernel R, user R
	e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_P | PTE_U;

	return 0;
}

这段代码的目的是为新的进程设置和初始化其虚拟内存布局。以下是每个步骤的详细解释:

  1. 分配一个页面用于页目录:在 x86 架构中,页目录是用于管理虚拟内存到物理内存映射的数据结构。每个进程都需要有自己的页目录。

  2. 设置 e->env_pgdir 并初始化页目录:e->env_pgdir 是指向页目录的指针,这一步将其设置为新分配的页目录,并进行初始化。

  3. 增加页目录的引用计数:这是为了防止在还有进程使用该页目录时被错误地释放。

  4. 复制内核的地址空间到新进程的页目录中:这是因为在用户空间和内核空间中,内核空间是共享的,每个进程的页目录中的内核部分都是一样的。

  5. 设置 UVPT 映射进程自己的页表为只读:UVPT 是一个特殊的虚拟地址,在用户空间的顶部,使得进程可以读取自己的页表。这是为了让进程能够查看和修改自己的内存布局,但是为了安全,它是只读的。

总的来说,这段代码的目的是创建和初始化一个新的进程的虚拟内存布局。

如何创建进程?

接下来讲解如何创建一个进程,下面的这段代码是在操作系统内核中创建一个新的进程的函数。函数的输入参数是一个二进制文件和一个进程类型。

//
// 使用 env_alloc 分配一个新的进程,
// 使用 load_icode 将指定的 elf 二进制文件加载到其中,并设置其 env_type。
// 此函数仅在内核初始化期间调用,运行第一个用户模式进程之前。
// 新进程的父 ID 设置为 0。
//
void
env_create(uint8_t *binary, enum EnvType type)
{
	struct Env *newenv;
	int ret;

	// 分配一个新的进程
	ret = env_alloc(&newenv, 0);
	if (ret < 0) {
		panic("env_create: env_alloc failed");
	}

	// 加载 ELF 二进制文件到新进程中
	load_icode(newenv, binary);

	// 设置进程类型
	newenv->env_type = type;

	// 如果新进程的类型是用户进程,那么将其设置为可运行状态
	if (newenv->env_type == ENV_TYPE_USER) {
		newenv->env_status = ENV_RUNNABLE;
	}
}
  1. 首先,函数定义了一个新的进程变量 newenv 和一个返回值 ret

  2. 然后,函数调用 env_alloc 函数来分配一个新的进程。env_alloc 的输入参数是新进程的父进程的 ID,这里设置为 0,表示这个新进程没有父进程。如果 env_alloc 函数返回值小于 0,表示进程分配失败,函数将会 panic 并输出错误信息。

  3. 如果进程分配成功,函数将调用 load_icode 函数来加载 ELF 二进制文件到新进程中。ELF 二进制文件通常是一个可执行程序,load_icode 函数将会把这个程序加载到新进程的地址空间中。

  4. 加载完二进制文件后,函数将设置新进程的类型。进程类型是一个枚举类型,可能的值包括用户进程、内核进程等。

  5. 最后,如果新进程的类型是用户进程,那么将其状态设置为可运行状态。这表示操作系统可以开始调度这个进程运行了。

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

总结

本文介绍了进程的概念、表示和创建过程,通过具体的代码例子使读者更好地理解操作系统中进程的实现。接下来讲解如何将程序加载到虚拟内存中。