「30天自制操作系统」Day22 - 用 C 语言编写应用程序

帮助发现 bug

上一天中,我们实现了 CPU 的异常处理功能,并且通过我们的多次恶意攻击可以发现,我们的系统处理异常的能力还是相当不错的。除此之外,还可以帮助我们在编写与应用程序时发现 bug。

我们来故意写一个 bug:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void api_putchar(int c);
void api_end(void);

void HariMain(void)
{
char a[100];
a[10] = 'A';
api_putchar(a[10]);
a[102] = 'B';
api_putchar(a[102]);
a[123] = 'C';
api_putchar(a[123]);
api_end();
}

这里我们的程序产生了数组越界行为,由于数组是保存在栈中的,我们先来一个函数处理栈异常,中断号为 0x0c。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
_asm_inthandler0c:
STI
PUSH ES
PUSH DS
PUSHAD
MOV EAX,ESP
PUSH EAX
MOV AX,SS
MOV DS,AX
MOV ES,AX
CALL _inthandler0c
CMP EAX,0
JNE end_app
POP EAX
POPAD
POP DS
POP ES
ADD ESP,4
IRETD

然后我们编写 inthandler0c 函数如下:

1
2
3
4
5
6
7
int *inthandler0c(int *esp)
{
struct CONSOLE *cons = (struct CONSOLE *) *((int *) 0x0fec);
struct TASK *task = task_now();
cons_putstr0(cons, "\nINT 0C :\n Stack Exception.\n");
return &(task->tss.esp0); /* 强制结束程序 */
}

结果,在显示出 AB 之后产生了异常,也就是说 C 会被判定为异常而 B 被放了过去。这是因为 a[102] 虽然超出了数组的边界,但却没有超出应用程序分配的数据段的边界。因此这虽然是个 bug,但不会产生异常。

现在我们再来添加一个功能,能够知道引发异常的指令的地址,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int *inthandler0c(int *esp)
{
struct CONSOLE *cons = (struct CONSOLE *) *((int *) 0x0fec);
struct TASK *task = task_now();
char s[30];
cons_putstr0(cons, "\nINT 0C :\n Stack Exception.\n");
sprintf(s, "EIP = %08X\n", esp[11]);
cons_putstr0(cons, s);
return &(task->tss.esp0); /* 强制结束应用程序 */
}

int *inthandler0d(int *esp)
{
struct CONSOLE *cons = (struct CONSOLE *) *((int *) 0x0fec);
struct TASK *task = task_now();
char s[30];
cons_putstr0(cons, "\nINT 0D :\n General Protected Exception.\n");
sprintf(s, "EIP = %08X\n", esp[11]);
cons_putstr0(cons, s);
return &(task->tss.esp0); /* 强制结束应用程序 */
}

上面代码的功能是将 esp 的 11 号元素(EIP)显示出来。

现在我们运行 bug1.hrb 显示 EIP = 00000042,通过对 bug1.map 和 bug1.lst 文件的比较,可以发现指向的正是 a[123] = ‘C’ 这条语句。通过这种方式我们就可以找到 bug 所在。

强制结束应用程序

我们将 Shift+F1 设定为强制结束建,按一下就可以结束程序(类似我们常用的 Ctrl+C),注意这里我们不能将该处理程序放在命令行窗口任务中,因为在应用程序运行的时候它不会读取 FIFO 缓冲区,强制结束键就失效了。

我们把它放在 bootpack.c 里,程序如下:

1
2
3
4
5
6
7
8
if (i == 256 + 0x3b && key_shift != 0 && task_cons->tss.ss0 != 0) {	/* Shift+F1 */
cons = (struct CONSOLE *) *((int *) 0x0fec);
cons_putstr0(cons, "\nBreak(key) :\n");
io_cli(); /* 不能再改变寄存器值时切换到其他任务 */
task_cons->tss.eax = (int) &(task_cons->tss.esp0);
task_cons->tss.eip = (int) asm_end_app;
io_sti();
}

原理很简单,当按下 Shift+F1 时,改写命令行窗口任务的寄存器值并 goto 到 asm_end_app。其中 asm_end_app 的代码如下:

1
2
3
4
5
6
_asm_end_app:
; EAX 为 tss.esp0 的地址
MOV ESP,[EAX]
MOV DWORD [EAX+4],0
POPAD
RET ; 返回 cmd_app

为了防止误操作,我们需要确认 task_cons->tss.ss0 不为 0 时才继续进行处理。因此我们要保证当应用程序运行时,该值一定不为 0;当应用程序没有运行时,该值一定为 0。修改后的 task_alloc 如下:

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
struct TASK *task_alloc(void)
{
int i;
struct TASK *task;
for (i = 0; i < MAX_TASKS; i++) {
if (taskctl->tasks0[i].flags == 0) {
task = &taskctl->tasks0[i];
task->flags = 1;
task->tss.eflags = 0x00000202; /* IF = 1; */
task->tss.eax = 0; /* 将其置为 0 */
task->tss.ecx = 0;
task->tss.edx = 0;
task->tss.ebx = 0;
task->tss.ebp = 0;
task->tss.esi = 0;
task->tss.edi = 0;
task->tss.es = 0;
task->tss.ds = 0;
task->tss.fs = 0;
task->tss.gs = 0;
task->tss.ldtr = 0;
task->tss.iomap = 0x40000000;
task->tss.ss0 = 0; /* 将其置为 0 */
return task;
}
}
return 0;
}

我们运行一下创建好的 bug,试一下能否强制中断。

用 C 语言显示字符串

利用之前的 _api_putstr0 函数,我们写一个 hello4.c

1
2
3
4
5
6
7
8
void api_putstr0(char *s);
void api_end(void);

void HariMain(void)
{
api_putstr0("hello, world\n");
api_end();
}

运行了一下什么都没显示出来,我们去掉对 Hari 开头 6 个字节的改写,并相应的修改 start_app。不过即便如此我们的 hello4.c 还是无法运行。为了寻找 bug,我们在字符串显示 API 被调用的时候,显示 EBX 的值。我们运行了一下,显示出了 00000400。EBX 中存放该值是因为 bim2hrb 认为要输出的字符串应该存放在 0x400 中。

由 bim2hrb 生成的 .hrb 文件其实是由代码和数据两部分构成的。数据部分会在应用程序启动时被传送到应用程序用的数据段中,而 .hrb 文件中数据部分的位置则存放在代码部分的开头一块区域中。

下面我们先来讲解一下 .hrb 文件:

0x0000 中存放的是数据段的大小。到此为止都固定为 64KB。

0x0004 中存放的是 “Hari” 这4个字节,是操作系统用来判断 这是不是一个应用程序文件的标记。

0x0008 中存放的内容为“数据段内预备空间的大小”,目前没什么用,设置为 0。

0x000c 中存放的是应用程序启动时 ESP 寄存器的初始值,也就是说在这个地址之前的部分会被作为栈来使用,而这个地址将被用于存放字符串等数据。在 hello4.hrb 中,这个值为 0x400。

0x0010 中存放的是需要向数据段传送的部分的字节数。

0x0014 中存放的是需要向数据段传送的部分在 .hrb 文件中的起始地址。

0x0018 中存放的是 0xe9000000 这个数值,E9 是 JMP 指令的机器语言编码,和后面 4 个字节合起来就表示 JMP 到应用程序运行的入口地址。

0x001c 中存放的是应用程序运行入口地址减去 0x20 后的值。

0x0020 中存放的是将来编写应用程序用 malloc 函数时要使用的地址,暂时不用管。

接下来我们修改一下 cmd_app。如果我们文件中找不到 Hari 标志则报错,数据段的大小根据 .hrb 中指定的值进行分配,将 .hrb 文件中的数据部分先复制到数据段后再启动程序。运行一下看:

终于成功了。注意到现在如果不是由 bim2hrb 生成的 hello.hrb 就会出错,因此如果我们有一段汇编写的程序,也需要先生成 .obj 文件,然后生成 .bim 文件并转换成 .hrb。

显示窗口

我们编写一个用来显示窗口的 API,首先要考虑一个问题:如何在调用 API 之后将值存入寄存器并返回给应用程序?

我们可以考虑通过 PUSHAD 和 POPAD 来实现。我们只要查出被传递的变量的地址,在那个地址的后面应该正好存放着相同的寄存器的值。然后只要修改那个值,就可以由 POPAD 获取修改后的值,实现将值返回给应用程序的功能。编写程序如下:

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
int *hrb_api(int edi, int esi, int ebp, int esp, int ebx, int edx, int ecx, int eax)
{
int ds_base = *((int *) 0xfe8);
struct TASK *task = task_now();
struct CONSOLE *cons = (struct CONSOLE *) *((int *) 0x0fec);
struct SHTCTL *shtctl = (struct SHTCTL *) *((int *) 0x0fe4);
struct SHEET *sht;
int *reg = &eax + 1; /* eax 后面的地址 */
/* 强行改写通过 PUSHAD 保存的值 */
/* reg[0] : EDI, reg[1] : ESI, reg[2] : EBP, reg[3] : ESP */
/* reg[4] : EBX, reg[5] : EDX, reg[6] : ECX, reg[7] : EAX */

if (edx == 1) {
cons_putchar(cons, eax & 0xff, 1);
} else if (edx == 2) {
cons_putstr0(cons, (char *) ebx + ds_base);
} else if (edx == 3) {
cons_putstr1(cons, (char *) ebx + ds_base, ecx);
} else if (edx == 4) {
return &(task->tss.esp0);
} else if (edx == 5) {
sht = sheet_alloc(shtctl);
sheet_setbuf(sht, (char *) ebx + ds_base, esi, edi, eax);
make_window8((char *) ebx + ds_base, esi, edi, (char *) ecx + ds_base, 0);
sheet_slide(sht, 100, 50);
sheet_updown(sht, 3);
reg[7] = (int) sht;
}
return 0;
}

这里补充一下各个寄存器的值:

寄存器
EDX 5
EBX 窗口缓冲区
ESI 窗口宽度
EDI 窗口高度
EAX 透明色
ECX 窗口名称

调用后,返回 EAX = 用于操作窗口的句柄。

编写对应的 API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
_api_openwin:	; int api_openwin(char *buf, int xsiz, int ysiz, int col_inv, char *title);
PUSH EDI
PUSH ESI
PUSH EBX
MOV EDX,5
MOV EBX,[ESP+16] ; buf
MOV ESI,[ESP+20] ; xsiz
MOV EDI,[ESP+24] ; ysiz
MOV EAX,[ESP+28] ; col_inv
MOV ECX,[ESP+32] ; title
INT 0x40
POP EBX
POP ESI
POP EDI
RET

现在我们运行一下:

成功了~

在窗口中描绘字符和方块

显示字符的 API 如下:

寄存器
EDX 6
EBX 窗口句柄
ESI 显示位置的 x 坐标
EDI 显示位置的 y 坐标
EAX 色号
ECX 字符串长度
EBP 字符串

描绘方块的 API 如下:

寄存器
EDX 7
EBX 窗口句柄
EAX x0
ECX y0
ESI x1
EDI y1
EBP 色号

程序实现很简单,只要调用我们之前的 putfonts8_asc、boxfill8 等函数就可以轻松实现了。

我们运行一下看看效果:

成功了~效果不错。