一个极简操作系统的代码实现

一个极简操作系统的代码实现

在网上看的demo OS实现时,发现一个名为Hurlex的demo OS project,实现精简,麻雀虽小五脏俱全,挺适合对OS实现进行代码级别的快速粗略了解一下的。

当然,高校也有类似的很不错的教学项目,比如MIT Xv6 OS哈工大李治军Linux-0.11 OS实验,这些有更加详细的配套文档和项目代码,更加适合深入的学习。

上面的demo OS都可以通过QEMU仿真器直接跑起来并且能调试,可以看用QEMU调Linux:
使用QEMU调试ARM64 Linux内核v6.0.9

Hurlex OS简介

Hurlex是一个支持IA32(x86架构)demo操作系统,项目相关信息如下:

项目仓库: https://github.com/hurley25/Hurlex-II
文档仓库:https://github.com/hurley25/hurlex-doc
在线文档: https://wiki.0xffffff.org/

看了下代码规模,也就8k出头的代码规模:

 ~/Hurlex-II $ find -name *.[chs] | xargs wc -l
    ...
    8218 total

OS如何启动

一个OS要启动,仅仅是OS本身还不够,还需要BIOS/Bootloader等,它们的核心作用就引导操作系统系统去启动。

  • 计算机是如何启动的?
  • Linux启动流程和grub详解
  • GNU GRUB(GRand Unified Bootloader)

打开电源后,BIOS开机自检,确定启动设备,安装启动设备启动设备上面安装的GRUB开始引导Linux,Linux首先先进行内核引导,通过跟切换,执行init程序,init程序确定启动级别,根据启动级别进行系统初始化和运行的服务,然后返回init启动终端,用户通过验证成功登陆Shell,这就是一个从开机到登陆的启动过程。

一个简化的Bootloader的伪代码流程示例,用于展示整个Bootloader的执行过程:

// Bootloader代码开始执行
bootloader_entry():
    hardware_init() // 初始化硬件和设备
    load_config_file() // 加载配置文件
    display_boot_menu() // 显示启动菜单
    wait_for_user_input() // 等待用户选择
    load_kernel_to_memory(selected_kernel) // 根据用户选择,加载相应的内核文件到内存
    jump_to_kernel_entry() // 跳转到内核入口点,启动内核

Hurlex OS实现

内核初始化

内核运行首先也是初始化部分,刚开始还没有栈,也是汇编代码执行,栈构造完毕进入到C代码部分。

这块看 Hurlex相关的源码如下:

1. 初始化(汇编阶段)

主要是就是构造了栈,然后让PC寄存器跳转到内核入口的C函数部分。

arch/i386/init/init_s.s  // <-- 初始化(汇编代码)
    [GLOBAL start] //内核代码入口,此处提供该声明给 ld 链接器
start:
    mov [mboot_ptr_tmp], ebx        // 将 ebx 中的指针存入 mboot_ptr_tmp
    mov esp, STACK_TOP              // 设置内核栈地址
    and esp, 0FFFFFFF0H             // 栈地址按照 16 字节对齐
    mov ebp, 0                      // 帧指针修改为 0

    call kern_entry  // 进入内核入口函数(C代码)

2. 初始化(C程序入口)

这个内核入口,进入C函数部分。

虚拟的页面每页占据4KB,按页为单位进行管理。物理内存也被分页管理,按照4KB分为一个个物理页框。虚拟地址到物理地址通过由页目录和页表组成的二级页表映射,页目录的地址放置在CR3寄存器里

arch/i386/init/init.c // 初始化(C程序)
kern_entry:
    mmap_tmp_page // 映射临时页表
        // 指定物理地址写
        // 映射 0x00000000-0x01000000 的物理地址到虚拟地址 0xC0000000-0xC1000000
        // 设置临时页表, 设置 cr3 寄存器
        __asm__ volatile ("mov %0, %%cr3" : : "r" (pgd_tmp));
    enable_paging  // 启用分页
    // 切换临时内核栈到分页后的新栈
    __asm__ volatile ("mov %0, %%esp\n\t" "xor %%ebp, %%ebp" : : "r" (kern_stack_top));
    // 更新全局 multiboot_t 指针指向
    glb_mboot_ptr = (multiboot_t *)((uint32_t)mboot_ptr_tmp + PAGE_OFFSET);
    kern_init() // <-- kmain.c
        arch_init
            gdt_init   // 初始化全局描述符表
            idt_init   // 初始化中断描述符表
            clock_init // 注册时间相关的处理函数, 时间片超时函数
        mm_init
            pmm_init  // 物理内存页初始化
                phy_pages_init
            vmm_init  // virtual memory init
                // 注册页错误中断的处理函数
                // 构造页目录
                // 构造页表映射,内核 0xC0000000~0xF8000000 映射到物理 0x00000000~0x38000000
            slob_init // slob 分配器初始化
        task_init // task管理 -- process/thread
            *idle_task = (struct task_struct *)kern_stack;
            idle_task->pid = alloc_pid();
            idle_task->need_resched = true;
            idle_task->stack = (void *)kern_stack_top;
            list_add(&idle_task->list, &task_list);
            register_interrupt_handler(0x80, syscall_handler); // 注册系统调用中断
        fs_init
            for(;;) 
                cpu_hlt(); //  __asm__ volatile ("hlt"); , cpu 空载

初始化函数完成,这个有点像一个进程main函数执行完毕,后面就是循环执行 hlt 让CPU空载,后续都是时钟中断等来触发调度,来执行可以执行的task了。调度的时机也有很多:时钟中断、任务主动让出exit或者sleep、内核空间返回用户空间根据条件判断等。

3. 时钟中断

内核初始化后,后续就会有时钟中断来定期触发调度了,去寻找可执行的任务来运行等。

clock_init
    register_interrupt_handler(IRQ0, clock_callback); // 注册时间片中断
||    
clock_callback
    schedule // 触发调度
    // 找到可运行的任务,并递增时间片

任务管理

OS通常这么说进程和线程:进程是资源管理(内存权限等)的最小单位,线程是调度的最小单位。Linux早起也只有进程,后面才有了线程的支持。

线程本质是一个执行流,硬件资源对应占用一个cpu core的硬线程,有一组寄存器和栈,加上对应的地址空间,即可执行。多个执行流,可以共享一部分地址空间资源,从而减少切换开销。

内核线程是内核中的一个执行流,共享内核空间全局内存信息,特权等级工作在内核态。

schedule

任务调度:主要就是找到下一个可执行的task,然后切换 上下文(Context switch),主要切换栈和PC,关键寄存器值修改一下即可,就可以执行新的任务了。

// 触发调度
schedule()  // <-- 时间片超时会调用到, important
    // find task
    task_next->runs_time++; // 时间片增加
    if (task_next != current)
        task_run(task_next);
            // 切进程页表
            switch_to(&prev->context, &next->context);
            // 执行栈切换
            // 这个一定是汇编代码,因为切换函数调用栈,是无法用C语言表达出来的

图示:
一个极简操作系统的代码实现_第1张图片

fork

任务创建:新建task_struct结构体 (PCB, Process Control Block) , 申请进程PID, 设置地址空间和状态,设置task状态等。

// arch/i386/task/task.c
do_fork
    task_struct *task = alloc_task_struct()
    copy_mm(clone_flags, task)
    copy_thread(task, pt_regs); // copy register
    // interrupt proc
    task->pid = alloc_pid();
    list_add(&task->list, &task_list);
    // interrupt proc
    wakeup_task(task);
        task->state = TASK_RUNNABLE;

exit

任务退出:释放资源,PCB/PID等,让出CPU,触发一次新的调度。比如,用户态程序main函数执行完毕就会调用到exit调用。

do_exit
    current->state = TASK_ZOMBIE; // 进程状态
    current->need_resched = true;
    free_pid(current->pid);
    cpu_idle()
        if (current->need_resched)
            schedule()

内存管理

物理地址

物理内存管理: Physical Memory Management. 通常物理内存按页来管理,物理地址也是全局空间固定的。

// arch/i386/mm/pmm.c
struct pmm_manager {
    const char *name;                                // 管理算法的名称
    void (*page_init)(page_t *pages, uint32_t n);    // 初始化
    uint32_t (*alloc_pages)(uint32_t n);             // 申请物理内存页(n为字节数)
    void (*free_pages)(uint32_t addr, uint32_t n);   // 释放内存页
    uint32_t (*free_pages_count)(void);              // 返回当前可用内存页
};
pmm_manager *pmm_manager = &ff_mm_manager;  // First-Fit 算法内存管理

First-Fit 算法是一种常见的连续内存分配算法,用于管理可用的物理内存空间。核心思想是将进程的内存需求分配给满足要求的第一个合适的可用内存块,基本工作流程:

  1. 初始化内存空间:将整个物理内存空间划分为多个可用的内存块,每个内存块都有一个大小和状态(已分配或未分配)。
  2. 请求内存:请求分配一定大小的内存时,First-Fit算法会从头开始遍历可用内存块列表,寻找第一个满足大小要求的空闲块。
  3. 分配内存:找到满足要求的空闲块后,算法将该块标记为已分配,并将所需的内存分配给进程。
  4. 更新内存块状态:根据实际分配情况,可能需要更新相应的内存块状态信息,例如更新块的大小和剩余空间。
  5. 返回分配结果:将分配的内存块的起始地址返回给进程,进程可以使用该地址来访问已分配的内存。

在实际应用中,First-Fit 算法常用于简单的内存管理系统或具有较少内存碎片问题的场景。对于需要更高内存利用率和更好的碎片管理的系统,可以考虑其他算法如最佳适应算法(Best Fit)或 伙伴算法(Buddy Algorithm)

虚拟地址

虚拟地址管理: Virtual Memory Management. 虚拟地址映射物理地址,申请物理页面,缺页处理等。

关键数据结构:

// 任务虚拟内存区间
struct mm_struct {
    pgd_t *pgdir;
    int vma_count;
    struct list_head vma_list;
};

struct vma_struct {
    struct mm_struct *mm;
    uint32_t vm_start;
    uint32_t vm_end;
    uint32_t vm_flags;
    struct list_head list;
};

关键处理:

//arch/i386/mm/vmm.c
vmm_init
    register_interrupt_handler(INT_PAGE_FAULT, &do_page_fault); // 注册页错误中断的处理函数
    // 页表数组指针
    // 构造页目录(MMU需要的是物理地址,此处需要减去偏移)
    // 构造页表映射
    // switch_pgd((uint32_t)ka_to_pa(pgd_kern));

// 物理地址转换内核虚拟地址
static inline void *pa_to_ka(void *pa)
    return (void *)((uint32_t)pa + KERNBASE);

map(pgd_t *pgd_now, uint32_t va, uint32_t pa, uint32_t flags)
    pte_idx = PTE_INDEX(va);
    pte = (pte_t *)(pgd_now[pgd_idx] & PAGE_MASK);
    if (!pte)
        pte = (pte_t *)alloc_pages(1); // 物理地址管理
        pte = (pte_t *)pa_to_ka(pte);
    tlb_reload_page(va);
        __asm__ volatile ("invlpg (%0)" : : "a" (va)); // CPU 更新页表缓存

文件系统

VFS(Virtual File System)是操作系统中的一个抽象层,用于统一管理不同文件系统的访问和操作。它提供了一套通用的接口和数据结构,使应用程序和操作系统能够以统一的方式访问和操作各种不同类型的文件系统,如ext4、NTFS、FAT等。VFS提供的主要通用接口和数据结构如下:

  1. 文件操作函数接口:打开、文件、写入、关闭等操作函数接口,用于对文件进行操作和访问。
  2. 文件结构体:表示打开的文件,包含与文件相关的信息,如关联的iNode、当前文件偏移量等。
  3. iNode(索引节点)结构体:表示文件或目录的元数据,包含文件模式、所有者信息、时间戳、文件类型等。
  4. 目录项结构体:表示目录中的一个条目,包含目录项名称和关联的 iNode。
  5. 超级块结构体:表示文件系统的元数据,包含根目录的 iNode、文件系统参数等。
  6. 文件系统操作函数接口:包括文件系统的挂载、卸载等操作函数接口,用于管理文件系统。
  7. 路径解析:提供函数用于解析文件路径,将路径转换为对应的iNode。

通过这些接口和数据结构,应用程序可以通过统一的方式对文件系统进行操作,无论是访问普通文件、目录还是其他文件类型。VFS 将底层文件系统的差异隐藏在后端,使应用程序能够以统一的方式与不同文件系统交互。

//fs/vfs.c
vfs_init
    init_mount_tree(mount); // 初始化VFS目录树
        add_filesystem(&fs_ramfs); // 添加根文件系统
        super_block *sb = alloc_super_block(); // 获取根文件系统的超级块结构
        inode *inode = alloc_inode(); // 初始化根结点 inode
        dentry *dentry = alloc_dentry(); // 初始化根结点 dentry

总结

想深刻理解操作系统而不是仅仅停留在概念,需要对应硬件体系结构有一定了解,然后才能明白OS很多核心部分到底是做什么的,为什么那么设计。比如:汇编指令集架构,函数调用约定,中断控制器,MMU,关键系统寄存器作用,特权或异常等级等。了解了这些,才能对OS课本上介绍的各种术语概念有更加深刻的理解。

你可能感兴趣的:(操作系统与Linux,linux,操作系统,体系结构)