Linux0.00内核学习
前言
这里的Linux0.00内核,指的是赵炯博士编著的《Linux内核完全剖析 -基于0.12内核》第四章末尾提供的一个简单多任务内核。
源码地址:http://www.oldlinux.org/Linux.old/bochs/linux-0.00-050613.zip
如在64位机器下编译此内核可能会遇到许多问题,本博客下的《64位系统下编译linux0.00内核 》记载了一种编译成功的案例,可供参考。
对照着源码动手写了一遍boot.s和head.s,过程中解开了许多疑惑,记录在此以供复习。
阅读源码(boot.s+head.s)
boot.s
! 加载内核镜像文件到指定的位置 ! 设置临时的GDT表项 (不开启分页) ! 进入保护模式,跳转到head执行 !按照设计,先将head加载到0x1000:0位置处,然后再复制到0x0000:0处 !启动扇区在内存中的代码段 bootseg=0x07c0 !内核被临时加载位置 tmpseg=0x1000 !内核对应的扇区个数 kernel_len=17 entry start start: !MBR被加载到内存后,位于0x07c0:0处,由于cs的值被初始化为0,因此这里要手动设置cs=0x7c0 !用jmpi 段间跳转命令实现 !jmpi offset,cs jmpi realstart,#bootseg realstart: !jmpi之后,此时cs=0x7c0 !设置ds,ss,sp !mov指令格式 mov dst,src mov ax,cs mov ds,ax mov ss,ax !设置栈顶指针,暂时不懂这里为什么要用到栈 :) mov sp,#0x400 !接下来的主要任务是借助bios中断,从软盘镜像中加载内核到内存0x10000处 !涉及到具体的bios中断例程的使用,不做细纠 load_system: mov dx,#0x0000 mov cx,#0x0002 mov ax,#tmpseg mov es,ax xor bx,bx mov ax,#0x200+kernel_len int 0x13 jnc load_success !失败则死循环 die: jmp die !到这里说明内核已经被加载到0x10000处 !接下来将内核模块移动到0x00000地址处 !0x1000:0开始,移动到0x0000:0,移动的内容共为8KB, !ds:si=0x1000:0 es:di=0x0000:0 load_success: cli !首先将ds设置为0x1000,(前面被设置为了0x07c0) Q:前面难道一定要将ds设置为0x7c0吗? mov ax,#tmpseg mov ds,ax !ax=0,用来设置es,di xor ax,ax mov es,ax !cx用于指定循环的次数,共4K次 mov cx,#0x1000 sub si,si sub di,di !每次传输一个word,两个byte rep movw !执行到这里,说明当前已经将kernel移动到0x00000了 !接下来为进入保护模式做准备 !目前还不太懂为什么要将ds设置为bootseg mov ax,#bootseg mov ds,ax !?明明没有使用idt为什么这里要加载idtr呢? 不太懂 lidt idtr_ !设置gdtr和idtr lgdt gdtr_ !接下来进行真正的开启保护模式操作:通过写CR0中的PG标志位来开启保护模式 mov ax,#0x0001 !lmsw指令的含义是加载源操作数到机器状态寄存器CR0中,且只加载源操作数的低四位 lmsw ax !开启保护模式后,这里我们立刻使用jmp跳转到cs:0处, !此时cs的含义是gdt选择子,8代表gdt表中的第一项, !根据gdt表的设计可知,此时跳转到0x00000处执行,即内核所在处的第一条指令 !jmpi offset,gdt_selector jmpi 0,8 !gdt表的内容,little-indian,先存低地址 ! gdt_table: .word 0,0,0,0 !第一项为空 !代码段: 基地址:0 段限长:2047 粒度:4K 权限:可读/执行 因此总共的线性地址大小为8MB .word 0x07ff .word 0x0000 .word 0x9a00 .word 0x00c0 !数据段,数据段和代码段映射在相同的线性地址空间,唯一的区别在于访问权限的不同, !数据段权限为可读写,不可执行 .word 0x07ff .word 0x0000 .word 0x9200 .word 0x00c0 !用于设置idtr idtr_: !idt表的长度 .word 0 !idt表的线性基地址,因为没有采用分页,所以线性基地址就等于物理地址 .word 0,0 !用于设置gdtr gdtr_: !gdt表的大小设置为2048Byte,每个表项8Byte,因此最多可以放256个表项 .word 0x07ff !gdtr中需要保存gdt_table的线性地址,gdt_table为相对bootsect开始的偏移, !因为bootsect被加载到0x7c0:0处,故实际gdt表的物理地址采用如下的方式计算得到 !由于没有采用分页,因此线性地址就等于物理地址 .word gdt_table+0x7c00,0 .org 510 .word 0xaa55
heas.s
#32位的head.s #head.s 主要需要完成的工作 #重新设置GDT,添加tss0,cs0,ds0,tss1,cs1,ds1等几个描述符 #设置IDT,设置好几个必要的中断处理函数 #为task0,task1进程设置必要的数据结构:tss段,栈等 #实现task0和task1的业务逻辑 #实现进程调度逻辑 #实现由内核态通过切换到用户态的逻辑 #######定义几个基本的常量和选择子 #用于设置时钟硬件 latch=11930 #dgt中的几个选择子 screen_seg_selector= 0x18 tss0_selector =0x20 ldt0_selector =0x28 tss1_selector =0x30 ldt1_selector =0x38 #进入head后首先重新设置gdt和idt .code32 .text .globl startup_32 startup_32: #重新设置32位下的ds,es,esp movl $0x10,%eax mov %ax,%ds #设置ss=0x10,esp,init_stack为临时的内核栈 lss init_stack,%esp call setup_idtr call setup_gdtr #设置好新的gdt表之后,重新设置相关段寄存器的值 movl $0x10,%eax mov %ax,%ds mov %ax,%es mov %ax,%fs mov %ax,%gs lss init_stack,%esp ###################### ##时钟中断硬件相关的设置,不做深入研究 movb $0x36,%al movl $0x43,%edx outb %al,%dx movl $latch,%eax movl $0x40,%edx outb %al,%dx movb %ah,%al outb %al,%dx ###################### ###接下来在idt表中设置时钟中段和系统调用相关的idt描述符 ###这里的实现是 中断号8(0x08)对应定时器中断,中断号128(0x80)对应系统调用 ##设置中断号为0x08的中断门描述符 #eax-段选择子:偏移值0-15 #edx-偏移值16-31:权限位 #eax对应0-3字节,dex对应4-7字节 #设置eax movl $0x00080000,%eax movw $timer_interrupt,%ax #设置edx movw $0x8e00, %dx #接下来就是定位到idt表中的0x08位置,设置idt中断门描述符 movl $0x08, %ecx #得到0x08项的地址,每项占用8Byte空间 lea idt_table(,%ecx,8),%esi #下面开始设置idt时钟中断门描述符 movl %eax,(%esi) movl %edx,4(%esi) ##设置中断号为0x80的idt陷阱门描述符 #设置eax,高位已经设置为了0x0008,这里只需再设置低16位 movw $system_call_interrupt,%ax #设置edx,注意这里的陷阱门描述符和时钟中断中断门描述符的区别 #ef00=1110 1111 0000 0000 #8e00=1000 1110 0000 0000 #区别在于DPL位,以及第8位 movw $0xef00, %dx #接下来就是定位到idt表中的第0x80位置,设置idt陷阱门描述符 movl $0x80, %ecx #得到0x80项的地址,每项占用8Byte空间 lea idt_table(,%ecx,8),%esi #下面开始设置idt系统调用陷阱门描述符,同上面的时钟中断门描述符设置过程 movl %eax,(%esi) movl %edx,4(%esi) ###到这里,已经设置好了idt中的中断门描述符和系统调用陷阱门 ###接下来就准备将系统模拟为task0的内核态的状态下 ###然后通过iret指令模拟从task0的内核态返回到task0的用户态 ###要模拟这个状态,至少需要进行如下设置: ###1、设置tss为tss0,表明当前进程为task0 ###2、设置ldt为ldt0,表明当前正处于task0 ###3、内核栈的结构处于“用户态中断切换到内核态”的状态 ###具体来说,在执行iret前,内核栈中存在如下的结构 ###【空】【原ss】 ###【 原esp 】 ###【 EFLAGS 】 ###【空】【原cs】 ###【 原EIP 】<-------esp pushfl #设置栈中EFLAGS中NT标志位为0,模拟的是陷阱门 andl $0xffffbfff,(%esp) popfl ##接下来设置tss为tss0 movl $tss0_selector,%eax ltr %ax ##接下来设置ldt为ldt0 movl $ldt0_selector,%eax lldt %ax ##设置current=0 movl $0,current ##开中断,马上要返回到用户态了,在此之前必须打开中断 sti ##接下来就是模拟中断发生时内核栈的结构,以便利用iret返回到task0的用户态 ###具体来说,在执行iret前,内核栈中存在如下的结构 ###【空】【原ss】 ###【 原esp 】 ###【 EFLAGS 】 ###【空】【原cs】 ###【 原EIP 】<-------esp pushl $0x17 pushl $init_stack pushfl pushl $0x0f pushl $task0 iret setup_gdtr: lgdt gdtr_value ret #设置好中断服务例程后,挂载idt #首先将256个idt_entry全部设置为default_interrupt例程 setup_idtr: ##搞不明白lea指令的含义 #202007272324 困了 #20200728继续开始 #这里按照idt描述符的格式设置eax,edx #eax-段选择子:偏移值0-15 #edx-偏移值16-31:权限位 #eax对应0-3字节,dex对应4-7字节 #得到default_interrupt例程的偏移地址 lea default_interrupt,%edx #eax选择子设置为0x0008,偏移值0-15位设置为0000 movl $0x00080000,%eax #将edx中的偏移地址的0-15位保存到eax的0-15位 movw %dx,%ax #设置edx0-15位中的权限值, #0x8e00=1(P)00(DPL)0 1110 000(空)0 0000(B) movw $0x8e00,%dx #经过上面三条指令,eax和edx已经设置好了,接下来就用这些值填满256个idt表项 #获取idt表的地址 lea idt_table,%edi #总共循环256次 mov $256,%ecx write_idt_item: #接下来是循环体,依次设置每个idt表项的0-3字节,4-7字节 #%idt描述符的0-3字节 movl %eax,(%edi) movl %edx,4(%edi) addl $8,%edi dec %ecx jne write_idt_item #设置idtr lidt idtr_value ret #系统服务,打印字符 write_char: #需要加载显存段 #需要用到gs来指示显存段,所以先保存原值 push %gs #ebx用作“临时存储单元” pushl %ebx #加载显存段 mov $screen_seg_selector, %ebx mov %bx,%gs #接下来就是具体的往显存写数据的过程,一般例程,不做重点研究 ##写显存 ############## movl scr_loc,%ebx # %bx*2得到实际该写的位置 shl $1,%ebx movb %al,%gs:(%ebx) # %bx/2得到当前已经写的字符数 shr $1,%ebx # 得到下一个字符的位置 incl %ebx # 2000=25*80 cmpl $2000,%ebx jb 1f #>2000,则从第一行第一列重新开始写入 movl $0,%ebx 1: #否则保存下一个字符的位置,下次直接从该位置写入字符 movl %ebx,scr_loc ############## #恢复寄存器值,返回 popl %ebx pop %gs ret #设置必要的中断处理函数,并挂载到idt #当前的系统总共设置了针对如下三种情况的中断处理函数 #1、时钟中断,用于驱动任务切换操作 #2、系统调用,用于用户调用内核的打印字符功能 #3、其它的中断 #1、时钟中断,用户切换task .align 4 timer_interrupt: push %ds #(不懂为什么要保存用户态的ds)A:因为接下来使用的是内核段,所以需要保存用户态段值 pushl %eax #(不懂为什么要保存eax)A:后面用到了eax,所以需要保存旧值 #切换到内核态的ds段选择子 movl $0x10,%eax mov %ax,%ds #设置中断控制器开中断,以允许后续的中断 movb $0x20,%al outb %al,$0x20 #接下来就是具体的任务切换逻辑 #通过jmp到tss选择子,让cpu硬件完成任务切换的功能 movl $1,%eax cmpl %eax,current je 1f #current!=1 ,即当前任务号为0 #切换到task1 #首先更新current mov %eax,current #切换到task1,通过jmp tss1的选择子:偏移(无用) 的形式切换到task1 #硬件会完成保存上下文,回复寄存器值的任务 ljmp $tss1_selector,$0 jmp 2f #Q:这里的jmp 2f能有机会执行吗?A:有的,因为需要等到iret指令,才会真正完成任务切换 #current==1 1: movl $0,current ljmp $tss0_selector,$0 2: popl %eax pop %ds iret #2、系统调用,提供打印字符服务 .align 4 system_call_interrupt: #首先系统服务过程中可能会使用的寄存器 push %ds pushl %edx pushl %ecx pushl %ebx pushl %eax #切换到内核数据段 Q:为什么要切换到内核数据段呢?这里需要访问内核数据段的内容吗?A:需要,write_char会用到内核数据段中的src_loc #换句话说,内核态提供服务,理所当然可能会用到内核数据段中的数据,“没用到内核数据段中的数据”这种情况是一种特例。 movl $0x10, %edx mov %dx,%ds #调用内核服务,现在使用的是内核态栈 call write_char #调用完服务后,恢复原寄存器值 popl %eax popl %ebx popl %ecx popl %edx pop %ds iret #3、其它的中断 .align 4 default_interrupt: #如果是其它的中断,本例的做法是往屏幕写一个字符 push %ds pushl %eax #指向内核数据段 movl $0x10,%eax mov %ax,%ds movl $67,%eax call write_char popl %eax pop %ds iret ####################### ##这里存放了内核的主要的数据结构和数据 ####################### #gdtr current:.long 0 scr_loc:.long 0 .align 4 #标识idt表的大小和位置 idtr_value: #idt的大小:单位为字节 .word 256*8-1 #idt表的线性地址 .long idt_table gdtr_value: .word (new_gdt_table_end-new_gdt_table)-1 .long new_gdt_table .align 8 #定义idt表的位置,预留了256*8大小的空间 idt_table: .fill 256,8,0 new_gdt_table: # 空 内核代码 内核数据 # task0代码 task0数据 task0tss task1代码 task1数据 task1tss(采用ldt的形式) .word 0,0,0,0 #第一项空 .quad 0x00c09a00000007ff #同boot中的内核代码段的设置一样 .quad 0x00c09200000007ff #同boot中的内核数据段的设置一样 .quad 0x00c0920b80000002 #显存段,往此段中写的数据会显示在屏幕上 #接下来设置task0的tss段描述符 .word 0x0068,tss0,0xe900,0x0000 #设置task0的ldt段描述符 .word 0x0040,ldt0,0xe200,0x0000 #接下来设置task1的tss段描述符 .word 0x0068,tss1,0xe900,0x0000 #设置task1的ldt段描述符 .word 0x0040,ldt1,0xe200,0x0000 #可以看到和task1和task0的tss段和ldt段的主要区别在于offset的不同 new_gdt_table_end: .fill 128,4,0 #这里设置的是刚刚进入32位保护模式时系统使用的栈,由于栈是向下增长, #esp:ss init_stack: .long init_stack .word 0x10 .align 8 #ldt for task0 ldt0: .quad 0x0000000000000000 .quad 0x00c0fa00000003ff .quad 0x00c0f200000003ff #由于task0和task1是以硬编码的形式嵌入在kernel中, #因此需要我们提前设置好task0和task1的tss段的内容 #tss段格式参见p127,按照严格的格式设置tss段的内容 tss0: .long 0 #前一任务tss选择子,本例不需要 .long task0_krn_stack,0x10 #内核态堆栈esp0 ss0 .long 0,0,0,0,0 #esp1,ss1,esp2,ss2,CR3 (本例不需要设置这些寄存器) .long 0,0,0,0,0 #eip,eflags,eax,ecx,edx .long 0,0,0,0,0 #ebx,esp,ebp,esi,edi(暂时搞不懂为什么eip这些也为空?返回到哪里执行呢?) #####这里可以为空的原因是,在模拟从task0内核态返回到task0的用户态过程中,会使用内核态栈中的值恢复相关寄存器的值, #####所以这里的eip,cs,eflags,ss,esp等这些字段可以被设置为空-20200729 #接下来是基本段寄存器 .long 0,0,0,0,0,0 #es,cs,ss,ds,fs,gs .long ldt0_selector,0x8000000 #ldt选择子,trace bitmap #从tss0的结尾到task0_krn_stack中间的这部分为task0的内核态堆栈空间 .fill 128,4,0 task0_krn_stack: .align 8 ldt1: .quad 0x0000000000000000 .quad 0x00c0fa00000003ff .quad 0x00c0f200000003ff #参考tss0,设置tss1 tss1: .long 0 #前一任务tss选择子,本例不需要 .long task1_krn_stack,0x10 #内核态堆栈esp0 ss0,ss0代表堆栈段选择子 .long 0,0,0,0,0 #esp1,ss1,esp2,ss2,CR3 (本例不需要设置这些寄存器) .long task1,0x200 #eip,eflags .long 0,0,0,0 #eax,ecx,edx,ebx .long task1_usr_stack,0,0,0 #esp(用户态堆栈空间),ebp,esi,edi #接下来是基本段寄存器 .long 0x17,0x0f,0x17,0x17,0x17,0x17 #es,cs,ss,ds,fs,gs (ldt中的段选择子,0x0f:代码段选择子 0x17:数据段选择子) .long ldt1_selector,0x8000000 #ldt选择子,trace bitmap #从tss0的结尾到task0_krn_stack中间的这部分为task0的内核态堆栈空间 .fill 128,4,0 task1_krn_stack: #######实现task0和task1的业务 ###task0:调用中断号为0x80的中断服务,往屏幕写一个字符 task0: mov $65,%al int $0x80 movl $0xffffff,%ecx ##loop每循环一次会递减%ecx,b的意思是向前跳转 1:loop 1b jmp task0 ###task1:调用中断号为0x80的中断服务,往屏幕写一个字符 task1: mov $66,%al int $0x80 movl $0xffffff,%ecx ##loop每循环一次会递减%ecx,b的意思是向前跳转 1:loop 1b jmp task1 .fill 128,4,0 task1_usr_stack:
总结
遇到的理解困难主要来自于:
1、对进程切换的具体细节,比如切换前后栈,tr、ldtr的变化缺乏准确的把握
2、对模拟成“当前处于task0的内核态”并“通过iret返回到task0的用户态”的原理和实现理解不足。
3、对汇编指令的生疏
4、纸上得来终觉浅
参考链接
[1] https://www.cnblogs.com/hongzg1982/articles/2117263.html linux0.11 head.s中lss相关说明
[2]https://www.cnblogs.com/SuperBlee/p/4095124.html [Operating System Labs] 我对Linux0.00中 head.s 的理解和注释