x86启动顺序 对于绝大多数计算机系统而言,操作系统和应用软件是存放在磁盘(硬盘/软盘)、光盘、EPROM、ROM、Flash等可在掉电后继续保存数据的存储介质上, 当计算机加电后,一般不直接执行操作系统,而是一开始会 到一个特定的地址开始执行指令 ,这个特定的地址 存放了系统初始化软件 ,通过执行系统初始化软件(可固化在ROM或Flash中,也称firmware,固件)完成基本I/O初始化和引导加载操作系统的功能
以基于Intel 80386的计算机为例,计算机加电后,整个物理地址空间如下图所示:
第一条指令
算机加电后,代码段寄存器 CS=0xF000h,指令指针寄存器 EIP=FFF0h,所以执行的第一条指令地址为 BASE+EIP=FFFF0000h+0000FFF0h=FFFFFFF0h ,这是BIOS的EPROM所在地(只读)
通常第一条指令是一条长跳指令,这样CS和EIP都会更新到BIOS代码中执行
启动qemu并让其停到执行第一条指令前,这需要增加一个参数”-S” :
然后通过按”Ctrl+Alt+2”进入qemu的monitor界面,为了了解80386此时的寄存器内容,在monitor界面下输入命令:
显示以下数据:
发现 CS selector = 0xf000,CS base = 0xffff0000,EIP = 0x0000fff0
当前指令地址为:0xf000 * 16 + 0x0000fff0 = 0xffff0
从BIOS到BootLoader
BIOS加载存储设备上,第一个扇区(通常512字节)到内存(0x7c00),然后跳转到 0x7c00 的第一条地址开始执行
这512字节就是 MBR,其中包含了BootLoader(最后两字节固定)
由于实模式下最高寻址1MB,故 0xFFFF0
处是一条跳转指令 jmp far f000:e05b
,跳转至BIOS真正的代码
之后便开始检测并初始化外设,与 0x000-0x3ff
建立数据结构,中断向量表IVT并填写中断例程
BIOS最后校验启动盘中位于0盘0道1扇区(MBR)的内容,如果此扇区末尾两个字节分别是魔数 0x55
和 0xaa
,则BIOS认为此扇区中存在可执行的程序,并加载该512字节数据到 0x7c00
,随后跳转至此继续执行
从BootLoader到OS
MBR是主引导记录(Master Boot Record),也被称为主引导扇区,是计算机开机以后访问硬盘时所必须要读取的第一个扇区,其内部前446字节存储了 bootloader 代码,其后是4个16字节的“磁盘分区表”
BootLoader完成的工作:
使系统从“实模式”变为“保护模式”,开启段机制(拥有4GB的访问空间)
从硬盘上读取 kernel in ELF 格式的 ucore kernel 并放到内存中固定位置
跳转到 ucore OS 的入口点,把控制权转移到 ucore OS 中
以下是一个简单的 MBR 结构:(该程序只会将 1 MBR
字符串打印到屏幕上并挂起)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 ;主引导程序 ;------------------------------------------------------------ SECTION MBR vstart=0x7c00 ; 起始地址编译为0x7c00 mov ax,cs ; 此时的cs为0,用0来初始化所有的段寄存器 mov ds,ax mov es,ax mov ss,ax mov fs,ax mov sp,0x7c00 ; 0x7c00 以下空间暂时安全,故可用做栈。 ; 清屏 利用0x06号功能,上卷全部行,则可清屏。 ; ----------------------------------------------------------- ;INT 0x10 功能号:0x06 功能描述:上卷窗口 ;------------------------------------------------------ ;输入: ;AH 功能号= 0x06 ;AL = 上卷的行数(如果为0,表示全部) ;BH = 上卷行属性 ;(CL,CH) = 窗口左上角的(X,Y)位置 ;(DL,DH) = 窗口右下角的(X,Y)位置 ;无返回值: mov ax, 0x600 mov bx, 0x700 mov cx, 0 ; 左上角: (0, 0) mov dx, 0x184f ; 右下角: (80,25), ; VGA文本模式中,一行只能容纳80个字符,共25行。 ; 下标从0开始,所以0x18=24,0x4f=79 int 0x10 ; int 0x10 ;;;;;;;;; 下面这三行代码是获取光标位置 ;;;;;;;;; ;.get_cursor获取当前光标位置,在光标位置处打印字符. mov ah, 3 ; 输入: 3 号子功能是获取光标位置,需要存入ah寄存器 mov bh, 0 ; bh寄存器存储的是待获取光标的页号 int 0x10 ; 输出: ch=光标开始行,cl=光标结束行 ; dh=光标所在行号,dl=光标所在列号 ;;;;;;;;; 获取光标位置结束 ;;;;;;;;;;;;;;;; ;;;;;;;;; 打印字符串 ;;;;;;;;;;; ;还是用10h中断,不过这次是调用13号子功能打印字符串 mov ax, message mov bp, ax ; es:bp 为串首地址, es此时同cs一致, ; 开头时已经为sreg初始化 ; 光标位置要用到dx寄存器中内容,cx中的光标位置可忽略 mov cx, 5 ; cx 为串长度,不包括结束符0的字符个数 mov ax, 0x1301 ; 子功能号13是显示字符及属性,要存入ah寄存器, ; al设置写字符方式 ah=01: 显示字符串,光标跟随移动 mov bx, 0x2 ; bh存储要显示的页号,此处是第0页, ; bl中是字符属性, 属性黑底绿字(bl = 02h) int 0x10 ; 执行BIOS 0x10 号中断 ;;;;;;;;; 打字字符串结束 ;;;;;;;;;;;;;;; jmp $ ; 始终跳转到这条代码,为死循环,使程序悬停在此 message db "1 MBR" ; 用\0 将剩余空间填满 times 510-($-$$) db 0 ; $指代当前指令的地址,$$指代当前section的首地址 ; 最后两位一定是0x55, 0xaa db 0x55,0xaa
加载 ELF 格式的 ucore OS kernel
附件:Intel80386启动过程
x86中断简述 在操作系统中,有三种特殊的中断事件:
异步中断(asynchronous interrupt):这是由CPU外部设备引起的外部事件中断,例如I/O中断、时钟中断、控制台中断等
同步中断(synchronous interrupt):这是CPU执行指令期间检测到不正常的或非法的条件(如除零错、地址访问越界)所引起的内部事件
陷入中断(trap interrupt):这是在程序中使用请求系统服务的系统调用而引发的事件
中断源
外部中断:外部设施产生的中断,具有异步性(不清楚它什么时候产生)
软件中断:软件,系统参数的中断,具有同步性(例如:INT 系统调用)
异常:程序错误,软件产生的异常,机器检查出的异常
这些都需要 OS 进行正确的处理
中断服务例程
每个中断异常与一个“中断服务例程ISR”关联(其关联关系存储在“中断描述符表IDT”中)
在“中断号”和“中断处理程序的地址”之间,通过“中断描述符表”建立了一种映射关系
中断描述符表
中断描述符表(Interrupt Descriptor Table, IDT)把每个中断或异常编号和一个指向中断服务例程的描述符联系起来,同GDT(全局描述符表)一样,IDT是一个8字节的描述符数组,但IDT的第一项可以包含一个描述符
IDT可以位于内存的任意位置,CPU通过IDT寄存器(IDTR)的内容来寻址IDT的起始地址
中断门描述符
中断/异常应该使用 Interrupt Gate
或 Trap Gate
,其中的唯一区别就是:
当调用 Interrupt Gate
时,Interrupt会被CPU自动禁止
而调用 Trap Gate
时,CPU则不会去禁止或打开中断,而是保留原样
IDT中包含了3种类型的中断门描述符(Descriptor)
Task-gate descriptor(任务门描述符)
Interrupt-gate descriptor(中断门描述符:中断方式用到)
Trap-gate descriptor(陷阱门描述符:系统调用用到)
下图显示了80386的中断门描述符、陷阱门描述符的格式:
x86中断处理 起始阶段
CPU执行完每条指令后,判断中断控制器中是否产生中断,如果存在中断,则取出对应的中断变量
CPU根据中断变量,到IDT中找到对应的中断描述符
通过获取到的中断描述符中的段选择子,从GDT中取出对应的段描述符,此时便获取到了中断服务例程的段基址与属性信息,跳转至该地址
CPU会根据CPL和中断服务例程的段描述符的DPL信息确认是否发生了 特权级的转换
若发生了特权级的转换,这时CPU会从当前程序的TSS信息(该信息在内存中的起始地址存在TR寄存器中)里取得该程序的内核栈地址,即包括内核态的ss和esp的值
并立即将系统当前使用的栈切换成新的内核栈(这个栈就是即将运行的中断服务程序要使用的栈)
紧接着就将当前程序使用的用户态的ss和esp压到新的内核栈中保存起来
CPU需要 开始保存当前被打断的程序的现场 (即一些寄存器的值),以便于将来恢复被打断的程序继续执行。这需要利用内核栈来保存相关现场信息,即依次压入当前被打断程序使用的eflags,cs,eip,errorCode(如果是有错误码的异常)信息
CPU利用中断服务例程的段描述符将其第一条指令的地址加载到cs和eip寄存器中, 开始执行中断服务例程 (这意味着先前的程序被暂停执行,中断服务程序正式开始工作)
终止阶段
每个中断服务例程在有中断处理工作完成后需要通过 iret
(或 iretd
)指令恢复被打断的程序的执行(恢复各个寄存器的数据等等),CPU执行IRET指令的具体过程如下:
程序执行这条 iret
指令时,首先会从内核栈里弹出先前保存的被打断的程序的现场信息,即eflags,cs,eip重新开始执行
如果存在特权级转换(从内核态转换到用户态),则还需要从内核栈中弹出用户态栈的ss和esp,即栈也被切换回原先使用的用户栈
如果此次处理的是带有错误码(errorCode)的异常,CPU在恢复先前程序的现场时,并不会弹出errorCode,需要要求相关的中断服务例程在调用iret返回之前添加出栈代码主动弹出errorCode
x86特权级别简述 特权级别(Privilege Level)是存在于 Descriptor(描述符)及 Segment Selector(段选择子,存储在段寄存器以及门描述符中) 中一个数值,当这些 Descriptor 或 Segment Selector 要进行某些操作,或者被别的对象访问时,该数值用于控制它们能够进行的操作或者限制它们的可访问性
特权级共分为四档,分别为0-3:
Kernel为第0特权级(ring 0)
用户程序为第3特权级(ring 3)
操作系统保护分别为第1和第2特权级
描述符特权级(DPL,Descriptor Privilege Level)
实施特权级保护的第一步,是为所有可管理的对象赋予一个特权级,以决定谁能访问它们,每个 Descriptor 都具有描述符特权级(DPL,Descriptor Privilege Level)字段,Descriptor 总是指向它所“描述”的目标对象,代表着该对象,因此该字段(DPL)实际上是目标对象的特权级
存储于段描述符中:
当前特权级(CPL,Current Privilege Level)
当处理器正在一个代码段中取指令和执行指令时,那个代码段的特权级叫做当前特权级(CPL,Current Privilege Level),正在执行的这个代码段,其选择子位于段寄存器CS中,其最低两位就是当前特权级的数值
存储于CS寄存器的段选择子中(CS中的DPL就是CPL):
请求特权级(RPL,Request Privilege Level)
我们知道,要将控制从一个代码段转移到另一个代码段,通常是使用 jmp
和 call
指令,并在指令中提供目标代码段的选择子,以及段内偏移量(入口点),而为了访问内存中的数据,也必须先将段选择子加载到段寄存器DS、ES、FS或者GS中,不管是实施控制转移,还是访问数据段,这都可以看成是一个请求,请求者提供一个段选择子,请求访问指定的段,从这个意义上来说,RPL也就是指请求者的特权级别(RPL,Request Privilege Level)
存储于段选择子中:(段选择子存储于各个段寄存器以及门描述符中:调用门、任务门、中断门、陷阱门)
输出特权级(IOPL,I/O Privilege Level)
除了那些特权级敏感的指令外,处理器还允许对各个特权级别所能执行的I/O操作进行控制,通常,这指的是端口访问的许可权,因为对设备的访问都是通过端口进行的
在处理器的标志寄存器EFLAGS中,位13、位12是IOPL位,也就是输入/输出特权级(IOPL,I/O Privilege Level),它代表着当前任务的I/O特权级别,某些指令,例如 IN,OUT,CLI 需要 I/O 特权,这些操作根据 IOPL 和 CPL 确定合法性
存储于EFLAGS中:
x86特权级别运用 特权级检查
在下述的特权级比较中,需要注意特权级越低,其ring值越大:
访问门时(中断、陷入、异常),要求 DPL[段] <= CPL <= DPL[门] (ring值比较)
访问门的代码权限比门的特权级要高,因为这样才能访问门(CPL <= DPL[门])
但访问门的代码权限比被访问的段的权限要低,因为通过门的目的是访问特权级更高的段,这样就可以达到低权限应用程序使用高权限内核服务的目的(CPL <= DPL[门])
简述:代码权限低于段权限时,可以通过访问门的方式来访问段
访问段时,要求 DPL[段] >= max {CPL, RPL} (ring值比较)
只能使用最低的权限来访问段数据
简述:请求权限与当前权限只要有一个低于段权限,就会导致访问失败
特权级切换
TSS
TSS(Task State Segment)是操作系统在进行进程切换时保存进程现场信息的段,其结构如下:
TSS中分别保留了 ring0、ring1、ring2 的栈,当用户程序从 ring3 跳至 ring0 时(例如执行中断),此时的栈就会从用户栈切换到内核栈,切换栈的操作从开始中断的那一瞬间就已完成(例如:从int 0x78
到中断处理例程之间)
TSS段的段描述符保存在GDT中,其 ring0 的栈会在初始化GDT时被一起设置,TR
寄存器会保存当前TSS的段描述符,以提高索引速度
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 static struct segdesc gdt [] = { SEG_NULL, [SEG_KTEXT] = SEG(STA_X | STA_R, 0x0 , 0xFFFFFFFF , DPL_KERNEL), [SEG_KDATA] = SEG(STA_W, 0x0 , 0xFFFFFFFF , DPL_KERNEL), [SEG_UTEXT] = SEG(STA_X | STA_R, 0x0 , 0xFFFFFFFF , DPL_USER), [SEG_UDATA] = SEG(STA_W, 0x0 , 0xFFFFFFFF , DPL_USER), [SEG_TSS] = SEG_NULL, }; static struct pseudodesc gdt_pd = { sizeof (gdt) - 1 , (uintptr_t )gdt }; static void gdt_init (void ) { load_esp0((uintptr_t )bootstacktop); ts.ts_ss0 = KERNEL_DS; gdt[SEG_TSS] = SEGTSS(STS_T32A, (uintptr_t )&ts, sizeof (ts), DPL_KERNEL); lgdt(&gdt_pd); ltr(GD_TSS); }
trapFrame
trapframe 结构是进入中断门所必须的结构,中断处理例程的入口代码用于保存上下文并构建一个 trapframe
trapframe 的结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 struct trapframe { struct pushregs tf_regs ; uint16_t tf_gs; uint16_t tf_padding0; uint16_t tf_fs; uint16_t tf_padding1; uint16_t tf_es; uint16_t tf_padding2; uint16_t tf_ds; uint16_t tf_padding3; uint32_t tf_trapno; uint32_t tf_err; uintptr_t tf_eip; uint16_t tf_cs; uint16_t tf_padding4; uint32_t tf_eflags; uintptr_t tf_esp; uint16_t tf_ss; uint16_t tf_padding5; } __attribute__((packed));
切换特权级的过程
特权级提升
在陷入的一瞬间,CPU会因为特权级的改变,索引TSS,切换 ss
和 esp
为内核栈,并按顺序自动压入user_ss
、user_esp
、user_eflags
、user_cs
、old_eip
以及err
之后CPU会在中断处理例程入口处,先将剩余的段寄存器以及所有的通用寄存器压栈,构成一个 trapframe
,然后将该 trapframe
传入给真正的中断处理例程并执行
该处理例程会判断传入的中断数(trapno
)并执行特定的代码,在提升特权级的代码中,程序会处理传入的 trapframe
信息中的 CS、DS、eflags
寄存器,修改上面的 DPL、CPL与IOPL 以达到提升特权的目的
将修改后的 trapframe
压入用户栈(这一步没有修改 user_esp
寄存器),并设置中断处理例程结束后将要弹出 esp
寄存器的值为用户栈的新地址(与刚刚不同,这一步修改了将要恢复的 user_esp
寄存器)
在内核中,“将修改后的trapframe压入用户栈”这一步,需要舍弃 trapframe
中末尾两个旧的ss
和esp
寄存器数据
特权级降低
与 ring3 调用中断不同,当 ring0 调用中断时,进入中断前和进入中断后的这个过程,栈不发生改变
修改后的 trapFrame
不需要像上面那样保存至将要使用的栈,因为当前环境下 iret
前后特权级会发生改变,执行该命令会弹出 ss
和 esp
,所以可以通过 iret
来设置返回时的栈地
x86栈简述 只有设置好的合适大小和地址的栈内存空间(简称栈空间),才能有效地进行函数调用,这里为了减少汇编代码量,我们就通过C代码来完成显示,由于需要调用C语言的函数,所以需要自己建立好栈空间,设置栈的代码如下:
由于start位置(0x7c00)前的地址空间没有用到,所以可以用来作为bootloader的栈,由于栈是向下长的,所以不会破坏start位置后面的代码,我们可以通过用gdb调试bootloader来进一步观察栈的变化:
1 2 qemu -hda bin/ucore.img -S -s gdb obj/bootblock.o
然后再GDB中输入以下指令来连接qemu:(可以使用 layout src 指令显示源码)
1 2 3 (gdb) target remote :1234 (gdb) break bootasm.S:68 (gdb) continue
接下来就可以通过 “ info registers esp ” 指令来打印 esp寄存器 的值了:
1 2 3 4 B+>69 movl $0x0 , %ebp ------------------------------------ (gdb) info register esp esp 0x6f00
1 2 3 4 5 70 movl $start, %esp >71 call bootmain ------------------------------------ (gdb) info register esp esp 0x7c00 0x7c00 <start>
可以发现,程序把“$start”中的数据赋值给了esp,这就是栈的起始地址(栈顶)
看看程序是怎么处理 call 指令的:
1 2 3 4 5 6 7 8 9 10 11 12 13 (gdb) si bootmain () at boot/bootmain.c:87(gdb) info registers espesp 0x7bfc 0x7bfc (gdb) x/4x 0x7bfc 0x7bfc: 0x00007c4f 0xc031fcfa 0xc08ed88e 0x64e4d08e (gdb) x/4i 0x7c4a 0x7c4a <protcseg+24>: call 0x7d0f <bootmain> 0x7c4f <spin>: jmp 0x7c4f <spin> 0x7c51 <spin+2>: lea 0x0 (%esi) ,%esi 0x7c54 <gdt>: add %al,(%eax)
x86显示字符串 bootloader 只在CPU和内存中打转无法让读者很容易知道 bootloader 的工作是否正常,为此在成功完成了保护模式的转换并且设置好栈后,就可以调用 bootmain 函数显示字符串了,在 lab1 中使用了显示器和并口两种外设来显示字符串,主要的代码集中在 bootmain.c 中
这里采用的是很简单的基于Programmed I/O (PIO)方式,PIO方式是一种通过CPU执行I/O端口指令来进行数据读写的数据交换模式,被广泛应用于硬盘、光驱等设备的基础传输模式中(效率低下,但编程简单)
计算机与IO接口的通信是通过计算机指令来实现的,通过软件指令选择IO接口上的功能、工作模式的做法,称为“IO接口控制编程”,通常是用端口读写指令in/out实现
端口是IO接口开发给CPU的接口,一般的IO接口都有一组端口,每个端口都有自己的用途
指令in/out使用方式如下:
1 2 3 4 5 6 7 in al, dx # al/ax 用于存放从端口读入的数据,dx指端口号 in ax, dx out dx, al out dx, ax out 立即数, al out 立即数, ax
在 bootmain.c 中的 lpt_putc 函数(定义在 console.c 中)完成了并口输出字符 的工作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 #define LPTPORT 0x378 static void lpt_putc (int c) { if (c != '\b' ) { lpt_putc_sub(c); } else { lpt_putc_sub('\b' ); lpt_putc_sub(' ' ); lpt_putc_sub('\b' ); } } static void lpt_putc_sub (int c) { int i; for (i = 0 ; !(inb(LPTPORT + 1 ) & 0x80 ) && i < 12800 ; i ++) { delay(); } outb(LPTPORT + 0 , c); outb(LPTPORT + 2 , 0x08 | 0x04 | 0x01 ); outb(LPTPORT + 2 , 0x08 ); } static inline void outb (uint16_t port, uint8_t data) { asm volatile ("outb %0, %1" :: "a" (data), "d" (port)) ; } static inline uint8_t inb (uint16_t port) { uint8_t data; asm volatile ("inb %1, %0" : "=a" (data) : "d" (port)) ; return data; } static void delay (void ) { inb(0x84 ); inb(0x84 ); inb(0x84 ); inb(0x84 ); }
读I/O端口地址 0x379,等待并口准备好
向I/O端口地址 0x378 发出要输出的字符
向I/O端口地址 0x37A 发出控制命令,让并口处理要输出的字符
在 bootmain.c 中的 serial_putc 函数(定义在 console.c 中)完成了串口输出字符 的工作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 #define COM1 0x3F8 #define COM_TX 0 #define COM_LSR 5 #define COM_LSR_TXRDY 0x20 static void serial_putc (int c) { if (c != '\b' ) { serial_putc_sub(c); } else { serial_putc_sub('\b' ); serial_putc_sub(' ' ); serial_putc_sub('\b' ); } } static void serial_putc_sub (int c) { int i; for (i = 0 ; !(inb(COM1 + COM_LSR) & COM_LSR_TXRDY) && i < 12800 ; i ++) { delay(); } outb(COM1 + COM_TX, c); }
读I/O端口地址 0x3f8+5 获得LSR寄存器的值,等待串口输出准备好
向I/O端口地址 0x3f8 发出要输出的字符
在 bootmain.c 中的 cga_putc 函数(定义在 console.c 中)完成了 CGA 字符方式在某位置输出字符的工作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 #define CRT_ROWS 25 #define CRT_COLS 80 #define CRT_SIZE (CRT_ROWS * CRT_COLS) typedef unsigned short uint16_t ;static uint16_t *crt_buf;static uint16_t crt_pos;static uint16_t addr_6845;static void cga_putc (int c) { if (!(c & ~0xFF )) { c |= 0x0700 ; } switch (c & 0xff ) { case '\b' : if (crt_pos > 0 ) { crt_pos --; crt_buf[crt_pos] = (c & ~0xff ) | ' ' ; } break ; case '\n' : crt_pos += CRT_COLS; case '\r' : crt_pos -= (crt_pos % CRT_COLS); break ; default : crt_buf[crt_pos ++] = c; break ; } if (crt_pos >= CRT_SIZE) { int i; memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof (uint16_t )); for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i ++) { crt_buf[i] = 0x0700 | ' ' ; } crt_pos -= CRT_COLS; } outb(addr_6845, 14 ); outb(addr_6845 + 1 , crt_pos >> 8 ); outb(addr_6845, 15 ); outb(addr_6845 + 1 , crt_pos); }
写I/O端口地址0x3d4,读I/O端口地址0x3d5,获得当前光标位置
在光标的下一位置的显存地址空间上写字符,格式是黑色背景/白色字符
设置当前光标位置为下一位置
练习1 - 镜像文件的生成 关于这部分,我觉得现在还不急着去分析 Makefile 的具体内容,就挂一下答案了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 bin /ucore.img| 生成ucore.img的相关代码为 | $(UCOREIMG): $(kernel) $(bootblock) | $(V)dd if =/dev/zero of=$@ count=10000 | $(V)dd if =$(bootblock) of=$@ conv=notrunc | $(V)dd if =$(kernel) of=$@ seek=1 conv=notrunc | | 为了生成ucore.img,首先需要生成bootblock、kernel | |> bin /bootblock | | 生成bootblock的相关代码为 | | $(bootblock): $(call toobj,$(bootfiles)) | $(call totarget,sign) | | @echo + ld $@ | | $(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 $^ \ | | -o $(call toobj,bootblock) | | @$(OBJDUMP) -S $(call objfile,bootblock) > \ | | $(call asmfile,bootblock) | | @$(OBJCOPY) -S -O binary $(call objfile,bootblock) \ | | $(call outfile,bootblock) | | @$(call totarget,sign) $(call outfile,bootblock) $(bootblock) | | | | 为了生成bootblock,首先需要生成bootasm.o、bootmain.o、sign | | | |> obj/boot/bootasm.o, obj/boot/bootmain.o | | | 生成bootasm.o,bootmain.o的相关makefile代码为 | | | bootfiles = $(call listf_cc,boot) | | | $(foreach f,$(bootfiles),$(call cc_compile,$(f),$(CC),\ | | | $(CFLAGS) -Os -nostdinc)) | | | 实际代码由宏批量生成 | | | | | | 生成bootasm.o需要bootasm.S | | | 实际命令为 | | | gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs \ | | | -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc \ | | | -c boot/bootasm.S -o obj/boot/bootasm.o | | | 其中关键的参数为 | | | -ggdb 生成可供gdb使用的调试信息。这样才能用qemu+gdb来调试bootloader or ucore。 | | | -m32 生成适用于32 位环境的代码。我们用的模拟硬件是32bit的80386 ,所以ucore也要是32 位的软件。 | | | -gstabs 生成stabs格式的调试信息。这样要ucore的monitor可以显示出便于开发者阅读的函数调用栈信息 | | | -nostdinc 不使用标准库。标准库是给应用程序用的,我们是编译ucore内核,OS内核是提供服务的,所以所有的服务要自给自足。 | | | -fno-stack-protector 不生成用于检测缓冲区溢出的代码。这是for 应用程序的,我们是编译内核,ucore内核好像还用不到此功能。 | | | -Os 为减小代码大小而进行优化。根据硬件spec,主引导扇区只有512 字节,我们写的简单bootloader的最终大小不能大于510 字节。 | | | -I<dir > 添加搜索头文件的路径 | | | | | | 生成bootmain.o需要bootmain.c | | | 实际命令为 | | | gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc \ | | | -fno-stack-protector -Ilibs/ -Os -nostdinc \ | | | -c boot/bootmain.c -o obj/boot/bootmain.o | | | 新出现的关键参数有 | | | -fno-builtin 除非用__builtin_前缀, | | | 否则不进行builtin函数的优化 | | | |> bin /sign | | | 生成sign工具的makefile代码为 | | | $(call add_files_host,tools/sign.c,sign,sign) | | | $(call create_target_host,sign,sign) | | | | | | 实际命令为 | | | gcc -Itools/ -g -Wall -O2 -c tools/sign.c \ | | | -o obj/sign/tools/sign.o | | | gcc -g -Wall -O2 obj/sign/tools/sign.o -o bin /sign | | | | 首先生成bootblock.o | | ld -m elf_i386 -nostdlib -N -e start -Ttext 0x7C00 \ | | obj/boot/bootasm.o obj/boot/bootmain.o -o obj/bootblock.o | | 其中关键的参数为 | | -m <emulation> 模拟为i386上的连接器 | | -nostdlib 不使用标准库 | | -N 设置代码段和数据段均可读写 | | -e <entry> 指定入口 | | -Ttext 制定代码段开始位置 | | | | 拷贝二进制代码bootblock.o到bootblock.out | | objcopy -S -O binary obj/bootblock.o obj/bootblock.out | | 其中关键的参数为 | | -S 移除所有符号和重定位信息 | | -O <bfdname> 指定输出格式 | | | | 使用sign工具处理bootblock.out,生成bootblock | | bin /sign obj/bootblock.out bin /bootblock | |> bin /kernel | | 生成kernel的相关代码为 | | $(kernel): tools/kernel.ld | | $(kernel): $(KOBJS) | | @echo + ld $@ | | $(V)$(LD) $(LDFLAGS) -T tools/kernel.ld -o $@ $(KOBJS) | | @$(OBJDUMP) -S $@ > $(call asmfile,kernel) | | @$(OBJDUMP) -t $@ | $(SED) '1,/SYMBOL TABLE/d; s/ .* / /; \ | | /^$$/d' > $(call symfile,kernel)| | | | 为了生成kernel,首先需要 kernel.ld init.o readline.o stdio.o kdebug.o | | kmonitor.o panic.o clock.o console.o intr.o picirq.o trap.o | | trapentry.o vectors.o pmm.o printfmt.o string.o | | kernel.ld已存在 | | | |> obj/kern/*/*.o | | | 生成这些.o文件的相关makefile代码为 | | | $(call add_files_cc,$(call listf_cc,$(KSRCDIR)),kernel,\ | | | $(KCFLAGS)) | | | 这些.o生成方式和参数均类似,仅举init.o为例,其余不赘述 | |> obj/kern/init/init.o | | | 编译需要init.c | | | 实际命令为 | | | gcc -Ikern/init/ -fno-builtin -Wall -ggdb -m32 \ | | | -gstabs -nostdinc -fno-stack-protector \ | | | -Ilibs/ -Ikern/debug/ -Ikern/driver/ \ | | | -Ikern/trap/ -Ikern/mm/ -c kern/init/init.c \ | | | -o obj/kern/init/init.o | | | | 生成kernel时,makefile的几条指令中有@前缀的都不必需 | | 必需的命令只有 | | ld -m elf_i386 -nostdlib -T tools/kernel.ld -o bin /kernel \ | | obj/kern/init/init.o obj/kern/libs/readline.o \ | | obj/kern/libs/stdio.o obj/kern/debug/kdebug.o \ | | obj/kern/debug/kmonitor.o obj/kern/debug/panic.o \ | | obj/kern/driver/clock.o obj/kern/driver/console.o \ | | obj/kern/driver/intr.o obj/kern/driver/picirq.o \ | | obj/kern/trap/trap.o obj/kern/trap/trapentry.o \ | | obj/kern/trap/vectors.o obj/kern/mm/pmm.o \ | | obj/libs/printfmt.o obj/libs/string.o | | 其中新出现的关键参数为 | | -T <scriptfile> 让连接器使用指定的脚本 | | 生成一个有10000 个块的文件,每个块默认512 字节,用0 填充 | dd if =/dev/zero of=bin /ucore.img count=10000 | | 把bootblock中的内容写到第一个块 | dd if =bin /bootblock of=bin /ucore.img conv=notrunc | | 从第二个块开始写kernel中的内容 | dd if =bin /kernel of=bin /ucore.img seek=1 conv=notrunc
简单分析一下其中的内容:
dd:用指定大小的块拷贝一个文件,并在拷贝的同时进行指定的转换
if=文件名:输入文件名,缺省为标准输入,即指定源文件 < if=input file >
of=文件名:输出文件名,缺省为标准输出,即指定目的文件 < of=output file >
count=blocks:仅拷贝blocks个块,块大小等于ibs指定的字节数
conv=conversion:用指定的参数转换文件
conv=notrunc:不截短输出文件
简述过程:
由上描述可以看出,首先先创建一个大小为10000字节的块,然后再将bootblock,kernel拷贝过去,然而生成 ucore.img 需要先生成kernel和bootblock
Makefile通过一系列命令生成了bootblock和kernel这两个elf文件,之后通过dd命令将bootblock放到第一个sector,将kernel放到第二个sector开始的区域(可以明显看出bootblock就是引导区,kernel则是操作系统内核)
而在这之前还通过sign对bootblock进行了修饰,在512个字节的最后两个字节写入了0x55AA,作为引导区的标记
练习2 - 单步跟踪BIOS的执行 没什么好写的,make debug 后就可以“任意发挥”了
记得在 tools/gdbinit 结尾加上
这是为了方便 练习3 而做出的操作,因为程序会默认在“kern_init”处打断点,直接跳过了bootloader
练习3 - 分析bootloader进入保护模式的过程 打开A20门
在PC及其兼容机的第20根地址线比较特殊,计算机系统中一般安排一个“门”控制该地址线是否有效,为了访问1M以上的存储单元,应该打开A20门,这种设置与实模式下只使用低端1M字节存储空间有关,与处理器是否工作在实方式还是保护方式无关(即是关掉A20,也可以进入保护模式)
注:在 8086 中有 20 根地址总线,通过 CS:IP 对的方式寻址,最大访问地址为 1MB
先执行一下指令,方便观察程序:
首先清理环境:
1 2 3 4 5 6 7 │B+>0x7c00 cli │ 0x7c01 cld │ 0x7c02 xor %eax,%eax │ 0x7c04 mov %eax,%ds │ 0x7c06 mov %eax,%es │ 0x7c08 mov %eax,%ss │ 0x7c0a in $0x64 ,%al
开启A20:通过将键盘控制器上的A20线置于高电位,使全部32条地址线可用(可以访问4G的内存空间)
1 2 3 4 5 6 7 8 9 10 11 12 seta20.1 : │ 0x7c0a in $0x64 ,%al │ 0x7c0c test $0x2 ,%al │ 0x7c0e jne 0x7c0a │ 0x7c10 mov $0xd1 ,%al │ 0x7c12 out %al,$0x64 seta20.1 : │ 0x7c14 in $0x64 ,%al │ 0x7c16 test $0x2 ,%al │ 0x7c18 jne 0x7c14 │ 0x7c1a mov $0xdf ,%al │ 0x7c1c out %al,$0x60
初始化GDT表
一个简单的GDT表和其描述符已经静态储存在引导区中,载入即可
进入保护模式
通过将cr0寄存器PE位置1便开启了保护模式
1 2 3 │ >0x7c23 mov %cr0,%eax │ 0x7c26 or $0x1 ,%ax │ 0x7c2a mov %eax,%cr0
设置段寄存器,并建立堆栈:
1 2 3 4 5 6 7 8 │ >0x7c32 mov $0x10 ,%ax │ 0x7c36 mov %eax,%ds │ 0x7c38 mov %eax,%es │ 0x7c3a mov %eax,%fs │ 0x7c3c mov %eax,%gs │ 0x7c3e mov %eax,%ss │ 0x7c40 mov $0x0 ,%ebp │ 0x7c45 mov $0x7c00 ,%esp
转到保护模式完成,进入boot主方法:
1 2 3 4 5 6 7 8 9 10 11 12 │ 0x7c4a call 0x7d0f ------------------------------------ │ >0x7d13 push %ebp │ │ 0x7d14 xor %ecx,%ecx │ │ 0x7d16 mov %esp,%ebp │ │ 0x7d18 mov $0x1000 ,%edx │ │ 0x7d1d push %esi │ │ 0x7d1e mov $0x10000 ,%eax │ │ 0x7d23 push %ebx │ │ 0x7d24 call 0x7c72 │ │ 0x7d29 cmpl $0x464c457f ,0x10000 │ │ 0x7d33 jne 0x7d74
练习4 - 分析bootloader加载ELF格式的OS的过程 readsect
:从设备的第secno扇区读取数据到dst位置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 static void readsect (void *dst, uint32_t secno) { waitdisk(); outb(0x1F2 , 1 ); outb(0x1F3 , secno & 0xFF ); outb(0x1F4 , (secno >> 8 ) & 0xFF ); outb(0x1F5 , (secno >> 16 ) & 0xFF ); outb(0x1F6 , ((secno >> 24 ) & 0xF ) | 0xE0 ); outb(0x1F7 , 0x20 ); waitdisk(); insl(0x1F0 , dst, SECTSIZE / 4 ); }
readseg
:简单包装了 readsect,可以从设备读取任意长度的内容(指定了要读取的字节数)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 static void readseg (uintptr_t va, uint32_t count, uint32_t offset) { uintptr_t end_va = va + count; va -= offset % SECTSIZE; uint32_t secno = (offset / SECTSIZE) + 1 ; for (; va < end_va; va += SECTSIZE, secno ++) { readsect((void *)va, secno); } }
bootmain
函数中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 bootmain(void ) { readseg((uintptr_t )ELFHDR, SECTSIZE * 8 , 0 ); if (ELFHDR->e_magic != ELF_MAGIC) { goto bad; } struct proghdr *ph , *eph ; ph = (struct proghdr *)((uintptr_t )ELFHDR + ELFHDR->e_phoff); eph = ph + ELFHDR->e_phnum; for (; ph < eph; ph ++) { readseg(ph->p_va & 0xFFFFFF , ph->p_memsz, ph->p_offset); } ((void (*)(void ))(ELFHDR->e_entry & 0xFFFFFF ))(); bad: outw(0x8A00 , 0x8A00 ); outw(0x8A00 , 0x8E00 ); while (1 ); }
练习5 - 实现函数调用堆栈跟踪函数 终于遇到一个需要写的练习了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 void print_stackframe (void ) { }
当然不是从零开始,程序已经写好了一些函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 #define STACKFRAME_DEPTH 20 static __noinline uint32_t read_eip (void ) { uint32_t eip; asm volatile ("movl 4(%%ebp), %0" : "=r" (eip)) ; return eip; } static inline uint32_t read_ebp (void ) { uint32_t ebp; asm volatile ("movl %%ebp, %0" : "=r" (ebp)) ; return ebp; } void print_debuginfo (uintptr_t eip) { struct eipdebuginfo info ; if (debuginfo_eip(eip, &info) != 0 ) { cprintf(" <unknow>: -- 0x%08x --\n" , eip); } else { char fnname[256 ]; int j; for (j = 0 ; j < info.eip_fn_namelen; j ++) { fnname[j] = info.eip_fn_name[j]; } fnname[j] = '\0' ; cprintf(" %s:%d: %s+%d\n" , info.eip_file, info.eip_line, fnname, eip - info.eip_fn_addr); } }
翻译翻译实验想让我们干什么:
打印 ebp eip 的地址
打印调用的参数
调用“print_debuginfo(eip-1)”打印C调用函数名和行号等
弹出一个调用堆栈帧(按照提示做)
首次进行尝试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 void print_stackframe (void ) { size_t ebp = read_ebp(); size_t eip = read_eip(); int i; cprintf("ebp:0x%08x eip:0x%08x" ,ebp,eip); cprintf("args:0x%08x\n" ,*(size_t *)(ebp+1 )); cprintf("args:0x%08x\n" ,*(size_t *)(ebp+2 )); cprintf("args:0x%08x\n" ,*(size_t *)(ebp+3 )); cprintf("args:0x%08x\n" ,*(size_t *)(ebp+4 )); cprintf("\n" ); print_debuginfo(eip - 1 ); eip = *(size_t *)(ebp + 1 ); ebp = *(size_t *)(ebp); }
回头看答案发现我少了一个循环,后来发现这是要求的一部分,另外,“read_ebp”和“read_eip”的返回参数类型是“uint32_t”,还是改为“uint32_t”比较好
再次尝试:(部分地方进行了修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 void print_stackframe (void ) { uint32_t ebp = read_ebp(); uint32_t eip = read_eip(); int i; for (i = 0 ; ebp != 0 && i < STACKFRAME_DEPTH; i ++) { cprintf("ebp:0x%08x eip:0x%08x\n" ,ebp,eip); cprintf("args_1:0x%08x\n" ,*(uint32_t *)(ebp+1 )); cprintf("args_2:0x%08x\n" ,*(uint32_t *)(ebp+2 )); cprintf("args_3:0x%08x\n" ,*(uint32_t *)(ebp+3 )); cprintf("args_4:0x%08x\n" ,*(uint32_t *)(ebp+4 )); } cprintf("\n" ); print_debuginfo(eip - 1 ); eip = *(uint32_t *)(ebp + 1 ); ebp = *(uint32_t *)(ebp); }
效果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 ➜ lab1 (THU.CST) os is loading ... Special kernel symbols: entry 0x00100000 (phys) etext 0x001032e9 (phys) edata 0x0010ea16 (phys) end 0x0010fd20 (phys) Kernel executable memory footprint: 64 KB ebp:0x00007b28 eip:0x00100a63 args_1:0x6a00007b args_2:0x0d6a0000 args_3:0x100d6a00 args_4:0x00100d6a ebp:0x00007b28 eip:0x00100a63 args_1:0x6a00007b args_2:0x0d6a0000 args_3:0x100d6a00 args_4:0x00100d6a
练习6 - 完善中断初始化和处理 中断描述符表(Interrupt Descriptor Table,IDT)是保护模式下用于存储中断处理程序的数据结构,CPU在接收到中断时,会根据中断向量在中断描述符表中检索对应的描述符
实验目的:
请编程完善“kern/trap/trap.c”中对中断向量表进行初始化的函数idt_init
在idt_init函数中,依次对所有中断入口进行初始化
使用mmu.h中的SETGATE宏,填充idt数组内容
每个中断的入口由“tools/vectors.c”生成,使用trap.c中声明的vectors数组即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 void idt_init (void ) { } static void trap_dispatch (struct trapframe *tf) { char c; switch (tf->tf_trapno) { case IRQ_OFFSET + IRQ_TIMER: break ; case IRQ_OFFSET + IRQ_COM1: c = cons_getc(); cprintf("serial [%03d] %c\n" , c, c); break ; case IRQ_OFFSET + IRQ_KBD: c = cons_getc(); cprintf("kbd [%03d] %c\n" , c, c); break ; case T_SWITCH_TOU: case T_SWITCH_TOK: panic("T_SWITCH_** ??\n" ); break ; case IRQ_OFFSET + IRQ_IDE1: case IRQ_OFFSET + IRQ_IDE2: break ; default : if ((tf->tf_cs & 3 ) == 0 ) { print_trapframe(tf); panic("unexpected trap in kernel.\n" ); } } } #define SETGATE(gate, istrap, sel, off, dpl) { \ (gate).gd_off_15_0 = (uint32_t)(off) & 0xffff; \ (gate).gd_ss = (sel); \ (gate).gd_args = 0; \ (gate).gd_rsv1 = 0; \ (gate).gd_type = (istrap) ? STS_TG32 : STS_IG32; \ (gate).gd_s = 0; \ (gate).gd_dpl = (dpl); \ (gate).gd_p = 1; \ (gate).gd_off_31_16 = (uint32_t)(off) >> 16; \ }
简单来说,就是要写一个“idt_init”函数来对中断向量表进行初始化,并且完善“trap_dispatch”函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 void idt_init (void ) { extern uintptr_t __vectors[]; int i; for (i=0 ;i<256 ;i++) { SETGATE(idt[i],0 ,GD_KTEXT,__vectors[i],DPL_KERNEL); } SETGATE(idt[T_SWITCH_TOK],0 ,GD_KTEXT,__vectors[T_SWITCH_TOK],DPL_USER); lidt(&idt_pd); }
1 2 3 4 5 case IRQ_OFFSET + IRQ_TIMER: ticks++; if (ticks%TICK_NUM == 0 ) print_ticks(); break ;
1 2 3 -check ticks: OK Total Score: 10 /40 make: *** [Makefile:241 :grade] 错误 1
基础分 10 分已经全部获得