本章实现由应用程序对操作系统功能的调用(即API,也叫系统调用)。简单来说,就是操作系统提前准备好接口,供外部的应用程序调用并实现相应的功能(前一章中,应用程序只是一个简单的HLT,并没有调用操作系统的接口,这一章就是操作系统准备好一个字符打印的接口,供应用程序使用)。
一 调用API显示单个字符
我们先来通过API显示单个字符,实现这个功能我们先把需要显示字符编码存入寄存器,然后再让应用程序能够调用cons_putchar函数。
这里存在两个问题,一个问题是函数没法接收存在寄存器上的字符编码,再一个问题是我们不知道cons_putchar函数的地址。所以我们先写一个函数_asm_cons_putchar,将寄存器的值推入栈中,再在这个函数中调用cons_putchar函数。如下图所示:
【1】首先,编写 cons_putchar 函数,功能是写一个字符:
void cons_putchar(struct CONSOLE *cons, int chr, char move)
{
char s[2];
s[0] = chr;
s[1] = 0;
if (s[0] == 0x09) { /* 制表符 */
for (;;) {
putfonts8_asc_sht(cons->sht, cons->cur_x, cons->cur_y, COL8_FFFFFF, COL8_000000, " ", 1);
cons->cur_x += 8;
if (cons->cur_x == 8 + 240) {
cons_newline(cons);
}
if (((cons->cur_x - 8) & 0x1f) == 0) {
break; /* 被32整除则break */
}
}
} else if (s[0] == 0x0a) { /* 换行 */
cons_newline(cons);
} else if (s[0] == 0x0d) { /* 回车 */
/* 先不做任何操作 */
} else { /* 一般字符 */
putfonts8_asc_sht(cons->sht, cons->cur_x, cons->cur_y, COL8_FFFFFF, COL8_000000, s, 1);
if (move != 0) {
/* move为0时,光标不后移 */
cons->cur_x += 8;
if (cons->cur_x == 8 + 240) {
cons_newline(cons);
}
}
}
return;
}
【2】在console_task中直接指定 cons_putchar 的地址:
*((int *) 0x0fec) = (int) &cons;
【3】在 naskfunc.nas 中写 _asm_cons_putchar ,由他调用 cons_putchar 的地址:
_asm_cons_putchar:
PUSH 1
AND EAX,0xff
PUSH EAX
PUSH DWORD [0x0fec] ; cons 地址,直接指定
CALL _cons_putchar
ADD ESP,12
RETF ; 因为是跨段调用,所以需要使用RETF
【4】_asm_cons_putchar 的地址编译之后可以看到,为0xbe3,在应用程序中调用 _asm_cons_putchar 的地址即可实现字符打印,这边先在之前HLT应用程序的基础上改动:
[BITS 32]
MOV AL,'A'
CALL 2*8:0xbe3 ; 应用程序所在段为“1003*8”,而操作系统所在段为“2*8”,
; 所以这边是跨段调用,需要加段号far-CALL。
fin:
HLT
JMP fin
在命令行中输入hlt,执行应用程序,效果如下,成功打印一个字符:
二 结束应用程序
目前的程序,执行hlt之后,打印完“A”就直接挂死了,这不行,得让它在操作系统调用之后就返回。这就需要在操作系统这边执行完 cmd_hlt 中,也使用CALL而非JMP。
首先,naskfunc.nas中,创建一个farcall函数,这个函数和farjump大同小异:
_farcall: ; void farcall(int eip, int cs);
CALL FAR [ESP+4] ; eip, cs
RET
然后,还要把hlt命令的处理改为调用farcall:
void cmd_hlt(struct CONSOLE *cons, int *fat)
{
/* 中略 */
if (finfo != 0) {
/*找到文件的情况*/
farcall(0, 1003 * 8); /*这里!*/
} else {
/*没有找到文件的情况*/
}
// ...
}
最后,改写应用程序,把HLT换成RETF:
[BITS 32]
MOV AL,'A'
CALL 2*8:0xbe3
RETF
这样,在命令行中执行完hlt之后,能够正常返回,并再次执行命令:
三 打造不随操作系统版本改变的API
回顾第一章里面的四个步骤,发现有一个地方不是很靠谱,就是 _asm_cons_putchar 的地址0xbe3。因为 cons_putchar 的地址0x0fec是在程序里面写死的,不会改变,而这个_asm_cons_putchar 的地址0xbe3会随着程序的修改编译而改变。
为了解决这个问题,我们得把_asm_cons_putchar 的地址固定下来。这边作者使用了类似于之前鼠标键盘操作的IDT。将_asm_cons_putchar 注册到IDT的空闲项,就可以将他的地址固定下来:
void init_gdtidt(void)
{
// ...
/* IDT的设置*/
set_gatedesc(idt + 0x20, (int) asm_inthandler20, 2 * 8, AR_INTGATE32);
set_gatedesc(idt + 0x21, (int) asm_inthandler21, 2 * 8, AR_INTGATE32);
set_gatedesc(idt + 0x2c, (int) asm_inthandler2c, 2 * 8, AR_INTGATE32);
set_gatedesc(idt + 0x40, (int) asm_cons_putchar, 2 * 8, AR_INTGATE32); / *这里!*/
return;
}
与此同时,还需要特别注意,之前用far-CALL调用的话,返回是RETF,现在用中断INT执行调用,返回需要改成IRETD。这边可以参考之前的文章【操作系统】30天自制操作系统--(5)分割编译与中断处理,另外再附一段关于JUMP、CALL、RET、RETF的描述:
JMP、CALL和RET指令的近转移形式只是在当前代码段中执行程序控制转移,因此不会执行特权级检查。JMP、CALL或RET指令的远转移形式会把控制转移到另外一个代码段中,因此处理器一定会执行特权级检查。
1、jmp指令紧紧进行执行流程的跳转,不会保存返回地址
2、call指令在进行流程跳转前会保存返回地址,以便在跳转目标代码中可以使用ret指令返回到call指令的下一条指令处继续执行。执行段内跳转时,只保存EIP;如果是段间跳转,还保存CS。
3、ret和retf:这两个指令的功能都是调用返回。
(1)ret在返回时只从堆栈中取得EIP;retf中的字母f表示far,即段间转移返回,要从堆栈中取出EIP和CS。
(2)两个指令都可以带参数,表示发生过程调用时参数的个数,返回时要从堆栈中退出相应个数的参数。
(3)恢复CS时,如果发现将 发生特权级变化(当前CS的低2位不等于从堆栈中取得的新的CS值的低2位。由跳转的相关理论可知,只有跳转到非一致代码段时才会发生特权级变化,那么, 也只有从非一致代码段返回时才会发生特权级变化的返回),则还要从调用者堆栈中取得ESP和SS恢复到相应寄存器中,也即恢复调用者堆栈。
根据上面的描述,就需要对进行改写:
_asm_cons_putchar:
STI
PUSH 1
AND EAX,0xff ; 将AH和EAX的高位置0,将EAX置为已存入字符编码的状态
PUSH EAX
PUSH DWORD [0x0fec] ; 读取内存并PUSH该值
CALL _cons_putchar
ADD ESP,12 ; 丢弃栈中的数据
IRETD ; 中断返回
这样,就可以在应用程序中使用INT 0X40来代替原来的CALL 2*8:0xbe3进行对_asm_cons_putchar的调用:
[BITS 32]
MOV AL,'h'
INT 0x40
RETF
至此,便通过IDT,把_asm_cons_putchar 的地址固定下来,使其不随操作系统版本的改变而改变。
四 为应用程序自由命名
至此,操作系统执行hlt时单独的分支,如果想执行其他的应用程序,还得专门再适配,这样就很麻烦。所以这边考虑做一个兼容处理,使得除了常用的命令行命令(mem、cls、dir、type)之外,系统可以根据命令行的内容搜索该文件,如果找到该应用程序(文件),则执行。反之则认为是“Bad Command!”。
创建 cmd_app 来判断文件名并创建运行对应的应用程序:
int cmd_app(struct CONSOLE *cons, int *fat, char *cmdline) {
struct MEMMAN *memman = (struct MEMMAN *) MEMMAN_ADDR;
struct FILEINFO *finfo;
struct SEGMENT_DESCRIPTOR *gdt = (struct SEGMENT_DESCRIPTOR *) ADR_GDT;
char name[18], *p;
int i;
for (i = 0; i < 13; i++) {
// 根据命令行生成文件名
if (cmdline[i] <= ' ') {
break;
}
name[i] = cmdline[i];
}
// 将文件后面置为 0
name[i] = 0;
// 寻找文件
finfo = file_search(name, (struct FILEINFO *) (ADR_DISKIMG + 0x002600), 224);
if (finfo == 0 && name[i - 1] != '.') {
name[i ] = '.';
name[i + 1] = 'H';
name[i + 2] = 'R';
name[i + 3] = 'B';
name[i + 4] = 0;
// 在后面加上 .hrb 继续寻找
finfo = file_search(name, (struct FILEINFO *) (ADR_DISKIMG + 0x002600), 224);
}
if (finfo != 0) {
// 找到文件并执行
p = (char *) memman_alloc_4k(memman, finfo->size);
file_loadfile(finfo->clustno, finfo->size, p, fat, (char *) (ADR_DISKIMG + 0x003e00));
set_segmdesc(gdt + 1003, finfo->size - 1, (int) p, AR_CODE32_ER);
farcall(0, 1003 * 8);
memman_free_4k(memman, (int) p, finfo->size);
cons_newline(cons);
return 1;
}
return 0;
}
在 cons_runcmd 中调用cmd_app即可:
void cons_runcmd(char *cmdline, struct CONSOLE *cons, int *fat, unsigned int memtotal)
{
if (strcmp(cmdline, "mem") == 0) {
cmd_mem(cons, memtotal);
} else if (strcmp(cmdline, "cls") == 0) {
cmd_cls(cons);
} else if (strcmp(cmdline, "dir") == 0) {
cmd_dir(cons);
} else if (strncmp(cmdline, "type ", 5) == 0) {
cmd_type(cons, fat, cmdline);
} else if (cmdline[0] != 0) {
if (cmd_app(cons, fat, cmdline) == 0) { /* 调用cmd_app检查 */
/* 没有找到对应的应用程序,则报错 */
putfonts8_asc_sht(cons->sht, 8, cons->cur_y, COL8_FFFFFF, COL8_000000, "Bad command.", 12);
cons_newline(cons);
cons_newline(cons);
}
}
return;
}
五 调用API显示字符串
目前已经具备了输出单个字符的能力,那么要显示字符串,首先想到的就是用循环来显示字符串:
[INSTRSET "i486p"]
[BITS 32]
MOV ECX,msg
putloop:
MOV AL,[CS:ECX]
CMP AL,0
JE fin
INT 0x40
ADD ECX,1
JMP putloop
fin:
RETF
msg:
DB "hello",0
这边还需要注意,_asm_cons_putchar中,在调用INT 0X40之后,寄存器ECX的值会发生变化,所以需要加上PUSHAD和POPAD,确保可以将全部寄存器的值还原:
_asm_cons_putchar:
STI
PUSHAD ; 新增入栈
PUSH 1
AND EAX,0xff
PUSH EAX
PUSH DWORD [0x0fec] ; cons 地址,直接指定
CALL _cons_putchar
ADD ESP,12
POPAD ; 新增出栈
IRETD
这样,就可以正常打印出字符串了:
但上面显示字符串,其本质还是在应用程序中循环调用的打印单个字符的API,这与实际的需求不符。为此,我们还需要提供API来直接打印字符串,这样应用程序调用一次就可以了。
采用两种方法来显示字符串:一种是遇到0之后结束、一种是打印指定长度。类似之前设计显示单个字符的API一样,显示字符串的API步骤如下:
【1】首先编写cons_putstr0、cons_putstr1函数,功能是显示字符串(区别在于指定长度与否):
void cons_putstr0(struct CONSOLE *cons, char *s) {
for (; *s != 0; s++) {
cons_putchar(cons, *s, 1);
}
return;
}
void cons_putstr1(struct CONSOLE *cons, char *s, int l)
{
int i;
for (i = 0; i < l; i++) {
cons_putchar(cons, s[i], 1);
}
return;
}
【2】之前单独绑定INT号的方式已经不适用了,因为如果API多的话,空闲的INT就不够用了。作者采用的方式是,将一类API绑定某个INT号,另外再通过一个特殊的功能参数,选择不同的API:
那么可以通过参数,将之前的cons_putchar与新加入的cons_putstr0、cons_putstr1绑在一起,得到一个新的 API ---- hrb_api :
void hrb_api(int edi, int esi, int ebp, int esp, int ebx, int edx, int ecx, int eax)
{
struct CONSOLE *cons = (struct CONSOLE *) *((int *) 0x0fec);
if (edx == 1) {
cons_putchar(cons, eax & 0xff, 1);
} else if (edx == 2) {
cons_putstr0(cons, (char *) ebx);
} else if (edx == 3) {
cons_putstr1(cons, (char *) ebx, ecx);
}
return;
}
【3】将之前的 _asm_cons_putchar 改写为一个新的函数 _asm_hrb_api ,并在其中调用hrb_api:
_asm_hrb_api:
STI
PUSHAD ; 用于保存寄存器的值
PUSHAD ; 由于向 hri_api 传值
CALL _hrb_api
ADD ESP,32
POPAD
IRETD
【4】设置中断注册表:
void init_gdtidt(void)
{
// ...
/* IDT的设置*/
set_gatedesc(idt + 0x20, (int) asm_inthandler20, 2 * 8, AR_INTGATE32);
set_gatedesc(idt + 0x21, (int) asm_inthandler21, 2 * 8, AR_INTGATE32);
set_gatedesc(idt + 0x2c, (int) asm_inthandler2c, 2 * 8, AR_INTGATE32);
set_gatedesc(idt + 0x40, (int) asm_hrb_api, 2 * 8, AR_INTGATE32); / *这里!*/
return;
}
【5】应用程序中调用这个新的API,需要附加功能号的选择:
[INSTRSET "i486p"]
[BITS 32]
MOV EDX,1 /* 附加功能号 */
MOV EBX,msg
INT 0x40
RETF
msg:
DB "hello",0
【特别注意】:上面附加功能号1是可以使用的,但是附加2会有问题。原因如下,这边需要特别注意一个问题,在显示单个字符的时候,我们使用了[CS:ECS]的方式特别指定了CS,因此可以成功读取msg的内容。
但在显示字符串时,由于无法指定段地址,程序误以为是DS而从完全错误的内存地址中读取了内容, 碰巧读出的内容是0,于是就什么都没有显示出来。
因此,我们需要在 API 中做改动,使其能将应用程序传递的地址转化成代码段内的地址
当初设置代码段的是 cmd_app,所以需要在此中向 hrb_api 传递代码段的起始位置的信息
我们无法从 cmd_app 直接向 hrb_api 传递信息,因此只好在内存中找个位置存放一下,之前用过0x0fec了,这边就用它前一个0x0fe8:
*((int*) 0x0fe8) = (int) p;
将原来的 ebx 改为从基地址出发的 cs_base+ebx:
void hrb_api(int edi, int esi, int ebp, int esp, int ebx, int edx, int ecx, int eax)
{
int cs_base = *((int *) 0xfe8);
struct CONSOLE *cons = (struct CONSOLE *) *((int *) 0x0fec);
if (edx == 1) {
cons_putchar(cons, eax & 0xff, 1);
} else if (edx == 2) {
cons_putstr0(cons, (char *) ebx + cs_base);
} else if (edx == 3) {
cons_putstr1(cons, (char *) ebx + cs_base, ecx);
}
return;
}
这样,在应用程序中附加功能码2,即调用API cons_putstr0可以正常显示了。
hello.nas中:
[INSTRSET "i486p"]
[BITS 32]
MOV ECX,msg
MOV EDX,1
putloop:
MOV AL,[CS:ECX]
CMP AL,0
JE fin
INT 0x40
ADD ECX,1
JMP putloop
fin:
RETF
msg:
DB "hello",0
hello2.nas中:
[INSTRSET "i486p"]
[BITS 32]
MOV EDX,2
MOV EBX,msg
INT 0x40
RETF
msg:
DB "hello",0
分别运行应用程序hello、hello2结果: