如何在没有操作系统的情况下运行程序?

308

如何在没有操作系统运行的情况下单独运行程序?您可以创建汇编程序,使计算机能够在启动时加载和运行它们,例如从闪存驱动器引导计算机并运行CPU上的程序吗?


7
什么架构?x86?ARM? - Kissiel
1
我是泛指,但很可能是x86或x64。 - user2320609
3
是的,这正是处理器启动的方式。不一定非得用汇编语言,通常会使用少量的汇编语言和一些其他支持来编写C语言引导程序。 - old_timer
37
想一想:如果没有这种能力,操作系统本身怎么启动和运行呢? :) - Seva Alekseyev
4个回答

861
可运行的示例
让我们创建并运行一些微小的裸机 hello world 程序,这些程序在以下设备上没有操作系统: 我们还将尽可能在 QEMU 模拟器上测试它们,因为这对开发更安全、更方便。QEMU 测试是在 Ubuntu 18.04 主机上进行的,使用预装的 QEMU 2.11.1。
所有 x86 示例的代码以及更多内容都在 此 GitHub 存储库 中。
如何在 x86 实际硬件上运行示例
记住,在真实硬件上运行示例可能是危险的,例如您可能会错误地清除磁盘或使硬件变得无用:只在不包含关键数据的旧机器上执行此操作!或者更好的办法是使用廉价的半可丢弃开发板,例如树莓派,参见下面的 ARM 示例。
对于典型的 x86 笔记本电脑,您需要执行类似以下的操作:
  1. Burn the image to an USB stick (will destroy your data!):

    sudo dd if=main.img of=/dev/sdX
    
  2. plug the USB on a computer

  3. turn it on

  4. 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.

例如,在我的T430上,我看到了以下内容。
打开电脑后,我需要按Enter键进入引导菜单:

enter image description here

然后,我需要按F12键选择USB作为启动设备:

enter image description here

从那里,我可以像这样选择 USB 作为启动设备:

enter image description here

或者,为了更改启动顺序并选择USB具有更高的优先级,这样我就不必每次手动选择它,我会在“启动中断菜单”屏幕上按F1,然后导航到:

enter image description here

引导扇区
在 x86 上,您可以做的最简单和最低级别的事情是创建一个主引导扇区(MBR),它是一种引导扇区类型,然后将其安装到磁盘上。
这里我们使用一个单独的 printf 调用来创建一个引导扇区:
printf '\364%509s\125\252' > main.img
sudo apt-get install qemu-system-x86
qemu-system-x86_64 -hda main.img

结果:

enter image description here

请注意,即使不进行任何操作,屏幕上已经打印了一些字符。 这些字符是由固件打印的,用于识别系统。
而在T430上,我们只会看到一个空白屏幕和闪烁的光标:

enter image description here

"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.

如果您不是`printf`大师,可以使用以下命令确认`main.img`的内容:
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:特殊的内存区域,如果写入,则会打印到屏幕上。可以在保护模式下使用。

  • 编写驱动程序并直接与显示硬件通信。这是“正确”的方法:更强大,但更复杂。

  • 串行端口。这是一种非常简单的标准化协议,可从主机终端发送和接收字符。

    在桌面上,它看起来像这样:

    enter image description here

    来源

    不幸的是,在大多数现代笔记本电脑上没有暴露,但对于开发板来说是常见的方法,请参见下面的ARM示例。

    这真的很遗憾,因为这样的接口非常有用例如用于调试Linux内核

  • 使用芯片的调试功能。例如,ARM将其称为半主机。在实际硬件上,它需要一些额外的硬件和软件支持,但在仿真器上,它可以是一个免费方便的选项。示例

这里我们将以 x86 为例进行 BIOS 示例,但需要注意这不是最健壮的方法。

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"

GitHub上游

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

结果:

enter image description here

在T430上:

enter image description here

测试环境:Lenovo Thinkpad T430,UEFI BIOS 1.16。磁盘是在Ubuntu 18.04主机上生成的。
除了标准的用户空间汇编指令外,我们还有:
  • .code16:告诉GAS输出16位代码

  • cli:禁用软件中断。这些中断可能会使处理器在hlt之后重新开始运行

  • int $0x10:进行BIOS调用。这是逐个打印字符的方法。

重要的链接标志是:
  • --oformat binary:输出原始二进制汇编代码,不像常规用户空间可执行文件那样将其包装在ELF文件中。
为了更好地理解链接器脚本部分,请熟悉链接的重定位步骤: 链接器是做什么的? 更酷的x86裸金属程序:
这里有一些我实现过的更复杂的裸金属设置:

使用C而非汇编

总结:使用GRUB multiboot,这将解决许多你从未考虑过的烦人问题。请参见下面的部分。

x86的主要困难在于BIOS仅将512个字节从磁盘加载到内存中,而使用C时可能会超出这512个字节的限制!

为了解决这个问题,我们可以使用双阶段引导加载程序。这会进行更多的BIOS调用,从磁盘中加载更多的字节到内存中。这是一个从头开始的最小化第二阶段汇编示例,使用int 0x13 BIOS calls
或者:
  • 如果你只需要在QEMU上使用它而不是真实的硬件,可以使用-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

C标准库
如果你想要使用C标准库,情况会变得更有趣,因为我们没有Linux内核,它通过POSIX实现了许多C标准库功能。
一些可能的选择,而不需要像Linux这样的完整操作系统,包括:
  • Write your own. It's just a bunch of headers and C files in the end, right? Right??

  • Newlib

    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 多重引导

引导扇区很简单,但并不是非常方便:

  • 每个磁盘只能有一个操作系统
  • 加载代码必须非常小,适合于 512 字节
  • 您必须自己进行大量的启动工作,例如进入保护模式

正因为这些原因,GNU GRUB 创建了一种更方便的文件格式,称为 multiboot。

最小工作示例:https://github.com/cirosantilli/x86-bare-metal-examples/tree/d217b180be4220a0b4a453f31275d38e697a99e0/multiboot/hello-world

我还在我的GitHub示例存储库上使用它,以便能够轻松地在真实硬件上运行所有示例,而不用一遍又一遍地烧录USB。
QEMU的结果:

enter image description here

T430:

enter image description here

如果将操作系统准备为多启动文件,则GRUB可以在常规文件系统中找到它。 这是大多数发行版所做的,将操作系统映像放在 /boot 下。 多启动文件基本上是带有特殊标头的 ELF 文件。 它们由GRUB指定:https://www.gnu.org/software/grub/manual/multiboot/multiboot.html 您可以使用 grub-mkrescue 将多启动文件转换为可启动磁盘。
固件
事实上,引导扇区不是运行于系统 CPU 上的第一个软件。
实际上首先运行的是所谓的固件,它是一种软件:
- 由硬件制造商制作 - 通常是闭源但可能基于C语言 - 存储在只读存储器中,因此更难/无法在没有供应商同意的情况下进行修改。
知名的固件包括:
  • BIOS:旧的 x86 固件,无处不在。SeaBIOS 是 QEMU 使用的默认开源实现。
  • UEFI:BIOS 的继任者,更好地标准化,但更加强大且异常臃肿。
  • Coreboot:崇高的跨架构开源尝试。

固件执行以下操作:

  • 遍历每个硬盘、USB、网络等,直到找到可启动的内容。

    当我们运行 QEMU 时,-hda 表示将 main.img 作为硬盘连接到硬件上,并且 hda 是第一个被尝试的,因此使用它。

  • 将前 512 字节加载到 RAM 内存地址 0x7c00,将 CPU 的 RIP 放置在那里并让其运行。

  • 在显示器上显示启动菜单或 BIOS 打印调用等内容。

固件提供了类似操作系统的功能,大多数操作系统都依赖于它。例如,Python子集已经被移植到BIOS / UEFI上运行:https://www.youtube.com/watch?v=bYQ_lq5dcvM 可以认为固件与操作系统无法区分,而固件是唯一可以进行“真正”的裸机编程的方式。
正如CoreOS开发者所说的那样:
困难的部分
当您启动PC时,构成芯片组(北桥、南桥和SuperIO)的芯片尚未正确初始化。即使BIOS ROM与CPU相距甚远,但CPU可以访问它,因为必须这样做,否则CPU将没有指令可执行。这并不意味着BIOS ROM被完全映射,通常不是这样。但是只有足够的映射才能启动引导过程。其他任何设备都可以忘记了。
当您在QEMU下运行Coreboot时,可以尝试使用Coreboot的更高层以及负载,但是QEMU提供的很少机会来尝试低级别的启动代码。首先,RAM从一开始就可以正常工作。
BIOS初始状态后
像硬件中的许多事物一样,标准化很弱,您不应该依赖代码在BIOS之后运行时寄存器的初始状态。
因此,请自己使用以下初始化代码: https://dev59.com/olwY5IYBdhLWcg3wh4Pc#32509555%ds%es 这样的寄存器具有重要的副作用,因此即使您没有明确使用它们,也应将它们清零。
请注意,一些仿真器比真实硬件更好,并提供良好的初始状态。但当您在真实硬件上运行时,一切都会崩溃。 El Torito 可以刻录到 CD 上的格式:https://en.wikipedia.org/wiki/El_Torito_%28CD-ROM_standard%29 还可以制作混合映像,可在 ISO 或 USB 上工作。这可以使用 grub-mkrescueexample)完成,并且也是 Linux 内核在使用 isohybridmake isoimage 上执行的操作。 ARM 在ARM中,总体思路是相同的。
对于IO,我们没有像BIOS一样广泛可用的半标准预装固件可供使用,因此我们可以进行的最简单的两种类型的IO是:
  • 串口,在开发板上广泛可用
  • 闪烁LED
我已上传: 一些与x86不同的地方包括:
  • IO是通过直接写入魔术地址来完成的,没有inout指令。

    这被称为内存映射IO

  • 对于一些真实的硬件,比如树莓派,你可以自己将固件(BIOS)添加到磁盘镜像中。

    这是一个好事情,因为它使得更新固件更加透明。

资源


4
Unikernels是一种替代方案,适用于那些不能或不想深入底层的人,但仍希望从其非常小的内存占用中受益。 - AndreLDM
1
@AndreLDM 我本来想要发布那篇基于Linux的Unikernel新闻的,但感觉还有些冒险。链接在这里:https://next.redhat.com/2018/11/14/ukl-a-unikernel-based-on-linux/ - Ciro Santilli OurBigBook.com
30
非常详细的答案,但“没有操作系统运行的程序就是操作系统”并不正确。你可以编写一个简单地闪烁 LED 灯的程序,但这并不意味着它是操作系统。一些运行在你的闪存驱动器上的微控制器固件代码也不构成操作系统。操作系统至少是一个抽象层,使得其他软件更容易编写。如今,最基本的要求是:如果没有调度程序,那么它很可能不是操作系统。 - Vitali
9
除了那段绝对胡言乱语的内容以外,你的回答很好。这是指任何不在操作系统上运行的程序都不是一个操作系统。 - curiousdannii
4
嗨,@MichaelPetch,只是为了避免启动扇区上的空值 :-) 可能不值得这样做。 - Ciro Santilli OurBigBook.com
显示剩余19条评论

179
如何在没有操作系统运行的情况下单独运行程序? 将二进制代码放置在处理器在重新启动后寻找的位置(例如,ARM上的地址0)。
您可以创建计算机可以在启动时加载和运行的汇编程序(例如,从闪存驱动器引导计算机并运行驻留在驱动器上的程序)吗? 一般性回答:是可以实现的。 通常称为“裸机编程”。 要从闪存驱动器读取,您需要了解USB,并且需要有一些驱动程序来处理此USB。该驱动器上的程序还必须以某种特定格式存在于某个特定文件系统中...这是引导加载程序通常会执行的操作,但如果固件仅加载小块代码,则您的程序可以包括自己的引导加载程序,因此它是自包含的。
许多ARM板可以让您执行其中的一些操作。有些具有用于基本设置的引导加载程序。
这里,您可以找到有关在Raspberry Pi上制作基本操作系统的详细教程。
编辑: 本文和整个wiki.osdev.org将回答您的大部分问题 此外,如果您不想直接在硬件上进行实验,可以使用类似qemu的虚拟化软件作为虚拟机运行。请参阅如何在虚拟化的ARM硬件上直接运行“hello world”这里

13

以操作系统为灵感

操作系统也是一个程序,因此我们可以通过从头开始创建或更改(限制或添加)一些小型操作系统的功能,然后在启动过程中运行它(使用ISO镜像)来创建自己的程序

例如,可以使用此页面作为起点:

如何编写简单的操作系统

在这里,整个操作系统完全适合于512字节的引导扇区(MBR)中!

这样或类似的简单操作系统可以用来创建一个简单的框架,使我们能够:

使引导加载磁盘上的后续扇区到RAM中,并跳转到该点以继续执行。或者您可以阅读FAT12有关内容,这是软盘驱动器上使用的文件系统,并实现该文件系统

然而,有很多可能性。例如,要查看一个更大的x86汇编语言操作系统,我们可以探索MykeOS,这是一个学习工具,展示了简单的16位实模式操作系统的工作原理,配有注释良好的代码广泛的文档

启动加载程序作为灵感

其他常见类型的在没有操作系统的情况下运行的程序也是启动加载程序。我们可以创建一个受此概念启发的程序,例如使用此网站:

如何开发自己的启动加载程序

上述文章还介绍了这种程序的基本架构

  1. 在0000:7C00地址正确加载到内存中。
  2. 调用使用高级语言编写的BootMain函数
  3. 在显示屏上显示“来自底层的Hello, world…”信息。

正如我们所看到的,这种架构非常灵活,可以实现任何程序,不一定是引导加载程序。

特别地,它展示了如何使用“混合代码”技术,借助于该技术,我们可以将CC++高级结构汇编低级命令相结合。这是一种非常有用的方法,但我们必须记住:

要构建程序并获得可执行文件,您需要16位模式下的汇编器和链接器。对于C/C++,您只需要能够创建16位模式下的目标文件的编译器

本文还展示了如何查看创建的程序的运行情况以及如何进行测试和调试。

UEFI应用程序的灵感

上述示例利用了在数据介质上加载扇区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)从网络加载代码,这使我们可以在计算机上运行程序,无论其操作系统甚至无论与计算机直接连接的任何存储介质

什么是网络引导(PXE)以及如何使用它?


1

我编写了一个基于Win32的C++程序,用于将汇编代码写入U盘的引导扇区。当计算机从U盘启动时,它可以成功地执行该代码 - 在这里查看C++ Program to write to the boot sector of a USB Pendrive

这个程序只有几行代码,应该在配置了Windows编译的编译器上编译 - 例如Visual Studio编译器 - 任何可用版本都可以。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接