0.前置

1.操作系统需有多路复用 隔离性 交互性

抽象硬件资源,内核做中间管理层,牺牲一点换强隔离和易用性。磁盘>文件系统,cpu>进程,物理内存>地址空间,各类io设备>文件描述符。

2.机器模式 内核模式 用户模式

用户空间:用户模式下能访问的内存,存的是进程自己的代码、数据、栈。每个进程的用户空间互相独立,完全隔离。

内核空间:只有监督者模式才能访问的内存,存内核的代码、全局数据、每个进程的内核栈。所有进程共享同一份内核空间,但用户态碰不到。

系统调用:应用程序通过执行用户层封装好的汇编函数(usys.S),将系统调用号和参数分别装入 a7 和 a0-a5 寄存器并触发 ecall 指令,使 CPU 自动提升特权级并强行跳转至跳板页(TRAMPOLINE);在跳板页中,汇编代码将全部用户寄存器安全备份到进程特有的陷阱帧(trapframe)中,并在切换为内核页表和内核栈后进入 C 语言陷阱处理函数(usertrap());内核读取 scause 寄存器确认其为系统调用后,将程序计数器加 4(指向下一条指令)并分发给 syscall() 函数;syscall() 根据 a7 中的调用号执行具体的内核函数,并将计算结果写入 p->trapframe->a0 中以覆盖原参数;最后,内核通过跳板页切回用户页表和用户栈,恢复除已更新为返回值的 a0 之外的所有用户寄存器,并执行 sret 指令降级返回用户态,使应用层得以从 a0 寄存器中直接读取系统调用结果。

3.内核源码组织架构

启动与入口:entry.s 内核的入口汇编文件。开机后 bootloader 把内核加载到内存,CPU 执行的第一条内核指令就在这里。它只做一件事:设置好初始栈指针,然后跳转到 C 语言的 start 函数。start.c 运行在最高权限的机器模式,只做最基础的硬件初始化:配置特权模式、初始化定时器、关闭虚拟地址映射。做完后通过 mret 指令切换到监督者模式,跳转到 main 函数。main.c 内核主函数,初始化所有子系统,内存分配器、进程系统、文件系统、中断控制器、设备驱动……全部初始化完成后,创建第一个用户进程,正式启动系统。

进程调度:proc.c进程管理核心文件,fork,wait,exit系统调用的内核逻辑。proc.h定义了pcb结构体。swtch.h进程上下文切换的汇编实现。

系统调用与陷入处理:syscall.c 系统调用分发器。sysproc.c 进程类系统调用的具体实现。sysfile.c 文件类系统调用的具体实现。trap.c陷入处理的核心 C 逻辑。trampoline.S / kernelvec.S 陷入的汇编入口代码,负责在用户态和内核态之间切换栈、保存和恢复寄存器。

内存管理:kalloc.c 物理内存分配器。内核需要内存时,调用 kalloc 申请一整页物理内存;用完调用 kfree 释放。vm.c虚拟内存管理,也就是页表的全部操作:创建页表、建立虚拟地址→物理地址的映射、取消映射、切换页表。每个进程独立的地址空间,就是靠这个文件维护的。

文件系统与io :file.c 文件描述符抽象层。它把普通文件、管道、控制台设备统一封装成文件描述符,对外提供一致的 read/write 接口。pipe.c 管道的内核实现。你用 | 连接两个命令,底层的数据传递、阻塞唤醒,都在这个文件里。exec.cexec 系统调用的实现。读取磁盘上的可执行文件,替换当前进程的地址空间和寄存器。你写 xargs 时反复调用的 exec,内核逻辑就在这里。fs.c磁盘文件系统的核心实现:inode、目录、路径解析、文件权限,都在这里。bio.c / log.cbio.c 是磁盘块缓存,把常用的磁盘块缓存在内存里提升性能;log.c 是文件系统日志,保证掉电或崩溃后文件系统不会损坏。

设备驱动: console.c / uart.c:控制台与串口驱动,负责处理键盘输入、屏幕输出。你在 qemu 里敲命令、看到打印,都是这两个文件在工作。virtio_disk.c:磁盘驱动,直接和虚拟磁盘硬件通信,读写磁盘扇区。plic.c:中断控制器驱动,管理所有硬件的中断信号,告诉 CPU 当前是哪个设备发来的中断。

1.trace

要求:写一个系统调用的跟踪函数 第二个参数为掩码(指定跟踪哪些调用) 后面是exec的新程序 跟踪它看出现几次调用,打印进程ID、系统调用名称以及返回值;

根据系统调用流程以及hint慢慢添加。首先看用户层trace.c,将第二个参数字符串转换成整数掩码,作为系统调用的参数,exec会保留当前进程id运行指定程序。

1.用户态层面  在 Makefile 中向 UPROGS 添加 $U/_trace ;user.h添加系统调用函数说明:int trace(int); usy.pl添加entry("trace");(Perl 脚本,自动生成汇编文件 user/usys.S

2.内核层面:syscall.h定义系统调用号 #define SYS_trace  22;

syscall.c 声明外部调用 extern uint64 sys_trace(void);

以及在static uint64 (*syscalls[])(void) 函数指针数组 添加[SYS_trace]   sys_trace ;

先在proc.h里的pcb结构体添加系统调用追踪掩码 int trace_mask;

在sysproc.c写sys_trace的具体实现

uint64
sys_trace(void)
{
    int mask;
    if(argint(0, &mask) < 0) //argint:从用户栈取出第0个int类型的参数,存入mask
    return -1;

    myproc()->trace_mask = mask; // myproc():获取当前进程的PCB指针
    return 0;
}

注意要求是追踪系统调用,只有在syscall.c的分发函数才会判断进程的各个系统调用号,故在分发函数里去实现追踪打印。

void
syscall(void)
{
  int num;
  struct proc *p = myproc();

  num = p->trapframe->a7;
  if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
    p->trapframe->a0 = syscalls[num]();
    //添加的是下面这个if语句 通过掩码去右移其系统调用号 判断其系统调用是不是要追踪的
     if ((p->trace_mask >> num) & 1)
    {
      printf("%d: syscall %s -> %d\n", p->pid, syscall_names[num],p->trapframe->a0);
    }
  } else {
    printf("%d %s: unknown sys call %d\n",
            p->pid, p->name, num);
    p->trapframe->a0 = -1;
  }
}


//还需要在上面加syscall_names[num]指针数组 打印其系统调用名称
static char *syscall_names[] = {
[SYS_fork]    "fork",
[SYS_exit]    "exit",
[SYS_wait]    "wait",
........
[SYS_mkdir]   "mkdir",
[SYS_close]   "close",
[SYS_trace]   "trace",
};

在proc.c修改fork(),让其子进程也被追踪;

子进程会继承父进程大部分内容,如进程结构体里的tracemask、文件描述符表、当前目录、内存页表(写时复制)、用户代码 / 数据 / 堆。 打开的文件、管道共享,文件偏移共用。pid不同。

struct proc 结构体:完全拷贝两份,互不共享;用户内存页:初期共享,写入才拷贝分离(cow);打开的文件:共享文件对象,引用计数增加。

...
  acquire(&wait_lock);
  np->parent = p;
  release(&wait_lock);

//添加这一行,子进程继承父进程的make
  np->trace_mask = p->trace_mask; 

  acquire(&np->lock);
  np->state = RUNNABLE;
  release(&np->lock);

结束。

2.sysinfo

要求:系统调用sysinfo用来获取内核传来的参数,与trace不同(入参)。主要有俩个,内存空闲大小,以及活跃的进程数量。

前置:用户传入的指针是用户虚拟地址,内核无法直接引用。阅读源码vm.c的copyout函数

//以用户虚拟地址页为单位,每次查页表得到物理地址,计算当前页可拷贝字节,把内核缓冲区数据复制到对应物理内存,循环直到全部数据拷贝完毕。
int
copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len) //当前进程的页表(翻译用户虚拟地址),用户虚拟地址,内核源地址,长度
{
  uint64 n, va0, pa0;

  while(len > 0){
    va0 = PGROUNDDOWN(dstva); //向下对齐到当前页首虚拟地址,dstva可能在页中间
    pa0 = walkaddr(pagetable, va0); //根据当前进程的页表,查找用户虚拟地址va0对应的物理地址pa0
    if(pa0 == 0)
      return -1;  //用户地址非法/缺页,返回错误(可能没映射或超出范围)
    n = PGSIZE - (dstva - va0); //计算当前页剩余空间大小
    if(n > len)
      n = len; //拷贝的长度不能超过剩余长度
    memmove((void *)(pa0 + (dstva - va0)), src, n); //将内核源地址src拷贝到物理地址pa0对应的页中,偏移量为dstva-va0,拷贝长度为n(当前页剩余空间)

    len -= n; //更新剩余长度
    src += n; //更新内核源地址
    dstva = va0 + PGSIZE; //更新用户虚拟地址为下一页的起始地址
  }
  return 0;
}

阅读kalloc.c 用于管理内核空间内存 通过页链表标注空间内存页 一页4kb

//物理内存分配器,供用户进程、内核栈、页表页和管道缓冲区使用。分配整个4096字节的页面。
#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "spinlock.h"
#include "riscv.h"
#include "defs.h"
//把pa_start到pa_end之间的物理内存页加入空闲链表
void freerange(void *pa_start, void *pa_end); 

// 内核代码和数据段的末尾地址,内核代码和数据段在内存中是连续的,end就是内核代码和数据段的末尾地址。
extern char end[]; 

//对应每个空闲的物理内存页,run结构体存储在空闲页的起始地址处,next指向下一个空闲页的run结构体。
struct run {
  struct run *next;
}; 

struct {
  struct spinlock lock; //自旋锁,保护空闲链表的并发访问
  struct run *freelist; //空闲链表的头指针,指向第一个空闲页的run结构体
} kmem; 

void
kinit()
{
  initlock(&kmem.lock, "kmem"); //初始化自旋锁,lock是自旋锁的结构体,"kmem"是锁的名字,用于调试和日志输出
  freerange(end, (void*)PHYSTOP); //把内核代码和数据段之后的物理内存页加入空闲链表,PHYSTOP是物理内存的结束地址
}

void
freerange(void *pa_start, void *pa_end) //释放从pa_start到pa_end之间的物理内存页,把它们加入空闲链表
{
  char *p;
  p = (char*)PGROUNDUP((uint64)pa_start); //把pa_start向上取整到页边界,PGROUNDUP是一个宏,把地址向上取整到最接近的页边界
  for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE) //循环遍历从p到pa_end之间的每个物理内存页,PGSIZE是页的大小,通常是4096字节
    kfree(p); //释放每个物理内存页,把它们加入空闲链表
}

void
kfree(void *pa) //释放一个物理内存页,把它加入空闲链表
{
  struct run *r; //定义一个run结构体指针r,用于操作空闲链表

  if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP) //检查pa是否是页对齐的,是否在内核代码和数据段之后,是否在物理内存范围内,如果不满足条件就panic
    panic("kfree"); 

  memset(pa, 1, PGSIZE); //把要释放的物理内存页填充为1,防止悬空指针引用

  r = (struct run*)pa; //把pa转换为run结构体指针r,r指向要释放的物理内存页的起始地址

  acquire(&kmem.lock); //获取自旋锁,保护空闲链表的并发访问
  r->next = kmem.freelist; //把r的next指针指向当前空闲链表的头指针,表示r将成为新的空闲页
  kmem.freelist = r; //把空闲链表的头指针指向r,表示r已经加入空闲链表
  release(&kmem.lock); //释放自旋锁,允许其他线程访问空闲链表
}

//分配一个4096字节的物理内存页,返回一个指针,内核可以使用它。如果内存无法分配,返回0。
void *
kalloc(void) 
{
  struct run *r; //定义一个run结构体指针r,用于操作空闲链表

  acquire(&kmem.lock); //获取自旋锁,保护空闲链表的并发访问
  r = kmem.freelist; //把r指向空闲链表的头指针,表示要分配的物理内存页是空闲链表的第一个页
  if(r)
    kmem.freelist = r->next; //如果r不为空,把空闲链表的头指针指向r的next,表示r已经从空闲链表中移除
  release(&kmem.lock); //释放自旋锁,允许其他线程访问空闲链表

  if(r)
    memset((char*)r, 5, PGSIZE); // fill with junk 填充为5,防止悬空指针引用
  return (void*)r; //返回r的地址,表示分配成功,如果r为空,表示分配失败,返回0
}

//总结流程
//初始化时,kinit 调 freerange
//freerange 把可用物理页逐页交给 kfree
//kfree 把页挂进 freelist
//运行时,kalloc 从 freelist 取页
//用完后再 kfree 放回去
//自旋锁就是忙等待互斥锁,用来保护短小的共享临界区。

做实验 看了下测试程序,主要通过三个小函数验证,应该不太重要。思路大概是在kalloc.c添加辅助函数获取内核空间,在proc.c添加辅助函数获取进程数。在sysproc.c实现sysinfo系统调用函数,要先定义一个和用户态一样的结构体,再通过copyout将数据拷贝给用户空间。

用户态操作和trace基本一样,多一步在user.h上方定义一下结构体 struct sysinfo;

修改kalloc.c 在末尾添加函数

// 统计系统总空闲内存
uint64
count_freemem(void)
{
    struct run *r;
    uint64 pages = 0;

    // 遍历空闲链表,数有多少个空闲页
    for(r = kmem.freelist; r; r = r->next)
        pages++;

    // 页数 × 每页大小 = 总空闲字节数
    return pages * PGSIZE;
}

修改proc.c 添加函数

// 统计当前活跃进程总数(状态不是UNUSED的进程)
int
count_proc(void)
{
    int cnt = 0;
    struct proc *p; //struct proc proc[NPROC];原结构体

    // 遍历整个进程表数组
    for(p = proc; p < &proc[NPROC]; p++){ //数组名隐式代表数组首元素的地址
        // 只要进程状态不是空闲未分配,就算活跃进程
        if(p->state != UNUSED)
            cnt++;
    }
    return cnt;
}

    将两个函数在kernel/defs.h(内核全局函数声明头文件)中声明 

    在sysproc.c添加系统调用

    // 放在 sysproc.c 顶部
    struct sysinfo {
        uint64 freemem;  // 空闲内存字节数
        uint nproc;      // 活跃进程数
    };
    
    
    uint64
    sys_sysinfo(void)
    {
        uint64 info_ptr;   // 保存用户传进来的结构体指针
        struct sysinfo info; // 内核里临时存数据的结构体
    
        //获取参数,argaddr 专门用来取地址类型的参数,对应 trace 里的 argint
        if(argaddr(0, &info_ptr) < 0)
            return -1;
    
        //统计两项数据,填到内核的结构体里
        info.freemem = count_freemem();
        info.nproc = count_proc();
    
        // 把内核里的结构体,拷贝到用户态的地址中
        // 参数:当前进程页表、用户目标地址、内核源地址、拷贝长度
        if(copyout(myproc()->pagetable, info_ptr, (char*)&info, sizeof(info)) < 0)
            return -1;
    
        return 0;
    }

    其他内核态头文件声明、系统调用号 和trace一样。

    Logo

    openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

    更多推荐