CC BY 4.0 (除特别声明或转载文章外)
如果这篇博客帮助到你,可以请我喝一杯咖啡~
实验题目
用汇编与 C 语言开发独立内核
实验目的
- 掌握 TASM 汇编语言与 TURBO C 语言汇合编程的方法。
- 实现内核与引导程序分离,掌握软盘上引导操作系统方法。
- 设计并实现一种简单的作业控制语言,建立具有较友好的控制命令的批处理原型操作系统,掌握操作系统提供用户界面和内部功能的实现方法。
实验要求
- 将实验二的原型操作系统分离为引导程序和 MYOS 内核,由引导程序加载内核,用 C 和汇编实现操作系统内核
- 扩展内核汇编代码,增加一些有用的输入输出函数,供 C 模块中调用
- 提供用户程序返回内核的一种解决方案
- 在内核的 C 模块中实现增加批处理能力
- 在磁盘上建立一个表,记录用户程序的存储安排
- 可以在控制台命令查到用户程序的信息,如程序名、字节数、在磁盘映像文件中的位置等
- 设计一种命令,命令中可加载多个用户程序,依次执行,并能在控制台发出命令
- 在引导系统前,将一组命令存放在磁盘映像中,系统可以解释执行
实验方案
实验环境
软件
- Windows 10, 64-bit (Build 17763) 10.0.17763
- Windows Subsystem for Linux [Ubuntu 18.04.2 LTS]:WSL 是以软件的形式运行在 Windows 下的 Linux 子系统,是近些年微软推出来的新工具,可以在 Windows 系统上原生运行 Linux。
- gcc version 7.3.0 (Ubuntu 7.3.0-27ubuntu1~18.04):C 语言程序编译器,Ubuntu 自带。
- NASM version 2.13.02:汇编程序编译器,通过
sudo apt install nasm
安装在 WSL 上。 - Oracle VM VirtualBox 6.0.4 r128413 (Qt5.6.2):轻量开源的虚拟机软件。
- VSCode - Insiders v1.33.0:好用的文本编辑器,有丰富的插件。
- hexdump for VSCode 1.7.2: VSCode 中一个好用的十六进制显示插件。
- GNU Make 4.1:安装在 Ubuntu 下,一键编译并连接代码,生成最终的文件。
大部分开发环境安装在 WSL 上,较之于双系统、虚拟机等其他开发方案,更加方便,也方便直接使用 Linux 下的一些指令。
硬件
开发环境配置
所用机器型号为 VAIO Z Flip 2016
- Intel(R) Core(TM) i7-6567U CPU @3.30GHZ 3.31GHz
- 8.00GB RAM
虚拟机配置
- 处理器内核总数:1
- RAM:4MB
实验原理
这次实验完成内容的结构大致如下:
flowchart TB
引导程序-->main
用户==交互==>C语言函数
subgraph 汇编与C语言混合编译的内核
main-.外部调用.-> 汇编函数
main.-> C语言函数
C语言函数-.内嵌.->汇编函数
end
subgraph 用户程序
汇编函数--> prg1
汇编函数--> prg2
汇编函数--> prg3
汇编函数--> prg4
end
引导程序
在上一个实验里已经说得很清楚了,这里就不再提了,可以直接看代码。
汇编与 C 语言混合编译的内核
这里的内核指的是汇编代码kernel.asm
和 C 语言代码kernel.c
混合编译而成的内核文件kernel.bin
。
汇编部分kernel.asm
内核的第一部分是汇编程序,设置软件中断,声明一个全局的_loadProgram
函数用于加载用户程序,然后直接调用 C 程序中的main
函数链接到 C 程序入口点。
在汇编程序中调用 C 函数比较简单,在程序段头部添加extern
标识符,然后在程序中直接call
即可。
这里由于不太想学古董级的tasm
,使用nasm
替代之。
C 语言部分kernel.c
内核的主要部分,提供了 shell 命令行功能,用于实现用户和系统的交互。
C 语言也可以声明外部函数extern _loadProgram
调用汇编函数。此外,C
语言还支持内嵌汇编代码,通过asm volatile
声明,其中volatile
标识符的作用是禁止编译器自动优化,具体的例子可以看我下面的代码。由于我用的编译器gcc
默认的内嵌汇编语法是AT&T
,在编译时要添加-masm=intel
换成 nasm
的语法。
虽然支持内嵌汇编,但是我还是不想整个 C 语言内核部分的代码都是这种鬼东西。因此,我只在putchar
(向当前位置写一个字符,并移动光标到下一有效位置)、getch
(无回显地读取一个字符)中使用了内嵌汇编,其余的屏幕 IO 操作都通过调用这两个函数实现。
混合编译link.ld
第一次搞这种东西着实想了很久…下面直接结合 Makefile 代码来解释。
1
2
kernel.bin: kernel.o kernel.obj
ld -m elf_i386 -N --oformat binary -Ttext 0x7e00 $^ -o $@
用 ld 结合链接文件 link.ld,将上述两个.o 文件链接生成内核二进制文件 kernel.bin。
1
2
kernel.o: kernel.c
gcc -march=i386 -m16 -mpreferred-stack-boundary=2 -ffreestanding -fno-PIE -masm=intel -c $< -o $@
把内核 C 语言部分编译。-march=i386
使用 i386 指令集;-m16
生成 16 位代码;-mpreferred-stack-boundary=2
栈指针按照$2\times 2=4$字节对齐;-ffreestanding
使输出程序能够独立运行;PIE 能使程序像共享库一样在主存任何位置装载,这需要将程序编译成位置无关,并链接为 ELF 共享对象,这里用-fno-PIE
关掉;-masm=intel
使用nasm
格式的内联汇编语法。
1
2
kernel.obj : kernel.asm
nasm -felf $< -o $@
把内核汇编部分编译成 ELF 文件。
用户程序
在 C 语言的内核代码中,用户程序的表这样建的,用一个结构体保存用户程序名、大小、运行指令、调用地址。
1
2
3
4
5
6
7
8
9
10
#define PROGRAM_NUM 4
const struct Program
{
const char *name, *size, *command;
int address;
} prg[PROGRAM_NUM] =
{{"prg1", " 137 bytes", "exec 1", 1},
{"prg2", " 139 bytes", "exec 2", 2},
{"prg3", " 149 bytes", "exec 3", 3},
{"prg4", " 155 bytes", "exec 4", 4}};
这里实现了一个任务栈用于批量加载用户程序。当单独执行某一程序时,main 函数并不会直接执行任务,而是将待做任务压入一个栈中;每次循环调用 main 函数时会先检查任务栈有没有清空,如果未清空则优先弹栈并执行栈中的任务。这样做的优点是批处理无需单独拉出来考虑,接受任务序列时按照反顺序压栈即可。
1
2
3
static int task[MAX_BUF_LEN], task_size = 0;
if (task_size)
return _loadProgram(prg[task[--task_size]].address + PrgSectorOffset), main();
和上一个实验一样,用户程序调用int20
中断用于检测是否有Ctrl + C
并退出。不一样的是,这次是退回操作系统内核而非引导程序。
实验过程
实验代码
bootloader.asm
从键盘读取任意按键后进入操作系统内核。
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
%macro print 5 ; string, length, x, y, color
pusha
push ax
push bx
push cx
push dx
push bp
push ds
push es
mov ax, 0b800H
mov gs, ax
mov ax, cs
mov ds, ax
mov bp, %1
mov ax, ds
mov es, ax
mov cx, %2
mov ax, 1301H
mov dh, %3
mov dl, %4
mov bx, %5
int 10H
pop es
pop ds
pop bp
pop dx
pop cx
pop bx
pop ax
popa
%endmacro
bits 16
UserPrgOffset equ 7e00H
PrgSectorOffset equ 2
org 7c00H
start:
print msg, 30, 0, 0, 07H
mov ah, 0
int 16H
loadOS:
mov ax, 0
mov es, ax
mov bx, UserPrgOffset
mov ah, 2
mov al, 17
mov dl, 0
mov dh, 0
mov ch, 0
mov cl, PrgSectorOffset
int 13H
call UserPrgOffset
jmp start
datadef:
msg db 'Press any key to enter WuK-OS.'
kernel.asm
操作系统内核的汇编部分代码,提供int 20h
中断用于从用户程序中检测Ctrl + C
并返回main
函数,并提供_loadProgram
函数用于加载用户程序。
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
bits 16
extern main
global _loadProgram
UserPrgOffset equ 0A100h
_start:
mov ax, 0000h
mov es, ax
mov ax, 20h
mov bx, 4
mul bx
mov si, ax
mov ax, _int20h
mov [es:si], ax
add si, 2
mov ax, cs
mov [es:si], ax
call main
_end:
jmp $
_loadProgram:
push ebp
mov ebp, esp
mov ecx, [ebp+8]
mov ax, cs
mov es, ax
mov bx, UserPrgOffset
mov ah, 2
mov al, 1
mov dl, 0
mov dh, 1
mov ch, 0
int 13H
jmp UserPrgOffset
mov esp, ebp
pop ebp
ret
_int20h:
mov ah, 01h
int 16h
jz _noclick
mov ah, 00h
int 16h
cmp ax, 2e03h ; 检测 Ctrl + C
jne _noclick
jmp main
_noclick:
iret
kerner.c
操作系统内核 C 语言部分的代码。
这里遇到一个问题,所有的字符串常量都不能直接传进函数里去…在课程群里问了之后也有别的同学遇到了这个情况。调了快一天心力交猝之后选择向现实低头,把所有的字符串先存进一个变量里去。
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
#define PrgSectorOffset 0
#define SCREEN_WIDTH 80
#define SCREEN_HEIGHT 25
#define MAX_BUF_LEN (SCREEN_WIDTH * SCREEN_HEIGHT)
#define PROGRAM_NUM 4
extern void _loadProgram(int);
void putchar(char c)
{
static int curX = 0, curY = 0;
if (c != '\r' && c != '\n')
{
asm volatile(
"push es\n"
"mov es, ax\n"
"mov es:[bx],cx\n"
"pop es\n"
:
: "a"(0xB800), "b"((curX * 80 + curY) * 2), "c"((0x07 << 8) | c)
:);
if (++curY >= SCREEN_WIDTH)
{
if (++curX >= SCREEN_HEIGHT)
curX = 0;
curY = 0;
}
asm volatile(
"int 0x10\n"
:
: "a"(0x0200), "b"(0), "d"((curX << 8) | curY));
return;
}
do
putchar(' ');
while (curY);
}
char getch()
{
char ch;
asm volatile("int 0x16\n"
: "=a"(ch)
: "a"(0x1000));
return ch;
}
void gets(char *s)
{
for (;; ++s)
{
putchar(*s = getch());
if (*s == '\r' || *s == '\n')
break;
}
*s = '\0';
}
void printf(const char *s)
{
for (; *s; ++s)
putchar(*s);
}
int strcmp(const char *s1, const char *s2)
{
while (*s1 && (*s1 == *s2))
++s1, ++s2;
return (int)*s1 - (int)*s2;
}
void main()
{
const struct Program
{
const char *name, *size, *command;
int address;
} prg[PROGRAM_NUM] =
{{"prg1", " 137 bytes", "exec 1", 1},
{"prg2", " 139 bytes", "exec 2", 2},
{"prg3", " 149 bytes", "exec 3", 3},
{"prg4", " 155 bytes", "exec 4", 4}};
static int task[MAX_BUF_LEN], task_size = 0;
if (task_size)
return _loadProgram(prg[task[--task_size]].address + PrgSectorOffset), main();
char str[MAX_BUF_LEN];
putchar('$'), putchar(' '), gets(str);
const char
CLEAR_COM[] = "clear",
HELP_COM[] = "help",
EXEC_COM[] = "exec",
EXIT_COM[] = "exit",
LS_COM[] = "ls";
if (!strcmp(str, CLEAR_COM))
for (int i = 0; i < SCREEN_HEIGHT; ++i)
putchar('\n');
else if (!strcmp(str, HELP_COM))
{
const char
HELP_INFO[] =
"WuK-shell v0.0.1\n"
"These shell commands are defined internally.\n"
"\n"
"Command Description\n"
"clear -- Clean the screen\n"
"help -- Show this list\n"
"exec -- Execute all the user programs\n"
"exec [num] -- Execute the num-th program\n"
"exit -- Exit OS\n"
"ls -- Show existing programs\n";
printf(HELP_INFO);
}
else if (!strcmp(str, EXEC_COM))
for (int i = PROGRAM_NUM - 1; ~i; --i)
task[task_size++] = i;
else if (!strcmp(str, EXIT_COM))
return;
else if (!strcmp(str, LS_COM))
for (int i = 0; i < PROGRAM_NUM; ++i)
printf(prg[i].name), printf(prg[i].size), putchar('\n');
else
for (int i = 0;; ++i)
{
if (i == PROGRAM_NUM)
{
printf(str);
const char
COMM_NOT_FOUND[] =
" : command not found, type \'help\' for available commands list.\n";
printf(COMM_NOT_FOUND);
break;
}
if (!strcmp(str, prg[i].command))
task[task_size++] = i;
}
return main();
}
link.ld
将wukos.asm
和kernel.c
两个文件编译出来的内容连接起来。
OUTPUT_FORMAT("elf32-i386")
ENTRY(_start)
SECTIONS
{
. = 0x7E00;
.text ALIGN (0x00) : {
*(.text);
}
.rodata ALIGN (4) : {
*(.rodata*);
}
.data ALIGN (4) : {
*(.data*);
}
.bss ALIGN (4) : {
*(COMMON);
*(.bss*);
}
}
prg1.asm~prg4.asm
和上一个实验中的a.asm
~d.asm
完全相同,这里不再放出。
Makefile
这次文件太多,只好手动写 Makefile 了…
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
BIN = bootloader.bin kernel.bin prg1.bin prg2.bin prg3.bin prg4.bin
IMG = wukos.img
all: clear $(BIN) $(IMG)
clear:
rm -f $(BIN) $(IMG)
kernel.bin: kernel.obj kernel.o
ld -m elf_i386 -N --oformat binary -Ttext 0x7e00 $^ -o $@
kernel.o: kernel.c
gcc -march=i386 -m16 -mpreferred-stack-boundary=2 -ffreestanding -fno-PIE -masm=intel -c $< -o $@
kernel.obj : kernel.asm
nasm -felf $< -o $@
%.bin: %.asm
nasm -fbin $< -o $@
%.img:
/sbin/mkfs.msdos -C $@ 1440
dd if=bootloader.bin of=$@ conv=notrunc
dd if=kernel.bin of=$@ seek=1 conv=notrunc
dd if=prg1.bin of=$@ seek=18 conv=notrunc
dd if=prg2.bin of=$@ seek=19 conv=notrunc
dd if=prg3.bin of=$@ seek=20 conv=notrunc
dd if=prg4.bin of=$@ seek=21 conv=notrunc
运行结果
引导程序
如图,启动后进入引导程序。
按下键盘任意按键后进入下一步。
操作系统
第一次进入系统时并没有完全清除屏幕上的提示信息,原因是我不希望自己写的内核过多地控制屏幕显示内容;取而代之的是,用户可以手动输入clear
指令用于清除屏幕上的内容。
测试一些指令
这是依次键入一个空指令、一个show
(不支持的指令)、一个help
指令(用于提示所有可执行的命令)、一个ls
指令(用于显示所用户程序和对应信息)后的运行界面。
可以看到,我的内核完美的识别出了支持的指令并正确运行,对于空指令和不支持的指令也会有相应的提示。
批处理
由于运行单个程序和批处理的原理和实际效果是完全相同的,这里只放出运行批处理的过程了。
如图,这是运行exec
指令后打开的第一个程序。做上一个实验时并没有在运行时清空屏幕,因此可以看到我的姓名是直接叠加在输出界面上的。
依次按 Ctrl+C 退出这个程序并进入下一个程序。运行完所有四个用户程序后效果如图所示。
清屏
前面说了,我不希望自己写的内核过多地自己控制屏幕显示,而是给用户提供一条指令用于清屏。输入clear
指令后如图,瞬间舒服了。
退出
如图,输入exit
指令后退出系统内核,不再响应用户的输入。
实验总结
这次实验出来后,朋友圈一片对这个实验的吐槽。究其原因,可能有以下几点:
- 老师给的
tcc
+tasm
方案过于老旧,很难有参考价值 gcc
的编译参数很多,只好慢慢摸索出一个「看起来能用」的编译选项,内在机理还是没有完全搞懂(这也可能是没办法传字符串的原因之一)- 各种玄学错误,一下能运行一下又运行不了,莫名其妙的卡死,莫名其妙的执行用户程序
- 单步调试时发现 C 编译出来的汇编和自己想的有时候完全不同,尝试关闭编译器优化
-O0
但是没有效果
并且,虽然勉强做完了,但是还遗留下 C 内核程序没有办法直接向函数传递字符串常量的问题,希望可以在日后的学习过程中解决。