在网上看的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
是一个支持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本身还不够,还需要BIOS/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() // 跳转到内核入口点,启动内核
内核运行首先也是初始化部分,刚开始还没有栈,也是汇编代码执行,栈构造完毕进入到C代码部分。
这块看 Hurlex
相关的源码如下:
主要是就是构造了栈,然后让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代码)
这个内核入口,进入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、内核空间返回用户空间根据条件判断等。
内核初始化后,后续就会有时钟中断来定期触发调度了,去寻找可执行的任务来运行等。
clock_init
register_interrupt_handler(IRQ0, clock_callback); // 注册时间片中断
||
clock_callback
schedule // 触发调度
// 找到可运行的任务,并递增时间片
OS通常这么说进程和线程:进程是资源管理(内存权限等)的最小单位,线程是调度的最小单位。Linux早起也只有进程,后面才有了线程的支持。
线程本质是一个执行流,硬件资源对应占用一个cpu core的硬线程,有一组寄存器和栈,加上对应的地址空间,即可执行。多个执行流,可以共享一部分地址空间资源,从而减少切换开销。
内核线程是内核中的一个执行流,共享内核空间全局内存信息,特权等级工作在内核态。
任务调度:主要就是找到下一个可执行的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语言表达出来的
任务创建:新建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;
任务退出:释放资源,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 算法是一种常见的连续内存分配算法,用于管理可用的物理内存空间。核心思想是将进程的内存需求分配给满足要求的第一个合适的可用内存块,基本工作流程:
在实际应用中,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提供的主要通用接口和数据结构如下:
通过这些接口和数据结构,应用程序可以通过统一的方式对文件系统进行操作,无论是访问普通文件、目录还是其他文件类型。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课本上介绍的各种术语概念有更加深刻的理解。