嘿,你好!
欢迎来到关于制作小型操作系统的系列的第七集。
在本集中,我们将从汇编语言迁移到 C 编程语言,以 C 语言创建我们手工制作的操作系统的某些部分(包括内核)。
为此,我们首先创建一个链接器脚本,然后阅读创建独立 C 程序所需的 GCC 开关。
希望在本集结束时,我们用 C 编程语言编写一些文本到屏幕上。
连接
回过头来,当我们谈论操作系统的内存布局时,我们看到需要知道程序在内存中的位置。在汇编引导加载程序中,我们用“org”指令赋予这种意义。在我们的 C 程序(内核)中,我们也有同样的故事。在这里,我们应该让链接器了解程序将在内存中加载的位置,因此链接器基于此链接我们的 C 程序。(基本上链接器在程序中重新定位地址)
链接器脚本
通知链接器的第一步是确定我们想要将 C 程序放在哪里。(C 内核)
让我们回顾一下操作系统的内存映射:
由于我们没有一个加载程序将程序的每个部分加载到内存中的不同位置(我们仍然使用 BIOS 中断将程序加载到内存中),我们应该编写一个“链接器脚本”,使链接器将所有部分彼此靠近在所需地址,因此 BIOS 加载器将程序作为一个整体加载到所需的地址。
现在我们知道了对链接器脚本的需求,让我们回顾一下实现此目的的步骤:
- 在内存中为您的程序找到一个位置。(在我们的例子中,是 C 内核)
- 创建一个简单的链接器脚本,将程序地址重新定位到所需地址周围,并为我们的操作系统创建输出(显然,我们的操作系统不支持著名的 Linux elf 格式)
- 对 LD 使用适当的开关,以通知它使用我们的自定义链接器脚本并...
内核的正确位置
让我们将 0xc350 视为内核的起始地址。因此,更新后的内存视图将是这样的:
编写链接器脚本
编写链接器脚本不是程序员的日常工作,因此最好在每次编写链接器脚本时检查语法参考。
我找到了一个很好的资源来编写链接器脚本:https://bravegnu.org/gnu-eprog/lds.html
按照以下步骤创建链接器脚本。
首先,在“mya/code”目录下为内核创建一个文件夹:
mkdir kernel && cd kernel
在内核目录中,创建一个名为“linker.ld”的文件。(这是我们的链接器脚本)
vim linker.ld
现在,我们可以编写自己的链接器脚本了。
这一切都从定义入口点开始。入口点是程序开始运行时执行的第一个位置。因为它是第一个被执行击中的地方,所以它是某些功能的好地方,例如将某些区域(如 BSS 和某些初始化)归零。
因此,我们在链接器脚本中添加了一个入口点:
ENTRY(entry)
如您所见,我们将入口点函数命名为“入口”。您可以使用所需的任何其他名称,稍后我们将定义此“条目”函数。
如果您尝试使用“ld”命令将某些对象文件链接在一起,您可能会看到来自 ld 的以下消息:
ld: warning: cannot find entry symbol _start; defaulting to 0000000000401000
这意味着默认的 LD 入口点为“_start”。
最好查看 LD 默认链接器脚本。例如,我们可以通过以下命令检查 LD 默认链接器脚本来确认上述观点:
ld –verbose
在入口点之后,我们应该定义输出格式。当我们更改 Nasm 的输出格式时,原因是一样的。著名的 ELF 输出格式(和其他格式)带有许多我们现阶段不需要的标头,更重要的是,我们仍然使用 BIOS 加载程序 (int 0x13)
并且没有加载器来读取该标头并据此决定将程序放在内存中的位置,然后......
因此,我们应该使用二进制输出格式来省略该标头。
OUTPUT_FORMAT("binary")
现在,是时候在输出中排列各部分了。请看以下链接器脚本:
ENTRY(entry) OUTPUT_FORMAT("binary") phys = 0x0000C350; SECTIONS { . = phys; .entry : { __entry_start = .; *(.entry) } .text : { __text_start = .; *(.text) } .data : { __data_start = .; *(.data) } .rodata : { __rodata_start = .; *(.rodata) } .bss : { __bss_start = .; *(.bss) } __end = .; }
正如您在链接器脚本中看到的,有“SECTIONS”部分,它描述了输出中不同程序部分的布局和阵容。
在“SECTIONS”的第一行中,您会看到:
. = phys;
这。(dot) 是链接器位置计数器。通过将0xc350分配给位置计数器,我们通知链接器0xc350是我们程序的起始地址。因此,链接器根据此地址管理寻址。
然后我们有 .entry、.text、.data、.rodata、.bss 等部分。在这里,您看到部分名称的顺序是链接器将它们放入输出中的顺序。所以顺序很重要。
如您所见,每个部分名称后面都跟着:(冒号)。
结构如下:
.section_name : {__label_name = .; *(.section_name)}
首先,这些“__label_name=.”只是位置计数器当前位置的标签,我们可以忽略它们。
什么是“*(.section_name)”?
好吧,创建目标文件并合并它们是我们在开发程序时通常执行的工作流程的一部分。
(图片来自 https://www.researchgate.net/figure/The-object-sections-in-three-object-files-shown-on-the-left-are-combined-by-the-linker_fig2_220404613)
如上图所示,我们希望链接器将目标文件中的所有“.text”部分合并到输出中的一个“.text”部分。
这正是 “*(.section_name) ” 在链接器脚本中的作用。
例如,通过以下行:
.text : { __text_start = .; *(.text) }
在链接器脚本中,我们告诉链接器输入文件中的所有“.text”部分都应转到输出中的“.text”部分。
仅此而已。关于链接器脚本,我们可以学到很多东西,但我把它留给你。
编译和链接阶段
本文部分摘自Osdev。您可以通过以下方式阅读原始文章:
https://wiki.osdev.org/Why_do_I_need_a_Cross_Compiler%3F
https://wiki.osdev.org/Libgcc
https://wiki.osdev.org/C_Library
编译器必须知道正确的目标平台(CPU、操作系统),否则会遇到麻烦。
可以通过调用以下命令来询问编译器当前使用的目标平台:
gcc -dumpmachine
如果您在 64 位 Linux 上进行开发,那么您将收到类似“x86_64-unknown-linux-gnu”的响应。这意味着编译器认为它正在为 Linux 创建代码。如果你使用这个 GCC 来构建你的内核,它将使用你的系统库、头文件、Linux libgcc,并且它会做出很多有问题的 Linux 假设。如果你使用交叉编译器,如 i686-elf-gcc,那么你会得到一个响应,比如 'i686-elf',这意味着编译器知道它正在做其他事情,你可以轻松正确地避免很多问题。
独立式和托管式
C 编译环境有两种类型:Hosted,其中标准库可用;和独立式,其中只有少数标头可用,仅包含定义和类型。托管环境用于用户空间编程,而独立环境用于内核编程。托管环境是默认的,但您可以通过将 -ffreestanding 传递给编译器来切换到独立环境。
独立标头包括:<float.h>、<iso646.h>、<limits.h>、<stdalign.h>、<stdarg.h>、<stdbool.h>、<stddef.h>、<stdint.h> 和 <stdnoreturn.h>。
(图片来自 https://ppci.readthedocs.io/en/latest/reference/lang/c.html)
与编译器而不是 ld 链接
您不应该直接调用 ld。交叉编译器能够用作链接器,将其用作链接器允许它在链接阶段进行控制。此控件包括将 -lgcc 扩展为只有编译器知道的 libgcc 的完整路径。
什么是 libgcc?
所有使用 gcc 编译的代码都必须与 libgcc 链接。它的确切内容取决于特定的目标、配置甚至命令行选项。GCC 无条件地假设它可以安全地发出对 libgcc 符号的调用,因此 GCC 编译的所有代码都必须与 libgcc 链接。默认情况下,当您与 GCC 链接时,该库会自动包含在内,并且无需执行任何操作即可使用它。
但是,由于显而易见的原因,内核通常不会与标准用户空间 libc 链接,而是与 -nodefaultlibs(由 -nostdlib 暗示)链接,这会禁用与 libc 和 libgcc 的自动链接。
您应该链接的选项
-nostdlib(与 -nostartfiles -nodefaultlibs 相同)
-nostdlib 选项与同时传递 -nostartfiles -nodefaultlibs 选项相同。您不希望在内核中使用起始文件(crt0.o、crti.o、crtn.o),因为它们仅用于用户空间程序。您不需要像 libc 这样的默认库,因为用户空间版本不适合内核使用。您应该只传递 -nostdlib,因为它与传递后两个选项相同。
什么是 LIBC?
C 标准库为 C 程序提供字符串操作 (string.h)、基本 I/O (stdio.h)、内存分配 (stdlib.h) 和其他基本功能。
在 Unix 平台上,该库被命名为 libc,并自动链接到每个可执行文件。
您需要一个具有必要功能的 C 标准库实现,以便在操作系统上运行 C 程序。
-LGCC公司
在传递 -nodefaultlibs 时禁用重要的 libgcc 库(由 -nostdlib 暗示)。编译器需要此库来执行许多它自己无法执行的操作,或者将其放入共享函数中更有效。您必须在链接行的末尾传递此库,在所有其他对象文件和库之后
这是由于经典的静态链接模型,其中静态库中的目标文件只有在被以前的对象文件使用时才会被拉入。与 libgcc 的链接必须在所有可能使用它的目标文件之后进行。
应传递给编译器的选项
您需要将一些特殊选项传递给编译器,以告诉它它不是在构建用户空间程序。
-独立式 (ffreestanded)
这很重要,因为它让编译器知道它正在构建内核而不是用户空间程序。GCC 的文档说您需要在独立模式下自己实现 memset、memcpy、memcmp 和 memmove 函数。
总结一下上面的解释,这就是编译和链接命令模式的样子:
编译:
i686-elf-gcc kernel.c -o kernel.o -ffreestanding
-独立式 (ffreestanded)
使 GCC 编译器不使用标准库。在独立模式下,我们可以使用一些标头:
<float.h>、<iso646.h>、<limits.h>、<stdalign.h>、<stdarg.h>、<stdbool.h>、<stddef.h>、<stdint.h> 和 <stdnoreturn.h>。
我们必须实施
memset、memcpy、memcmp 和 memmove
自己。
要链接:
i686-elf-gcc -T link.ld boot.o kernel.o -o kernel.bin -nostdlib -ffreestanding -lgcc
i686-elf-gcc
使用 gcc 而不是 ld,因为 gcc 将 “-lgcc” 扩展到 i686-elf-gcc 交叉编译器的 libgcc 的完整路径。
-T 链接.ld
使用名为“link.ld”的链接器脚本,而不是默认的 GCC 链接器脚本
-诺斯特德利布
禁用不必要的 C 标准库,例如 libc,因此我们不能使用以下库,如果需要,我们必须自己实现它们:
<string.h>、<stdio.h>、<stdlib.h>
还禁用了 libgcc 自动链接。
-独立式 (ffreestanded)
与编译器选项相同
-LGCC公司
由于 libgcc 被 -nostdlib 禁用,并且 gcc 需要它进行链接,因此我们将 -lgcc 传递给链接器,然后链接器将扩展到交叉编译器的 libgcc 的路径。(因为我们使用 GCC 而不是 LD)
现在我们知道了基础知识,让我们开始编码吧。
在链接器脚本部分,我们引入了“entry”,现在我们要实现它。为了简单起见,我们内核的入口点内没有初始化和声明。实际上,我们什么都不做,只是跳到 C 程序的主要功能。
这里是“entry.asm”:
bits 32 section .entry global entry extern start entry: call start hlt
您会看到它只是调用一个名为“start”的函数。
这实际上是我们 C 内核中的一个函数。
那么,为什么不现在就实现第一个简单内核呢?
让我们开始吧。在 kernel 目录中创建一个名为 kernel.c 的文件,并使用以下代码:
void __attribute__((cdecl)) start() { int a = 5; int b = 10; int c = a+b; char* x = 0xb8000; *x = 'X'; for(;;); }
这将在函数“start”中创建一个简单的 C 程序,在屏幕上打印“X”字母。
注意:如果一切顺利,我们可以在图形上播放一集。
0xb8000. 这是视频显示内存的地址,它是硬件映射的。
在那里(在图形情节中),我们将讨论0xb8000地址并解释它是如何工作的。
什么是 __attribute__((CDeCl)) ?
这是一个函数属性。函数属性指示编译器执行某些操作。
例如,cdecl 属性使编译器假定调用函数从用于传递参数的堆栈空间中弹出。
在 C 编程语言中,有两个主要的函数调用约定,即“cdecl”和“stdcall”。
您可以在下图中看到差异:
(图片来自 https://mfranc.com/blog/net-internals-sorting-part3)
您可以在 https://gcc.gnu.org/onlinedocs/gcc/x86-Function-Attributes.html#index-cdecl-function-attribute_002c-x86-32 上阅读有关其他 x86 函数属性的更多信息
还行。现在让我们把所有东西放在一起并创建输出。为此,请在内核目录中创建一个 Makefile,并使用以下代码:
TARGET_ASMFLAGS += -f elf TARGET_CFLAGS += -ffreestanding -nostdlib TARGET_LIBS += -lgcc TARGET_LINKFLAGS += -T linker.ld -nostdlib TARGET_CC = ../toolchain/i686-elf/bin/i686-elf-gcc TARGET_LD = ../toolchain/i686-elf/bin/i686-elf-gcc kernel.img: kernel.bin dd if=kernel.bin of=kernel.img kernel.bin: entry.obj kernel.obj $(PROGRAMMER_LIBS) $(TARGET_LD) $(TARGET_LINKFLAGS) -Wl,-Map=kernel.map -o $@ $^ $(TARGET_LIBS) @echo "--> Created kernel.bin" kernel.obj: kernel.c $(TARGET_CC) $(TARGET_CFLAGS) -c -o kernel.obj kernel.c @echo "--> Compiled: " kernel.obj entry.obj: entry.asm nasm $(TARGET_ASMFLAGS) -o entry.obj entry.asm @echo "--> Compiled: " entry.obj .PHONEY: clean clean: @echo remove main object files rm -f kernel.obj entry.obj kernel.map kernel.bin kernel.img
,然后使用以下命令创建内核的二进制输出:
make
结果是这样的:
恭喜。我们刚刚制作了能够在裸机上运行的 C 内核。
距离测试它只剩下一步了。将内核加载到内存中,然后跳转到其地址(0xc350)。
要将内核加载到内存中,我们需要再次0x13 BIOS int。
因此,打开“boot.asm”文件(位于代码目录中),并将以下代码添加到“load_hda”函数中:
mov ah, 2 mov al, 1 ; count of sectors mov ch, 0 ; start of cylinder (C) mov cl, 1 ; start of sector (S) (starts from 1) mov dh, 0 ; head (H) mov dl, 0x81 ; read from hdb mov bx, 0xC350 ; buffer int 0x13
要跳转到内核地址,请将以下代码添加到“second_stage.asm”中“pmode_main”函数的末尾:
jmp 0xc350
最后一步是通知 QEMU 将“kernel.bin”文件包含为“HDB”。
为此,请从以下位置编辑代码目录中的 makefile:
run: $(MAKE) all && qemu-system-i386 -fda boot.bin -hda second_stage.bin -s -S
自:
run: $(MAKE) all && qemu-system-i386 -fda boot.bin -hda second_stage.bin -hdb kernel/kernel.bin -s -S
我还向 makefile 的 0xc350(内核的开头)添加了一个断点。到目前为止,Makefile 如下所示:
boot.bin: boot.asm nasm -f bin -o boot.bin boot.asm second_stage.bin: second_stage.asm nasm -f bin -o second_stage.bin second_stage.asm all: boot.bin second_stage.bin .PHONY: run debug run: $(MAKE) all && qemu-system-i386 -fda boot.bin -hda second_stage.bin -hdb kernel/kernel.bin -s -S debug: #qemu-system-i386 -s -S -fda boot.bin gdb -ex "target remote :1234" -ex "b *0x7c00" -ex "b *0xa411" -ex "b *0xc350" -ex "set tdesc filename gdb_asset/target.xml" -ex "layout asm"
现在,要应用更改,请使用以下命令:
make all
准备好测试了吗?
还行。前往“code”目录,并一如既往地使用以下命令打开 QEMU:
make run
并使用以下命令打开 GDB:
make debug
如下图所示,内核在 0xc350 加载。
当执行以下 C 代码的操作码时:
char* x = 0xb8000; *x = 'X';
您可以在屏幕上看到字母“X”:
在这一点上,只剩下一件事了。每次更改内核文件时,都应在内核目录中使用 make 应用更改,然后返回代码目录并使用 make 运行和调试运行项目。让我们通过从主 Makefile 运行第二个 Makefile(内核目录中的那个)来自动执行此过程。
为此,请更改“code”目录中的 makefile,如下所示:
boot.bin: boot.asm nasm -f bin -o boot.bin boot.asm second_stage.bin: second_stage.asm nasm -f bin -o second_stage.bin second_stage.asm all: boot.bin second_stage.bin .PHONY: run debug run: $(MAKE) all cd kernel && $(MAKE) qemu-system-i386 -fda boot.bin -hda second_stage.bin -hdb kernel/kernel.bin -s -S debug: #qemu-system-i386 -s -S -fda boot.bin gdb -ex "target remote :1234" -ex "b *0x7c00" -ex "b *0xa411" -ex "b *0xc350" -ex "set tdesc filename gdb_asset/target.xml" -ex "layout asm"
还行。我认为是时候结束这篇文章了。
这是关于“制作微型操作系统”的第 7 篇文章,我期待听到您的反馈,所以如果有任何建议,请告诉我。
您可以在 “mya” GitHub 存储库中访问此项目的代码:https://github.com/flydeoo/mya
此外,您可以在以下位置查看第 7 集的发布:https://github.com/flydeoo/mya/releases/tag/v0.07
感谢您的阅读,敬请期待下一集。
原文始发于微信公众号(安全狗的自我修养):第 7 集:迁移到 C
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论