文章目录
一.基础
1.准备工作
环境配置:
本文章使用Ubuntu 18.04.5 LTS开发
运行如下命令安装包
sudo apt-get install build-essential nasm genisoimage bochs bochs-sdl
2.编程语言
选择C作为开发语言,使用GCC编译器。本文默认读者具有C语言基础。
将使用C编程语言和GCC 开发操作系统。我们之所以使用C语言,是因为开发OS需要对生成的代码进行非常精确的控制并直接访问内存。也可以使用其他提供相同功能的语言,但本书仅涉及C。 该代码将使用一种特定于GCC的类型属性:
__attribute __((packed))
此属性使我们能够确保编译器完全按照我们在代码中定义的方式使用结构的内存布局。 由于具有此属性,因此示例代码可能难以使用GCC以外的C编译器进行编译。 在编写汇编代码时,我们选择NASM 作为汇编器。 Bash 用作脚本语言。
我们使用make来build,因此你需要对makefile有一定的了解。
3.环境选择
我们使用虚拟机来运行操作系统,因为这样比在物理机上更加的方便。
我们使用bochs。已经在1.包含在内。
4.Booting
上面的图片说明了control flow
引导加载程序
BIOS程序会将PC的控制权转移到称为引导加载程序的程序。引导加载程序的任务是将控制权转让给我们,操作系统开发人员和我们的代码。但是,由于硬件的某些限制1和向后兼容性,引导加载程序通常分为两部分:
引导加载程序的第一部分将控制权转移到第二部分,最后将控制权交给PC。
编写引导加载程序涉及编写许多与BIOS交互的底层代码。
因此,将使用现有的引导加载程序:GNU GRand统一引导加载程序(GRUB)。
使用GRUB,可以将操作系统构建为普通的ELF [18]可执行文件,然后由GRUB将其加载到正确的内存位置。
内核的编译要求以特定的方式将代码布置在内存中。
GRUB将通过跳转到内存中的某个位置来将控制权转移到操作系统。 在跳转之前,GRUB将寻找一个数字以确保它实际上是在跳转到操作系统,而不是一些随机代码。 这个数是GRUB遵循的多重引导规范的一部分。 一旦GRUB跳了起来,操作系统就可以完全控制计算机了。
操作系统的这一部分必须用汇编代码编写,因为C需要一个不可用的堆栈。将以下代码保存在一个名为loader.s的文件中:
global loader ; the entry symbol for ELF
MAGIC_NUMBER equ 0x1BADB002 ; define the magic number constant
FLAGS equ 0x0 ; multiboot flags
CHECKSUM equ -MAGIC_NUMBER ; calculate the checksum
; (magic number + checksum + flags should equal 0)
section .text: ; start of the text (code) section
align 4 ; the code must be 4 byte aligned
dd MAGIC_NUMBER ; write the magic number to the machine code,
dd FLAGS ; the flags,
dd CHECKSUM ; and the checksum
loader: ; the loader label (defined as entry point in linker script)
mov eax, 0xCAFEBABE ; place the number 0xCAFEBABE in the register eax
.loop:
jmp .loop ; loop forever
该操作系统唯一要做的就是将非常具体的数字0xCAFEBABE写入eax寄存器。如果操作系统没有将0xCAFEBABE号码放在eax寄存器中,则该可能性很小。
可以使用以下命令将loader.s文件编译为32位ELF目标文件:
nasm -f elf32 loader.s
5.链接内核
现在必须链接代码以生成可执行文件,与链接大多数程序相比,这需要一些额外的考虑。我们希望GRUB在大于或等于0x00100000(1兆字节(MB))的内存地址上加载内核,因为GRUB本身,BIOS和内存映射的I / O使用小于1 MB的地址。因此,需要以下链接程序脚本(为GNU LD编写):
ENTRY(loader) /* the name of the entry label */
SECTIONS {
. = 0x00100000; /* the code should be loaded at 1 MB */
.text ALIGN (0x1000) : /* align at 4 KB */
{
*(.text) /* all text sections from all files */
}
.rodata ALIGN (0x1000) : /* align at 4 KB */
{
*(.rodata*) /* all read-only data sections from all files */
}
.data ALIGN (0x1000) : /* align at 4 KB */
{
*(.data) /* all data sections from all files */
}
.bss ALIGN (0x1000) : /* align at 4 KB */
{
*(COMMON) /* all COMMON sections from all files */
*(.bss) /* all bss sections from all files */
}
}
将链接程序脚本保存到名为link.ld的文件中。
现在可以使用以下命令链接可执行文件:
ld -T link.ld -melf_i386 loader.o -o kernel.elf
最终的可执行文件将称为kernel.elf。
6.获取GRUB
我们将使用的GRUB版本是GRUB Legacy,因为可以在同时使用GRUB Legacy和GRUB 2的系统上生成OS ISO映像。更具体地说,将使用GRUB Legacy stage2_eltorito引导程序。
通过从ftp://alpha.gnu.org/gnu/grub/grub-0.97.tar.gz下载源代码,可以从GRUB 0.97构建此文件。但是,配置脚本无法在Ubuntu上正常运行,因此可以从http://littleosbook.github.com/files/stage2_eltorito下载二进制文件。将文件stage2_eltorito复制到已经包含loader.s和link.ld的文件夹中。
7.建立ISO映像
可执行文件必须放置在可以由虚拟机或物理机加载的介质上。在本书中,我们将使用ISO [22]映像文件作为介质,但也可以使用软盘映像,具体取决于虚拟机或物理机支持的内容。 我们将使用程序genisoimage创建内核ISO映像。首先必须创建一个文件夹,其中包含将在ISO映像上的文件。以下命令创建文件夹并将文件复制到正确的位置:
mkdir -p iso/boot/grub # create the folder structure
cp stage2_eltorito iso/boot/grub/ # copy the bootloader
cp kernel.elf iso/boot/ # copy the kernel
必须中目录iso/boot/grub/下创建一个配置文件 menu.lst
default=0
timeout=0
title os
kernel /boot/kernel.elf
文件层次结构如下:
iso
|-- boot
|-- grub
| |-- menu.lst
| |-- stage2_eltorito
|-- kernel.elf
使用如下命令生成ISO镜像:
genisoimage -R \
-b boot/grub/stage2_eltorito \
-no-emul-boot \
-boot-load-size 4 \
-A os \
-input-charset utf8 \
-quiet \
-boot-info-table \
-o os.iso \
iso
可以使用man genisoimage
查看参数含义
os.iso 就是生成的iso镜像文件。
8.启动Bochs
现在我们可以使用Bochs运行os.iso。Bochs需要配置文件俩启动。
bochsrc.txt
megs: 32
display_library: sdl
romimage: file=/usr/share/bochs/BIOS-bochs-latest
vgaromimage: file=/usr/share/bochs/VGABIOS-lgpl-latest
ata0-master: type=cdrom, path=os.iso, status=inserted
boot: cdrom
log: bochslog.txt
clock: sync=realtime, time0=local
cpu: count=1, ips=1000000
需要注意的是,你需要更改romimage和vgaromimage,根据你安装Bochs的位置。
有了配置文件,使用如下命令启动。
bochs -f bochsrc.txt -q
标志-f告诉Bochs使用给定的配置文件,标志-q告诉Bochs跳过交互式开始菜单。现在,您应该看到Bochs启动并显示带有GRUB信息的控制台。 退出Bochs后,显示Boch生成的日志:
========================================================================
Bochs x86 Emulator 2.6
Built from SVN snapshot on September 2nd, 2012
========================================================================
00000000000i[ ] LTDL_LIBRARY_PATH not set. using compile time default '/usr/lib/bochs/plugins'
00000000000i[ ] BXSHARE not set. using compile time default '/usr/share/bochs'
00000000000i[ ] lt_dlhandle is 0x5595e763fd40
00000000000i[PLGIN] loaded plugin libbx_unmapped.so
00000000000i[ ] lt_dlhandle is 0x5595e7640ae0
00000000000i[PLGIN] loaded plugin libbx_biosdev.so
00000000000i[ ] lt_dlhandle is 0x5595e76423f0
00000000000i[PLGIN] loaded plugin libbx_speaker.so
00000000000i[ ] lt_dlhandle is 0x5595e7642c40
00000000000i[PLGIN] loaded plugin libbx_extfpuirq.so
00000000000i[ ] lt_dlhandle is 0x5595e7643420
00000000000i[PLGIN] loaded plugin libbx_parallel.so
00000000000i[ ] lt_dlhandle is 0x5595e7645060
00000000000i[PLGIN] loaded plugin libbx_serial.so
00000000000i[ ] lt_dlhandle is 0x5595e7648e10
00000000000i[PLGIN] loaded plugin libbx_gameport.so
00000000000i[ ] lt_dlhandle is 0x5595e76496d0
00000000000i[PLGIN] loaded plugin libbx_iodebug.so
00000000000i[ ] reading configuration from bochsrc.txt
00000000000i[ ] lt_dlhandle is 0x5595e764a030
00000000000i[PLGIN] loaded plugin libbx_sdl.so
00000000000i[ ] installing sdl module as the Bochs GUI
00000000000i[ ] using log file bochslog.txt
Next at t=0
(0) [0x00000000fffffff0] f000:fff0 (unk. ctxt): jmp far f000:e05b ; ea5be000f0
<bochs:1> cont
在命令行中输入cont
ctrl-c 然后exit
cat bochslog.txt | grep 'EAX='
现在,您应该在输出中的某处看到由Bochs模拟的CPU寄存器的内容。如果在输出中发现RAX = 00000000CAFEBABE或EAX = CAFEBABE(取决于运行的Bochs是否支持64位),则说明您的操作系统已成功启动!
二.过渡到C
前言
本段,我将介绍使用C而不是汇编来编写操作系统。虽然汇编与CPU和寄存器的交互非常的高效。但是使用C对于我们来说更加友好。
1.建立一个Stack
使用C的一个先决条件是堆栈,因为所有non-trivial的C程序都使用堆栈。
设置堆栈并不比使esp寄存器指向正确对齐(建议从性能上建议4字节对齐)的空闲内存区域的末端(记住,堆栈向x86上的低地址增长)更难。
我们可以将esp指向内存中的随机区域,因为到目前为止,内存中唯一的东西是GRUB,BIOS,OS内核和某些内存映射的I / O。但这不是一个好主意-我们不知道有多少可用内存,或者esp指向的区域是否被其他东西使用。
一个更好的主意是在内核的ELF文件的bss节中保留一块未初始化的内存。最好使用bss部分而不是data部分来减小OS可执行文件的大小。
由于GRUB理解ELF,因此GRUB会在加载OS时分配bss节中保留的所有内存。 NASM伪指令resb可以 用于声明未初始化的数据:
KERNEL_STACK_SIZE equ 4096 ; size of stack in bytes
section .bss
align 4 ; align at 4 bytes
kernel_stack: ; label points to beginning of memory
resb KERNEL_STACK_SIZE ; reserve stack for the kernel
无需担心将未初始化的内存用于堆栈,因为不可能读取尚未写入的堆栈位置(无需手动指针摆弄)。
(正确的)程序无法在没有先将元素推入堆栈的情况下从堆栈中弹出元素。因此,堆栈的存储位置将始终在被读取之前被写入。 然后通过将esp指向kernel_stack存储器的末尾来设置堆栈指针:
mov esp, kernel_stack + KERNEL_STACK_SIZE ; point esp to the start of the
; stack (end of memory area)
2.在汇编代码中调用C代码
我们要做的下一步是call function from assembly code.
对于如何从汇编代码中调用C代码,有许多不同的约定[。这里使用了cdecl调用约定,因为这是GCC使用的约定。 cdecl调用约定指出函数的参数应通过堆栈(在x86上)传递。
该函数的参数应按从右到左的顺序推入堆栈,即,首先推入最右边的参数。函数的返回值放置在eax寄存器中。以下代码显示了一个示例:
/* The C function */
int sum_of_three(int arg1, int arg2, int arg3)
{
return arg1 + arg2 + arg3;
}
; The assembly code
external sum_of_three ; the function sum_of_three is defined elsewhere
push dword 3 ; arg3
push dword 2 ; arg2
push dword 1 ; arg1
call sum_of_three ; call the function, the result will be in eax
3.包装结构
你经常会遇到“配置字节”,这些字节是按特定顺序排列的位的集合。下面是一个32位的示例:
Bit: | 31 24 | 23 8 | 7 0 |
Content: | index | address | config |
比起来用一个unsigned int,处理这样的结构,我们更加推荐包装结构。
struct example {
unsigned char config; /* bit 0 - 7 */
unsigned short address; /* bit 8 - 23 */
unsigned char index; /* bit 24 - 31 */
};
在上一个例子中,我们不能保证这个结构的大小是32bit。因为编译器会做一些事。它会增加padding,以使其对齐。为了达到我们的目的,我们需要告诉编译器,这里不需要你插手。
struct example {
unsigned char config; /* bit 0 - 7 */
unsigned short address; /* bit 8 - 23 */
unsigned char index; /* bit 24 - 31 */
} __attribute__((packed));
4.编译C代码
当进行编译时,我们需要很多的标志参数。这是因为C代码不应假定存在标准库,因为我们的操作系统没有可用的标准库。有关标志的更多信息,请参见GCC手册。 用于编译C代码的标志是:
-m32 -nostdlib -nostdinc -fno-builtin -fno-stack-protector -nostartfiles
-nodefaultlibs
这个推荐加上,毕竟我们不能忽略warning。
-Wall -Wextra -Werror
5.build工具
这里我们使用make来编译和测试运行操作系统。
一个Makefile文件如下:
OBJECTS = loader.o kmain.o
CC = gcc
CFLAGS = -m32 -nostdlib -nostdinc -fno-builtin -fno-stack-protector \
-nostartfiles -nodefaultlibs -Wall -Wextra -Werror -c
LDFLAGS = -T link.ld -melf_i386
AS = nasm
ASFLAGS = -f elf
all: kernel.elf
kernel.elf: $(OBJECTS)
ld $(LDFLAGS) $(OBJECTS) -o kernel.elf
os.iso: kernel.elf
cp kernel.elf iso/boot/kernel.elf
genisoimage -R \
-b boot/grub/stage2_eltorito \
-no-emul-boot \
-boot-load-size 4 \
-A os \
-input-charset utf8 \
-quiet \
-boot-info-table \
-o os.iso \
iso
run: os.iso
bochs -f bochsrc.txt -q
%.o: %.c
$(CC) $(CFLAGS) $< -o $@
%.o: %.s
$(AS) $(ASFLAGS) $< -o $@
clean:
rm -rf *.o kernel.elf os.iso
这里我们在看下目录的结构
.
|-- bochsrc.txt
|-- iso
| |-- boot
| |-- grub
| |-- menu.lst
| |-- stage2_eltorito
|-- kmain.c
|-- loader.s
|-- Makefile
在写好kmain.c后我们可以使用make run。它将会编译内核,然后在bochs中启动。
三.输出
前言
在这里将会介绍如何在console中展示文本。然后编写我们的第一个driver(act as a layer between the kernel and the hardware)。
1.和硬件交互
与硬件进行交互的方式通常有两种,内存映射的I / O和I / O端口。
如果硬件使用内存映射的I / O,则可以写入特定的内存地址,并且硬件将使用新数据进行更新。帧缓冲区就是一个例子,稍后将对其进行详细讨论。
例如,如果将值0x410F写入地址0x000B8000,则会在黑色背景上看到白色的字母A(有关更多详细信息,请参见帧缓冲区部分)。
如果硬件使用I / O端口,则必须使用out和in的汇编代码指令与硬件进行通信。
out指令有两个参数:I / O端口的地址和要发送的数据。in指令采用单个参数,即I / O端口的地址,并从硬件返回数据。
可以将I / O端口视为与硬件进行通信的方式与使用套接字与服务器进行通信的方式相同。光标(闪烁的矩形) 帧缓冲区是通过PC上的I / O端口控制的硬件的一个示例。
2.帧缓冲区
帧缓冲区是一种硬件设备,能够在屏幕上显示内存缓冲区。帧缓冲区有80列和25行,行和列索引从0开始(因此行被标记为0-24)。
2.1写入文本
通过帧缓冲区将文本写入控制台是通过内存映射的I / O完成的。
帧缓冲区的内存映射I / O的起始地址为0x000B8000 。
存储器分为16位单元,其中16位确定字符,前景色和背景色。最高的八位是字符的ASCII 值,位7-4是背景,位3-0是前景,
如下图所示:
Bit: | 15 14 13 12 11 10 9 8 | 7 6 5 4 | 3 2 1 0 |
Content: | ASCII | FG | BG |
Color Value Color Value Color Value Color Value
Black 0 Red 4 Dark grey 8 Light red 12
Blue 1 Magenta 5 Light blue 9 Light magenta 13
Green 2 Brown 6 Light green 10 Light brown 14
Cyan 3 Light grey 7 Light cyan 11 White 15
第一个单元格对应于控制台上的第零行,第零列。使用ASCII表,可以看到A对应于65或0x41。因此,要在位置(0,0)处写入具有绿色前景(2)和深灰色背景(8)的字符A,请使用以下汇编代码指令:
mov [0x000B8000], 0x4128
然后,第二个单元格对应于第零行第一列,因此其地址为:
0x000B8000 + 16 = 0x000B8010
通过将地址0x000B8000视为char指针char * fb =(char *)0x000B8000,也可以在C语言中完成对帧缓冲区的写入。然后,将A写入具有绿色前景和深灰色背景的位置(0,0)变为:
fb[0] = 'A';
fb[1] = 0x28;
下面展示来如何应用其到函数中:
/** fb_write_cell:
* Writes a character with the given foreground and background to position i
* in the framebuffer.
*
* @param i The location in the framebuffer
* @param c The character
* @param fg The foreground color
* @param bg The background color
*/
void fb_write_cell(unsigned int i, char c, unsigned char fg, unsigned char bg)
{
fb[i] = c;
fb[i + 1] = ((fg & 0x0F) << 4) | (bg & 0x0F)
}
之后,可以这样用:
#define FB_GREEN 2
#define FB_DARK_GREY 8
fb_write_cell(0, 'A', FB_GREEN, FB_DARK_GREY);
2.2移动光标
移动帧缓冲区的光标是通过两个不同的I / O端口完成的。光标的位置由16位整数确定:
0表示行0,列0; 1表示行0,列1; 80表示一行,零列,依此类推。由于位置为16位大,而汇编代码指令自变量为8位,因此必须分两圈发送该位置,首先发送8位,然后发送接下来的8位。
帧缓冲器有两个I / O端口,一个用于接收数据,另一个用于描述接收的数据。
端口0x3D4 是用于描述数据的端口,
而端口0x3D5 用于数据本身。
要将光标设置在第一行第零列(位置80 = 0x0050),将使用以下汇编代码指令:
out 0x3D4, 14 ; 14 tells the framebuffer to expect the highest 8 bits of the position
out 0x3D5, 0x00 ; sending the highest 8 bits of 0x0050
out 0x3D4, 15 ; 15 tells the framebuffer to expect the lowest 8 bits of the position
out 0x3D5, 0x50 ; sending the lowest 8 bits of 0x0050
不能在C中直接执行out汇编代码指令。因此,最好将一个汇编代码包装在一个函数中,该函数可以通过cdecl调用standar从C中进行访问
global outb ; make the label outb visible outside this file
; outb - send a byte to an I/O port
; stack: [esp + 8] the data byte
; [esp + 4] the I/O port
; [esp ] return address
outb:
mov al, [esp + 8] ; move the data to be sent into the al register
mov dx, [esp + 4] ; move the address of the I/O port into the dx register
out dx, al ; send the data to the I/O port
ret ; return to the calling function
通过将此函数存储在名为io.s的文件中并创建头文件io.h,可以从C方便地访问out汇编代码指令:
#ifndef INCLUDE_IO_H
#define INCLUDE_IO_H
/** outb:
* Sends the given data to the given I/O port. Defined in io.s
*
* @param port The I/O port to send the data to
* @param data The data to send to the I/O port
*/
void outb(unsigned short port, unsigned char data);
#endif /* INCLUDE_IO_H */
应用:
#include "io.h"
/* The I/O ports */
#define FB_COMMAND_PORT 0x3D4
#define FB_DATA_PORT 0x3D5
/* The I/O port commands */
#define FB_HIGH_BYTE_COMMAND 14
#define FB_LOW_BYTE_COMMAND 15
/** fb_move_cursor:
* Moves the cursor of the framebuffer to the given position
*
* @param pos The new position of the cursor
*/
void fb_move_cursor(unsigned short pos)
{
outb(FB_COMMAND_PORT, FB_HIGH_BYTE_COMMAND);
outb(FB_DATA_PORT, ((pos >> 8) & 0x00FF));
outb(FB_COMMAND_PORT, FB_LOW_BYTE_COMMAND);
outb(FB_DATA_PORT, pos & 0x00FF);
}
2.3Driver
driver应该提供一个接口使OS中的其它代码能和framebuffer交互。
接口应提供的功能没有对与错,但是建议使用带有以下声明的write函数:
int write(char *buf, unsigned int len);
write funciton将长度为len的缓冲区buf的内容写入屏幕。在写入字符后,写入功能应自动使光标前进,并在必要时滚动屏幕。
3.串口
串行端口是用于在硬件设备之间进行通信的接口,尽管几乎在所有主板上都可用,但如今它很少以DE-9连接器的形式暴露给用户。串行端口易于使用,更重要的是,它可以用作Bochs中的日志记录实用程序。
如果计算机支持一个串行端口,那么它通常支持多个串行端口,但是我们将仅使用其中一个端口。
这是因为我们将仅使用串行端口进行日志记录。此外,我们仅将串行端口用于输出,而不用于输入。
串行端口通过I / O端口完全控制。
3.1配置串口
需要发送到串行端口的第一个数据是配置数据。为了使两个硬件设备能够互相通信,它们必须在几件事上达成共识。这些东西包括: 用于发送数据的速度(位或波特率) 如果应该对数据使用任何错误检查(奇偶校验位,停止位) 表示数据单位的位数(数据位)
3.2配置线路
配置线路意味着配置如何通过线路发送数据。串行端口有一个I / O端口,即线路命令端口,用于配置。 首先,将设置发送数据的速度。串行端口具有一个内部时钟,其运行频率为115200 Hz。设置速度意味着将除数发送到串行端口,例如发送2将导致115200/2 = 57600 Hz的速度。 除数是16位数字,但我们一次只能发送8位。因此,我们必须发送一条指令告知串口 首先要期望最高的8位,然后是最低的8位。 这是通过将0x80发送到线路命令端口来完成的。 一个例子如下所示:
#include "io.h" /* io.h is implement in the section "Moving the cursor" */
/* The I/O ports */
/* All the I/O ports are calculated relative to the data port. This is because
* all serial ports (COM1, COM2, COM3, COM4) have their ports in the same
* order, but they start at different values.
*/
#define SERIAL_COM1_BASE 0x3F8 /* COM1 base port */
#define SERIAL_DATA_PORT(base) (base)
#define SERIAL_FIFO_COMMAND_PORT(base) (base + 2)
#define SERIAL_LINE_COMMAND_PORT(base) (base + 3)
#define SERIAL_MODEM_COMMAND_PORT(base) (base + 4)
#define SERIAL_LINE_STATUS_PORT(base) (base + 5)
/* The I/O port commands */
/* SERIAL_LINE_ENABLE_DLAB:
* Tells the serial port to expect first the highest 8 bits on the data port,
* then the lowest 8 bits will follow
*/
#define SERIAL_LINE_ENABLE_DLAB 0x80
/** serial_configure_baud_rate:
* Sets the speed of the data being sent. The default speed of a serial
* port is 115200 bits/s. The argument is a divisor of that number, hence
* the resulting speed becomes (115200 / divisor) bits/s.
*
* @param com The COM port to configure
* @param divisor The divisor
*/
void serial_configure_baud_rate(unsigned short com, unsigned short divisor)
{
outb(SERIAL_LINE_COMMAND_PORT(com),
SERIAL_LINE_ENABLE_DLAB);
outb(SERIAL_DATA_PORT(com),
(divisor >> 8) & 0x00FF);
outb(SERIAL_DATA_PORT(com),
divisor & 0x00FF);
}
必须配置发送数据的方式。也可以通过发送一个字节通过线路命令端口来完成。 8位的布局如下所示:
Bit: | 7 | 6 | 5 4 3 | 2 | 1 0 |
Content: | d | b | prty | s | dl |
我们将使用大部分为标准值0x03 ,这意味着长度为8位,无奇偶校验位,1个停止位和禁用中断控制。如以下示例所示,它被发送到线路命令端口:
/** serial_configure_line:
* Configures the line of the given serial port. The port is set to have a
* data length of 8 bits, no parity bits, one stop bit and break control
* disabled.
*
* @param com The serial port to configure
*/
void serial_configure_line(unsigned short com)
{
/* Bit: | 7 | 6 | 5 4 3 | 2 | 1 0 |
* Content: | d | b | prty | s | dl |
* Value: | 0 | 0 | 0 0 0 | 0 | 1 1 | = 0x03
*/
outb(SERIAL_LINE_COMMAND_PORT(com), 0x03);
}
3.3配置缓存区
当通过串行端口传输数据时,无论是在接收还是发送数据时,数据都会被放置在缓冲区中。这样,如果您将数据发送到串行端口的速度快于通过有线网络发送数据的速度,则它将被缓冲。但是,如果您发送太多数据太快,则缓冲区将已满,并且数据将丢失。换句话说,缓冲区是FIFO队列。 FIFO队列配置字节如下图所示:
我们使用0xC7=11000111:
启用FIFO
清除接收和发送FIFO队列
使用14个字节作为队列大小
3.4配置调制解调器
我们不需要启动interrupts,因为我们目前不处理接受的数据。
因此我们使用 0x03 = 00000011 (RTS=1 and DTS =1).
3.5向串口中写入数据
通过数据I / O端口将数据写入串行端口。但是,在写入之前,发送FIFO队列必须为空(必须完成所有先前的写入)。
如果线路状态I / O端口的位5等于1,则发送FIFO队列为空。 通过in汇编代码指令读取I / O端口的内容。
无法使用C中的in汇编代码指令,因此必须将其包装(与out汇编代码指令相同):
global inb
; inb - returns a byte from the given I/O port
; stack: [esp + 4] The address of the I/O port
; [esp ] The return address
inb:
mov dx, [esp + 4] ; move the address of the I/O port to the dx register
in al, dx ; read a byte from the I/O port and store it in the al register
ret ; return the read byte
修改io.h
/* in file io.h */
/** inb:
* Read a byte from an I/O port.
*
* @param port The address of the I/O port
* @return The read byte
*/
unsigned char inb(unsigned short port);
3.6配置Bochs
要保存第一个串行串口的输出,必须更新Bochs配置文件bochsrc.txt。
com1配置指示Bochs如何处理第一个串行端口:
com1:enabled = 1,mode = file,dev = com1.out
串行端口1的输出现在将存储在文件com1.out中。
四.分段
x86中的分段意味着通过分段访问内存。段是地址空间的一部分,可能重叠,由基地址和限制指定。要寻址分段存储器中的字节,请使用48位逻辑地址:16位用于指定段,而32位用于指定所需段内的偏移量。将偏移量添加到段的基地址,然后根据段的限制检查所得的线性地址-参见下图。如果一切正常(包括暂时忽略的访问权限检查),则结果为线性地址。禁用分页时,线性地址空间将1:1映射到物理地址空间,并且可以访问物理内存。
要启用分段,您需要设置一个描述每个分段的表-分段描述符表。在x86中,有两种类型的描述符表:全局描述符表(GDT)和本地描述符表(LDT)。 LDT由用户空间进程设置和管理,并且所有进程都有自己的LDT。如果需要更复杂的细分模型,则可以使用LDT-我们不会使用它。 GDT由所有人共享-它是全局性的。
1.访问内存
在大多数情况下,访问内存时无需显式指定要使用的段。处理器具有六个16位段寄存器:cs,ss,ds,es,gs和fs。寄存器cs是代码段寄存器,并指定在获取指令时要使用的段。每次访问堆栈时(通过堆栈指针esp)都使用寄存器ss,而ds用于其他数据访问。操作系统可以随意使用寄存器es,gs和fs。 下面的示例显示了段寄存器的隐式使用:
func:
mov eax, [esp+4]
mov ebx, [eax]
add ebx, 8
mov [eax], ebx
ret
可以将上面的示例与以下示例进行比较,以下示例明确使用了段寄存器:
func:
mov eax, [ss:esp+4]
mov ebx, [ds:eax]
add ebx, 8
mov [ds:eax], ebx
ret
您不需要使用ss来存储堆栈段选择器,而使用ds来存储数据段选择器。您可以将堆栈段选择器存储在ds中,反之亦然。但是,为了使用上面显示的隐式样式,必须将段选择器存储在它们的缩进寄存器中。
2.GDT
GDT / LDT是8字节段描述符的数组。
GDT中的第一个描述符始终是一个空描述符,并且永远不能用于访问内存。
GDT至少需要两个段描述符(加上空描述符),因为该描述符不仅包含基本字段和限制字段,还包含更多信息。
与我们最相关的两个字段是“类型”字段和“描述符特权级别”(DPL)字段。
表格显示,“类型”字段不能同时可写和可执行。因此,需要两个段:
一个段用于执行要放入cs的代码(类型是Execute-only或Execute-Read),
一个段用于读写数据(类型是Read / Write)以放入另一个段寄存器。
DPL指定使用该段所需的特权级别。
x86允许四个特权级别(PL),从0到3,其中PL0是最高特权。多数情况 操作系统(例如Linux和Windows),仅使用PL0和PL3。
但是,某些操作系统(例如MINIX)会使用所有级别。 内核应该能够执行任何操作,因此它使用DPL设置为0(也称为内核模式)的段。 当前特权级别(CPL)由CS中的段选择器确定。 下表描述了所需的段。
请注意,这些段重叠-它们都包含整个线性地址空间。在我们的最小设置中,我们将仅使用细分来获取权限级别。
3.加载GDT
使用lgdt汇编指令。询问指定GDT的开始和大小的结构的地址。如以下示例所示,最简单的方法是使用“压缩的结构”对这些信息进行编码:
struct gdt {
unsigned int address;
unsigned short size;
} __attribute__((packed));
如果eax寄存器的内容是该结构的地址,则可以将GDT加载如下所示的汇编代码:
lgdt [eax]
如果使该指令可从C获得,则可能与输入和输出汇编代码指令的方式相同,这可能会更容易。 加载GDT后,需要使用相应的段选择器加载段寄存器。下图和表描述了段选择器的内容:
段选择器的偏移量被添加到GDT的开头,以获取段描述符的地址:第一个描述符为0x08,第二个描述符为0x10,因为每个描述符为8个字节。由于操作系统的内核应以特权级别0执行,因此请求的特权级别(RPL)应该为0。 对于数据寄存器而言,加载段选择器寄存器很容易-只需将正确的偏移量复制到寄存器即可:
mov ds, 0x10
mov ss, 0x10
mov es, 0x10
.
.
.
要加载cs,我们必须做一个“far jump”:
; code here uses the previous cs
jmp 0x08:flush_cs ; specify cs when jumping to flush_cs
flush_cs:
; now we've changed cs to 0x08
far jump是指我们明确指定完整的48位逻辑地址的跳转:要使用的段选择器和要跳转到的绝对地址。它将首先将cs设置为0x08,然后使用其绝对地址跳转到flush_cs。
五.中断和输入
现在,操作系统可以产生输出了,如果它也可以得到一些输入,那就太好了。 (操作系统必须能够处理中断,以便从键盘读取信息)。当诸如键盘,串行端口或计时器之类的硬件设备向CPU发出信号,通知该设备状态已更改时,就会发生中断。由于程序错误,CPU本身也可以发送中断,例如,当程序引用了它无权访问的内存时,或者当程序将数字除以零时。最后,还有软件中断,它们是由int汇编代码指令引起的中断,通常用于系统调用。
1.中断处理程序
中断由IDT处理。IDT为每一个中断描述了一个处理程序。中断被编号为0~255, 第i个中断的处理程序被定义在IDT表中的第i个位置,有三种不同的处理中断的程序:
Task handler 任务处理程序
Interrupt handler 中断处理程序
Trap handler 陷阱处理程序
任务处理程序使用特定于Intel x86版本的功能,因此此处不介绍它们(有关更多信息,请参阅Intel手册[33],第6章)。中断处理程序和陷阱处理程序之间的唯一区别是,中断处理程序禁用了中断,这意味着您在处理中断的同时无法获得中断。在本书中,我们将使用陷阱处理程序并在需要时手动禁用中断。
2.在IDT中创建条目
offset 是一个指向代码的指针(特别是汇编语言程序)。
例如,为一个开始与0xDEADBEEF的处理程序创建一个条目,并且以0优先级运行。
0xDEAD8E00
0x0008BEEF
are used。
如果IDT被表示unsigned integer idt[512] ,并且注册上面的例子作为处理中断0的处理程序。
idt[0] = 0xDEAD8E00
idt[1]= 0x0008BEEF