今天的内容继续以显示为主,将实现文字的显示并描绘鼠标箭头,以及为了之后实现鼠标的移动,初步引入GDT与IDT。
1. 结构体的引入
同属于一类的变量,在处理时一个一个传入函数太过于麻烦,如果能打个包作为一个参数传入,在使用时再分别引用,代码会简洁不少。而这个打成的包就是结构体。
struct BOOTINFO {
char cyls, leds, vmode, reserve;
short scrnx, scrny;
char *vram;
};
void HariMain(void)
{
char *vram;
int xsize, ysize;
struct BOOTINFO *binfo;
init_palette();
binfo = (struct BOOTINFO *) 0x0ff0;
xsize = (*binfo).scrnx;
ysize = (*binfo).scrny;
vram = (*binfo).vram;
init_screen(vram, xsize, ysize);
for (;;) {
io_hlt();
}
}
这里将前面通过BIOS获取并已经保存在内存里的一些信息打包成了结构体。从前面的内容(【读书笔记-《30天自制操作系统》-2】Day3)可以看到,从0x0ff0开始恰好依次存放了CYLS,LEDS等信息,通过结构体的成员就可以直接访问。
这里binfo是BOOTINFO类型的指针,访问成员时需要通过(*binfo).vram的形式。而获取结构体指针中成员变量的值,更简洁的写法可以使用"->"符号:
void HariMain(void)
{
struct BOOTINFO *binfo = (struct BOOTINFO *) 0x0ff0;
init_palette();
init_screen(binfo->vram, binfo->scrnx, binfo->scrny);
for (;;) {
io_hlt();
}
}
而对于一个结构体变量,如struct BOOTINFO binfo,要获取其成员变量的值,只要直接使用"."运算符就可以了。
binfo.vram, binfo.scrnx, binfo.scrny
2. 从显示字符到显示变量
2.1 单个字符的显示
接下来实现文字的显示,先从单个字符的显示开始。思路是这样:在8x16的矩形内,用0表示不填充颜色,1表示填充颜色,用填充1的区域来构成一个字符,如下图所示:
根据以上的填充方式,每一行可以用一个十六进制数来表示,定义了字符“A”:
static char font_A[16] = {
0x00, 0x18, 0x18, 0x18, 0x18, 0x24, 0x24, 0x24,
0x24, 0x7e, 0x42, 0x42, 0x42, 0xe7, 0x00, 0x00
};
而用下面这个函数来将“A”显示出来:
void putfont8(char *vram, int xsize, int x, int y, char c, char *font)
{
int i;
char *p, d /* data */;
for (i = 0; i < 16; i++) {
p = vram + (y + i) * xsize + x;
d = font[i];
if ((d & 0x80) != 0) { p[0] = c; }
if ((d & 0x40) != 0) { p[1] = c; }
if ((d & 0x20) != 0) { p[2] = c; }
if ((d & 0x10) != 0) { p[3] = c; }
if ((d & 0x08) != 0) { p[4] = c; }
if ((d & 0x04) != 0) { p[5] = c; }
if ((d & 0x02) != 0) { p[6] = c; }
if ((d & 0x01) != 0) { p[7] = c; }
}
return;
}
可以看出函数putfont8中,每执行一次for循环体,其实是检查这一行需要填充的颜色。将整个8x16方格中需要填充颜色的部分填充之后,字符A也就显示出来了。
理解了显示字符的原理之后,其实可以使用定义好的字符库,比如作者在代码中提供的可以自由使用的OSASK字体。这套字体实现了ASCII字符的编码,共256个字符,每个字符需要16个字节,因此共4096字节。在C语言中可以这样引用:
extern char hankaku[4096]
在ASCII编码中,"A"的字符编码是0x41,要使用字体库中的A字符可以写成
hankaku + 0x41*16
或者直接写成
hankaku + 'A'*16
这样实现单个字符显示的代码如下:
void HariMain(void)
{
struct BOOTINFO *binfo = (struct BOOTINFO *) 0x0ff0;
extern char hankaku[4096];
init_palette();
init_screen(binfo->vram, binfo->scrnx, binfo->scrny);
putfont8(binfo->vram, binfo->scrnx, 8, 8, COL8_FFFFFF, hankaku + 'A' * 16);
putfont8(binfo->vram, binfo->scrnx, 16, 8, COL8_FFFFFF, hankaku + 'B' * 16);
putfont8(binfo->vram, binfo->scrnx, 24, 8, COL8_FFFFFF, hankaku + 'C' * 16);
putfont8(binfo->vram, binfo->scrnx, 40, 8, COL8_FFFFFF, hankaku + '1' * 16);
putfont8(binfo->vram, binfo->scrnx, 48, 8, COL8_FFFFFF, hankaku + '2' * 16);
putfont8(binfo->vram, binfo->scrnx, 56, 8, COL8_FFFFFF, hankaku + '3' * 16);
for (;;) {
io_hlt();
}
}
执行的结果如下,分别显示了字符ABC与123。
2.2 字符串的显示
完成了单个字符的显示,也就可以顺理成章地实现字符串的显示了。C语言中的字符串其实是以’\0’结尾的char型数组,而‘\0’的字符编码是0x00,于是有下面的字符串显示程序:
void putfonts8_asc(char *vram, int xsize, int x, int y, char c, unsigned char *s)
{
extern char hankaku[4096];
for (; *s != 0x00; s++) {
putfont8(vram, xsize, x, y, c, hankaku + *s * 16);
x += 8;
}
return;
}
这个函数的入参是一个char*指针,for循环中其实是把"\0"前的字符一个一个显示出来,原理上还是很简单的。这样在程序中就可以直接显示一个完整的句子:
void HariMain(void)
{
struct BOOTINFO *binfo = (struct BOOTINFO *) 0x0ff0;
init_palette();
init_screen(binfo->vram, binfo->scrnx, binfo->scrny);
putfonts8_asc(binfo->vram, binfo->scrnx, 8, 8, COL8_FFFFFF, "ABC 123");
putfonts8_asc(binfo->vram, binfo->scrnx, 31, 31, COL8_000000, "Haribote OS.");
putfonts8_asc(binfo->vram, binfo->scrnx, 30, 30, COL8_FFFFFF, "Haribote OS.");
for (;;) {
io_hlt();
}
}
最后的两条putfonts8_asc语句是为了展示出不同的文字效果:
这样显示出的文字带一点阴影,更有立体感。看来作者对于美化显示效果也颇费了一番心思。
2.3 变量的显示
接下来再显示变量。这里需要用到sprintf。为什么C语言中的prinf不能使用呢?因为printf用于输出,而输出字符串的方式,各个操作系统有所不同。sprintf只对内存进行操作,因此可以应用于所有的操作系统。新增代码如下:
sprintf(s, "scrnx = %d", binfo->scrnx);
putfonts8_asc(binfo->vram, binfo->scrnx, 16, 64, COL8_FFFFFF, s);
sprintf的使用方法:sprintf(地址, 格式,值,值,值,……),其中%d代表十进制数。运行的结果如图:
同样的方式,鼠标箭头也可以描绘出来:
void init_mouse_cursor8(char *mouse, char bc)
/* 准备鼠标指针(16x16) */
{
static char cursor[16][16] = {
"**************..",
"*OOOOOOOOOOO*...",
"*OOOOOOOOOO*....",
"*OOOOOOOOO*.....",
"*OOOOOOOO*......",
"*OOOOOOO*.......",
"*OOOOOOO*.......",
"*OOOOOOOO*......",
"*OOOO**OOO*.....",
"*OOO*..*OOO*....",
"*OO*....*OOO*...",
"*O*......*OOO*..",
"**........*OOO*.",
"*..........*OOO*",
"............*OO*",
".............***"
};
int x, y;
for (y = 0; y < 16; y++) {
for (x = 0; x < 16; x++) {
if (cursor[y][x] == '*') {
mouse[y * 16 + x] = COL8_000000;
}
if (cursor[y][x] == 'O') {
mouse[y * 16 + x] = COL8_FFFFFF;
}
if (cursor[y][x] == '.') {
mouse[y * 16 + x] = bc;
}
}
}
return;
}
与显示单个字符稍有不同的是这里使用的是16x16的方框。其中"bc"指的是背景色。16x16的方框内除了显示鼠标箭头的区域,还需要将其他部分填充为背景色。为了实现背景色的填充,需要增加下面这个函数:
void putblock8_8(char *vram, int vxsize, int pxsize,
int pysize, int px0, int py0, char *buf, int bxsize)
{
int x, y;
for (y = 0; y < pysize; y++) {
for (x = 0; x < pxsize; x++) {
vram[(py0 + y) * vxsize + (px0 + x)] = buf[y * bxsize + x];
}
}
return;
}
运行以上两个函数:
init_mouse_cursor8(mcursor, COL8_008484);
putblock8_8(binfo->vram, binfo->scrnx, 16, 16, mx, my, mcursor, 16);
sprintf(s, "(%d, %d)", mx, my);
putfonts8_asc(binfo->vram, binfo->scrnx, 0, 0, COL8_FFFFFF, s);
显示的效果如下:
3. GDT与IDT初步
鼠标箭头是有了,但要想让它真正动起来,需要的工作还不少,不是简单一篇的内容能够说完的。本篇里先引入GDT与IDT的概念,为后续实现鼠标箭头的移动打基础。
3.1 GDT
GDT是什么,又为什么要引入这个概念呢?
这还要从内存分段管理来说起。对于多进程的操作系统,可以同时运行多个程序。为了防止各个程序使用的内存范围发生重叠,分段是一种解决方法。每个程序都在分给自己的一段内存上运行,并且将这一段内存的起始地址看作0来进行处理,这样编写程序也更为方便,分割成的段被成为segment。
通过分段的方式管理内存,定位一个内存地址仍然使用的是起始地址+偏移的形式。执行
MOV AL, [DS:EBX]
指令时,这里的起始地址使用的是DS所表示的段的起始地址,EBX表示的则是在段内的偏移。
为了表示一个段,需要有以下信息:
段的大小;
段的起始地址;
段的管理属性(禁止写入,禁止执行,系统专用等);
CPU用8个字节,64位来存放这样一个段的信息,但段寄存器仍然是那个16位的寄存器,无法存放这么多信息。这时候,就需要参考上一篇【读书笔记-《30天自制操作系统》-3】Day4中调色板的使用方法。设定一个段号,存放在段寄存器里,并且预先设置号段与段号的对应关系。
8位的调色板模式下能够使用的色号是0-255,作为16位的寄存器,段寄存器理论上能够存储的段号应该是0-65535。但实际上由于CPU设计的限制,段寄存器的低3位不能用来表示段号,因此实际能够存储的段号只有0-8191,共8192个段。8192个段,所需要的空间是8192*8=65536字节,也就是64KB。这64KB的数据也就是GDT:global (segment) descriptor table,意思是全局段号记录表。CPU无力存储这么大的数据,因此这64KB的数据仍然是存放在内存里。将这段内存的起始地址和有效设定个数存放在CPU的GDTR寄存器中,GDT的设定就完成了。
3.2 IDT
再来简单说说IDT。IDT是interrupt descriptor table的缩写,也就是中断记录表的意思。
所谓的中断,是为了使CPU能够及时对外部设备的动作做出响应,又不影响CPU正常工作处理的一种机制。CPU的运行速度极快,对于主频100MHz的CPU,每秒钟可以执行100000000条指令。对于鼠标这样的设备,并不确定用户在什么时间会触发鼠标的动作。采用定时查询的机制,时间间隔短了,会占用CPU大量的时间用于查询,无法完成正常的工作;时间间隔长了,用户的响应又不及时。中断机制就是用来解决这一问题的。
在CPU正常工作状态下,如果产生了中断,这时CPU就会停下当前处理的工作并将当前的工作状态保存,处理中断。中断处理结束后,CPU重新获取中断前的工作状态,继续工作。所以使用鼠标时,需要使用中断,也就需要设置IDT。
IDT中记录的是中断号码与调用函数之间的关系,这样产生了某一号码的中断,CPU就可以执行相应的调用函数,完成中断处理。而为了设置IDT,必须先设置好GDT,这也是引入GDT的原因。
关于GDT与IDT的设置,代码如下:
struct SEGMENT_DESCRIPTOR {
short limit_low, base_low;
char base_mid, access_right;
char limit_high, base_high;
};
struct GATE_DESCRIPTOR {
short offset_low, selector;
char dw_count, access_right;
short offset_high;
};
void init_gdtidt(void)
{
struct SEGMENT_DESCRIPTOR *gdt = (struct SEGMENT_DESCRIPTOR *) 0x00270000;
struct GATE_DESCRIPTOR *idt = (struct GATE_DESCRIPTOR *) 0x0026f800;
int i;
/* GDT初始化 */
for (i = 0; i < 8192; i++) {
set_segmdesc(gdt + i, 0, 0, 0);
}
set_segmdesc(gdt + 1, 0xffffffff, 0x00000000, 0x4092);
set_segmdesc(gdt + 2, 0x0007ffff, 0x00280000, 0x409a);
load_gdtr(0xffff, 0x00270000);
/* IDT初始化 */
for (i = 0; i < 256; i++) {
set_gatedesc(idt + i, 0, 0, 0);
}
load_idtr(0x7ff, 0x0026f800);
return;
}
void set_segmdesc(struct SEGMENT_DESCRIPTOR *sd, unsigned int limit, int base, int ar)
{
if (limit > 0xfffff) {
ar |= 0x8000; /* G_bit = 1 */
limit /= 0x1000;
}
sd->limit_low = limit & 0xffff;
sd->base_low = base & 0xffff;
sd->base_mid = (base >> 16) & 0xff;
sd->access_right = ar & 0xff;
sd->limit_high = ((limit >> 16) & 0x0f) | ((ar >> 8) & 0xf0);
sd->base_high = (base >> 24) & 0xff;
return;
}
void set_gatedesc(struct GATE_DESCRIPTOR *gd, int offset, int selector, int ar)
{
gd->offset_low = offset & 0xffff;
gd->selector = selector;
gd->dw_count = (ar >> 8) & 0xff;
gd->access_right = ar & 0xff;
gd->offset_high = (offset >> 16) & 0xffff;
return;
}
结构体SEGMENT_DESCRIPTOR 用8个字节来存放段信息。从代码中可以看出,GDT地址从0x00270000开始(这是作者选取未被使用的内存随意设定的)。
set_segmdesc的入参包括段信息SEGMENT_DESCRIPTOR *sd,段地址上限limit,段基址base以及段属性ar。
以下语句
set_segmdesc(gdt + 1, 0xffffffff, 0x00000000, 0x4092);
set_segmdesc(gdt + 2, 0x0007ffff, 0x00280000, 0x409a);
设置了两个段。段1的基址为0,上限为4G,是所能管理的全部内存;段2则是从0x00280000地址开hi的512KB内存。至于属性0x4092和0x409a所代表的含义,仍然要留待下一篇来讲解了。
完成两个段的设置之后,再通过
load_gdtr(0xffff, 0x00270000);
设置好GDTR寄存器,GDT的设置就基本完成了。
IDT的设置与GDT的设置类似,当前的代码中只是将IDT整体都初始化为0。
下一篇中,会对中断与IDT的设置进行更为详细的讲解,敬请期待。