GITHUB: https://github.com/trb331617/miniOS
运行效果:
实验环境:
VMware Workstation 15 Pro; CentOS-7-x86_64-Minimal-1908;
bochs-2.6.8; gcc-4.8.5;
References:
《操作系统真象还原》,郑钢
《x86汇编语言:从实模式到保护模式》,李忠
《汇编语言(第3版)》,王爽
=========================================================================================
Linux中90%以上的代码都是用在资源管理、调度策略、算法及数据结构等方面。操作系统受制于硬件的支持, 很大程度上它的能力取决于硬件的能力,很多操作都是硬件自动完成的。比如,
1) 处理器进入0特权级时, 会自动在任务状态段TSS中获得0特权级的栈地址;
2) 中断发生后, 处理器由低特权级进入高特权级, 它会自动把ss3 esp3 eflags cs eip依次压入栈中(转移后的高特权级栈)。
3) 所以,执行iret指令从高特权级返回低特权级时,处理器可以从当前高特权级栈中获取低特权级的栈段选择子及偏移量。
……
因此,要想全面理解操作系统,不仅需要了解上层软件的算法、原理、实现, 还要了解很多硬件底层的内容。
本项目实现的mini操作系统,包含:
1)内核线程、用户进程、fork和execv、任务调度;
2)中断(时钟, 键盘, 硬盘, 系统调用等)、内存管理、文件系统、shell、管道;
3)基于二元信号量的锁、环形队列。
mbr, 512Bytes; loader, 1.4KB; kernel, 87KB, 8406lines C.
=========================================================================================
BIOS
CPU的硬件电路被设计成只能运行内存中的程序(内存比较快)。
在开机加电的一瞬间,CPU的cs:ip寄存器被强制初始化为0xF000:0xFFF0(BIOS入口地址),此处16字节的内容是跳转指令"jmp f000:e05b"(BIOS程序起始地址)。BIOS的主要工作是:
1)检测、初始化硬件,硬件自己提供了一些初始化的功能调用,BIOS直接调用;
2)建立中断向量表IVT,这样就可以通过"int 中断号"来实现相关的硬件调用。这些功能的实现也是基于对硬件的IO操作。不过在保护模式下,中断向量表已经不存在了,取而代之的是中断描述符表IDT(Interrupt Descriptor Table)。
文本显示:0xB 8000起始的32KB内存区域是用于文本显示,往0xB 8000处输出的字符会直接落到显存中,显存中有了数据,显卡会自动将其搬到显示器屏幕上。
; FILE: boot/mbr.asm
; 截取部分代码
mov ax, 0xb800
mov gs, ax
; 打印字符串
mov byte [gs:0x00], 'M'
mov byte [gs:0x01], 0xa4 ; 显示属性
=========================================================================================
MBR
Master Boot Record, 主引导记录, 位于0x7c00。0x7C00,是BIOS把MBR加载到内存后自动跳转过去的地址。
功能:从硬盘指定位置处加载 loader, 并跳转。
(本项目中, mbr所在的位置, 将会在loader阶段解析kernel ELF时覆盖)
; FILE: boot/mbr.asm
; 截取部分代码
LOADER_BASE_ADDR equ 0x900 ; 自定义loader被加载到物理内存位置
LOADER_START_SECTOR equ 0x02 ; 自定义loader位于硬盘的扇区号
; 读取loader程序
mov eax, LOADER_START_SECTOR ; 起始逻辑扇区号,LBA28编址
mov bx, LOADER_BASE_ADDR ; 要写入的目标地址
mov cx, 4 ; 要读入的扇区数,这里设为4个扇区
call read_hard_disk_0
jmp LOADER_BASE_ADDR + 0x100 ; loader.bin文件头部偏移256字节
times 510-($-$$) db 0
db 0x55, 0xaa
=========================================================================================
LOADER
功能:
1)调用BIOS中断获取内存大小;2)构建GDT,开启保护模式;3)加载kernel;
4)构建页目录表和页表,开启分页机制;5)解析kernel的ELF,将ELF文件中的段segment拷贝到各段自己被编译的虚拟地址处;
6)跳转
--------------------------------------------
1)调用BIOS中断获取内存大小
调用BIOS中断0x15获取内存大小,并将其值存放在 loader.bin头部(地址0x900),内核将会从该位置读取内存大小(kernel/memory.c mem_init())。
2)构建GDT,开启保护模式
; FILE: boot/loader.asm
; 截取部分代码
; 构建gdt及其描述符
; 0号段描述符
gdt_0: dd 0x0000_0000
dd 0x0000_0000
; 1号代码段
gdt_code: dd 0x0000_ffff
dd DESC_CODE_HIGH4
; 2号数据段和栈段
gdt_stack: dd 0x0000_ffff
dd DESC_DATA_HIGH4
; 3号显存段
; 基地址0xb_8000, 段大小0xb_ffff-0xb_8000=0x7fff, 粒度4KB, 段界限0x7fff/4k=7
gdt_video: dd 0x8000_0007 ; limit=(0xb_ffff-0xb_8000)/4k=0x7
dd DESC_VIDEO_HIGH4 ; dpl为0
; gdt基址和界限值
gdt_size dw $-gdt_0-1
gdt_base dd gdt_0
; 加载gdt
; lgdt指令的操作数是一个48位(6字节)的内存区域,低16位是gdt的界限值,高32位是gdt的基地址
; GDTR, 全局描述符表寄存器
lgdt [gdt_size]
; 打开地址线A20
; 芯片ICH的处理器接口部分,有一个用于兼容老式设备的端口0x92,端口0x92的位1用于控制A20
in al, 0x92
or al, 0000_0010B
out 0x92, al
; 禁止中断
; 保护模式和实模式下的中断机制不同,在重新设置保护模式下的中断环境之前,必须关中断
cli
; 开启保护模式
; CR0的第1位(位0)是保护模式允许位(Protection Enabel, PE)
mov eax, cr0
or eax, 1
mov cr0, eax
; 清空流水线、重新加载段选择器
jmp dword sel_code:protcetmode_beginning
[bits 32]
protcetmode_beginning:
; ....
2.1 构建GDT。
2.2 加载GDT。lgdt指令,将GDT的基地址、界限值载入至GDTR寄存器。
2.3 打开地址线A20。
第21条地址线A20:实模式下,处理器访问内存的方式是将段寄存器的内容左移4位,再加上偏移地址,以形成20位的物理地址。实模式下,32位处理器的段寄存器的内容仅低20位有效,高20位全部为0(即,只能使用20根地址线)。故,处理器只能访问1MB内存。(回绕)
2.4 禁止中断。
在设置好保护模式下的中断环境之前,必须关中断(指令cli)。保护模式下的中断机制和实模式不同,原有的中断向量表IVT不再适用。而且,保护模式下,BIOS中断也不能再用,因为它们是实模式下的代码。
2.5 将CR0的PE位置1,开启保护模式。
控制实模式/保护模式切换的开关是CR0寄存器。CR0是处理器内部的控制寄存器(Control Register),是32位的寄存器,包含了一系列用于控制处理器操作模式和运行状态的标志位。CR0的第1位(位0)是保护模式允许位(Protection Enable, PE),该位置1,则处理器进入保护模式,按保护模式的规则开始运行。
3)加载内核
; FILE: boot/loader.asm
; 截取部分代码
KERNEL_BIN_BASE_ADDR equ 0x70000 ; 自定义kernel被加载到物理内存位置
KERNEL_START_SECTOR equ 0x09 ; 自定义kernel位于硬盘的扇区号
mov ax, sel_data
mov ds, ax
; 加载kernel,从硬盘读取到物理内存
; 这里为了简单,选择了在开启分页之前加载
mov eax, KERNEL_START_SECTOR ; kernel.bin在硬盘中的扇区号
mov ebx, KERNEL_BIN_BASE_ADDR ; 从磁盘读出后,写入到ebx指定的物理内存地址
mov ecx, 200 ; 读入的扇区数
call read_hard_disk_0
4)构建页目录表和页表,开启分页机制
地址转换,是由处理器和操作系统共同协作完成的,处理器在硬件上提供地址转换部件,操作系统提供转换过程中所需要的页表。
开启分页机制之前,加载到cr3寄存器中的页目录表基地址是物理地址,页表中页表项的地址自然也是物理地址了。虽然内存分页机制的作用是将虚拟地址转换成物理地址,但其转换过程相当于是在关闭分页机制下进行的,过程中所涉及到的地址都被CPU当作物理地址直接送上地址总线,不会被分页机制再次转换(否则会递归转换下去)。
; FILE: boot/loader.asm
; 截取部分代码
PAGE_DIR_TABLE_POS equ 0x10_0000 ; 自定义页目录表基地址,1MB
mov eax, PAGE_DIR_TABLE_POS ; 页目录表PDT基地址
add eax, 0x1000 ; 4KB,此时eax为第一张页表的基地址
mov ebx, eax ; 为.create_pte做准备,ebx为基址
; 将页目录表第0和第0x300即768项都指向第一张页表, 为将地址映射为内核地址做准备
; 0x300项 * 每个页目录项对应4MB = 3GB
or eax, PG_US_U | PG_RW_W | PG_P ; 定义该页目录项的属性为用户属性,所有特权级别都可以访问
mov [PAGE_DIR_TABLE_POS + 0x0], eax ; 第1个目录项, 指向第一张页表的物理基地址
mov [PAGE_DIR_TABLE_POS + 0x300*4], eax ; 第0x300个目录项, 也指向第一张页表
; 一个页表项占用4字节, 0xc00表示第768个页表占用的目录项,0xc00以上的目录项指向内核空间
sub eax, 0x1000 ; 4KB,重新指向自定义的页目录PDT基地址
mov [PAGE_DIR_TABLE_POS + 1023*4], eax ; 使最后一个目录项指向页目录表自己的地址
; 骚trik, 用于后面修改页目录项和页表项, 用于查找虚拟地址对应的物理地址
; 创建页表项(PTE)Page Table Entry
; 本项目的mbr、loader、内核都放置在物理内存的低端1MB内
mov ecx, 256 ; 1M低端内存 / 每页大小4k = 256
mov esi, 0
mov edx, PG_US_U | PG_RW_W | PG_P ; 属性为7,US=1,RW=1,P=1
.create_pte:
mov [ebx+esi*4], edx ; ebx已赋值为0x10_1000, 即自定义的第一张页表基地址
add edx, 4096 ; 4KB
inc esi
loop .create_pte
; 页目录中创建内核其它页表的PDE ???
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000 ; 此时eax为第二张页表的位置
or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项的属性US,RW和P位都为1
mov ebx, PAGE_DIR_TABLE_POS
mov ecx, 254 ; 第769~1022的所有目录项数量
mov esi, 769
.create_kernel_pde:
mov [ebx+esi*4], eax
inc esi
add eax, 0x1000 ; 页大小为4KB
loop .create_kernel_pde
sgdt [gdt_size] ; 存储到原来gdt的位置
add dword [gdt_base], 0xc000_0000 ; 全局描述符表寄存器GDTR也用的是线性地址
lgdt [gdt_size] ; 将修改后的GDT基地址和界限值加载到GDTR
; 令CR3寄存器指向页目录
mov eax, PAGE_DIR_TABLE_POS ; 把页目录地址赋给控制寄存器cr3
mov cr3, eax
; 开启分页机制
; 从此,段部件产生的地址就不再被看成物理地址,而是要送往页部件进行变换,以得到真正的物理地址
mov eax, cr0
or eax, 0x8000_0000 ; 打开cr0的pg位(第31位),开启分页机制
mov cr0, eax
4.1 物理内存1MB之上:
第1个4KB, 为页目录表PDT
第2个4KB, 为创建的第一张页表(第0和第768(0x300)个页目录项都指向它)
第769~1022个页目录项一共指向254个页表
最后一个页目录项(第1023个)指向页目录表PDT本身
因此,共256个页,正好1M。即,物理内存1MB之上的1MB已用于页目录表和页表。
4.2 控制寄存器CR3指向页目录表基地址
4.3 将CR0的PG位置1,开启分页机制
5)解析kernel的ELF,将ELF文件中的段segment拷贝到各段自己被编译的虚拟地址处
; FILE: boot/loader.asm
; 截取部分代码
KERNEL_BIN_BASE_ADDR equ 0x70000; 自定义kernel被加载到物理内存位置
; 遍历段时,每次增加一个段头的大小e_phentsize
mov dx, [KERNEL_BIN_BASE_ADDR + 42] ; 偏移42字节处是属性e_phentsize, 表示program header大小
; 为了找到程序中所有的段,必须先获取程序头表(程序头program header的数组)
mov ebx, [KERNEL_BIN_BASE_ADDR + 28] ; 偏移28字节是e_phoff, 表示第1个program header在文件中的偏移量
; 其实该值是0x34, 不过还是谨慎一点,这里来读取实际值
add ebx, KERNEL_BIN_BASE_ADDR ; 加上内核的加载地址,得程序头表的物理地址
; 程序头的数量e_phnum,即段的数量,
mov cx, [KERNEL_BIN_BASE_ADDR + 44] ; 偏移44字节是e_phnum, 表示有几个program header
; 遍历段
.each_segment:
cmp byte [ebx + 0], 0 ; 若p_type等于0, 说明此program header未使用
je .PTNULL
; 为函数memcpy压入参数, 参数是从右往左依次压入
; 函数原型类似于 memcpy(dst, src, size)
push dword [ebx + 16] ; 压入memcpy的第3个参数size
; program header中偏移16字节的地方是p_filesz
mov eax, [ebx + 4] ; program header中偏移4字节的位置是p_offset
add eax, KERNEL_BIN_BASE_ADDR ; 加上kernel.bin被加载到的物理地址, eax为该段的物理内存地址
push eax ; 压入memcpy的第2个参数src源地址
push dword [ebx + 8] ; 压入memcpy的第1个参数dst目的地址
; program header中偏移8字节的位置是p_vaddr,这就是目的地址
call mem_cpy ; 调用mem_cpy完成段复制
add esp, 12 ; 清理栈中压入的三个参数
.PTNULL:
add ebx, edx ; ebx指向下一个program header
; dx为program header大小, 即e_phentsize
loop .each_segment
将ELF文件中的段segment拷贝到各段自己被编译的虚拟地址处,将这些段单独提取到内存中,这就是所谓的内存中的程序映像。分析程序中的每个段segment,如果段类型不是PT_NULL(空程序类型),就将该段拷贝到编译的地址中
6)跳转
; FILE: boot/loader.asm
; 截取部分代码
KERNEL_ENTRY_POINT equ 0xc0001500
mov esp, 0xc009f000 ; 自定义内核主线程PCB中的栈顶
jmp KERNEL_ENTRY_POINT
这里将kernel的入口定义为 0xc000_1500,对应的在编译内核kernel.bin时需要指定该地址。
ld -m elf_i386 -Ttext 0xc0001500 -e main -o kernel.bin %.o
=========================================================================================
kernel
main
init是内核启动的第一个程序, pid为1, 后续的所有进程都是它的孩子。init是所有进程的父进程, 它还要负责回收所有子进程的资源。init是用户级进程, 因此要调用 process_execute() 来创建进程,这一步是在init_all()中的thread_init()中完成的。
// FILE: kernel/main.c
// 截取部分代码
int main(void)
{
init_all(); // kernel/init.c 初始化所有模块
cls_screen();
console_put_str("[OS@localhost /]$ ");
// 主线程完成使命后退出
thread_exit(running_thread(), true);
return 0;
}
/* init进程 */
void init(void)
{
unsigned int ret_pid = fork();
if(ret_pid) // 父进程
{
int status, child_pid;
while(1) // init在此处不停地回收过继给它的子进程
{
child_pid = wait(&status);
printf("i am init, my pid is %d, i recieve a child, it's pid is %d, status is %d\n", \
child_pid, status);
}
}
else // 子进程
{
my_shell();
}
panic("ERROR: during init, should not be here");
}
内核的main_thread完成系统的初始化工作,然后thread_exit。
// FILE: kernel/init.c
// 截取部分代码
/* 初始化所有模块 */
void init_all()
{
idt_init(); // 初始化中断
mem_init(); // 初始化内存管理系统
thread_init(); // 初始化main_thread线程, 创建init进程、idle线程
timer_init(); // 初始化PIT, 可编程定时计时器Programmable Interval Timer
console_init(); // 初始化控制台
keyboard_init(); // 初始化键盘
tss_init(); // 初始化TSS(任务状态段)并安装到GDT, 同时安装DPL为3的代码段和数据段
syscall_init(); // 初始化系统调用
intr_enable(); // ide_init 需要打开中断
ide_init(); // 初始化硬盘
filesys_init(); // 初始化文件系统
}
中断和系统调用
本项目支持的中断有:时钟、键盘、硬盘、int 0x80(系统调用)。
中断
|---- FILE: kernel/interrupt.c idt_init()
| | 初始化中断
| | 构建IDT,这里IDT中的每一项都指向对应的一段汇编代码,再由汇编调用C语言中断处理函数
| | 初始化可编程中断控制器8259A,放开所需要的中断
| | 中断发生时,会根据IDTR中的IDT基地址+中断向量*8,跳转到对应的汇编代码
| |
| | idt_desc_init() 初始化中断描述符表 struct gate_desc idt[IDT_DESC_CNT]
| | 中断描述符中包含了中断处理程序所在段的段选择子和段内偏移地址
| | make_idt_desc(&idt[i], IDT_DESC_ATTR_DPL0, intr_entry_table[i])
| | idt[i]中的每一项都指向对应的一段汇编代码intr_entry_table[i], 再由汇编调用C语言中断处理函数idt_table[i]
| | exception_init() 初始化异常名称, 并注册通用的中断处理函数idt_table[i] = general_intr_handler
| | pic_init() 初始化8259A
| | 主片8259A上打开的中断有: IRQ0的时钟、IRQ1的键盘和级联从片的IRQ2, 其它全部关闭
| | 从片8259A上打开IRQ14的硬盘
| | asm volatile("lidt %0" : : "m" (idt_operand))
| | 指令lidt把IDT的界限值、基地址加载到IDTR寄存器
| |
| |---- FILE: kernel/core_interrupt.asm
| | | 模板intr_%1_entry重复展开并构成intr_entry_table[]
| |
| |---- FILE: lib/io.h
| | | 内联汇编实现的读写端口函数
| | | 凡是包含io.h的文件,都会获得一份io.h中所有函数的拷贝
| | | inline是建议处理器将函数编译为内嵌方式,即在该函数调用处原封不动地展
| |
| |---- 系统调用
| | | make_idt_desc(&idt[0x80], IDT_DESC_ATTR_DPL3, syscall_handler)
| | | 系统调用对应为0x80号中断, 中断处理程序为汇编syscall_handler, 再调用中断处理函数syscall_table[]
| | |
| | |---- FILE: kernel/core_interrupt.asm syscall_handler 系统调用统一入口
| | | | 从内核栈中获取cpu自动压入的用户栈指针esp
| | | | 从用户栈中读取系统调用参数, 再压入内核栈
| | | | call子功能号对应的系统调用实现
| | | | intr_exit从中断返回
| | |---- FILE: user/syscall-init.c syscall_table[] 每个系统调用对应的中断处理程序
| | |---- FILE: lib/syscall.h SYSCALL_NR 系统调用号
| | |---- FILE: lib/syscall.c 每个系统调用对外(用户)的接口
内存管理
这里先从指定位置处读取LOADER写入的物理内存大小。本项目中,物理内存的配置为32M(bochs配置文件bochsrc.cfg中"megs: 32"),减去低端的1MB、减去LOADER开启分页机制时创建PDT和PT占用的1MB(紧邻低端1MB之上),还有30MB,内核和用户内存池各占15M。所以,内核物理内存池的起始地址为 0x20_0000(2MB)。
4GB虚拟地址空间中,高1GB为内核空间,其中1GB之上的1MB虚拟空间已在LOADER阶段映射到物理内存的低端1MB。所以,内核虚拟地址池的起始地址为0xc010_0000(1GB+1MB)。
以页(4KB)为单位的内存管理,采用bitmap(位图)技术。本项目中,自定义内核物理内存的bitmap存放于0xc009_a000,自定义内核主线程栈顶为0xc009_f000、内核主线程PCB为0xc009_a000。所以,本系统最大支持4个页框的位图(一个页框大小的位图可表示128M内存,4个页框即512M),用于内核/用户物理内存池bitmap、内核虚拟地址池bitmap。
// FILE: kernel/memory.c
// 截取部分代码
/* 内存池结构,用于管理内存池中的所有物理内存 */
struct pool{
struct lock lock; // 申请内存时互斥, 避免公共资源的竞争
struct bitmap pool_bitmap; // 内存池用到的位图结构,用于管理物理内存
unsigned int phy_addr_begin; // 内存池所管理物理内存的起始地址
unsigned int pool_size; // 内存池字节容量,本物理内存池的内存容量
};
/* 虚拟地址池结构 */
struct virtual_addr{
struct bitmap vaddr_bitmap; // 虚拟地址用到位图结构
unsigned int vaddr_begin; // 虚拟地址起始值
};
/* 位图 */
struct bitmap{
unsigned int bitmap_bytes_len;
unsigned char *bits; // 位图所在内存的起始地址
};
struct pool kernel_pool, user_pool; // 内核内存池和用户内存池
struct virtual_addr kernel_vaddr; // 用于给内核分配虚拟地址
/* 初始化内存池 */
static void mem_pool_init(unsigned int mem_size)
{
// 伪代码
/* 初始化内核物理内存池、用户物理内存池 */
kernel_pool.phy_addr_begin = 2MB; // 起始地址
user_pool.phy_addr_begin = 2MB+15MB;
kernel_pool.pool_size = 15MB; // 内存池大小
user_pool.pool_size = 15MB;
kernel_pool.pool_bitmap.bitmap_bytes_len = 15MB/4KB/8bits; // 位图大小
user_pool.pool_bitmap.bitmap_bytes_len = 15MB/4KB/8bits;
kernel_pool.pool_bitmap.bits = (void *)0xc009_a000; // 指定位图起始地址
user_pool.pool_bitmap.bits = (void *)(0xc009_a000 + kbm_length);
bitmap_init(&kernel_pool.pool_bitmap); // 初始化位图
bitmap_init(&user_pool.pool_bitmap);
lock_init(&kernel_pool.lock); // 初始化锁
lock_init(&user_pool.lock);
/* 初始化内核虚拟地址池 */
kernel_vaddr.vaddr_bitmap.bitmap_bytes_len = kbm_length; // 与内核物理内存池大小一致
// 这里将其安排在紧挨着内核内存池和用户内存池所用的位图之后
kernel_vaddr.vaddr_bitmap.bits = (void *)(0xc009_a000 + kbm_length + ubm_length);
kernel_vaddr.vaddr_begin = 0xc010 0000; // 内核虚拟地址池的起始地址, 即3G+1M
bitmap_init(&kernel_vaddr.vaddr_bitmap); // 初始化位图
}
基于bitmap,实现了以页为单位的内存管理。 虚拟地址是连续的,但物理地址可能连续,也可能不连续。一次性申请count个虚拟页之后,再依次为每一个虚拟页申请物理页,并在页表中依次添加映射关联。
在以页(4KB)为单位的内存管理基础上,实现小内存块的管理,可满足任意内存大小的分配与释放(malloc/free)。这里采用arena模型。
/* 内存块描述符信息 */
struct mem_block_desc{
unsigned int block_size; // 内存块大小
unsigned int blocks_per_arena; // 每个arena可容纳此mem_blcok的数量
struct list free_list; // 空闲内存块mem_block链表
};
/* 内存仓库arena 元信息 */
struct arena{
struct mem_block_desc *desc; // 此arena关联的mem_block_desc
unsigned int count; // large为true时, count表示页框数; 否则, 表示空间mem_block数量
bool large; // 内存分配大于1024字节时为true
};
/* 内存块 */
struct mem_block{
struct list_elem free_elem;
};
// 内核内存块描述符数组
// 本系统支持7种规格的内存块: 16 32 64 128 256 512 1024字节
struct mem_block_desc k_block_descs[7];
内存管理系统
|---- FILE: kernel/memory.c mem_init()
| | 初始化内存管理系统
| | mem_pool_init() 初始化内存池: 内核虚拟地址池、内核/用户物理内存池
| | 虚拟地址池: 虚拟地址bitmap、虚拟地址池起始地址
| | 物理内存池:物理内存bitmap、物理内存起始地址、物理内存池大小
| | block_desc_init(k_block_descs) 初始化内核内存块描述符数组struct mem_block_desc k_block_descs[7]
| | 7种规格: 16 32 64 128 256 512 1024字节
| | 用户进程也有自己的内存块描述符数组, 定义在PCB中
| |
| |---- FILE: lib/bitmap.c bitmap_init() bitmap_scan() bitmap_set()
| | bitmap的基本操作
|
|
|---- FILE: kernel/memory.c malloc_page()
| | 页为单位的内存分配(基于bitmap技术)
| | 虚拟地址池中一次性申请count个虚拟页 vaddr_get()
| | 依次为每个虚拟页申请物理页, 并在页表中做映射
| | palloc() 在物理内存池中分配一个物理页
| | page_table_add() 页表中添加虚拟地址与物理地址的映射
| | 二级页表映射
| | pde_ptr(vaddr) 若页目录项不存在, 则先从内核空间申请一个物理页, 再将物理地址及属性写入PDE
| | pte_ptr(vaddr) 在虚拟地址对应的页表项中PTE写入物理地址及其属性
| |
| FILE: kernel/memory.c mfree_page()
| | 页为单位的内存释放
| | addr_v2p(vaddr) 获取虚拟地址对应的物理地址, 判断是内核/用户物理内存池
| | pfree(pg_phy_addr) page_table_pte_remove() 物理页挨个归还给物理内存池, 并清除虚拟地址所在的PTE
| | vaddr_remove() 一次性将连续的cout个虚拟页地址归还给虚拟地址池
| |
| |---- FILE: lib/bitmap.c bitmap_scan() bitmap_set()
|
|
|---- FILE: kernel/memory.c sys_malloc()
| | 任意内存大小的分配(基于arena模型)
| | 判断是内核线程还是用户进程, 再从块描述符数组struct mem_block_desc中匹配合适的规格
| | 若该规格的free_list为空, 申请一页内存作为arena, 再将arena拆分成该规格的内存块, 并添加到free_list
| | 内存分配: list_pop(&(descs[desc_index].free_list)
| |
| FILE: kernel/memory.c sys_free()
| | 任意内存大小的释放
| | 先将内存块回收到free_list:
| | struct mem_block *bk = vaddr
| | struct arena *ar = block2arena(bk)
| | list_append(&ar->desc->free_list, &bk->free_elem)
| | 再判断此arena中的内存块是否都空闲, 若是则释放arena
| | if(++ar->count == ar->desc->blocks_per_arena)
| | 将arena中所有的内存块从free_list中去掉, 释放arena(4KB页)
| |
| |---- FILE: lib/list.c 链表 list_empty() list_append() list_remove()
| |---- FILE: thread/sync.c 锁 lock_acquire() lock_release()
| |---- FILE: kernel/memory.c malloc_page() mfree_page() arena2block() block2arena()
二级页表映射
|---- trick: 页目录表的基地址赋值给CR3和最后一个页目录项
|
|---- FILE: kernel/memory.c addr_v2p()
| | 虚拟地址转换为物理地址
| | 页表项所在的物理地址 pte = pte_ptr(vaddr)
| | 页表项的值去掉属性+虚拟地址的偏移部分 (*pte & 0xfffff000) + (vaddr & 0x00000fff)
| |
| |---- pte_ptr()
| | | 得到虚拟地址对应页表项所在的物理地址
| | | (0xffc00000 + ((vaddr & 0xffc00000) >> 10) + PTE_IDX(vaddr) * 4)
| |
| |---- PDE_IDX()
| | | 得到虚拟地址所在页表的索引号
| | | ((addr & 0x003ff000) >> 12)
// 由结构体成员得到起始地址
#define offset(struct_type, member) (int)(&((struct_type *)0)->member)
#define elem2entry(struct_type, struct_member_name, elem_ptr) \
(struct_type *)((int)elem_ptr - offset(struct_type, struct_member_name))
内核线程/用户进程
内核主线程/idle线程/init进程
内核线程/用户进程
|---- FILE: thread/thread.c thread_init()
| | 初始化线程环境
| | list_init() 初始化就绪/全部队列
| | pid_pool_init() 初始化pid池, 指定起始pid为1 (基于bitmap和lock)
| |
| | process_execute(init, "init") 创建第一个用户进程init, 其pid为1
| | make_main_thread() 将当前内核main函数创建为线程
| | idle_thread = thread_start("idle", 10, idle, NULL) 创建idle线程
| |
| | 至此, 参与调度的有: init进程、main线程、idle线程
| |
| |---- FILE: user/process.c process_execute()
| | | 创建用户进程
| | | struct task_struct *thread = get_kernel_pages(1) 申请1页内核内存作为用户进程PCB
| | | init_thread() 在PCB中初始化线程基本信息
| | | 申请pid allocate_pid()
| | | {bitmap_scan(); bitmap_set(); return (index + pid_pool.pid_start);}
| | | 进程名、状态READY、内核态时的栈顶(PCB顶部)、优先级、嘀嗒时间数
| | | 初始化文件描述符数组pthread->fd_table[8], 标准输入/输出/错误012, 其余为-1
| | | 根目录作为默认工作路径 pthread->current_work_dir_inode_id = 0
| | | 父进程pid初始为-1
| | | 页目录表pgdir初始为NULL, 在紧接着的create_page_dir()创建页目表时赋值
| | | create_user_vaddr_bitmap() 创建用户虚拟内存空间的bitmap
| | | 指定用户进程虚拟地址起始值; 申请1页内核内存
| | |
| | | thread_create(thread, start_process, filename)
| | | 初始化PCB中的thread_stack。当处理器进入kernel_thread函数体时,
| | | 栈顶为返回地址、栈顶+4为参数function、栈顶+8为参数func_arg
| | | kthread_stack->eip = kernel_thread 函数kernel_thread
| | | kthread_stack->function = start_process 函数start_process
| | | kthread_stack->func_arg = filename 待创建的进程
| | |
| | | thread->pgdir = create_page_dir() 创建页目录表
| | | 在内核空间申请一页内存作为用户进程的页目录表 get_kernel_pages(1)
| | | 从内核页目录表中拷贝内核空间的页目录项到用户进程的页目录中 memcpy()
| | | 页目录表物理基地址写入页目录表最后一项
| | |
| | | block_desc_init(thread->u_block_desc) 初始化内存规格信息, 为malloc/free做准备
| | | list_append() 添加到就绪队列和全部队列
| |
| |---- FILE: thread/thread.c make_main_thread()
| | | 将kernel中main函数完善为主线程
| | | main线程早已运行, 在LOADER阶段已预留并指定了PCB "mov esp, 0xc009_f000"
| | | 即, PCB基址为0xc009_e000
| | | main_thread = running_thread();
| | | init_thread(main_thread, "main", 31);
| | | list_append() 添加到全部队列
| |
| |---- FILE: thread/thread.c thread_start()
| | | 创建内核线程
| | | struct task_struct *thread = get_kernel_pages(1) 申请1页内核内存作为内核线程PCB
| | | init_thread() 在PCB中初始化线程基本信息
| | | 初始化PCB底部的线程信息struct task_struct
| | | 申请pid allocate_pid()
| | | thread_create(thread, function, func_arg)
| | | 初始化PCB中的thread_stack
| | | 不同于创建进程时的参数function为start_process, 这里直接为所要创建的线程,
| | | 即switch_to()任务切换后, 将直接执行对应的线程函数
| | | kthread_stack->eip = kernel_thread 函数kernel_thread
| | | kthread_stack->function = function 线程对应的函数
| | | kthread_stack->func_arg = func_arg 线程参数
| | |
| | | list_append() 添加到就绪队列和全部队列
|
|---- FILE: thread/thread.c idle()
| | 系统空闲时运行的线程(block/unblock)
| | while(1){thread_block(TASK_BLOCKED); asm volatile("sti; hlt" : : : "memory");}
| | idle线程在创建时会被加入到就绪队列, 因此会执行一次, 然后阻塞;
| | 当就绪队列为空时, schedule会将idle解除阻塞, 也就是唤醒
| |
| |---- thread_block()
| | | 当前线程将自己阻塞
| | | 修改线程状态为阻塞、触发调度, 切换线程执行
| | | schedule() 由当前阻塞线程主动触发的任务调度
| | | 由于idle线程触发调度后没有被加入就绪队列, 所以将得不到执行, 除非被唤醒
| |
| |---- thread_unblock()
| | | 将指定的线程解阻塞
| | | 添加到就绪队列头部, 修改状态为READY
| | | list_push(&thread_ready_list, &pthread->general_tag)
| | | pthread->status = TASK_READY
|
|---- FILE: kernel/main.c main()
| | 内核主线程
| | init_all()
| | thread_exit(running_thread(), true);
| | 主线程完成使命后退出 return 0;
|
|---- FILE: kernel/main.c init()
| | init进程
| | 第一个用户进程, pid为1
| | init是所有进程的父进程, 它还要负责回收过继给它的子进程
| | if(fork()) // 父进程
| | { while(1){wait(&status);} }
| | else // 子进程
| | { my_shell(); }
fork/wait/exit
fork/wait/exit
|---- FILE: lib/syscall.c fork()
|---- FILE: user/fork.c sys_fork()
| | 创建子进程
| | 1) 申请1页内核内存作为子进程PCB get_kernel_pages(1)
| | 2) 复制父进程的资源 copy_process(child, parent)
| | 3) 添加到就绪队列和全部队列 list_append()
| | 4) 父进程返回子进程的pid return child_thread->pid
| |
| |---- FILE: user/fork.c copy_process()
| | 复制父进程的资源
| | 1) 复制父进程的PCB、虚拟地址位图到子进程, 申请进程pid, 修改子进程的进程信息
| | copy_pcb_vaddr_bitmap_stack0()
| | 2) 为子进程创建页表, 并复制内核1G空间对应的页目录项 create_page_dir()
| | 3) 复制父进程进程体(代码和数据资源)给子进程 copy_body_stack3()
| | 逐字节再逐位查看位图, 若有数据则先拷贝到内核空间
| | 切换为子进程的页表, 再将数据从内核空间复制到子进程的用户空间
| | 恢复父进程的页表
| | 4) 构建子进程thread_stack和修改返回值pid build_child_stack()
| | intr_0_stack->eax = 0 // 根据abi约定, eax为函数返回值, fork为子进程返回0
| | *(intr_0_stack - 1) = intr_exit // switch_to的返回地址设置为intr_exit, 直接从中断返回
| | // 子进程被调度时, 直接从中断返回, 即实现了从fork之后的代码处继续执行
| | 5) 更新文件inode的打开数 update_inode_open_counts()
|
|---- FILE: lib/syscall.c wait(&status)
|---- FILE: user/wait_exit.c sys_wait()
| | 等待子进程调用exit再回收子进程
| | while(1){
| | // 子进程状态为HANGING, 即已调用exit, 则回收子进程
| | child = list_traversal(&thread_all_list, find_hanging_child, parent_thread->pid)
| | thread_exit(child_thread, false); // 传入false, 使thread_exit执行完后回到此处
| | return child_pid
| | // 否则, 说明子进程仍在运行, 则将自己挂起
| | child_elem = list_traversal(&thread_all_list, find_child, parent_thread->pid);
| | thread_block(TASK_WAITING);
| |
| |---- FILE: thread/thread.c thread_exit()
| | 回收线程/子进程的PCB和页目录表, 并从调度队列中删除
| | mfree_page(PF_KERNEL, thread->pgdir, 1); // 回收页目录表
| | mfree_page(PF_KERNEL, thread, 1); // 回收PCB
| | list_remove() // 从调度队列中删除
| | release_pid() // 释放pid
|
|---- FILE: lib/syscall.c exit()
|---- FILE: user/wait_exit.c sys_exit()
| | 子进程退出
| | child_thread->exit_status = status // 将status存入自己的pcb
| | // 将进程child的所有子进程都过继给init
| | list_traversal(&thread_all_list, init_adopt_a_child, child_thread->pid);
| | release_prog_resourece(child_thread) // 回收进程child的资源
| | // 如果父进程正在waiting, 则先唤醒父进程
| | if(parent_thread->status == TASK_WAITING) thread_unblock(parent_thread);
| | // 将自己挂起, 等待父进程获取其status, 并回收其pcb
| | thread_block(TASK_HANGING);
| |
| |---- FILE: user/wait_exit.c release_prog_resourece()
| | 释放用户进程资源
| | 1) 遍历页目录表和页表将用户空间部分已分配的用户物理页在位图中清0
| | free_a_phy_page(page_phy_addr)
| | 2) 将用户虚拟地址池bitmap本身占用的内核物理页释放
| | mfree_page(PF_KERNEL, bitmap, bitmap_page_count);
| | 3) 关闭进程打开的文件
| | sys_close(fd_index)
任务调度
1) 基于时钟中断的任务调度, 时钟的中断处理程序 intr_timer_handler()
处理器进入0特权级时, 会自动在任务状态段TSS中获得0特权级的栈地址, 即current线程PCB顶部的struct intr_stack
中断的入口intr_%1_entry会执行一系列压栈操作(FILE: kernel/core_interrupt.asm),
(若是从低特权级进入高特权级, 还会自动压栈: ss esp eflags cs eip err)
2) (此时是current的PCB) 函数schedule()中, 若next为进程则修改TSS的esp0(next被中断时使用, 这里还是current的), 然后
调用 switch_to(current_thread, next), 将自动在PCB顶部继续压栈(可参考struct thread_stack):
next current_thread eip/retaddr(这里的eip指向的是函数schedule()中switch_to的下一条指令代码)
3) switch_to()
1. (此时是current的PCB) 继续压栈: esi edi ebx ebp
2. (为current保存esp到PCB底部) 从[esp+5*4]处得到栈中参数current_thread(即PCB底部), 再mov [eax], esp
3. (切换esp为next的esp) 从[esp+6*4]处得到栈中参数next(即PCB底部, PCB底部保存的是esp值), 再mov esp, [eax]
4. (此时是next的PCB) 出栈: ebp ebx edi esi
5. (利用ret自动从栈中弹出给eip的特性实现执行流的切换) ret
若该任务是第一次执行, 则此时eip为kernel_thread()函数(这是在thread_create()函数中初始化的)
执行kernel_thread()时会将栈中的2个值当作其参数function和arg, 执行function(arg);
(栈中的function和arg也是在thread_create()中初始化的)若为线程, function为直接线程函数;
若为进程, function为start_process(), arg为进程程序
若该任务并非第一次执行, 则此时eip指向的是函数schedule()中switch_to的下一条指令代码(类似于这里的current)
而swit_to是schedule最后一句代码, 因此执行流程马上回到schedule的调用者intr_timer_handler中
schedule也是intr_timer_handler中最后一句代码, 因此会回到core_interrupt.asm中的jmp intr_exit,
从而恢复任务的全部寄存器映像, 之后通过iretd指令退出中断, 任务被完全彻底恢复
基于时钟中断的任务调度
|---- FILE: device/timer.c timer_init()
| | frequency_set()初始化可编程定时计时器8253
| | 使用8253来给IRQ0引脚上的时钟中断信号“提速”,使其发出的中断信号频率快一些。
| | 默认的频率是18.206Hz,即一秒内大约发出18 次中断信号。
| | 通过对8253编程,使时钟一秒内发100次中断信号,即中断信号频率为100Hz.
| |
| | register_handler(0x20, intr_timer_handler) FILE: kernel/interrupt.c
| | 注册安装中断处理程序 idt_table[vector_id] = function
| | 时钟的中断向量号0x20
| |
| |---- FILE: device/timer.c intr_timer_handler()
| | 时钟的中断处理程序
| | running_thread()通过esp得到PCB基地址 asm("mov %%esp, %0" : "=g"(esp))
| | if(current_thread->ticks == 0)
| | schedule(); // 若当前任务时间片用完,则调度
| | else
| | current_thread->ticks--; // 将当前任务的时间片-1
| |
| |---- FILE: thread/thread.c schedule()
| | 任务调度 (由时钟中断触发)
| | *** 调度 ****************
| | 1) 若状态为RUNNING说明时间片到了, 则添加到就绪队列尾并重置ticks、状态改为READY
| | 2) 就绪队列
| | 若就绪队列为空, 则唤醒idle_thread线程 thread_unblock(idle_thread)
| |
| | 正常则弹出第一个线程, 由结构体成员得到首地址(PCB基地址), 状态改为RUNNING
| | 3) process_activate(next) FILE: user/process.c
| | 更新CR3切换页目录表, 进程还需要修改TSS的esp0
| |
| | 4) switch_to(current_thread, next) 切换任务
| |
| |---- FILE: user/process.c process_activate(next)
| | | 更新CR3切换页目录表, 用户进程还需要修改TSS的esp0
| | | page_dir_activate() 更新CR3
| | | 内核线程页目录基地址为0x10_0000, 用户进程为addr_v2p(pthread->pgdir)
| | | asm volatile("movl %0, %%cr3" : : "r"(pagedir_phy_addr) : "memory")
| | | update_tss_esp(pthread) 若是用户进程, 则需要更新TSS中的esp0
| | | tss.esp0 = (unsigned int)pthread + PAGE_SIZE
| | | 模仿Linux: 一个CPU上的所有任务共享同一个TSS,之后不断修改同一个TSS的内容
| | | TSS中esp0字段的值为pthread的0级栈, 用户进程由用户态进入内核态时所用的栈
| | | 处理器会自动到TSS中获取esp0作为用户进程在内核态的栈地址
| |
| |---- FILE: thread/switch.asm switch_to(current_thread, next);
| | 切换栈、切换执行流eip
| | *********************************************
| | 此处的理解需结合线程栈信息struct thread_stack和函数thread_create()
| | *********************************************
| | 保存current线程的寄存器, 将next线程的寄存器装载到处理器
| | 传入的2个参数自动压入了current栈中, 这2个参数为2线程PCB基地址
| | PCB底部为线程/进程信息struct task_struct, 第一个成员为栈顶地址
| | 切换栈: 伪代码 mov [current], esp; mov esp, [next];
| | 切换执行线程: ret。这里利用了ret的特性, 自动从栈弹出给eip
| | 栈中存放eip处, 已由函数thread_create赋值为kernel_thread()
| |
| |---- FILE: thread/thread.c kernel_thread(function, func_arg)
| | 执行创建线程时指定的函数/执行进程启动函数
| | 这里2个参数的值已由函数thread_create赋值在栈中
| | intr_enable()开中断, 避免时钟中断被屏蔽而无法调度其它线程
| | function(func_arg)
| | 若为线程,则执行新线程对应的函数
| | 若为进程,则执行start_process(filename)
| |
| |---- FILE: user/process.c start_process(filename)
| | 开启用户进程
| | 假装从中断返回, 由0特权级进入用户态3特权级
| | 1) 假装。构造用户进程的中断栈信息struct intr_stack(位于PCB顶部)
| | edi esi ebp esp_dummy; ebx edx ecx eax; gs ds es fs;
| | (待执行的用户进程)eip cs eflags; (用户空间的3特权级栈)esp ss
| | 2) 从中断返回。jmp intr_exit
| |
| |---- FILE: kernel/core_interrupt.asm intr_exit
| | 从中断返回 iretd
文件系统(待补充)
<------ 待补充 ------>
shell
shell, 由init()进程fork()的子进程
|---- FILE: shell/shell.c my_shell()
| | while(1){
| | print_prompt()
| | readline()
| | if(pipe_symbol){ 包含管道符, 则重定向stdin stdout, 并依次执行cmd_execute() }
| | else { 无管道, 则直接执行cmd_execute(argc, argv) }
| | }
| |
| |---- FILE: shell/shell.c cmd_execute()
| | 执行命令
| | 内部命令: ls cd pwd ps clear mkdir rmdir rm help,
| | 则直接系统调用
| | 外部命令: cat
| | if(fork()) // 父进程
| | child_pid = wait(&status)
| | else // 子进程
| | execv(argv[0], argv)
| |
| |---- FILE: lib/syscall.c execv()
| |---- FILE: user/exec.c sys_execv()
| | 加载新程序替换当前进程并执行
| | 加载 entry_point = load(path)
| | 修改进程名 memcpy(curr->name, path, TASK_NAME_LEN)
| | 构建内核栈
| | ebx = argv ecx = argc
| | eip = entry_point esp = 0xc000_0000
| | 假装从中断返回, 执行新程序
| | exec不同于fork, 为使新程序更快被执行, 直接从中断返回
| | asm volatile("movl %0, %%esp; jmp intr_exit" : : "g" (intr_0_stack) : "memory")
| |
| |---- FILE: user/exec.c load()
| | | 从文件系统加载用户程序, 并返回程序入口elf_header.e_entry
| | | 解析ELF文件
| |
| |---- FILE: kernel/core_interrupt.asm intr_exit
| | 从中断返回 iretd
user
简易版C运行库
C运行库, C RunTime Library, CRT. CRT主要功能是初始化运行环境,然后调用用户进程的main(),最后调用exit()回收用户进程的资源。
CRT代码才是用户程序的第一部分,用户进程的main()实质上是被夹在CRT中执行的。
使用ar命令将start.o和所需要的库文件打包成静态库文件simple_crt.a,即本项目中的简陋版C运行库。
start.asm:call main; call exit
; FILE: command/start.asm
[bits 32]
extern main
extern exit
SECTION .text
; 用户程序真正的第一个函数, 是程序真正入口
GLOBAL _start ; _start 链接器默认的入口符号, 也可以 ld -e 指定入口符号
_start:
; 下面这2个要和execv()和load()之后指定的寄存器一致
push ebx ; 压入 argv
push ecx ; 压入 argc
call main
; ABI规定, 函数返回值是在eax寄存器中
; 将main的返回值通过栈传给exit, gcc用eax存储返回值, ABI规定
push eax ; 把main的返回值eax压栈
; 为下面调用exit系统调用压入的参数, 相当于exit(eax)
call exit ; exit 将不会返回
编译链接
# ar 命令将string.o syscall.o stdio.o assert.o start.o打包成静态库文件simple_crt.a
ar rcs simple_crt.a $OBJS start.o
# 编译用户程序
gcc $CFLAGS $LIBS -o $BIN".o" $BIN".c"
# 链接
ld -m elf_i386 $BIN".o" simple_crt.a -o $BIN # 默认 -e _start
lib
锁
锁
| struct lock pid_lock; // 申请pid FILE: thread/thread.c
| struct lock console_lock; // 控制台锁 FILE: device/console.c console_init()
|
|---- FILE: thread/sync.c lock_init(&console_lock)
| | 初始化锁
| | 基于二元信号量实现的锁
| | struct semaphore{
| | unsigned char value; struct list waiters; };
| | 信号量初值, 此信号量上阻塞的所有线程
| | struct lock{
| | struct task_struct *holder; struct semaphore sema; unsigned int holder_repeat_num; };
| | 锁的持有者, 信号量, 锁的持有者重复申请锁的次数
|
|---- FILE: thread/sync.c lock_acquire()
| | 获取锁
| | sema_down(&lock->semaphore)信号量P操作
| | while(sema->value == 0) // value为0表明已经被别人持有
| | list_append()当前线程把自己加入该锁的等待队列
| | thread_block()当前线程阻塞自己,并触发调度,切换线程
| | ************************************* 调度 *********
| | lock->holder = running_thread()
| |
| |---- FILE: thread/thread.c thread_block()
| | 当前线程将自己阻塞
| | 修改线程状态为阻塞、触发调度, 切换线程执行
| |
| |---- FILE: thread/thread.c schedule()
| | 任务调度 (由当前阻塞线程主动触发)
|
|---- FILE: thread/sync.c lock_release()
| 释放锁
| lock->holder = NULL
| sema_up(&lock->semaphore)信号量V操作
| list_pop(&sema->waiters)从等待队列中取出一个线程
| thread_unblock()唤醒该阻塞线程: 将阻塞线程加入就绪队列,并修改状态为READY
| sema->value++
|
|---- FILE: thread/thread.c thread_unblock()
| 唤醒阻塞线程
| 将阻塞线程加入就绪队列,并修改状态为READY
环形队列
环形队列
| struct ioqueue keyboard_buf; // 键盘
| struct ioqueue * xxx = xxx; // 管道
|
|---- FILE: device/ioqueue.c ioqueue_init(&keyboard_buf)
| | 初始化环形队列
| | 结合锁机制、生产者消费者模型
| | struct ioqueue{
| | struct lock lock; // 锁
| | struct task_struct *producer, *consumer; // 睡眠的生产者/消费者
| | char buf[buffersize]; // 缓冲区
| | signed int head, tail; }; // 队首写入, 队尾读出
|
|---- FILE: device/ioqueue.c ioq_getchar()
| | 消费者消费一个字符
| | while(ioq_empty(ioq)) // 缓冲区为空时, 消费者睡眠
| | lock_acquire(&ioq->lock); // 获取锁, 每个锁对应的信号量都会有一个阻塞队列
| | ioq_wait(&ioq->consumer); // 消费者睡眠
| | lock_release(&ioq->lock); // 释放锁
| | char byte = ioq->buf[ioq->tail]; // 消费一个字符
| | if(ioq->producer !=NULL) wakeup(&ioq->producer); // 唤醒生产者(生产者睡眠是因为缓冲区满)
|
|---- FILE: device/ioqueue.c ioq_putchar()
| | 生产者生产一个字符
| | while(ioq_full(ioq)) // 缓冲区满时, 生产者睡眠
| | lock_acquire(&ioq->lock); // 获取锁, 每个锁对应的信号量都会有一个阻塞队列
| | ioq_wait(&ioq->producer); // 生产者睡眠
| | lock_release(&ioq->lock); // 释放锁
| | ioq->buf[ioq->head] = byte; // 生产一个字符
| | if(ioq->consumer !=NULL) wakeup(&ioq->consumer); // 唤醒消费者(消费者睡眠是因为缓冲区空)
附
物理地址/线性地址/虚拟地址/逻辑地址
1)实模式下,"段基址+段内偏移地址"经过段部件的处理,直接输出的就是物理地址,CPU可以直接用此地址访问内存。
2)保护模式下,"段基址+段内偏移地址"经段部件处理后为线性地址。(但此处的段基址不再是真正的地址,而是一个选择子,本质上是个索引,类似于数组下标,通过这个索引便能在GDT中找到相应的段描述符。段描述符记录了该段的起始、大小等信息,这样便得到了段基址。)若没有开启地址分页功能,此线性地址就被当作物理地址来用,可直接访问内存。
3)保护模式+分页机制,若开启了分页功能,线性地址则称为虚拟地址(虚拟地址、线性地址在分页机制下都是一回事)。虚拟地址要经过CPU页部件转换成具体的物理地址,这样CPU才能将其送上地址总线取访问内存。
逻辑地址,无论是在实模式或保护模式下,段内偏移地址又称为有效地址,也称为逻辑地址,这是程序员可见的地址。最终的地址是由段基址和段内偏移地址组合而成。实模式下,段基址在对应的段寄存器中(cs ds es fs gs);保护模式下,段基址在段选择子寄存器指向的段描述符中。所以,只要给出段内偏移地址就行了,再加上对应的段基址即可。
访问外部硬件有2个方式
1)将某个外设的内存映射到一定范围的地址空间中,CPU通过地址总线访问该内存区域时会落到外设的内存中,这种映射让CPU访问外设的内存就如同访问主板上的物理内存一样。比如显卡,显卡是显示器的适配器,CPU不直接和显示器交互,只和显卡通信。显卡上有片内存叫显存,被映射到主机物理内存上的低端1MB的0xB 8000 ~ 0xB FFFF。CPU访问这片内存就是访问显存,往这片内存上写字节便是往屏幕上打印内容。
2)外设是通过IO接口与CPU通信的,CPU访问外设,就是访问IO接口,由IO接口将信息传递给另一端的外设。CPU从来不知道有这些设备的存在,它只知道自己操作的IO接口。
将数据和代码分开的好处
1)可以赋予不同的属性,使程序更安全。如,数据,只读/只写/可读可写;代码,只读。
2)提高CPU内部缓存的命中率,使程序运行得更快。局部性原理,CPU内部有针对数据和针对指令的两种缓存机制。
3)节省内存。当一个程序的多个副本同时运行(比如同时执行多个ls命令),可以把只读的代码段共享,没必要在内存中同时存在多个相同的代码段。
ret/iret
ret指令(return), 从栈顶弹出2字节来替换ip寄存器。ret只置改变ip, 不改变段基址, 属于近返回。
iret指令(中断返回), 从栈中弹出数据到寄存器cs、eip、eflags, 并根据特权级是否改变,判断是否要恢复旧栈, 即
是否将栈中位于ss_old和esp_old位置的值弹出到寄存ss和esp。