写个操作系统吧!
作者:互联网
写个操作系统吧!
参考书籍:
- 《操作系统真相还原》
- 《x86汇编语言:从实模式到保护模式》
中断
中断的分类:
-
外部中断:外部设备提供的中断信号
- INTR(INTeRrupt):可被屏蔽的中断,例如:网卡接收到数据等。
- NMI(Non Maskable Interrupted):无法被屏蔽的中断,例如:电源掉电等。
-
内部中断:
-
软中断:由软件发起,即执行了对应的中断指令。
int 8位立即数
:根据中断向量表或中断描述符表调用对应的中断处理程序。int3
:断点调式命令,触发中断向量号3。into
:中断溢出指令,触发中断向量号4。bound
:检查数组索引越界指令,触发中断向量号5。ud2
:未定义指令,触发中断向量号6。
-
异常:指令执行期间CPU内部发生错误引起的,无法被屏蔽。
按照异常的严重程度,可分为以下三种:
- Fault,故障:可被修复的错误类型。
- Trap,陷阱:通常用在调试中。
- Abort,终止:一旦出现则无法修复,操作系统会将该程序抹掉。
-
操作系统对中断的处理方式:
把中断处理程序划分为上半部和下半部。
上半部:处理一些紧急且重要的事情。(会关闭中断,处理完成后再开启中断)
下半部:处理一些重要但不紧急的事情,由操作系统在适合的时候执行。(允许中断发生)
IDT中断描述符表
保护模式下用于存储中断处理程序入口的表。
实模式下存储中断处理程序入口的表称为中断向量表(IVT)。
门:
-
任务门
与任务状态段(TSS)配合使用,是Intel在硬件一级提供的任务切换机制。
任务门可以存在全局描述符表GDT、局部描述符表LDT、中断描述符表IDT中。
-
中断门
包含中断处理程序所在段的段选择子和段内偏移地址。通过此方式进入中断后,标志寄存器eflags中的IF位自动设置为0,也就是自动关闭中断,避免中断嵌套。
中断门只允许存在于中断描述符表IDT中。
-
陷阱门
与中断门一样,但是通过此方式进入中断,不会自动关闭中断。
-
调用门
提供给用户进入特权0级的方式,其DPL为3。
调用门记录了例程的地址,不能通过
int
指令调用,只能通过call
和jmp
指令调用。可以安装在全局描述符表GDT和局部描述符表LDT中。
中断向量表和中断描述符表的区别:
中断向量表:实模式
存在于低端内存中,即
0x0 ~ 0x3ff
,大小为1024个字节,每个中断向量大小为4个字节,因此中断向量表可以容纳256个中断向量
中断描述符表:保护模式
中断描述符表寄存器(IDTR),该寄存器分为两部分,
0~15
位是表界限,16~47
位为IDT的基地址,最多容纳8192个描述符。
中断处理过程:
- CPU根据中断向量号定位到中断描述符。
- CPU进行特权级检查
- 执行中断处理程序
两个中断有关的命令
cli
:关中断,把eflags寄存器的IF位设置为0。sti
:开中断,把eflags寄存器的IF位设置为1。
中断处理过程中栈的变化:
中断错误码:
参数解释:
- EVT:用来指明中断源是否来自外部设备,1是,0否。
- IDT:表示选择子是否指向中断描述符表,1是,0否。
- TI:表示是选择子使用GDT还是LDT,1LDT,0GDT。
中断控制器8259A
构造:
外部设备发起中断到CPU处理中断的流程:
- 外设发起中断,该中断信号被送入到
8259A
的某个IRQ接口
。 8259A
收到该信号后,根据IMR寄存器
判断该信号是否被屏蔽。(1屏蔽,0放行)8259A
将IRQ接口对应的该IRR寄存器
的bit置为1。优先仲裁器PR
从IRR寄存器
中挑选一个优先级最大的中断(即IRQ的位置越小,优先级越大),通过INT接口
发送INTR信号
给CPU
。CPU
收到该信号后,通过INTA接口
回复一个INTA信号
,表示CPU
准备完成可以接收中断向量号。8259A
收到INTA信号
后,将对应的IRR寄存器
上的位设置为0。CPU
再次发送INTA信号
给8259A
,8259A
发送\(起始向量号 + IRQ接口号 = 该设备的中断向量号\)给CPU
。CPU
根据该中断向量号
找到对应中断程序入口,执行对应的中断处理程序。- 当中断处理程序结束后,如果
8259A
的EOI通知(End Of Interrupt)
设置为非自动模式,则中断处理程序需要在结束处向8259A
发送EOI通知
,8259A
收到EOI通知
将ISR寄存器
中对应的bit设置为0。
如果
EOI通知
设置为自动模式,则8259A
会在收到第二次INTA信号
后就自动将对应的ISR寄存器
的bit设置为0。
可编程的计数器/定时器8253
8253提供了三个计数器,分别对应端口0x40 ~ 0x42
(16位)。
20计数器在计时到期后就会发出时钟中断信号,中断代理8259A就可以收到。
进程和线程的运行机制
线程创建
由内核进行管理,不具备属于自己的虚拟地址空间,线程创建时,通过
get_kernel_pages
函数向内核内存池申请一页的内存空间作为PCB。注意:线程栈处于该内存页的顶端,线程相关信息处于内存页的低端。
线程创建时通过
thread_start
函数创建,该函数主要的职责是从内核内存池中申请1页的内存空间,作为PCB,即struct task_struct*
结构体。再初始化线程相关信息,如:线程名称、优先级、线程状态等,再初始化线程的内核栈,即将分配到的内存页的顶端作为栈的起始位置。thread->self_kstack = (uint32_t*) ((uint32_t) thread + PG_SIZE)
。然后,再把线程的PCB块纳入thread_ready_list
和thread_all_list
队列进行管理。
线程调度
线程的调度完全依赖于时钟中断函数。具体代码在
timer.c
文件中。在
init.c
中会暴露出一个init_all
函数,作为main.c
文件中的第一个函数调用。该函数负责初始化所有内核模块,也包括定时器的初始化,即timer_init
函数。这个函数是在timer.c
文件中。
timer_init
函数主要设置了PIT8253
定时器的定时周期,即以一定的频率向CPU发出中断信号,再将intr_timer_handler
中断处理函数注册到中断描述符表中,对应的中断向量号为0x20
(注册函数:register_handler
),即interrupt.c
文件中的idt_table
数组。当CPU收到一个中断信号时,就会调用
kernel.s
文件中的intr_entry_table
数组,而该数组中的中断处理函数,都是使用同一个模板,具体逻辑就是:保存当前中断上下文(相关寄存器),再调用interrupt.c
文件中注册好的idt_table
数组。再说回
intr_timer_handler
函数,该函数的主要逻辑如下:
intr_timer_handler
函数的步骤
- 先通过
running_thread
获取当前线程。- 检查线程是否栈溢出,即查看
struct task_struct*
结构体的stack_magic
属性是否被篡改。- 将线程运行的总时间片数加一。
- 将
timer.c
中的全局变量ticks
加一,表示从操作系统内核加载到现在所运行过的时间片数。- 判断线程的可用时间片
ticks
是否为0,如果为0,则表示时间片用完,进行调度,即调用thread.c
文件中的schedule
函数。否则,将可用时间片减一,结束中断。(结束中断后,回返回当前线程之前正在运行的函数,并恢复其上下文(寄存器))说了这么多,调度的所有关键点都在
schedule
函数上。
schedule
函数的步骤:
- 通过
running_thread
函数获取当前运行的线程cur
。- 判断当前线程是否处于
TASK_RUNNING
状态,如果是,则表明该线程是因为时间片用完,则进行线程调度的,那么将cur
放入就绪队列thread_ready_list
,重置该线程的可用时间片cur->ticks = cur->priority
,设置线程的状态为TASK_READY
。- 从就绪队列
thread_ready_list
的队头pop
出一个线程,更新线程状态为TASK_RUNNING
。- 调用
process_activate
函数,判断当前PCB是用户进程还是内核线程?如果是用户进程,则调用page_dir_activate
函数修改cr3
寄存器,即修改页目录表的起始地址为用户虚拟地址空间。否则修改页目录表的起始地址为0x100000
,即为内核虚拟地址空间。(因为在调度时,前一个PCB有可能是用户进程,所有也需要更新cr3
寄存器)。如果是用户进程,则需要修改tss
的esp0
值,即修改0特权级栈
为内核栈,即(uint32_t *)((uint32_t)pthread + PG_SIZE)
。- 最后调用
switch_to
函数,该函数位于switch.s
文件中,保存当前线程上下文,即将寄存器的值压入线程栈中,同时恢复即将调度的线程的上下文,最后通过ret
指令,获取栈中的值,跳转到kernel_thread
函数中去执行。补充:
当跳转到
kernel_thread
函数后,会先开启中断,在调用线程栈中的thread_stack::function
函数。从而实现从一个指令流跳转到另一个指令流。
进程创建
进程相比于线程,多出了一个
3特权级栈
和虚拟地址空间
。步骤:
- 通过
process_execute
函数传入调用的函数指针(void*)
和进程名称,在从内核内存池中申请一个页面作为内核栈,进行线程相关的初始化,即:init_thread -> (create_user_vaddr_bitmap) -> init_stack
。create_user_vaddr_bitmap
是初始化进程的虚拟地址空间的bitmap
,将虚拟地址空间的起始地址设置为0x8048000
,同时在内核内存池中分配出几个页来作为bitmap
。- 创建用户进程的页目录表,先向内核内存池申请一个页面来作为页目录表,同时将内核的页目录表的第
768
项到1023
项都复制到用户进程的页目录表中。- 关闭中断。
- 将该PCB加入
thread_all_list
和thread_ready_list
队列- 开启中断。
进程调度
进程的调度相比于线程的不同之处,即进程需要在调度后,需要初始化用户进程的上下文,即恢复寄存器原先的值,并且将
esp
修改为3特权级栈
的地址值。线程调度就不需要,通过
switch_to
函数的ret
指令跳转到kernel_thread
函数中执行给定的thread_func
函数即可。进程调度,则同样通过
switch_to
函数的ret
指令跳转到kernel_thread
函数中执行给定的thread_func
函数,但是这个thread_func
函数,在process_execute
函数里调用init_stack
时,已经指定为start_process
函数。
start_process
函数的主要流程:
- 获取当前PCB,并设置PCB的中断栈
intr_stack
结构。主要就是把栈中的eip
值改为用户给的启动函数,同时修改栈中cs
和ss
的值为用户态下的段选择子,修改esp
指针为3特权级栈
(到这里才实际分配用户栈空间)。- 修改
esp
寄存器的值为PCB中断栈的起始地址,通过jmp
指令跳转到kernel.s
文件的intr_exit
函数中。intr_exit
这个函数主要就是恢复esp
寄存器指向的栈中值到gs、fs、es、ds
寄存器中,同时通过iretd
指令退出中断模式(欺骗CPU,来跳转到3特权级栈
)- 由于
eip
寄存器指向用户给定的启动函数,那么进程就从用户给的函数开始执行。
系统调用
中断发生后,处理器从低特权进入高特权,它会把
ss3、esp3、eflag、cs、eip
寄存器依次压入栈中,共20字节。
系统调用流程
在用户进程中导入
syscall.h
头文件。里面有对应的系统调用函数,具体实现在
syscall.c
文件中。以
write
系统调用为例:
- 用户调用
write
函数,传入一个对应的字符串参数,这是函数内部会调用对应的宏_syscall1
。_syscall1
宏会传入系统调用子功能号和参数,子功能号对应的就是SYSCALL_NR
枚举(枚举会被转化为整数),_syscall1
宏的功能就是,调用asm volatile
宏定义(C语言内联汇编),把参数和子功能号压入栈中,并发起中断,即int $0x80
指令,再将中断处理后的结果从eax
寄存器取出来放入到ret_val
变量中,并返回。0x80
中断的具体实现在kernel.s
文件,该中断处理函数在interrupt.c
文件的完成注册,即在中断描述符表中加入该函数的中断描述符。具体路径:idt_init() -> idt_desc_init() -> make_idt_desc(&idt[0x80], IDT_DESC_DPL3, syscall_hanlder)
。- 现在看下
syscall_handler
函数,该函数的主要作用就是保存当前线程上下文,然后从栈中取出esp3
指针,即用户栈指针,因为系统调用是在用户态下调用的,当CPU特权级发生变化时,CPU会负责将esp3等寄存器的值压入栈中,然后从栈中获取子功能号和系统调用参数,回调syscall.c
文件中定义的全局数组syscall_table
,该数组每一个元素都是对应子功能号(子功能号对应数组下标)的系统调用处理函数,当回调返回后,再将eax寄存器的值压入栈中,调用intr_exit
函数退出中断。
内存管理
分配内存的步骤:
- 在虚拟内存池中分配虚拟地址,相关函数是
vaddr_get
,此函数会操作内核的虚拟内存位图kernel_vaddr.vaddr_bitmap
或用户虚拟内存位图pcb->user_program_vaddr.vaddr_bitmap
。 - 在物理内存池中分配物理地址,相关函数是
palloc
,此函数会操作内核的物理内存池位图kernel_pool->pool_bitmap
或用户物理内存池位图user_pool->pool_bitmap
。 - 在页表中完成虚拟地址到物理地址的映射,相关函数是
page_table_add
。
以上三个步骤封装在
malloc_page
函数中。
释放内存的步骤:
- 在物理内存池中释放物理页地址,相关函数是
pfree
。 - 在页表中去掉虚拟地址的映射,原理是将页表项的P位设置为0(即表示对应的数据不在内存中),相关函数是
page_table_pte_remove
。 - 在虚拟内存池中释放虚拟地址,相关函数是
vaddr_remove
,操作的位图同vaddr_get
函数。
以上三个步骤封装在
mfree_page
函数中。
标签:写个,操作系统,thread,中断,调用,线程,寄存器,函数 来源: https://www.cnblogs.com/Lht1/p/15893699.html