哈哈,终于开始写操作系统实验啦,简直就是心魔了,看我这次怎么打败这个大boss!
主程序
首先从os-lab/src/main.c这里开始吧,看看主程序做了些什么。
void game_init()
{
init_serial();
init_timer();
init_idt();
init_intr();
set_timer_intr_handler(timer_event);
set_keyboard_intr_handler(keyboard_event);
printk("game start!\n");
enable_interrupt();
main_loop();
assert(0);
}
串行接口
第一句话就是初始化串行串口,它在os-lab0/include/game.h中定义,在os-lab0/src/device/serial.c中实现
微信计算机主机与外部设备连接,基本使用了两类接口:串行接口serial port和并行接口parallel port。
并行接口是指数据的各位同时进行传说,所以传输速度快,但是当传输距离较远,位数也多时,会使通信线路变复杂,成本也就提高了。
串行接口则是将数据一位位地顺序传送,所以通信线路简单,只需要一对传输线就可以实现双向通信,还可以使用电话线,使得成本大大降低。
内联汇编
in_byte和out_byte是对计算机硬件中的端口进行读写,所以需要用到内联汇编,相关可见。。。
in_byte便是从端口port处读入数据,out_byte则是将数据写入端口port处。
COM port
从代码中可以看出串行接口地址为0x3F8,why?还有接下来那些写端口的操作都是在干嘛呢?wiki中是这样介绍的
串行接口一般有4组COM(comunication) port,一般是使用前两组COM1和COM2,四组COM port的端口起始位置分别为:
COM Port |
IO Port |
COM1 |
3F8h |
COM2 |
2F8h |
COM3 |
3E8h |
COM4 |
2E8h |
通过COM1的端口基地址0x3F8,之后就可以通过offset访问这组接口中的寄存器。
偏移量可以从0~7,不同偏移量对应不同寄存器,对应关系如下:
IO Port Offset |
Setting of DLAB |
Register mapped to this port |
+0 |
0 |
Data register. Reading this registers read from the Receive buffer. Writing to this register writes to the Transmit buffer. |
+1 |
0 |
Interrupt Enable Register. |
+0 |
1 |
With DLAB set to 1, this is the least significant byte of the divisor value for setting the baud rate. |
+1 |
1 |
With DLAB set to 1, this is the most significant byte of the divisor value. |
+2 |
- |
Interrupt Identification and FIFO control registers |
+3 |
- |
Line Control Register. The most significant bit of this register is the DLAB. |
+4 |
- |
Modem Control Register. |
+5 |
- |
Line Status Register. |
+6 |
- |
Modem Status Register. |
+7 |
- |
Scratch Register. |
从图中可以看出一共有10个寄存器,这些寄存器都是8位寄存器。
偏移为0,1时,在DLAB不同情况下会分别对应两个不同的寄存器。
那DLAB是什么呢?
DLAB
上图中+3偏移量对应
线路控制寄存器(line control register),它的最高位便是DLAB(Divisor Latch Access Bit),除数锁存访问位,这是用来干什么的?
在说它用来干嘛之前,首先要介绍一下波特率。
波特率是计算机串口通信时的速率,即信号被调制以后单位时间内的变化。
比如比如1s传送200个字符,每个字符有10位,那么波特率便是200Bd(Baud),比特率则是200*10bps = 2000 bps。
波特率描述了单位时间内设备接受或发送的码元个数,通过不同的调制方式(编码方式),比如在计算机通信中会用到各种不同的编码,那么对1byte大小的数据,可能得到2byte的编码数据,也可能得到10位的编码数据,而波特率并不关心每个码元的位数,而只是考虑码元个数。
串行控制器the serial controller(UART)在内部有有一个每秒滴答115200次的时候,为了控制系统的波特率,便引入a clock divisor,系统波特率便为115200/divisor Bd。
所以DLAB的引入便是为了设置这个divisor。
设置divisor的过程如下:
- 首先将Line Control Register的最高位设为1,也就是DLAB位设为1,这样offset = 0,1便分别指向了the divisor register的低8位和高8位。
- 将divisor的低字节(也就是低8位)信息写入到端口offset=0指向的寄存器
- 将divisor的高字节(也就是高8位)信息写入到端口offset=1指向的寄存器。
- 写完之后便将Line Control Register的DLAB位清0,这样offset = 0指向数据寄存器,offset = 1指回中断使能寄存器(Interrupt Enable Register)
那我们回过头来看代码:
#define SERIAL_PORT 0x3F8
这里将端口基地址设为0x3F8,也就是使用COM1 port。
out_byte(SERIAL_PORT + 1, 0x00);
因为offset = 1端口指向的是中断使能寄存器,所以清零便是关中断。
out_byte(SERIAL_PORT + 3, 0x80);
这一句将Line Control Register的最高位也就是DLAB设为1,所以offset = 0,1端口便可以用来设置divisor,下面做的也正是如此。
out_byte(SERIAL_PORT + 0, 0x01);
out_byte(SERIAL_PORT + 1, 0x00);
将divisor设为1,所以波特率为115200Bd。
线性控制寄存器
out_byte(SERIAL_PORT + 3, 0x03);
这一步设置线性控制寄存器。
线性控制寄存器可以控制好几部分内容
DLAB为最高位;
6~4位用于控制校验位,具体如下:
Bit 5 |
Bit 4 |
Bit 3 |
Parity |
- |
- |
0 |
NONE |
0 |
0 |
1 |
ODD |
0 |
1 |
1 |
EVEN |
1 |
0 |
1 |
MARK |
1 |
1 |
1 |
SPACE |
None即没有,ODD和EVEN便是我们耳熟能详的奇偶校验位啦,剩下两个我不清楚,没见过诶。
最低两位用于设置字符长度,也即每个码元的长度:
Bit 1 |
Bit 0 |
Character Length (bits) |
0 |
0 |
5 |
0 |
1 |
6 |
1 |
0 |
7 |
1 |
1 |
8 |
第3位用于设置终止位的位数。
Bit 2 |
Stop bits |
0 |
1 |
1 |
1.5 / 2 (depending on character length) |
如果字符长度为5,那么终止位只能为1/1.5位,其他情况下,终止位则为1/2位。
所以上面那句首先清空DLAB,设置校验位为无,终止位位数为1,字符长度则为8。
时钟初始化
串行接口初始完之后就轮到时钟了。
关于时钟的编程叫做Programmable Interval Timer(PIT,可编程时钟计时器)。
PIT芯片由一个oscillator振荡器,一个prescalar预分频器以及3个独立的frquency dividers分频器组成。
每一个分频器都有一个输出,用于控制外部电路(比如输出IRQ 0)。
oscillator
oscillator用于产生一个大致为1.193182MHz的频率。
Frequency Dividers
分频器的基本思路是用一个数去除oscillator产生的频率,这样便可以得到一个更低的频率。
所以这里便需要一个counter来做除数。
oscillator每一次产生脉冲信号,都会使得counter减1,当counter减为0时,便可以输出一个信号,并且counter重置为初值。
如果有一个200Hz的输入信号,counter设置为10,那么得到的输出信号频率便是20Hz啦。
PIT芯片中为frequency divider有一个16位寄存器,所以counter可以取值0~65535,因为0不能做除数,所以大多情况下都会用0来代替65536。
PIT芯片有3个单独的分频器,分别代表了3个不同的channels通道。
channel0直接与IRQ0相连,所以它是产生中断信号的最好选择哦。
channel1已经被淘汰啦,以后也没什么用。
channel与PC Speaker相连。
在启动BIOS的时候,channel0的分频器便被设为0(也就是65536),如此便得到18.2065HZ的一个输出频率。
I/O Ports
PIT芯片使用如下的I/O端口:
I/O port Usage
0x40 Channel 0 data port (read/write)
0x41 Channel 1 data port
0x42 Channel 2 data port
0x43 Mode/Command register(write only, a read is ignored)
每一个8位数据端口都是一样的,用于设置counter的16位reload值,或是读取channel目前的16位counter值。
当channel的counter减为0时,此channel的输出边作出改变,接着counter被重置为reload value。
Mode/Command register
Bits Usage
6,7
select Channel
00 = channel0
01 = channel1
10 =channel2
11 Read-back Command
4,5 Access Mode
00 =Latch count value command
01 =lobyte only
10 =hibyte only
11 =lobyte/hibyte
1,2,3 Operating Mode
001
=hardware re-triggrable one-shot
010 =rate generator
011 =square wave generator
100 =softwae triggered strobe
101 =hardware trigger strobe
110 =same as 010
111 =same as 011
0 BCD/Binary Mode:0=16-bid binary,1=four-digit BSC
来看代码out_byte(TIMER_PORT, 0X34)
所以选择channel0,访问模式则为lobyte/hibyte,Operating Mode为rate generator, 16-bit binary。
因为数据端口是8位,而counter是16位,所以芯片就需要知道数据端口是读/写counter的高字节位还是低字节位。
“lobyte only"则只对低字节进行处理(低8位),”hibyte only“则是对counter的高字节位进行处理。
在"lobyte/hibyte"访问模式中,counter的16位被设定为,在8位的数据端口上,先访问低8位数据,紧接着访问高8位数据。
所以在代码中我们能看到先写低8位
out_byte(TIMER_PORT + 0, counter % 256);
再写高8位,
out_byte(IMTER_PORT + 0, counter / 256);
rate generator和square wave generator两种操作模式都可以用来生成IRQ定时器,一般情况下OS和BIOS都是采用Square wave generator模式,如果采用rate generator模式,则可以增加频率准确率。
IDT初始化
NR_IRQ在cpu.h中定义,为256。GateDescriptor在memory.h中定义。
首先看门描述符,它长度是8个字节,也就是64位,所以"offset_15_0 :16"也就表明offset_15_0长度为16位bits。
门描述符也就是一个段描述符,下面对Intel x86的分段存储结构进行一些说明,具体参考的是孙钟秀教授主编的操作系统教程中4.6节。
Intel x86的分段
Intel x86系列CPU有三种工作模式:实地址模式,保护模式以及虚拟8086模式。
在保护模式下,采用分段机制,可启用分页机制,所以有段式,页式,段页式三种虚拟存储管理模式。
Inel x86实现虚拟存储管理的核心便是内存中的两张描述符表GDT(Global Descriptor Table)以及LDT(Local Descriptor Table)。
每个进程都有它自己的LDT,用来描述这个进程的代码段,数据段,堆栈段以及扩充段等的基地址,段大小和有关控制信息。系统的所有进程则共享一个GDT,用于描述系统段,包括操作系统的段大小,段基址,相关控制信息以及所有进程共享的系统资源。
所有当发生进程切换的时候,LDT则更换为待运行进程的LDT,GDT则保持不变,分别用LDTR,GDTR两个寄存器保持LDT,GDT的基地址。
下面说一下段描述符是如何使用的:
现在物理地址都是2^32 = 4GB大小,而虚拟地址是48位,也就有2^48 = 64TB大小。
因为物理地址是32位长度,所以48位中的低32位为offset,高16位则为段选择符:
段选择符47:32 偏移量31:0
16位段选择符的结构如下:
index15:3 T:2 RPL1:0
T = 0时从GDT中选择描述符,T= 1时从LDT中选择描述符。
RPL是描述符请求的特权级,此处不管它,所以用于做下标的index有13位的。
那么LDT与GDT分别都有2^13 = 8192个存储器分段。
于是流程便是:
首先,将虚拟地址的高16位取出,放入机器的6个段寄存器之一,比如代码段选择符放入CS中,数据段选择符则放入DS中,堆栈段选择符放入SS中。
之后,根据段选择符的第3位T决定是从LDT还是GDT中查找段描述符。
接着根据段选择符的高13位index从段描述符表中取出对应的段描述符,然后从段描述符中得到32位段基址,与虚拟地址的32位偏移相加就得到了32位的线性地址。
假如不采用分页机制,那么此时就得到了物理地址。
一个段描述符构造如下:
struct Descriptro
{
//段限长一共20位
uint32_t limit_15_0 :16;
//段基址一共32位
uint32_t base_15_0 :16;
uint32_t base23_16 :8;
//访问位,=0未访问,=1已访问,用于淘汰页面
uint32_t A :1;
//段类型和保护方式,比如可执行代码段或是只读数据段
uint32_t TYPE :3;
//段内容标志,=1位代码或数据段,=0是系统段。
uint32_t S :1;
//描述符特权级0~3
uint32_t DPL :2;
//=1表示段包含有效基址和段界限,否则无定义
uint32_t P :1;
uint32_t limt_19_16 :4;
//用户编程可用位
uint32_t AVL :1;
//就是0
uint32_t zero :1;
//=1为32位代码段,=0为16位代码段
uint32_t D :1;
//=0表示以字节为单位,段长度则为2^20B,=1表示以页面为单位,一个页面4KB,所以段长为2^20 * 4KB = 4GB。
uint32_t G :1;
uint32_t base_31_24 :8;
};
在代码中实现的是中断门描述符和陷阱门描述符,关于门描述符还有调用门描述符,任务门描述符,暂且不提。
中断门描述符以及陷阱门描述符都存储在IDT中,IDT包含256个中断描述符,与中断,异常一一对应。
所以每个中断/异常都有一个在0~255的向量号,用于在IDT中的索引,其中0x80即128号是用于系统调用的。
下图来自 http://book.2cto.com/201310/34281.html ,比较形象地说明了根据中断向量号得到中断处理程序的过程。
首先48位寄存器IDTR中的高32位存储了IDT基址,低16位存储的是IDT大小。
根据IDT基址,加上向量号索引,得到了中断门描述符。
从中断门描述符中我们可以得到16位段选择器,放入CS寄存器,之后从GDT中得到段基址,之后加上32位偏移,便可以得到中断处理程序地址啦。
在idt.c中首先定义IDT表,门描述符与普通的段描述符之间的差别体现在:
1.type变为4位
2.没有段基址,有段选择符
所以下面对中断门的初始化,首先是偏移的设置,之后是选择符的设置,selector只有13位,向左偏移3位之后得到的T=0,说明是从GDT中找段基址,RPL则为0,是最大权限了。
pad0是8位0,没什么用的。
4位type设置为0xE。
system设置为false,因为不是系统段。
之后设置优先级权限privilege_level。
present表明这个描述符是有效的。
最后是偏移量的高16位设置。
陷阱门的初始化除了type不同,其它设置都一样啦。
之后我们看它对陷阱门以及中断门的初始化
我们回过头来看init_idt函数,
它首先将IDT中所有项都指向irq_empty()处理函数。
之后将0~13号异常分别指向vec0,...,vec13()处理函数。
接着设置32号中断处理函数为irq0()。
关于IDT中的向量号,0~31号对应异常或硬件非屏蔽中断,32~47对应硬件可屏蔽中断,48~255则分配给软件中断,0x80(128)用来实现系统调用。
关于段选择符selector的设置,我们看memory.h中的定义:
一共定义了3个段,内核代码段偏移为1,内核数据段偏移为2,
因为中断处理程序是放在代码段中,所以selector都设置为SEG_KERNAL_CODE。
优先级dpl则都设置为最高的DPL_KERNAL。
关于中断处理程序,在do_irq.S中定义,是汇编代码
.global告诉编译器后面跟的是全局变量或是全局函数,这里定义的便是全局函数啦。
pushl $0:将常量0压入栈顶,push指令的一般形式为[pushl S],其中l代表数据格式为双字,S为源操作数,目的操作数默认为栈顶。
asm_do_irq如下:
pushal /*保存通用寄存器中的上下文环境*/
/*
在pushal指令中各寄存器的入栈顺序分别为:
%eax->%ecx->%edx->%ebx->%esp->%ebp->%esi->%edi
总共占用4*8=32字节
*/
与pushal相似的还有pushfl,将flags寄存器的值放入栈顶。
这样经过pushl $0;pushal;一个TrapFrame就填充好了。
之后入栈下%esp,调用irq_handle,接着出栈%esp,所以在%esp上加4,因为栈指针是往下生长的,入栈的话,栈指针要减。
接着继续清栈,将通用寄存器出栈popal,最后就是中断向量号出栈,addl $4,%esp。然后就返回啦。
irq_handle根据传入的TrapFrame得到中断向量号tf->irq。注意啊,因为上面只有时钟中断irq0(向量号1000)被初始化了,所以我们按键盘时输出的tf->irq只会是-1,即用irq_empty来处理的。所以为了得到正确结果,这里我们是要自己将键盘输入中断也加进去的呀。
Intr初始化
最后一个啦,
暂时没找到相关资料,暂且不提这个啦。