如果这篇博客帮助到你,可以请我喝一杯咖啡~
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 代码来解释。
kernel.bin: kernel.o kernel.obj
ld -m elf_i386 -N --oformat binary -Ttext 0x7e00 $^ -o $@
用 ld 结合链接文件 link.ld,将上述两个.o 文件链接生成内核二进制文件 kernel.bin。
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
格式的内联汇编语法。
kernel.obj : kernel.asm
nasm -felf $< -o $@
把内核汇编部分编译成 ELF 文件。
用户程序
在 C 语言的内核代码中,用户程序的表这样建的,用一个结构体保存用户程序名、大小、运行指令、调用地址。
#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 函数时会先检查任务栈有没有清空,如果未清空则优先弹栈并执行栈中的任务。这样做的优点是批处理无需单独拉出来考虑,接受任务序列时按照反顺序压栈即可。
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
从键盘读取任意按键后进入操作系统内核。
%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
函数用于加载用户程序。
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 语言部分的代码。
这里遇到一个问题,所有的字符串常量都不能直接传进函数里去…在课程群里问了之后也有别的同学遇到了这个情况。调了快一天心力交猝之后选择向现实低头,把所有的字符串先存进一个变量里去。
#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 了…
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 内核程序没有办法直接向函数传递字符串常量的问题,希望可以在日后的学习过程中解决。