post on 28 Mar 2019 about 8906words require 30min
CC BY 4.0 (除特别声明或转载文章外)
如果这篇博客帮助到你,可以请我喝一杯咖啡~
用汇编与 C 语言开发独立内核
sudo apt install nasm
安装在 WSL 上。大部分开发环境安装在 WSL 上,较之于双系统、虚拟机等其他开发方案,更加方便,也方便直接使用 Linux 下的一些指令。
所用机器型号为 VAIO Z Flip 2016
这次实验完成内容的结构大致如下:
flowchart TB
引导程序-->main
用户==交互==>C语言函数
subgraph 汇编与C语言混合编译的内核
main-.外部调用.-> 汇编函数
main.-> C语言函数
C语言函数-.内嵌.->汇编函数
end
subgraph 用户程序
汇编函数--> prg1
汇编函数--> prg2
汇编函数--> prg3
汇编函数--> prg4
end
在上一个实验里已经说得很清楚了,这里就不再提了,可以直接看代码。
这里的内核指的是汇编代码kernel.asm
和 C 语言代码kernel.c
混合编译而成的内核文件kernel.bin
。
kernel.asm
内核的第一部分是汇编程序,设置软件中断,声明一个全局的_loadProgram
函数用于加载用户程序,然后直接调用 C 程序中的main
函数链接到 C 程序入口点。
在汇编程序中调用 C 函数比较简单,在程序段头部添加extern
标识符,然后在程序中直接call
即可。
这里由于不太想学古董级的tasm
,使用nasm
替代之。
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
并退出。不一样的是,这次是退回操作系统内核而非引导程序。
从键盘读取任意按键后进入操作系统内核。
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.'
操作系统内核的汇编部分代码,提供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
操作系统内核 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();
}
将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*);
}
}
和上一个实验中的a.asm
~d.asm
完全相同,这里不再放出。
这次文件太多,只好手动写 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
的编译参数很多,只好慢慢摸索出一个「看起来能用」的编译选项,内在机理还是没有完全搞懂(这也可能是没办法传字符串的原因之一)-O0
但是没有效果并且,虽然勉强做完了,但是还遗留下 C 内核程序没有办法直接向函数传递字符串常量的问题,希望可以在日后的学习过程中解决。
Related posts