介绍
当我开始阅读 Lion's Book (A Commentary on the UNIX Operating System) 时,有一件事很快引起了我的注意,那里给出了一些非常常见的函数的实际实现,例如 putc,它们直接与 CPU(一些基于 PDP 的小型计算机)通信。由于我总是喜欢边读书边做事情,我开始想知道,我究竟如何编写自己的 putc 版本(或任何其他函数),让我在 CPU 上运行任意命令。答案很简单,简单明了。
在花了很多时间阅读和弄清楚事情之后,我很惊讶互联网上有多少内容可用,这不是专有知识,每个人显然都建立了一个)。我在许多现有文章中发现的问题是,这些文章中的大多数都有点过时,或者它们遗漏了一些基本的东西。这激发了我写这篇文章的灵感。虽然它不像其他一些教程那样详尽无遗,但它旨在解决内核开发中一些最基本的事情(并提供有用的参考)。
在本文的最后,读者应该对引导内核有一些了解,以及在开始构建 DIY 内核之前需要考虑哪些事项。
linux程序设计与安全开发
-
恶意软件开发
-
-
-
windows网络安全防火墙与虚拟网卡(更新完成)
-
-
windows文件过滤(更新完成)
-
-
USB过滤(更新完成)
-
-
游戏安全(更新中)
-
-
ios逆向
-
-
windbg
-
-
还有很多免费教程(限学员)
-
-
-
更多详细内容添加作者微信
-
-
先决条件
本文中的所有代码都在 Ubuntu 22.04 上进行了测试,但只要您可以访问以下软件包,就可以随意使用您选择的任何发行版:
-
build-essential(适用于 gcc 和相关库)
-
qemu-system(用于仿真 i386 系统)
-
nasm(首选汇编程序)
-
mtools(它用于操作 MS-DOS 文件,nasm 显然出于某种原因需要它)
-
gcc-multilib(需要在 64 位主机上交叉编译 32 位代码)
-
VIM/GIT/VSCODE(收藏夹编辑器、版本控制工具等)
除此之外,还有很多阅读:)
让我们开始吧!
简单的程序
让我们在汇编中编写一个简单的代码作为 test.asm
jmp 0x1234
我们对运行不太感兴趣,而是逐个分析它,因此如此简单。
编译它
nasm -f bin test.asm
-f bin 指定输出为原始二进制文件
该格式不会生成目标文件:除了您编写的代码外,它不会在输出文件中生成任何内容。
bin
我们想要看到原始二进制文件的原因是,当我们编写代码时,我们确切地知道我们得到的输出是什么。没有正常程序附带的外观或运行时环境。
让我们看一下输出的十六进制转储
hd test
我们可以看到三个字节 e9 31 12,从地址 0 开始
查找英特尔文档,我们可以看到 E9 是 JMP 的操作码(特别是 E9 cw 部分,因为我们没有指定 BITS,因此它的构建代码为 16 位代码)。
现在,让我们尝试构建 32 位代码
BITS 32
add eax, ebx
十六进制转储的结果
请注意,它只需要两个字节。第一个字节是操作码
d8 在这里代表什么?让我们查一下架构格式
我们已经知道 01 是 add 的操作码,d8 必须是 ModR/M。
ModR/M:许多引用内存中操作数的指令在主操作码后面都有一个寻址形式说明符字节(称为 ModR/M
字节)。ModR/M 字节包含三个信息字段:
• mod 字段与 r/m 字段组合形成 32 个可能的值:8 个寄存器和 24 个寻址模式。
• reg/opcode 字段指定寄存器编号或另外三位操作码信息。reg/opcode 字段的用途
在主操作码中指定。
• r/m 字段可以将寄存器指定为操作数,也可以与 mod 字段组合以对寻址模式进行编码。有时,mod 字段和 r/m 字段的某些组合用于表示
某些指令的操作码信息。
下面给出了 ModR/M 的查找表。
在上面的图表中,如果我们看一下 d8,我们可以看到 row 是 eax。列提供另一个操作数(如果有)。在这里,我们可以从列中看到下一个操作数是 ebx。
同样,如果我们有如下代码:
BITS 32
sub eax, ebx
十六进制转储将包含与 modR/M 相同的 d8,并带有 SUB 的操作码。
让我们尝试一些更复杂的东西
BITS 32
jmp [eax*2 + ebx]
ff 是 jump 的操作码,24 像往常一样是 modR/M 字节。再次使用 ModR/M Byte 表查找 32 位寻址表单 24。
行显示 [ — ][ — ],这意味着 SIB 遵循 ModR/M 字节
SIB 字节为 43,其中行显示为 EAX*2,列显示为 EBX。
除此之外,我们还可以看到十六进制转储,有时会派上用场。
nasm -l/dev/stdout test.asm
在这一点上,您必须知道如何查找十六进制转储指令并参考英特尔手册以了解特定指令的用途(以及它是否与我们的预期相同)。它可能在顶部看到,在调试一些非常微妙的错误时可以派上用场。
ELF 格式
存在一种称为 ELF 的可执行格式,它在基于 UNIX /启发的系统中非常流行。了解 ELF 很重要,因为它允许我们定义程序的入口点、存储空间和其他各种东西
从技术上讲,ELF是在编译和链接后将程序(或程序的一部分)存储在磁盘中的格式。ELF文件分为多个部分,最重要的是:
-
。发短信:
本部分包含程序的代码。
2. .数据
用于存储全局变量的数据部分。
局部(自动)变量存储在堆栈中。
3. .rodata 域名
只读数据存储在此处,例如字符串
4. BSS的
未初始化的数据(未初始化的变量和数组等)
调试和其他目的的其他部分。
有关这方面的更多信息,请参见 OSDev 中的 ELF 文章。本文稍后将详细讨论这些部分。
ELF在行动
已经看到了ELF格式的基本结构。让我们看看它是如何付诸实践的。
考虑以下(有点奇怪)程序:
int add(int a, int b)
{
return a+b;
}
int main(int argc, char* argv)
{
char* s="Hello World!";
int sum;
int list[]={1,2,3};
return 0;
}
让我们编译并链接它:
gcc -m32 hello.c -o hello
现在让我们来检查一下:
readelf -hW hello
上图显示了 ELF 文件文件头中的条目(readelf 中的 -h 选项)。第一个标头是魔术字节,用于识别文件是否符合 ELF 标准。
由于我们的程序在 GNU/Linux 系统上是作为 32 位程序编译的,因此它显示了该程序所期望的必填字段(更多内容见 ELF)。
按照标准,ELF文件如下所示:
我们刚刚看到的是文件的第一部分,ELF 标头。在程序上做十六进制转储,我们得到
这与 ELF 文件布局一致,即 ELF 可执行文件/对象文件应以 ELF 标头开头。
第一行中的幻数,即 7f 45 4c 46,即前四个字节将文件标识为 ELF 文件。
7楼 45 4C 46 01 01 01 01 00 00 00 00 00 00 00 00 00
下一个字节标识计算机大小等
所有这些都在 ELF 规范中给出
在内部,ELF 文件头基于以下结构:
这里没有太多有趣的东西(除了一些明显的信息),但主要的兴趣点是e_entry,它定义了我们程序的切入点。
e_entry:此成员提供虚拟地址,系统首先将控制权传输到该地址,从而启动进程。如果文件没有关联的输入点,则此成员保持零。
在上面的程序中,入口点地址是0x1070。问题来了,我们在这个地址有什么指示?为了知道这一点,让我们生成 hello.c 的汇编代码。
objdump -S hello
打开相应生成的hello.s,查看地址0x1070
这不是我们的主要内容,这是所谓的_start。不赘述太多细节(请参阅 osdev 条目),但所做的是初始化 main 的运行时环境。这包括堆栈初始化、一些设置和调用 exit(main(argc, argv))。所有这些东西都来自一个叫做crt0.o的东西,它是包含c运行时初始化的内联汇编代码。知道这一点很重要,因为当我们推出内核时,我们需要类似的设置才能使我们的 C 代码正常工作。
接下来,让我们查看程序标题。
readelf -lW hello
程序头是结构数组,它描述了系统需要准备执行程序的段或信息。
PHDR 提供程序头信息。INTERP 包含解释器的路径名(此处为 /lib/ld-linux.so.2),每当该程序加载时,系统都会从解释器的文件段创建初始进程映像,这意味着系统不是使用原始的可执行文件段映像,而是为解释器编写内存映像,然后为我们的程序提供运行时环境。
LOAD 包含基本上可加载的元素(这些元素被加载到内存中,请注意 PHDR 和 INTERP 不是我们想要的内存)。
可以通过以下方式查看部分标题
readelf -SW hello
部分包含用于链接的所有信息。重要的部分是
.bss 此部分包含未初始化的数据,这些数据有助于
程序的内存映像。根据定义,当程序开始
运行时,系统
用零初始化数据。此部分的类型为 SHT_NOBITS。属性
类型为 SHF_ALLOC 和 SHF_WRITE。。数据此部分包含有助于
程序内存映像的初始化数据。此部分属于 SHT_PROGBITS 类型
。属性类型为 SHF_ALLOC 和
SHF_WRITE。.rodata 域名此部分包含只读数据,这些数据通常
有助于流程映像中的不可写段。
此部分属于 SHT_PROGBITS 类型。使用的
属性是 SHF_ALLOC。。发短信此部分包含程序
的“文本”或可执行指令。此部分属于 SHT_PROGBITS 类型。使用的
属性是 SHF_ALLOC 和 SHF_EXECINSTR。
在执行此操作之前,让我们查看一些部分,将程序(带有源代码符号)重新编译为
gcc -m32 -g hello.c -o hello
让我们看一下汇编代码
objdump -S hello
查看 .text 部分
可以看出,它始于0x1070
添加功能如下图所示
主要功能
.rodata 部分,其中包含字符串“hello world”
在这一点上,我们有一些想法,哪个部分做了什么,以及如何将它们映射到流程图像中。
编写自己的 DIY 引导加载程序
从这里开始,实际的内核编写内容。我们将从编写引导加载程序开始,以了解内核是如何引导的。
引导加载程序是需要执行以下功能的软件:
-
为内核创建运行时环境。
-
将内核加载到内存中。
-
将程序控制流传输到内核。
在开始引导加载程序之前,让我们先了解引导过程。
启动过程包括以下步骤:
-
按计算上的电源按钮(不要说)。
-
主板初始化固件(芯片组等)并使 CPU 运行。
-
在多处理器系统中,一个 CPU 被动态选择为运行所有 BIOS 和内核初始化代码的引导处理器 (BSP)。其余的处理器(此时称为应用程序处理器 (AP))将保持停止状态,直到稍后它们被内核显式激活。
-
在这种原始的上电状态下,处理器处于实模式,内存分页禁用(所有冰雹 8086!
-
CPU中的大多数寄存器在上电后都具有明确定义的值,EIP保持0xFFFFFFF0->复位向量。
-
主板确保复位向量处的指令跳转到映射到 BIOS 入口点的内存位置。
-
然后,CPU 开始执行 BIOS 代码,从而初始化计算机中的某些硬件。
-
之后,BIOS 启动开机自检 (POST),测试计算机中的各种组件。
-
开机自检涉及测试和初始化的混合,包括整理PCI设备的所有资源,包括中断、内存范围、I/O端口。
-
开机自检后,BIOS想要启动一个操作系统,该操作系统必须在某处找到:硬盘驱动器,CD-ROM驱动器,软盘等。
-
BIOS 现在读取磁盘映像的第一个 512 字节扇区(扇区零),如果它包含幻数 (0x55AA)。这称为主引导记录。
-
BIOS 将 MBR 的内容加载到内存位置0x7c00,然后跳转到该位置以开始执行 MBR 中的任何代码。
因此,为了编写我们的引导加载程序,我们需要在 0x7c00 处添加代码,并在 510 和 511 字节偏移量(最后两个字节)处添加幻数。
让我们创建 bootloader.asm
BITS 16
ORG 0x7c00
MOV AL, 65
MOV AH, 0x0E ;Tell BIOS that we need to print one charater on screen.
MOV BH, 0x00 ;Page no.
MOV BL, 0x07 ;Text attribute 0x07 is lightgrey font on black background
INT 0x10
JMP $
TIMES 510 - ($ - $$) db 0
DW 0xAA55 ; Boot Signiture
它的代码相对简单,第一部分是关于位的,为了简单起见,我们将从 16 位程序开始。此代码中的重要行是 ORG 0x7c00。这意味着我们的代码应该从 0x7c00 开始。这是必需的,因为 BIOS 会在此地址将控制权移交给我们。
MOV AL, 65
MOV AH, 0x0E ;Tell BIOS that we need to print one charater on screen.
MOV BH, 0x00 ;Page no.
MOV BL, 0x07 ;Text attribute 0x07 is lightgrey font on black background
INT 0x10
前四行用于准备中断0x10。此接口提供对视频(显示)服务的访问。
AH=0Eh 表示我们要将输出设置为电传打字输出,因为我们想在屏幕上写一个字符。字符需要设置为 AL,即 65 又名“A”,BH = 页码,BL = 颜色。页码用于双重缓冲。如果你想写很多内容,你可以在不同的页面上写东西。它不会显示在屏幕上,但您可以调用中断以快速显示不同的页面。
JMP $
TIMES 510 - ($ - $$) db 0
DW 0xAA55 ; Boot Signiture
NASM 中的 $ 指向当前地址,因此 JMP $ 会创建一个无限循环。
前缀会导致指令被多次组合。例如。TIMES 64 db 0 将定义值为 0 的 64 个字节。定义会话的开始。因此,($ — $$) 可以通过使用来判断您进入该部分的距离。TIMES
$$
在当前位置,我们已经使用了一些字节(从开始到现在等于 $ — $$),我们需要用零填充剩余的字节,最多 510 个。这是由 TIMES 510 — ($ — $$) db 0 完成的。
最后两个字节是幻数,它定义当前扇区是可启动的。
编译和运行者
nasm -f bin bootloader.asm -o bootloader
qemu-system-i386 bootloader
默认情况下,它假设我们从硬盘启动,因为我们的代码在正确的位置,它打印“A”
引导加载程序的十六进制转储如下所示
您可以注意到 last 之前的魔术字节是 55 和 AA。此文件的大小应精确为 512 字节。
现在,让我们将其作为引导加载程序添加到磁盘映像中,而不是将其作为原始二进制文件运行。我们之所以要这样做,是因为我们想从这个引导加载程序加载我们的内核可执行文件,为此我们需要一些存储空间,我们可以在第一个扇区(前 512 字节)添加引导加载程序,然后是我们的内核。
dd if=bootloader of=disk.img bs=512 count=1 conv=notrunc # save to disk image
qemu-system-i386 -machine q35 -fda disk.img # run qemu again
它会将我们的磁盘读取为软盘并再次打印“A”
现在,让我们创建另一个代码,将其视为我们的内核代码。想法是引导加载程序应该,好吧..引导,然后调用内核代码。
创建另一个 bootloader.asm。
BITS 16
ORG 0x7c00
mov ax, 0x50 ; Set up the data segment (es) to 0x5000
mov es, ax
xor bx, bx
mov al, 2 ; read 2 sector
mov ch, 0 ; track 0
mov cl, 2 ; sector to read (The second sector)
mov dh, 0 ; head number
mov dl, 0 ; drive number
mov ah, 0x02 ; read sectors from disk
int 0x13 ; call the BIOS routine
jmp 0x50:0x0 ; jump and execute the sector!
TIMES 510 - ($ - $$) db 0
DW 0xAA55 ; Boot Signiture
上面的 got 类似于我们之前编写的引导加载程序。int 0x13 只是肉汁。它使用气缸盖扇区 (CHS) 寻址提供基于扇区的硬盘和软盘读写服务。
数据将存储在 ES:BX 中,即数据缓冲区。
mov ax, 0x50 ; Set up the data segment (es) to 0x5000
mov es, ax
xor bx, bx
上面的行只是将 es 设置为 0x50,将 bx 设置为 0x0,因此读取的数据(包含我们的指令)将存储在 0x50:0 或 0x500。这就是为什么我们跳转到0x500并有效地将执行移交给我们的新代码的原因。
让我们编写另一个程序,我们的引导加载程序可以调用,这将是我们的内核。内核.asm
MOV AL, 66
MOV AH, 0x0E ;Tell BIOS that we need to print one charater on screen.
MOV BH, 0x00 ;Page no.
MOV BL, 0x07 ;Text attribute 0x07 is lightgrey font on black background
INT 0x10
JMP $
让我们编译两者
nasm -f bin bootloader.asm -o bootloader
nasm -f bin kernel.asm -o kernel
内核的十六进制转储如下所示:
让我们将两者都复制到磁盘映像中。第一个扇区将包含引导加载程序 (MBR),第二个扇区将包含内核。
dd if=bootloader of=disk.img bs=512 count=1 conv=notrunc # first sector
dd if=kernel of=disk.img bs=512 count=1 seek=1 # second sector
让我们看一下最终的磁盘映像
注意 disk.img 末尾的内核十六进制 (b0 42 b4 0e b7 00 b3 07 cd 10 eb fe),它验证我们在扇区 2 中有内核。
如果你现在运行它,你会得到字符 B,它是从内核代码发出的,而不是前面的字符 A。
qemu-system-i386 -machine q35 -fda disk.img # run qemu again
现在,我们对引导加载程序有了一点了解,是时候推出一些真正的内核了,它可以做打印 A 或 B 以外的事情。在我们开始之前,很明显引导是多么复杂,在这里我们只为软盘等简单存储做了这件事,尝试对硬盘等其他存储进行同样的操作需要自己的努力,这不是本文的动机。从本节中抽出一点,了解引导是如何完成的,以及我们如何被蹦床放入我们的内核代码中。
引导你的 DIY 内核
真正的内核代码包含 C 语言中的高级代码和汇编中的低级体系结构特定代码的组合。本节重点介绍在 C 语言中构建更真实的内核,其中有一部分汇编代码(基于 NASM 语法)和链接器脚本(以确保内存中的正确布局)。对于引导加载,我们将使用 grub2。
正如我们已经看到的,没有适当的运行时,C 代码就无法运行(在伪装的 C 代码中回想一下_start)。我们需要先实现我们自己的 crt0 版本,然后才能在 c 中运行我们的内核代码。
回顾一下,crt0 的作用是:
-
初始化程序中需要它的所有内容
-
为堆栈设置空间并获取内存块。
-
初始化 .bss 部分
-
调用 main 函数
为了设置这些东西,我们将需要链接器脚本。
让我们再次从创建简单的内核代码开始。
loader.asm
bits 32
MAGIC_NUMBER equ 0x1BADB002
ALIGN_MODULES equ 0x00000001
; calculate the checksum (all options + checksum should equal 0)
CHECKSUM equ -(MAGIC_NUMBER + ALIGN_MODULES)
section .multiboot ;according to multiboot spec
dd MAGIC_NUMBER ;set magic number for
;bootloader
dd ALIGN_MODULES ;set flags
dd CHECKSUM ;set checksum
section .text
global start
extern main ;defined in the C file
start:
cli ;block interrupts
mov esp, stack_space ;set stack pointer
mov ebp, stack_space ;set stack pointer
push ebx
call main
hlt ;halt the CPU
section .bss
resb 8192 ;8KB for stack
stack_space:
链接.ld
OUTPUT_FORMAT(elf32-i386)
ENTRY(start)
SECTIONS
{
. = 1M;
.text ALIGN(4K):
{
*(.multiboot)
*(.text)
}
.data : { *(.data) }
.bss : { *(.bss) }
}
内核.c
int main()
{
return 0;
}
蛴螬.cfg
set timeout=1
menuentry "DIY Kernel" {
multiboot /boot/kernel.elf
}
生成文件
CC = gcc
CFLAGS = -m32 -nostdlib -nostdinc -fno-builtin -fno-stack-protector
-nostartfiles -nodefaultlibs -Wall -Wextra -Werror -Wno-error=main -ggdb -c
LDFLAGS = -T link.ld -melf_i386
AS = nasm
ASFLAGS = -f elf
OBJS = kernel.o loader.o
all: kernel iso
kernel: $(OBJS)
ld $(LDFLAGS) $(OBJS) -o kernel.elf
iso: kernel
mkdir -p iso
mkdir -p iso/boot
mkdir -p iso/boot/grub
cp kernel.elf iso/boot/kernel.elf
cp grub.cfg iso/boot/grub/grub.cfg
grub-mkrescue -o os.iso iso/
run: iso
qemu-system-i386 -boot d -cdrom os.iso -m 512
%.o: %.c
$(CC) $(CFLAGS) $< -o $@
%.o: %.asm
$(AS) $(ASFLAGS) $< -o $@
clean:
rm -rf $(OBJS)
rm -rf os.iso
rm -rf kernel.elf
为了让生活更轻松,.gdbinit
define hook-stop
# Translate the segment:offset into a physical address
printf "[%4x:%4x] ", $cs, $eip
x/i $cs*16+$eip
end
layout asm
layout reg
set architecture i8086
target remote localhost:1234
set history save on
set history size 1000
运行整个过程
make run
要实际查看正在发生的事情,请运行
make debug
在新选项卡(在当前工作目录上)中,运行
gdb kernel.elf
在 gdb 中,键入 b main 并按 c。 它应该在 C 代码中的 main 函数处中断/停止。如果是这样,恭喜你,你已经启动了你的第一个现实内核。
现在,让我们逐一进入每个文件。
注意:代码可在 https://github.com/itsankitkp/kernel_learn/tree/main/ch2 找到
loader.asm
bits 32
第一行定义我们的代码是 32 位代码。
让我们根据 grub 的要求从特定于 OS 映像的行(我们想要使用)
除了操作系统映像使用的格式的标头外,OS 映像还必须包含一个名为 Multiboot 标头的附加标头。多重引导标头必须完全包含在操作系统映像的前 8192 个字节中,并且必须长字(32 位)对齐。
此外,多重引导标头的布局应如下所示
因此,为了让 multiboot 正常工作,我们应该有 Multiboot 标头,至少有 3 个字段,即 magic、flags 和 checksum。此部分必须完全包含在操作系统映像的前 8192 个字节中,并且必须长字(32 位)对齐。
更多关于魔法领域的信息,请点击这里。
呸!,现在它是如何付诸实践的。再次查看 loader.asm 中代码的其余部分
MAGIC_NUMBER equ 0x1BADB002
ALIGN_MODULES equ 0x00000001
; calculate the checksum (all options + checksum should equal 0)
CHECKSUM equ -(MAGIC_NUMBER + ALIGN_MODULES)
section .multiboot ;according to multiboot spec
dd MAGIC_NUMBER ;set magic number for
;bootloader
dd ALIGN_MODULES ;set flags
dd CHECKSUM ;set checksum
定义了一个名为 multiboot 的部分,在其中,我们定义了 3 个双字 (DWORD)。你为什么要问双字?
有关变量,请参阅 NASM 文档。
字节是一个字节(8 位),word 是 16 位,dword 是 32 位。由于多重引导规范需要 32 位的所有这三个变量,因此我们应该使用 dword。
section .text
global start
extern main ;defined in the C file
start:
cli ;block interrupts
mov esp, stack_space ;set stack pointer
mov ebp, stack_space ;set stack pointer
push ebx
call main
hlt ;halt the CPU
上面的代码包含 .text 部分,其中包含我们的代码。如前所述,如果不设置运行时环境,我们不能直接在裸机中调用 c 代码。此代码通过设置堆栈指针 (ESP) 来实现这一点,EBP 指向堆栈段,将其视为当前帧中的本地堆栈(此处表示 main 函数将在本地使用的堆栈)。
完成所有这些工作后,我们可以调用 main 函数(就此代码而言,它是外部函数,需要在重定位期间解析)。
stack_space (堆栈大小)定义为 .bss 部分下方的 8kB。Resb 用于定义未初始化的存储空间(以字节为单位)。
RESB
、 、 、 、 、 和 设计用于模块的 BSS 部分:它们声明未初始化的存储空间。每个操作数都有一个操作数,即要保留的字节数、字数、双字数或任何操作数。RESW
RESD
RESQ
REST
RESO
RESY
RESZ
section .bss
resb 8192 ;8KB for stack
stack_space:
您可以通过以下方式检查stack_space值:
readelf -s kernel.elf | grep stack_space
这可以通过在 main 添加断点来验证(运行 make debug 并打开 gdb kernel.elf,类型 b main)
并运行:
info registers ebp
info registers esp
请注意 esp 比 ebp 低,这是因为堆栈向下增长,有些东西被推入堆栈。
这就是 loader.asm 的全部内容
让我们转到下一个文件 link.ld。这被链接器使用,引用手册页
LD 合并了许多对象和存档文件,重新定位其数据并绑定符号引用。
这很简单。
OUTPUT_FORMAT(elf32-i386)
ENTRY(start)
SECTIONS
{
. = 1M;
.text ALIGN(4K):
{
*(.multiboot)
*(.text)
}
.data : { *(.data) }
.bss : { *(.bss) }
}
OUTPUT_FORMAT定义二进制文件的类型。ENTRYPOINT指定如果我们运行此代码,则应从哪里开始执行。
如果在 kernel.elf 的 ELF 标头中看到入口点地址
readelf -h kernel.elf
并检查符号表中的相应地址
readelf -s kernel.elf | grep 100010
将看到它指向启动函数。这一切都是因为 ENTRY(start) 而发生的。
SECTIONS
{
. = 1M;
.text ALIGN(4K):
{
*(.multiboot)
*(.text)
}
.data : { *(.data) }
.bss : { *(.bss) }
}
Sections 定义可执行文件的输出部分。. = 1M 将入口点设置为地址为 1 MB 的 .text/kernel 代码(0x100000,1 后面有 5 个零)。这纯粹是引导加载程序端的约定(他们并不真正知道我们的代码在哪里,这也是为什么我们有一个保留地址 0x7C00 的原因,CPU 用于在重置/启动后加载第一条指令)。
ALIGN 表示
返回位置计数器 () 或与下一个对齐边界对齐的任意表达式。
.
它确保我们的文本段的地址是 4K 的倍数。
文本段以 multiboot 标头(根据多重引导规范的要求)开头,后跟加载程序的 .text 段,其中包含代码。
.data 和 .bss 段按原样定义。这些部分可以通过以下方式查看
objdump -hw kernel.elf
请注意,没有 .data 部分,因为我们还没有数据(读取全局变量)。
objdump -s -j .data kernel.elf
如果将任何全局变量添加到 main,请这样说
static int a=10;
int main()
{
return a;
}
再次运行 make 并检查 objdump
我们将看到 .data 部分已创建。
请注意,它位于 .bss 部分之前,就像链接器脚本一样。
下一个文件是 kernel.c
int main()
{
return 0;
}
这里没什么可看的,我们没有适用于裸机系统的标准库,因此我们必须从头开始编写所有基本函数,如 printf 等。但是现在不需要 iy,目前我们需要确保我们达到 main 函数,其余的函数可以在此基础上构建。
接下来是 makefile,虽然我们现在可以在不创建 makefile 的情况下生活,但编写它使我们的生活变得非常方便。
生成文件
CC = gcc
CFLAGS = -m32 -nostdlib -nostdinc -fno-builtin -fno-stack-protector
-nostartfiles -nodefaultlibs -Wall -Wextra -Werror -Wno-error=main -ggdb -c
LDFLAGS = -T link.ld -melf_i386
AS = nasm
ASFLAGS = -f elf
OBJS = kernel.o loader.o
all: kernel iso
kernel: $(OBJS)
ld $(LDFLAGS) $(OBJS) -o kernel.elf
iso: kernel
mkdir -p iso
mkdir -p iso/boot
mkdir -p iso/boot/grub
cp kernel.elf iso/boot/kernel.elf
cp grub.cfg iso/boot/grub/grub.cfg
grub-mkrescue -o os.iso iso/
run: iso
qemu-system-i386 -boot d -cdrom os.iso -m 512
%.o: %.c
$(CC) $(CFLAGS) $< -o $@
%.o: %.asm
$(AS) $(ASFLAGS) $< -o $@
clean:
rm -rf $(OBJS)
rm -rf os.iso
rm -rf kernel.elf
Makefile 不是我的专长,所以我会省略细节(互联网上有很多文档可以做到这一点)。它的基本结构可以这样解释:
假设我们想为茶做 write make 文件
泡茶
它的配方将如下所示
comman_to_boil = add
ingredients = tea milk sugar
tea: boil # how to boil is defined elsewhere
boil:
$(command_to_boil) $(ingredients) # variables and commands
所以,如果你叫泡茶,它会看看它是否可以满足,如果煮沸可用,如果没有,那么它将运行煮沸部分,依此类推。在堆栈的底部,我们将对每个步骤进行实际实施。
现在让我们回顾一下 makefile。
CC = gcc
CFLAGS = -m32 -nostdlib -nostdinc -fno-builtin -fno-stack-protector
-nostartfiles -nodefaultlibs -Wall -Wextra -Werror -Wno-error=main -ggdb -c
LDFLAGS = -T link.ld -melf_i386
AS = nasm
ASFLAGS = -f elf
OBJS = kernel.o loader.o
这些是此文件中定义的变量。可以通过调用 $(var_name) 来访问它。CC 是 gcc 的缩写,它编译了我们的 C 代码,CFLAGS 是给 gcc 的标志。-m32 再次指定它是 32 位代码,其余标志如下:
-nostdlib
链接时不要使用标准系统启动文件或库。不会将启动文件传递给链接器,只有您指定的库。编译器可能会生成对 、 和 的调用。这些条目通常由 libc 中的条目解析。指定此选项时,应通过其他机制提供这些入口点。memcmp
memset
memcpy
memmove
-nostdinc 不要在标准系统目录中搜索头文件。
-fno-builtin 不识别不以“__builtin_”开头作为前缀的内置函数。
对于 -fno-stack-protector,我们可以看到 stack protector 标志用于添加堆叠保护逻辑,我们明确表示我们不需要。
-fstack-protector 发出额外的代码来检查缓冲区溢出,例如堆栈粉碎攻击。
-nostartfiles 链接时不要使用标准系统启动文件
-nodefaultlibs 链接时不要使用标准系统库。只有指定的库才会传递给链接器,而指定系统库链接的选项(如 -static-libgcc 或 -shared-libgcc)将被忽略。
-墙基本上启用所有警告
-Wall 这启用了所有关于某些用户认为有问题的结构的警告,并且很容易避免(或修改以防止警告),即使与宏结合使用也是如此。
-Wextra 这将启用一些 -Wall 未启用的额外警告标志。
-Werror 将所有警告设为错误。这是必需的,因为内核代码非常微妙,不应忽略任何警告。
-Werror 将所有警告都变成错误。
-Wno-error=main 被添加以忽略有关 main 函数的错误(因为它没有标准语法),我们现在可以忽略它。
-ggdb 生成调试信息供 GDB 使用。这意味着使用最具表现力的可用格式(DWARF、stabs 或本机格式,如果两者都不支持),如果可能的话,包括 GDB 扩展。
有关更多信息,请访问 gcc 文档。
ld 命令使用 LD 标志。
LDFLAGS = -T link.ld -melf_i386
其中 -T 指定链接器脚本,-m 指定 elf 格式,即 i386(32 位)。
NASM 用作汇编程序(如 ASM 中指定),同样用作 ASFLAGS
将格式 -f 指定为 elf。
在 makefile 中进一步
all: kernel iso
kernel: $(OBJS)
ld $(LDFLAGS) $(OBJS) -o kernel.elf
iso: kernel
mkdir -p iso
mkdir -p iso/boot
mkdir -p iso/boot/grub
cp kernel.elf iso/boot/kernel.elf
cp grub.cfg iso/boot/grub/grub.cfg
grub-mkrescue -o os.iso iso/
run: iso
qemu-system-i386 -boot d -cdrom os.iso -m 512
debug: iso
qemu-system-i386 -boot d -cdrom os.iso -m 512 -s -S
%.o: %.c
$(CC) $(CFLAGS) $< -o $@
%.o: %.asm
$(AS) $(ASFLAGS) $< -o $@
所有都提供了 make all 的配方,同样可以使用 make kernel、make iso、make run 和 make debug。
让我们考虑 make all 的情况,如果调用 make all,它需要 kernel 和 iso 才能完成,因此 kernel 是先构建的,然后是 iso。
同样,内核需要 OBJS 文件存在,这由
%.o: %.c
$(CC) $(CFLAGS) $< -o $@
%.o: %.asm
$(AS) $(ASFLAGS) $< -o $@
“%.c”作为模式与任何以“.c”结尾的文件名匹配。%.asm 也是如此(与程序集文件匹配)。
%< 和 %@ 是自动变量
$<
第一个先决条件的名称。
$@
规则目标的文件名。
在上面的代码中,第一个先决条件是 .o(这是我们的 c/asm 文件),目标是目标文件 (*.o)。
运行 make file 后,您一定已经看到了这样的输出(假设没有对象文件,运行 make clean 来清理它们,记住 makefiles 从待处理的步骤开始,如果对象文件已经存在,它不会再次运行它),
正如你所看到的,前两行生成目标文件(来自程序集和 C),然后将其链接到 elf 可执行文件中。
nasm -f elf loader.asm -o loader.o
gcc -m32 -nostdlib -nostdinc -fno-builtin -fno-stack-protector -nostartfiles -nodefaultlibs -Wall -Wextra -Werror -Wno-error=main -ggdb -c kernel.c -o kernel.o
ld -T link.ld -melf_i386 loader.o kernel.o -o kernel.elf
我们终于得到了我们的内核可执行文件kernel.elf。现在,我们需要告诉 grub 这是我们的可执行文件。这是在 grub.cfg 中完成的
set timeout=1
menuentry "DIY Kernel" {
multiboot /boot/kernel.elf
}
menuentry:这定义了一个名为 title 的 GRUB 菜单条目。当从菜单中选择此条目时,GRUB 会将所选环境变量设置为值 — id if — id,执行大括号内给出的命令列表,如果列表中的最后一个命令成功返回并加载了内核,它将执行该命令。
boot
multiboot:从文件加载 multiboot 内核映像。该行的其余部分作为内核命令行逐字传递。使用此命令后,必须重新加载任何模块
因此,每当用户选择menuentry(名为“DIY Kernel”)时,它都会在大括号下运行部分,这会调用 multiboot 来加载内核映像,这是我们的 elf 文件。请注意位置为 /boot/kernel.elf。这是我们创建的 iso image 的目录布局,如 Makefile 中所示。并复制 grub.cfg 和内核代码。
mkdir -p iso
mkdir -p iso/boot
mkdir -p iso/boot/grub
cp kernel.elf iso/boot/kernel.elf
cp grub.cfg iso/boot/grub/grub.cfg
为什么我们把 grub 配置存储在 /boot/grub/grub.cfg 中?
再次引用文档:
GRUB 的正常启动过程包括将 'prefix' 环境变量设置为核心镜像中设置的值 ,将 'root' 变量设置为匹配,从前缀加载 'normal' 模块,然后运行 'normal' 命令(参见 normal)。此命令负责读取 /boot/grub/grub.cfg、运行菜单以及执行 GRUB 应该执行的所有有用操作。
grub-install
一旦 grub.cfg 和内核映像被放置在 iso 目录中的正确位置,我们就会调用 grub-mkrescue。
GRUB 支持 El Torito 规范中的无仿真模式。这意味着您可以使用 GRUB 的整个 CD-ROM,而不必制作软盘或硬盘映像文件,这可能会导致兼容性问题。
如果需要,将配置文件设为 grub.cfg 在 iso/boot/grub 下(请参阅配置),并将光盘的任何文件和目录复制到 iso/ 目录。
输入 grub.cfg 配置文件时将正确设置根设备,因此您可以引用 CD 上的文件名,而无需使用显式设备名称。这样可以更轻松地生成可在光驱和 USB 大容量存储设备上使用的救援图像。
甜!我们通过运行来制作救援盘
grub-mkrescue -o os.iso iso
ISO 是包含我们的内核和 grub 配置的文件夹。
救援磁盘可以由以下方式运行(这与 make run 相同)
qemu-system-i386 -boot d -cdrom os.iso -m 512
-m 指定 RAM 大小,boot 是从定义 os 的 cdrom 开始的(这是os.iso,微妙的)。
如果要启用 gdb 调试器,只需传递 -S -s 即可
该选项将使 QEMU 在 TCP 端口 1234 上侦听来自 gdb 的传入连接,并使 QEMU 在您告诉它从 gdb 之前不会启动客户机。
-s
-S
这与 make debug 相同。
qemu-system-i386 -boot d -cdrom os.iso -m 512 -s -S
若要附加调试器,请运行
gdb kernel.elf
在 gdb 中,运行
target remote localhost:1234
现在,您可以添加断点(例如在主断点或开始时)等等!为了让生活更轻松(键入目标远程..变得非常快),让我们也添加.gdbinit文件。
.gdbinit 格式
define hook-stop
# Translate the segment:offset into a physical address
printf "[%4x:%4x] ", $cs, $eip
x/i $cs*16+$eip
end
layout asm
layout reg
set architecture i8086
target remote localhost:1234
set history save on
set history size 1000
就是这样!您已成功启动到内核。花点时间珍惜你的成就。
继续前进
接下来的步骤将是设置 GDT、中断处理程序和分页,然后是处理堆和加载其他程序/多任务处理。我不会说我们可以收工,直到我们可以在内核中运行一些hello world程序。这是一条漫长的道路,需要大量的奉献和工作,但最终结果是值得的。更多的文章将跟进,同时我提到了非常有用的参考资料和大量信息。
引用
crt0,主启动文件
f78 crt0,主启动文件 Contents|首页 |上一页|下一页 crt0 (C RunTime 0) 文件包含初始启动...
users.informatik.haw-hamburg.de
https://raw.githubusercontent.com/tuhdo/os01/master/Operating_Systems_From_0_to_1.pdf
这是操作系统开发的圣经,不幸的是它还没有完成。这篇文章只是对这本书的快速总结。
扩展主页
OSDev Wiki 总是需要您的帮助!有关更多信息,请参阅愿望清单。
wiki.osdev.org
OSDev,如果你在这里找不到操作系统相关的资源,你就不会在任何地方找到它;)
JamesM 的内核开发教程
这组教程旨在带您完成对 x86 的简单 UNIX 克隆操作系统的编程。
www.jamesmolloy.co.uk
有点过时,但它有很多理论
关于操作系统开发的小书
本文是编写自己的 x86 操作系统的实用指南。它旨在为...
littleosbook.github.io
关于操作系统开发的非常好的书,这是我开始学习操作系统的旅程的地方。
介绍
关于 x86 体系结构和操作系统的简介
samypesse.gitbook.io
一本很棒的书,里面有在高中项目中创建内核的人的实际代码(那时我几乎不会做代数)。唯一的问题是代码是法语和C++
英特尔手册
原文始发于微信公众号(安全狗的自我修养):创建自己的内核:第 1 部分
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论