// head.s包含32位保护模式初始化设置代码、时钟中断代码、系统调用中断代码和两个任务的代码。
// 在初始化完成之后程序移动到任务0开始执行,并在时钟中断控制下进行任务0和1之间的切换操作。
LATCH = 11930 // 定时器初始计数值,即每隔10毫秒发送一次中断请求。 问:为何是这个值? 因为8253芯片的时钟输入频率是1.193180mhz
SCRN_SEL = 0x18 // 屏幕显示内存段选择符。 问:以下这些选择符是怎么定的值?根据段选择符的定义:位bit[15-3]为段索引,bit2为0表示GDT,1表示LDT,bit[1:0]表示RPL。所以0x18二进制为[00011 0 00]表示GDT表中的第三个描述符。
TSS0_SEL = 0x20 // 任务0的TSS段选择符。 0x20二进制为[00100 0 00],表示选择GDT表中的第四个描述符
LDT0_SEL = 0x28 // 任务0的LDT段选择符。 0x28二进制为[00101 0 00],表示选择GDT表中的第五个描述符
TSS1_SEL = 0x30 // 任务1的TSS段选择符。 0x30二进制为[00110 0 00],表示选择GDT表中的第六个描述符
LDT1_SEL = 0x38 // 任务1的LDT段选择符。 0x38二进制为[00111 0 00],表示选择GDT表中的第七个描述符
.text // 表示可执行代码段(问:实际在编译时有什么影响吗?.text,.data,.bss,用于分别定义当前代码段,数据段和未初始化数据段,在链接多个目标模块时,链接程序会根据它们的类别把各个目标模块中的相应段分别组合在一起。)
startup_32:
// 首先加载数据段寄存器DS、堆栈段寄存器SS和堆栈指针ESP。所有段的线性基地址都是0.
movl $0x10, %eax // 0x10是GDT中数据段选择符。 0x10二进制为[00010 0 00],表示选择GDT表中的第二个描述符
mov %ax, %ds // ds就是存放数据段选择符的段寄存器,这条指令将ax的值0x0010传递给ds寄存器,ax是eax的低16位
lss init_stack, %esp // LSS:加载堆栈段
/*
装入全指针指令LDS,LES,LFS,LGS,LSS
这些指令有两个操作数。第一个操作数是一个有效地址,第二个是一个通用寄存器。指令从该地址
取得48位全指针,将选择符装入相应的段寄存器,而将32位偏移量装入指定的通用寄存器。注意在
内存中,指针的存放形式总是32位偏移量在前面,16位选择符在后面。LDS/LSS等装入指针以后,
就可以用DS:[ESI]/SS:[ESP]等这样的形式来访问指针指向的数据了。
比如这里:
lss init_stack,%esp
而:
init_stack:
.long init_stack ;四字节地址
.word 0x10 ;段选择符,同数据段选择符
这样执行后SS中装入段选择符0x10,ESP中装入init_stack的地址,栈顶在init_stack标号处。
*/
// 在新的位置重新设置IDT和GDT表。
call setup_idt
call setup_gdt
movl $0x10, %eax
mov %ax, %ds // 重新设置GDT表后,虽然段选择符没变,但是实际的描述表位置已经改变(setup_gdt子程序中用lgdt指令更新了GDTR寄存器中gdt表的位置和长度),所以要重新加载段寄存器
mov %ax, %es
mov %ax, %fs
mov %ax, %gs
lss init_stack, %esp // 重新加载SS和ESP
// 设置8253定时芯片。把计数器通道0设置成每隔10毫秒向中断控制器发送一个中断请求信号。
// 下面介绍一下8253定时芯片:
// 8253具有3个独立的计数通道,采用减1计数方式。在门控信号有效时,每输入1个计数脉冲,通道作1次计数操作。当计数脉冲是已知周期的时钟信号时,计数就成为定时。
// 方式3为:方波发生器,最适合计算机。
movb $0x36, %al // 控制字:设置通道0工作在方式3、计数器初值采用二进制。
movl $0x43, %edx // 8253芯片控制字寄存器写端口。
outb %al, %dx // 向I/O端口写入一个字节,这里是向端口0x43写入0x36
movl $LATCH, %eax // 初始计数值设置为LATCH(1193180/100),即频率100HZ。
movl $0x40, %edx // 通道0的端口。
outb %al, %dx // 分两次把初始计数值写入通道0.
movb %ah, %al
outb %al, %dx
// 在IDT表第8和第128(0x80)项处分别设置定时中断门描述符和系统调用陷阱门描述符。
// 这里先解释一下int $0x80:
// int $0x80是一条AT&T语法的中断指令,用于Linux的系统调用。
// Linux系统下的汇编语言比较喜欢用AT&T的语法,如果翻译成Intel的语法就是int 80h,就像我们在Intel的语法下的DOS汇编中经常用的int 21h调用DOS中断,同样如果换成AT&T语法就是int $0x80。
// 不过无论使用那一种语法,int $0x80或者int 80h都是针对Linux的,在DOS或者Windows下不起相应作用。反之亦然。
movl $0x00080000, %eax // 中断程序属内核,即EAX高字是内核代码段选择符0x0008(即索引为1,TI=0,RPL=00)
movw $timer_interrupt, %ax // 设置定时中断门描述符。取定时中断处理程序地址。eax低字(低16字节)
movw $0x8E00, %dx // 中断门类型是14(屏蔽中断),特权级0或硬件使用。
movl $0x08, %ecx // 开机时BIOS设置的时钟中断向量号8.这里直接使用它。
lea idt(0, %ecx, 8), %esi // 把IDT描述符0x08地址放入ESI中,idt+0+ecx*8 => esi,标号"idt"是IDT表的地址,ecx这里是0x08,所以此时esi指向idt表的第64字节处,每个描述符占8字节,即第8个中断门描述符处,然后设置该描述符
movl %eax, (%esi) // 将相应数值填入门描述符的低4字节,填段选择符和中断函数地址低16位
movl %edx, 4(%esi) // 填充门描述符的高4字节,描述符属性和中断函数地址的高16位
/* 定时中断调用过程(为简便省去特权级检查等):定时中断的向量号为8,所以发生中断的时候
,CPU会根据IDTR寄存器(上面call setup_idt已经设置好了IDTR)中提供的IDT表的基地址
,找到第8个中断门描述符,也即这里设置的门描述符。根据门描述符中的段选择符找到相应段
描述符,这里是找到内核代码段,这里的内核代码段的基地址是0.该基地址加上
timer_interrupt就是中断函数入口。所以最终可以调用timer_interrupt函数。
*/
movw $system_interrupt, %ax // 设置系统调用陷阱门描述符。取系统通调用处理程序地址。
movw $0xef00, %dx // 陷阱门类型是15,特权级3的程序可执行。
movl $0x80, %ecx // 系统调用向量号是0x80。
lea idt(, %ecx, 8), %esi // 把IDT描述符项0x80地址放入ESI中,然后设置该描述符。
movl %eax, (%esi) // 将相应数值填入描述符的低4字节,填段选择符(没有改动还是0x08)和中断函数地址低16位
movl %edx, 4(%esi) // 填充门描述符的高4字节,描述符属性和中断函数地址的高16位
// 好了,现在我们为移动到任务0(任务A)中执行来操作堆栈内容,在堆栈中人工建立中断返回时的场景。
// 注: 由于处于特权级0的代码不能直接把控制权转移到特权级3的代码中执行,但中断返回操作是可以的,因此当初始化GDT、IDT和定时芯片结束后,我们就利用中断返回指令IRET来启动运行第1个任务。
// 具体实现方法是在初始堆栈init_stack中人工设置一个返回环境。即把任务0的TSS段选择符加载到任务寄存器LTR中、LDT段选择符加载到LDTR中以后,
// 把任务0的用户栈指针(0x17:init_stack)和代码指针(0x0f:task0)以及标志寄存器压入栈中,然后执行中断返回指令IRET。
// 该指令会弹出堆栈上的堆栈指针作为任务0的用户栈指针,恢复假设的任务0的标志寄存器内容,并且弹出栈中代码指针放入CS:EIP寄存器中,从而开始执行任务0的代码,
// 完成了从特权级0到特权级3的控制转移。
pushfl // EFLAGS入栈
andl $0xffffbfff, (%esp) // 复位标志寄存器EFLAGS中的嵌套任务标志。
popfl // EFLAGS出栈
// 解释一下EFLAGS寄存器中的NT标志:
// 位14是嵌套任务标志(Nested Task)。它控制这被中断任务和调用任务之间的链接关系。在使用CALL指令、中断或异常执行任务调用时,处理器会设置该标志。在通过使用IRET指令从一个任务返回时,处理器会检查并修改这个NT标志。
// 使用POPF/POPFD指令也可以修改这个标志,但是在应用程序中改变这个标志的状态会产生不可意料的异常。
// 嵌套任务标志NT用来控制中断返回指令IRET的执行。具体规定如下:
// (1) 当NT=0,用堆栈中保存的值恢复EFLAGS、CS和EIP,执行常规的中断返回操作;
// (2) 当NT=1,通过任务转换实现中断返回。
movl $TSS0_SEL, %eax // 把任务0的TSS段选择符加载到任务寄存器TR。
ltr %ax
movl $LDT0_SEL, %eax // 把任务0的LDT段选择符加载到局部描述符表寄存器LDTR。
lldt %ax // TR和LDTR只需人工加载一次,以后CPU会自动处理。
movl $0, current // 把当前任务号0保存在current变量中。
sti // 现在开启中断,并在栈中营造中断返回时的场景。
pushl $0x17 // 把任务0当前局部空间数据段(堆栈段)选择符入栈。
/* 问:0x17是怎么来的?
答:0x17是任务0的局部数据段选择符,0x17的二进制为[00010 1 11]故为选择Index=2,
TI=1(表示在LDT中),RPL=3的段描述符,根据该段描述符可知为数据段,但是我们又看
到下面有ldt0和ldt1二个局部描述符表中都有0x17这个段选择符,那这里怎么区分选择
的是哪个局部段描述符表里的数据段呢,很简单因为TI=1,直接根据LDTR中的内容
来寻找局部段描述符表。上面已经用lldt指令加载了ldt0段描述符在GDT表中的段选择符到
LDTR中,所以这里的0x17段选择符选择的是LDT0的第二个段(数据段)。注:lgdt加载的
是6字节的操作数,表示GDT表的基地址和长度。而lldt加载的是相应LDT表段描述符在GDT
表中的段选择符,根据该段选择符就能找到LDT表的基地址和长度。如根据LDT0_SEL段选择
符就能找到GDT表中ldt0的段描述符,然后根据LDT0段的描述符找到ldt0表的地址,
最终找到ldt0的数据段。这就是为什么每个LDT都必须在GDT中有一个段描述符和段选择符。
*/
pushl $init_stack // 把堆栈指针入栈(也可以直接把ESP入栈),在任务切换到任务1时,该值被弹出堆栈做为任务0的用户栈在TSS中保存,详见任务状态段有关任务切换的描述。
pushfl // 把标志寄存器入栈。
pushl $0x0f // 把当前局部空间代码段选择符入栈。
pushl $task0 // 把代码指针入栈。注意!pushl和push也是有区别的!
iret // 执行中断返回指令,从而切换到特权级3的任务0中执行。
/* 出栈时的内容为:任务0的代码指针task0,局部空间代码段选择符,标志寄存器,堆栈指针,
局部空间数据段(堆栈段)选择符,ldt0段选择符,tss0段选择符.
*/
// 以下是设置GDT和IDT中描述符项的子程序。
setup_gdt: // 使用6字节操作数lgdt_opcode设置GDT表位置和长度。
lgdt lgdt_opcode // lgdt指令加载GDT的入口地址(这里由lgdt_opcode指出)到GDTR中
ret
setup_idt: //首先在256个门描述符中都设置中断处理函数为ignore_int,然后用lidt加载IDT表。
lea ignore_int, %edx // 设置方法与设置定时中断门描述符的方法一样。
movl $0x00080000, %eax // 选择符位0x0008,即同内核代码段。
movw %dx, %ax // (注:ax为eax的低16位),设置中断函数地址
movw $0x8E00, %dx // 门描述符属性
lea idt, %edi // 取idt表地址
mov $256, %ecx // 循环设置所有256个门描述符项。
rp_idt: movl %eax, (%edi) // 填IDT表
movl %edx, 4(%edi)
addl $8, %edi // IDT表地址加8字节,即跳到下一门描述符
dec %ecx // 重复256次
jne rp_idt
lidt lidt_opcode // 加载LDTR
ret
// 显示字符子程序。取当前光标位置并把AL中的字符显示在屏幕上。整屏可显示80X25个字符。
write_char:
push %gs // 首先保存要用到的寄存器,eax由调用者负责保存
pushl %ebx
mov $SCRN_SEL, %ebx // 然后让GS指向显示内存段(0xb8000)
mov %bx, %gs
movl scr_loc, %ebx // 再从变量scr_loc中取目前字符显示位置值
shl $1, %ebx // 因为屏幕上每个字符还有一个属性字节,因此字符实际
movb %al, %gs:(%ebx) // 显示位置对应的显示内存偏移地址要乘2
shr $1, %ebx // 把字符放在显示内存后把位置值除2加1,此时位置值对应
incl %ebx // 下一个显示位置,如果该位置大于2000,则复位为0。
cmpl $2000, %ebx
jb 1f // 没有大于2000,跳到标号1f
movl $0, %ebx // 复位为0
1: movl %ebx, scr_loc // 最后把这个位置值保存起来(scr_loc)
popl %ebx // 并弹出保存的寄存器内容(恢复调用该子程序前ebx,gs的值),返回。
pop %gs
ret
// 以下是3个中断处理程序:默认中断、定时中断和系统调用中断。
// ignore_int是默认的中断处理程序,若系统产生了其他中断,则会载屏幕显示一个字符‘C’。
.align 2 // align是对齐的指令 (注意:之后来好好研究一下关于对齐这个问题)
ignore_int:
push %ds
pushl %eax
movl $0x10, %eax // 首先让DS指向内核数据段,因为中断程序属于内核。
mov %ax, %ds
movl $67, %eax // 在AL中存放字符'C'的代码,调用显示程序显示在屏幕上。
call write_char
popl %eax
pop %ds
iret
// 这是定时中断处理程序。其中主要执行任务切换操作。
.align 2
timer_interrupt:
push %ds
pushl %eax
movl $0x10, %eax // 首先让DS指向内核数据段。
mov %ax, %ds
movb $0x20, %al // 然后立刻允许其他硬件中断,则向8253发送EOI命令。
outb %al, $0x20
movl $1, %eax
cmpl %eax, current
je 1f
movl %eax, current // 若当前任务是0,则把1存入current,并跳转到任务1
ljmp $TSS1_SEL, $0 // 去执行。对于造成任务切换的长跳转偏移值无用,但需要写上。
jmp 2f
1: movl $0, current // 若当前任务是1,则把0存入current,并跳转到任务0
ljmp $TSS0_SEL, $0
2: popl %eax
pop %ds
iret
// 系统调用中断int0x80处理程序。该示例只有一个显示字符功能。
// 说明:system_interrup这个中断处理程序将由两个任务来调用。
.align 2
system_interrupt:
push %ds
pushl %edx
push %ecx
pushl %ebx
pushl %eax
movl $0x10, %edx // 首先让DS指向内核数据段
mov %dx, %ds
call write_char // 然后调用显示字符子程序write_char, 显示AL中的字符
popl %eax
popl %ebx
popl %ecx
popl %edx
pop %ds
iret
/*****************************************************************/
current: .long 0 // 当前任务号(0或1)。
scr_loc: .long 0 // 屏幕显示位置。按从左上角到右下角顺序显示。
.align 2
lidt_opcode:
.word 256*8-1 // 加载IDTR寄存器的6字节操作数:表长度和基地址。
.long idt
lgdt_opcode:
.word (end_gdt-gdt)-1 // 这个16位数表示GDT的段限长
.long gdt // 这个32位数表示GDT的基地址
.align 8
idt: .fill 256,8,0 // IDT表空间。每个门描述符8字节,共占用2KB字节。
gdt: .quad 0x0000000000000000 // GDT表。第1个描述符不用。
.quad 0x00c09a00000007ff // 第2个是内核代码段描述符。其选择符是0x08。
.quad 0x00c09200000007ff // 第3个是内核数据段描述符。其选择符是0x10。
.quad 0x00c0920b80000002 // 第4个是显示内存段描述符。其选择符是0x18。
.word 0x68, tss0, 0xe900, 0x0 // 第5个是TSS0段的描述符。其选择符是0x20
.word 0x40, ldt0, 0xe200, 0x0 // 第6个是LDT0段的描述符。其选择符是0x28
.word 0x68, tss1, 0xe900, 0x0 // 第7个是TSS1段的描述符。其选择符是0x30
.word 0x40, ldt1, 0xe200, 0x0 // 第8个是LDT1段的描述符。其选择符是0x38
end_gdt:
.fill 128,4,0 // 初始内核堆栈空间
init_stack:
.long init_stack // 堆栈段偏移位置。
.word 0x10 // 堆栈段同内核数据段
// 下面是任务0的LDT表段中的局部段描述符。
.align 8
ldt0: .quad 0x0000000000000000 // 第1个描述符,不用。
.quad 0x00c0fa00000003ff // 第2个局部代码段描述符,对应选择符是0x0f
.quad 0x00c0f200000003ff // 第3个局部数据段描述符,对应选择符是0x17
// 下面是任务0的TSS段的内容。注意其中标号等字段在任务切换时不会改变。
tss0: .long 0 /* back link */
.long krn_stk0, 0x10 /* esp0, ss0 ,krn_stk0为任务0内核栈顶指针*/
.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 */
.long 0, 0, 0, 0, 0, 0 /* es, cs, ss, ds, fs, gs 任务切换时会填入相应值*/
.long LDT0_SEL, 0x8000000 /* ldt, trace bitmap */
.fill 128, 4, 0 // 这是任务0的内核栈空间。 任务0的用户栈其实是init_stack,在IRET之前已经手工的把任务0的用户栈基址入栈
krn_stk0:
// 下面是任务1的LDT表段内容和TSS段内容
.align 8
ldt1: .quad 0x0000000000000000 // 第1个描述符,不用
.quad 0x00c0fa00000003ff // 选择符是0x0f,基地址=0x00000。
.quad 0x00c0f200000003ff // 选择符是0x17,基地址=0x00000。
tss1: .long 0 /* back link */
.long krn_stk1, 0x10 /* esp0, 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 usr_stk1, 0, 0, 0 /* esp, ebp, esi, edi */
.long 0x17, 0x0f, 0x17, 0x17, 0x17, 0x17 /* es, cs, ss, ds, fs, gs */
.long LDT1_SEL, 0x8000000 /* ldt, trace bitmap */
.fill 128, 4, 0 // 这是任务1的内核栈空间。其用户栈直接使用初始栈空间。
krn_stk1:
// 下面是任务0和任务1的程序,他们分别循环显示字符'A'和'B'。
task0:
movl $0x17, %eax // 首先让DS指向任务的局部数据段。
movw %ax, %ds // 因为任务没有使用局部数据,所以这两句可省略。
movb $65, %al // 把需要显示的字符'A'放入寄存器中。
int $0x80 // 执行系统调用,显示字符。
movl $0xfff, %ecx // 执行循环,起延时作用。
1: loop 1b // loop指令:每次循环CX递减1。等到CX为0的时候就退出循环
jmp task0 // 跳转到任务代码开始处继续显示字符。
task1:
movb $66, %al // 把需要显示的字符'B'放入寄存器中。
int $0x80 // 执行系统调用,显示字符。
movl $0xfff, %ecx // 执行循环,起延时作用。
1: loop 1b
jmp task1 // 跳转到任务代码开始处继续显示字符。
.fill 128,4,0 // 这是任务1的用户栈空间。
usr_stk1: