用汇编与C语言开发独立内核

实验题目

用汇编与 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.asmkernel.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 内核程序没有办法直接向函数传递字符串常量的问题,希望可以在日后的学习过程中解决。