如何在没有操作系统运行的情况下单独运行程序?您可以创建汇编程序,使计算机能够在启动时加载和运行它们,例如从闪存驱动器引导计算机并运行CPU上的程序吗?
如何在没有操作系统运行的情况下单独运行程序?您可以创建汇编程序,使计算机能够在启动时加载和运行它们,例如从闪存驱动器引导计算机并运行CPU上的程序吗?
Burn the image to an USB stick (will destroy your data!):
sudo dd if=main.img of=/dev/sdX
plug the USB on a computer
turn it on
tell it to boot from the USB.
This means making the firmware pick USB before hard disk.
If that is not the default behavior of your machine, keep hitting Enter, F12, ESC or other such weird keys after power-on until you get a boot menu where you can select to boot from the USB.
It is often possible to configure the search order in those menus.
printf '\364%509s\125\252' > main.img
sudo apt-get install qemu-system-x86
qemu-system-x86_64 -hda main.img
\364
in octal == 0xf4
in hex: the encoding for a hlt
instruction, which tells the CPU to stop working.
Therefore our program will not do anything: only start and stop.
We use octal because \x
hex numbers are not specified by POSIX.
We could obtain this encoding easily with:
echo hlt > a.S
as -o a.o a.S
objdump -S a.o
which outputs:
a.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <.text>:
0: f4 hlt
but it is also documented in the Intel manual of course.
%509s
produce 509 spaces. Needed to fill in the file until byte 510.
\125\252
in octal == 0x55
followed by 0xaa
.
These are 2 required magic bytes which must be bytes 511 and 512.
The BIOS goes through all our disks looking for bootable ones, and it only considers bootable those that have those two magic bytes.
If not present, the hardware will not treat this as a bootable disk.
hd main.img
00000000 f4 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 |. |
00000010 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 | |
*
000001f0 20 20 20 20 20 20 20 20 20 20 20 20 20 20 55 aa | U.|
00000200
20
是 ASCII 中的空格。
BIOS 固件从磁盘中读取这 512 字节,将它们放入内存,并设置 PC 到第一个字节以开始执行它们。
Hello world boot sector
现在我们已经创建了一个最小程序,让我们转向 hello world。
显而易见的问题是:如何进行输入输出?有几个选项:
请求固件(例如BIOS或UEFI)为我们执行操作。
VGA:特殊的内存区域,如果写入,则会打印到屏幕上。可以在保护模式下使用。
编写驱动程序并直接与显示硬件通信。这是“正确”的方法:更强大,但更复杂。
串行端口。这是一种非常简单的标准化协议,可从主机终端发送和接收字符。
在桌面上,它看起来像这样:
来源。
不幸的是,在大多数现代笔记本电脑上没有暴露,但对于开发板来说是常见的方法,请参见下面的ARM示例。
这真的很遗憾,因为这样的接口非常有用例如用于调试Linux内核。
使用芯片的调试功能。例如,ARM将其称为半主机。在实际硬件上,它需要一些额外的硬件和软件支持,但在仿真器上,它可以是一个免费方便的选项。示例。
main.S
.code16
mov $msg, %si
mov $0x0e, %ah
loop:
lodsb
or %al, %al
jz halt
int $0x10
jmp loop
halt:
hlt
msg:
.asciz "hello world"
link.ld
SECTIONS
{
/* The BIOS loads the code from the disk to this location.
* We must tell that to the linker so that it can properly
* calculate the addresses of symbols we might jump to.
*/
. = 0x7c00;
.text :
{
__start = .;
*(.text)
/* Place the magic boot bytes at the end of the first 512 sector. */
. = 0x1FE;
SHORT(0xAA55)
}
}
使用以下命令进行组装和链接:
as -g -o main.o main.S
ld --oformat binary -o main.img -T link.ld main.o
qemu-system-x86_64 -hda main.img
.code16
:告诉GAS输出16位代码
cli
:禁用软件中断。这些中断可能会使处理器在hlt
之后重新开始运行
int $0x10
:进行BIOS调用。这是逐个打印字符的方法。
--oformat binary
:输出原始二进制汇编代码,不像常规用户空间可执行文件那样将其包装在ELF文件中。使用C而非汇编
总结:使用GRUB multiboot,这将解决许多你从未考虑过的烦人问题。请参见下面的部分。
x86的主要困难在于BIOS仅将512个字节从磁盘加载到内存中,而使用C时可能会超出这512个字节的限制!
为了解决这个问题,我们可以使用双阶段引导加载程序。这会进行更多的BIOS调用,从磁盘中加载更多的字节到内存中。这是一个从头开始的最小化第二阶段汇编示例,使用int 0x13 BIOS calls:-kernel
选项,它将整个ELF文件加载到内存中。 这是我用该方法创建的ARM示例。kernel7.img
的ELF文件中加载镜像,就像QEMU -kernel
一样。仅供教育目的,这里是一个单阶段最小的C语言示例:
main.c
void main(void) {
int i;
char s[] = {'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd'};
for (i = 0; i < sizeof(s); ++i) {
__asm__ (
"int $0x10" : : "a" ((0x0e << 8) | s[i])
);
}
while (1) {
__asm__ ("hlt");
};
}
entry.S
.code16
.text
.global mystart
mystart:
ljmp $0, $.setcs
.setcs:
xor %ax, %ax
mov %ax, %ds
mov %ax, %es
mov %ax, %ss
mov $__stack_top, %esp
cld
call main
linker.ld
ENTRY(mystart)
SECTIONS
{
. = 0x7c00;
.text : {
entry.o(.text)
*(.text)
*(.data)
*(.rodata)
__bss_start = .;
/* COMMON vs BSS: https://dev59.com/qGQn5IYBdhLWcg3wcWvM */
*(.bss)
*(COMMON)
__bss_end = .;
}
/* https://stackoverflow.com/questions/53584666/why-does-gnu-ld-include-a-section-that-does-not-appear-in-the-linker-script */
.sig : AT(ADDR(.text) + 512 - 2)
{
SHORT(0xaa55);
}
/DISCARD/ : {
*(.eh_frame)
}
__stack_bottom = .;
. = . + 0x1000;
__stack_top = .;
}
set -eux
as -ggdb3 --32 -o entry.o entry.S
gcc -c -ggdb3 -m16 -ffreestanding -fno-PIE -nostartfiles -nostdlib -o main.o -std=c99 main.c
ld -m elf_i386 -o main.elf -T linker.ld entry.o main.o
objcopy -O binary main.elf main.img
qemu-system-x86_64 -drive file=main.img,format=raw
Write your own. It's just a bunch of headers and C files in the end, right? Right??
Detailed example at: https://electronics.stackexchange.com/questions/223929/c-standard-libraries-on-bare-metal/223931
Newlib implements all the boring non-OS specific things for you, e.g. memcmp
, memcpy
, etc.
Then, it provides some stubs for you to implement the syscalls that you need yourself.
For example, we can implement exit()
on ARM through semihosting with:
void _exit(int status) {
__asm__ __volatile__ ("mov r0, #0x18; ldr r1, =#0x20026; svc 0x00123456");
}
as shown at in this example.
For example, you could redirect printf
to the UART or ARM systems, or implement exit()
with semihosting.
embedded operating systems like FreeRTOS and Zephyr.
Such operating systems typically allow you to turn off pre-emptive scheduling, therefore giving you full control over the runtime of the program.
They can be seen as a sort of pre-implemented Newlib.
GNU GRUB 多重引导
引导扇区很简单,但并不是非常方便:
正因为这些原因,GNU GRUB 创建了一种更方便的文件格式,称为 multiboot。
我还在我的GitHub示例存储库上使用它,以便能够轻松地在真实硬件上运行所有示例,而不用一遍又一遍地烧录USB。固件执行以下操作:
遍历每个硬盘、USB、网络等,直到找到可启动的内容。
当我们运行 QEMU 时,-hda
表示将 main.img
作为硬盘连接到硬件上,并且 hda
是第一个被尝试的,因此使用它。
将前 512 字节加载到 RAM 内存地址 0x7c00
,将 CPU 的 RIP 放置在那里并让其运行。
在显示器上显示启动菜单或 BIOS 打印调用等内容。
%ds
和 %es
这样的寄存器具有重要的副作用,因此即使您没有明确使用它们,也应将它们清零。grub-mkrescue
(example)完成,并且也是 Linux 内核在使用 isohybrid
的 make isoimage
上执行的操作。
ARM
在ARM中,总体思路是相同的。a few simple QEMU C + Newlib and raw assembly examples here on GitHub.
The prompt.c example for example takes input from your host terminal and gives back output all through the simulated UART:
enter a character
got: a
new alloc of 1 bytes at address 0x0x4000a1c0
enter a character
got: b
new alloc of 2 bytes at address 0x0x4000a1c0
enter a character
See also: How to make bare metal ARM programs and run them on QEMU?
a fully automated Raspberry Pi blinker setup at: https://github.com/cirosantilli/raspberry-pi-bare-metal-blinker
See also: How to run a C program with no OS on the Raspberry Pi?
To "see" the LEDs on QEMU you have to compile QEMU from source with a debug flag: https://raspberrypi.stackexchange.com/questions/56373/is-it-possible-to-get-the-state-of-the-leds-and-gpios-in-a-qemu-emulation-like-t
Next, you should try a UART hello world. You can start from the blinker example, and replace the kernel with this one: https://github.com/dwelch67/raspberrypi/tree/bce377230c2cdd8ff1e40919fdedbc2533ef5a00/uart01
First get the UART working with Raspbian as I've explained at: https://raspberrypi.stackexchange.com/questions/38/prepare-for-ssh-without-a-screen/54394#54394 It will look something like this:
Make sure to use the right pins, or else you can burn your UART to USB converter, I've done it twice already by short circuiting ground and 5V...
Finally connect to the serial from the host with:
screen /dev/ttyUSB0 115200
For the Raspberry Pi, we use a Micro SD card instead of an USB stick to contain our executable, for which you normally need an adapter to connect to your computer:
Don't forget to unlock the SD adapter as shown at: https://askubuntu.com/questions/213889/microsd-card-is-set-to-read-only-state-how-can-i-write-data-on-it/814585#814585
https://github.com/dwelch67/raspberrypi looks like the most popular bare metal Raspberry Pi tutorial available today.
IO是通过直接写入魔术地址来完成的,没有in
和out
指令。
这被称为内存映射IO。
对于一些真实的硬件,比如树莓派,你可以自己将固件(BIOS)添加到磁盘镜像中。
这是一个好事情,因为它使得更新固件更加透明。
资源
操作系统也是一个程序,因此我们可以通过从头开始创建或更改(限制或添加)一些小型操作系统的功能,然后在启动过程中运行它(使用ISO镜像)来创建自己的程序。
例如,可以使用此页面作为起点:
在这里,整个操作系统完全适合于512字节的引导扇区(MBR)中!
这样或类似的简单操作系统可以用来创建一个简单的框架,使我们能够:
使引导加载磁盘上的后续扇区到RAM中,并跳转到该点以继续执行。或者您可以阅读FAT12有关内容,这是软盘驱动器上使用的文件系统,并实现该文件系统。
然而,有很多可能性。例如,要查看一个更大的x86汇编语言操作系统,我们可以探索MykeOS,这是一个学习工具,展示了简单的16位实模式操作系统的工作原理,配有注释良好的代码和广泛的文档。
其他常见类型的在没有操作系统的情况下运行的程序也是启动加载程序。我们可以创建一个受此概念启发的程序,例如使用此网站:
上述文章还介绍了这种程序的基本架构:
- 在0000:7C00地址正确加载到内存中。
- 调用使用高级语言编写的BootMain函数。
- 在显示屏上显示“来自底层的Hello, world…”信息。
正如我们所看到的,这种架构非常灵活,可以实现任何程序,不一定是引导加载程序。
特别地,它展示了如何使用“混合代码”技术,借助于该技术,我们可以将C或C++的高级结构与汇编的低级命令相结合。这是一种非常有用的方法,但我们必须记住:
要构建程序并获得可执行文件,您需要16位模式下的汇编器和链接器。对于C/C++,您只需要能够创建16位模式下的目标文件的编译器。
本文还展示了如何查看创建的程序的运行情况以及如何进行测试和调试。
上述示例利用了在数据介质上加载扇区MBR的事实。然而,我们可以通过例如使用UEFI应用程序更深入地探索:
除了加载操作系统外,UEFI还可以运行UEFI应用程序,这些应用程序作为文件驻留在EFI系统分区中。它们可以从UEFI命令shell、固件的启动管理器或其他UEFI应用程序执行。 UEFI应用程序可以独立于系统制造商开发和安装。
一种类型的UEFI应用程序是操作系统加载程序,例如GRUB、rEFInd、Gummiboot和Windows Boot Manager;它将操作系统文件加载到内存中并执行它。此外,操作系统加载程序可以提供用户界面,以允许选择要运行的另一个UEFI应用程序。 像UEFI shell这样的实用程序也是UEFI应用程序。
如果我们想要开始创建这样的程序,我们可以从以下网站开始:
EFI编程:创建“Hello, World”程序 / UEFI编程-第一步
众所周知,有一整组恶意软件(即程序)在操作系统启动之前运行。
其中大部分作用于MBR扇区或UEFI应用程序,就像上面提到的所有解决方案一样,但也有一些使用其他入口点,例如卷引导记录(VBR)或BIOS:
至少有四种已知的BIOS攻击病毒,其中两种是用于演示目的。
或许还有其他的。
Bootkits已经从概念验证发展到大规模分发,并且现在已经有效地成为开源软件。
我认为,在这种情况下,值得一提的是有各种引导操作系统(或用于此目的的可执行程序)的形式。虽然有很多,但我想特别注意使用网络引导选项(PXE)从网络加载代码,这使我们可以在计算机上运行程序,无论其操作系统甚至无论与计算机直接连接的任何存储介质:
我编写了一个基于Win32的C++程序,用于将汇编代码写入U盘的引导扇区。当计算机从U盘启动时,它可以成功地执行该代码 - 在这里查看C++ Program to write to the boot sector of a USB Pendrive。
这个程序只有几行代码,应该在配置了Windows编译的编译器上编译 - 例如Visual Studio编译器 - 任何可用版本都可以。