一、系统调用brk
brk系统调用的作用是修改堆内存的上限。我们的操作系统从8M ~ 128M 是用户进程的内存空间。我们会把进程的ELF文件映射到 8M 开始的位置, 有text段、data段、bss段。这些段结束后就是堆内存。堆内存使用了多少就需要使用brk来标记。brk系统调用时 malloc/free 函数的基础。
当程序运行时,如果你使用了如 malloc 这样的内存分配函数,它最终可能会通过 brk 或 mmap 来向内核申请更多内存。而 brk 就是负责调节 堆的顶端位置 的系统调用。
int32 sys_brk(void *addr)
{
LOGK("task brk 0x%p\n", addr);
u32 brk = (u32)addr;
ASSERT_PAGE(brk); // 判断brk是否是页开始的位置
task_t *task = running_task();
assert(task->uid != KERNEL_USER); // 判断是否是用户
assert(KERNEL_MEMORY_SIZE < brk < USER_STACK_BOTTOM); // 判断brk是否是
u32 old_brk = task->brk; // 获取进程当前的堆内存边界
if (old_brk > brk) // 如果当前边界大于新申请的边界,那就释放内存映射
{
for (; brk < old_brk; brk += PAGE_SIZE)
{
unlink_page(brk);
}
}
else if (IDX(brk - old_brk) > free_pages) // 如果新的增加brk大于了剩余的空闲页,就返回-1,没有可用内存了。
{
return -1; // out of memory
}
task->brk = brk;
return 0;
}
int32 brk(void *addr)
{
return _syscall1(SYS_NR_BRK, (u32)addr);
}
如上所是:sys_brk函数中brk系统调用的实现函数。在系统调用里增加brk,brk为45号系统调用,这与当前的linux系统保持一致。我们在user_init_thread进行brk系统调用,当使用这块内存空间时会产生缺页异常,缺页异常处理函数会把申请的页进行映射。用户进程就可以访问这些内存了。缺页异常处理函数的代码也需要略微调整,增加判断 vaddr < task->brk。
二、任务id
这节涉及两个系统调用,获取任务id和父任务id。首先需要在task_t这个结构中增加 pid 和 ppid 两个元素。调用的实现比较简单, 直接返回任务的pid和ppid即可。
pid_t sys_getpid() // 获取进程 id
{
task_t *task = running_task();
return task->pid;
}
pid_t sys_getppid() // 获取父进程 id
{
task_t *task = running_task();
return task->ppid;
}
static task_t *get_free_task()
{
for (size_t i = 0; i < NR_TASKS; i++)
{
if (task_table[i] == NULL)
{
task_t *task = (task_t *)alloc_kpage(1); // todo free_kpage
memset(task, 0, PAGE_SIZE);
task->pid = i;
task_table[i] = task;
return task;
}
}
panic("No more tasks");
}
如上所示:在从任务列表中获取空闲任务时,直接将任务的pid设置为数组的索引。运行测试:test进程的id号为2, init进程的进程id为1,他们的父进程id都是0,因为目前还没有父进程,没有给task_t结构体的pid元素赋值。
三、系统调用fork
fork的作用是创建一个子进程,子进程的返回值为0,父进程的返回值为子进程的id。该函数是一次调用,两次返回。如下为系统调用fork的实现:
pid_t task_fork()
{
task_t *task = running_task(); // 当前进程为父进程,需保证父进程没有阻塞,且正在执行。
// node是任务阻塞节点,确保当前任务不在任何链表中,且任务状态为运行态
assert(task->node.next == NULL && task->node.prev == NULL && task->state == TASK_RUNNING);
task_t *child = get_free_task(); // 从任务数组中获取空闲数组并将数组索引赋给id
pid_t pid = child->pid; // 此处的pid就是子进程在任务组的索引
memcpy(child, task, PAGE_SIZE); // 拷贝父进程的PCB和内核栈给子进程
child->pid = pid; // 设置子进程的pid
child->ppid = task->pid; // 设置子进程的父id
child->ticks = child->priority;
child->state = TASK_READY;
child->vmap = kmalloc(sizeof(bitmap_t)); // 拷贝用户进程虚拟内存位图 todo kfree
memcpy(child->vmap, task->vmap, sizeof(bitmap_t)); // 复制父进程的虚拟内存位图给子进程
void *buf = (void *)alloc_kpage(1); // 拷贝虚拟位图缓存 todo free_kpage
memcpy(buf, task->vmap->bits, PAGE_SIZE); // 将父进程的虚拟内存位图缓存也拷贝给子进程
child->vmap->bits = buf;
child->pde = (u32)copy_pde(); // 拷贝页目录
task_build_stack(child); // 构造 child 内核栈 ROP
return child->pid;
}
task_build_stack函数用来构造子进程的内核栈,内核栈的概念可以参考前面进入用户模式的文章。task_build_stack函数中将 eax 寄存器赋为0。 因此子进程的返回值就是0。以下为task_build_stack的函数实现:
static void task_build_stack(task_t *task) // 入参为子进程所在的内存页
{
u32 addr = (u32)task + PAGE_SIZE;
addr -= sizeof(intr_frame_t);
intr_frame_t *iframe = (intr_frame_t *)addr;
iframe->eax = 0; // 设置eax寄存器为0 ,也就是子进程的返回值为0
addr -= sizeof(task_frame_t);
task_frame_t *frame = (task_frame_t *)addr;
frame->ebp = 0xaa55aa55;
frame->ebx = 0xaa55aa55;
frame->edi = 0xaa55aa55;
frame->esi = 0xaa55aa55;
frame->eip = interrupt_exit;
task->stack = (u32 *)frame;
}
这里子进程的栈帧中 eip 被设置为了 中断退出。它的作用是调度器切换进程时,相当于跳转到 interrupt_exit,它负责恢复中断上下文并用 iret 跳回用户态。父进程调用 fork() 后,会从内核返回,继续跑它的代码(已经回用户态)。子进程的创建是“复制”了父进程的内存、寄存器等状态,但它还没有被 CPU 执行,此时子进程处于内核态。当调度器首次切换到子进程,子进程要“从 fork 返回”,继续执行它自己的用户态代码。
fork时还有一个事情需要做的就是拷贝页目录,如下为拷贝页目录的实现:
page_entry_t *copy_pde()
{
task_t *task = running_task(); // 获取当前运行的进程,也就是父进程
page_entry_t *pde = (page_entry_t *)alloc_kpage(1); // 申请一页内存存储页目录 todo free
memcpy(pde, (void *)task->pde, PAGE_SIZE); // 拷贝父进程的页目录给子进程
page_entry_t *entry = &pde[1023]; // 将最后一个页表指向页目录自己,方便修改
entry_init(entry, IDX(pde));
page_entry_t *dentry;
for (size_t didx = 2; didx < 1023; didx++) // 遍历页目录
{
dentry = &pde[didx];
if (!dentry->present)
continue;
page_entry_t *pte = (page_entry_t *)(PDE_MASK | (didx << 12)); // 如果页表存在就继续遍历页表
for (size_t tidx = 0; tidx < 1024; tidx++)
{
entry = &pte[tidx];
if (!entry->present)
continue;
assert(memory_map[entry->index] > 0); //如果页框存在 判断对应物理内存引用大于 0
entry->write = false; // 对该页置为只读
memory_map[entry->index]++; // 对应物理页引用加 1
assert(memory_map[entry->index] < 255); // 物理页引用要小于255
}
u32 paddr = copy_page(pte); // 拷贝一页8M以上的物理内存,并把这页内存映射到0这个位置
dentry->index = IDX(paddr);
}
set_cr3(task->pde);
return pde;
}
static u32 copy_page(void *page) // 拷贝一页,返回拷贝后的物理地址,入参是一个虚拟地址
{
u32 paddr = get_page(); // 获取一个8M以上的物理页,物理地址存储在 paddr 中。
// 获取虚拟地址 0x00000000 对应的 页表项。也就是说,我们想 临时把虚拟地址 0 映射到 paddr 所指向的物理页
page_entry_t *entry = get_pte(0, false);
entry_init(entry, IDX(paddr)); // 初始化这个页表项 entry,把它映射到物理页 paddr
memcpy((void *)0, (void *)page, PAGE_SIZE); 通过 memcpy() 把 page 指向的数据,拷贝到地址 0x00000000 所指向的新物理页
entry->present = false;
return paddr;
}
如上,巧妙了利用了第0页地址进行物理也数据的拷贝,因为第0页我们没有做映射。这里还有一个注意点,就是拷贝页目录时我们将页框设置为了只读。这是写时复制原理,如果直接拷贝所有物理内存页,会非常耗时和浪费内存。而实际上,很多页(尤其是代码段、只读数据段)根本不会被写入。所以 OS 采用策略:父子进程页表指向同一物理页(共享页框)将这些页设置为只读,当某个进程尝试写这些页时 → 触发页保护异常 → 内核捕获后再复制该页。
在用户进程调用fork
static void user_init_thread()
{
u32 counter = 0;
while (true)
{
pid_t pid = fork();
if (pid)
{
printf("fork after parent %d, %d, %d\n", pid, getpid(), getppid());
}
else
{
printf("fork after child %d, %d, %d\n", pid, getpid(), getppid());
}
hang();
sleep(100);
}
}
如上,如果fork调用返回为0,说明当前运行的是父进程,如果不为0,则当前运行的是子进程。
四、系统调用exit
exit的作用是终止当前进程。实现如下:
void task_exit(int status)
{
task_t *task = running_task();
// 当前进程没有阻塞,且正在执行
assert(task->node.next == NULL && task->node.prev == NULL && task->state == TASK_RUNNING);
task->state = TASK_DIED;
task->status = status;
free_pde();
free_kpage((u32)task->vmap->bits, 1);
kfree(task->vmap);
// 将子进程的父进程赋值为自己的父进程
for (size_t i = 0; i < NR_TASKS; i++)
{
task_t *child = task_table[i];
if (!child)
continue;
if (child->ppid != task->pid)
continue;
child->ppid = task->ppid;
}
LOGK("task 0x%p exit....\n", task);
schedule();
}
exit主要来进行内存的释放。将子进程的父进程赋值为自己的父进程, 这个作用是如果爹要死了,就把儿子托管给爷爷。当前进程已经结束,因此需要进行一次调度。此时还有一页内存没有被释放,就是任务PCB所在的那页内存。因此需要父进程来给它收尸,如果父进程没有进行收尸,那这个进程就进入僵尸状态。因此还需要一个waitpid系统调用来给子进程收拾。
五、系统调用waitpid
waitpid用来获取子进程的退出状态,也就是帮子进程收尸。实现如下:
pid_t task_waitpid(pid_t pid, int32 *status)
{
task_t *task = running_task();
task_t *child = NULL;
while (true)
{
bool has_child = false;
for (size_t i = 2; i < NR_TASKS; i++)
{
task_t *ptr = task_table[i];
if (!ptr)
continue;
if (ptr->ppid != task->pid)
continue;
if (pid != ptr->pid && pid != -1)
continue;
if (ptr->state == TASK_DIED)
{
child = ptr;
task_table[i] = NULL;
goto rollback;
}
has_child = true;
}
if (has_child)
{
task->waitpid = pid;
task_block(task, NULL, TASK_WAITING);
continue;
}
break;
}
// 没找到符合条件的子进程
return -1;
rollback:
*status = child->status;
u32 ret = child->pid;
free_kpage((u32)child, 1);
return ret;
}
父进程进程waitpid系统调用,首先会检查是否有子进程,如果没有就返回-1。
六、系统调用time
time的作用是获取当前时间戳,即从 1970-01-01 00:00:00 开始的秒数。实现比较简单,如下:
time_t sys_time()
{
return startup_time + (jiffies * JIFFY) / 1000;
}
如上,时间戳直接从系统启动时间,加上启动以来经过的 时钟滴答(tick)次数。
「真诚赞赏,手留余香」
真诚赞赏,手留余香
使用微信扫描二维码完成支付
