内容简介:Seeing a program you wrote running directly on the bare metal is deeply satisfying to anyone who enjoys writing software. And it turns out that creating such a program from scratch is easier than it sounds. The hardest part is figuring out where to start.
Seeing a program you wrote running directly on the bare metal is deeply satisfying to anyone who enjoys writing software. And it turns out that creating such a program from scratch is easier than it sounds. The hardest part is figuring out where to start. In this tutorial, I'll show you how to write, build and run the classic "Hello, World!" in pure assembly.
Well, almost – on a modern computer, our assembly application doesn't just have direct access to the hardware. Instead it must communicate with an intermediary firmware layer on the motherboard, through the so-called Unified Extensible Firmware Interface (UEFI).
Creating a "Hello, World!" UEFI application is a three-step process:
- Set up a virtual machine with UEFI support.
- Install an assembler that can output UEFI applications.
- Write the actual program and run it!
Setting up QEMU
We could test our application by repeatedly copying it to a USB drive, rebooting our computer and then seeing if it works. Obviously, we don't want to do that. Instead, we'll be testing our application inside a virtual machine, and QEMU is a great fit for this purpose.
Step 1 — Install QEMU
If you are using a GNU/Linux distribution, there's a good chance
that it has a qemu
package available. I'll assume that
you're using Ubuntu, but you can easily adapt these instructions to
whichever system you're using.
$ sudo apt-get install qemu
Once the intallation finishes, you should have qemu-system-x86_64
availabe in your path:
$ qemu-system-x86_64 --version QEMU emulator version 2.11.1(Debian 1:2.11+dfsg-1ubuntu7.23) Copyright (c) 2003-2017 Fabrice Bellard and the QEMU Project developers
While most modern computers come with UEFI-support built-in, QEMU does not. Instead, we will use something called OVMF , which is a firmware layer that adds UEFI support to QEMU.
Step 2 — Set up OVMF
Again, there is probably a package available for your distribution. If you are using Ubuntu, run:
$ sudo apt-get install ovmf
This should install the file OVMF.fd
somewhere on your system. Figure out
where:
$ find / -name OVMF.fd 2>/dev/null /usr/share/ovmf/OVMF.fd
Then, go ahead and copy this file to your working directory:
$ cp /usr/share/ovmf/OVMF.fd .
(On some GNU/Linux distributions, the relevant package may install both a 32-bit and a 64-bit version; in this case, use the 64-bit version.)
The UEFI firmware provides a bare-bones environment called the UEFI shell. We'll want to test the application that we build from this shell. Let's check that it's working correctly.
Step 3 — Try the UEFI shell.
You should have the
file OVMF.fd
available in your working directory from step
2. To tell QEMU about this firmware, use the argument -bios
OVMF.fd
. We also need to specify -net none
so QEMU won't try to boot over the network:
$ qemu-system-x86_64 -bios OVMF.fd -net none
This will land you in a shell that looks a little something like this:
UEFI Interactive Shell v2.2 EDK II UEFI v2.70 (EDK II, 0x00010000) Mapping table BLK0: Alias(s): PciRoot(0x0)/Pci(0x1,0x1)/Ata(0x0) Press ESC in 1 seconds to skip startup.nsh or any other key to continue. Shell>
Try typing help -b
to get a list of
commands.
We need some way of accessing the executable that we build from inside the UEFI shell. The shell can read FAT32 file systems. Conveniently, QEMU can mount a directory in the host file system as a FAT32 formatted drive in the virtual machine.
Step 4 — Open files from the UEFI shell
We'll store the files on our emulated drive in a directory on the
host called drive/
. For now, let's just create a small test
file that we'll try to read:
$ mkdir drive $ echo "This is a test" > drive/test.txt
To make this directory available as a FAT32 formatted drive inside QEMU,
use the -drive
option like this:
$ qemu-system-x86_64 -bios OVMF.fd -net none -drive format=raw,file=fat:rw:<u>drive/</u>
Inside QEMU, you will now see that there is a new file system
available called FS0:
. Switch to it and look for the text
file that you just created:
Shell> fs0: FS0:\> dir Directory of: FS0:\ 03/21/2020 17:46 15 test.txt 03/21/2020 16:49 10,565 NvVars 2 File(s) 10,580 bytes 0 Dir(s) FS0:\> cat test.txt This is a test
Great! We can now transfer our executable into QEMU and open it from the shell. This gives us an environment that is very similar to what we would get if we inserted a FAT32 formatted USB drive into a real computer with a UEFI shell installed.
Assembling a UEFI application with FASM
It would be nice if we could just put a bunch of machine code on a USB drive and have our computer execute that. Unfortunately, that's not how it works. All modern operating systems store executables as object files . An object file contains both the machine code itself and some information about how that code should be loaded and executed.
The specific object file format used by UEFI is the so-called Portable
Executable
format. This format is also used by Microsoft Windows
for .exe
and .dll
files.
An assembler is a program that generates an object file from assembly code. There are many assemblers, but for this tutorial we'll be using FASM , because it's easy to use and has built-in support for the object file format we need.
Step 5 — Install FASM
On Ubuntu, FASM is available in the standard repository:
$ sudo apt-get install fasm
Check that you have FASM installed and in your path:
$ fasm flat assembler version 1.73.13 usage: fasm <source /> [output] optional settings: -m <limit> set the limit in kilobytes for the available memory -p <limit> set the maximum allowed number of passes -d <name>=<value> define symbolic variable -s <file> dump symbolic information for debugging
FASM can generate code for many different output formats. We need to
tell it to generate a 64-bit Portable Executable with some extra flags
set to specify that it's a UEFI application. This is done using the format
directive:
format pe64 efi
Let's start by writing a program that is even simpler than "Hello,
World!" – a program that does absolutely nothing. This program will
start at the main:
label and then immediately return to the
caller:
entry main main: ret
The Portable Executable format, like most executable file formats,
supports multiple sections
. One section contains the
program code, another contains data that is loaded into RAM when the
program is started, and so on. The machine code should sit in a section
called .text
, and we should specify that data in this
section may be both executed and read:
section '.text' executable readable
Thus, the final program, which does nothing and then exists, is:
format pe64 efi entry main section '.text' executable readable main: ret
Now we just need to assemble this program, and then we'll be able to run it inside QEMU.
Step 6 — Assemble and run an empty executable
Save the program as empty.asm
:
$ cat > empty.asm format pe64 efi entry main section '.text' executable readable main: ret
Then, assmble it with FASM, and put the output in drive/empty
$ mkdir -p drive $ fasm empty.asm drive/empty
Now, we can mount drive/
as a FAT32 formatted drive in
QEMU. Then, we just need to run the application off the drive from
inside the UEFI shell. If everything goes according to plan, you should
see nothing happen:
$ qemu-system-x86_64 -bios OVMF.fd -net none -drive format=raw,file=fat:rw:drive/ UEFI Interactive Shell v2.2 EDK II UEFI v2.70 (EDK II, 0x00010000) Mapping table FS0: Alias(s):HD0a1:;BLK1: PciRoot(0x0)/Pci(0x1,0x1)/Ata(0x0)/HD(1,MBR,0xBE1AFDFA,0x3F,0xFBFC1) BLK0: Alias(s): PciRoot(0x0)/Pci(0x1,0x1)/Ata(0x0) BLK2: Alias(s): PciRoot(0x0)/Pci(0x1,0x1)/Ata(0x0) Press ESC in 1 seconds to skip startup.nsh or any other key to continue. Shell> fs0:\empty Shell>
Now that we have figured out how to assemble a UEFI application and run it inside QEMU, let's see if we can do something a little bit more interesting. Yes, it's finally time to write our "Hello, World!" program!
Using UEFI services to write output
The UEFI firmware provides some standardized services. Among them is a service that we can use to show text on the screen called the Simple Text Output Protocol. The UEFI Specification contains everything you could ever possibly want to know about UEFI – and then some. (This tutorial uses version 2.8, updated in Feburary 2020.)
For the purposes of writing "Hello, World!", we are mostly interested in the following sections:
- Sections 2.3.1 and 2.3.4 (Data types and x86-64 calling conventions)
- Section 4 (EFI System Table)
- Section 12 (Console support)
If anything in this tutorial is unclear, you can probably find an answer to your question in one of those sections. I'll try to summarize the important parts here.
When our UEFI application is started, a pointer to the EFI System
Table will be in RDX
. The System Table is a structure that
contains, among other things, a pointer to a Simple Text Output Protocol.
This protocol is itself a structure that contains a function to print
output to the screen.
The specification contains definitions of all the structures that we need to worry about. On page 92, we find:
typedef struct { EFI_TABLE_HEADER Hdr; CHAR16 *FirmwareVendor; UINT32 FirmwareRevision; EFI_HANDLE ConsoleInHandle; EFI_SIMPLE_TEXT_INPUT_PROTOCOL *ConIn; EFI_HANDLE ConsoleOutHandle; EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL *ConOut; EFI_HANDLE StandardErrorHandle; EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL *StdErr; EFI_RUNTIME_SERVICES *RuntimeServices; EFI_BOOT_SERVICES *BootServices; UINTN NumberOfTableEntries; EFI_CONFIGURATION_TABLE *ConfigurationTable; } EFI_SYSTEM_TABLE;
We need to calculate the offset to ConOut
inside this
structure. The header is 12 bytes (cf. the definition on page 90), UINT32
is 4 bytes, and EFI_HANDLE
and all
pointer types are 8 bytes. However, note that each member of the
structure is 8-byte aligned; this means that there are 4 bytes of padding
after the header and the UINT32
. So, in fact, the ConOut
pointer will be at index (12 + 4)
+ (4 + 4) + 8 · 5 = 64
in the structure.
Thus, we can load the Simple Text Output Protocol ConOut
like so:
;; Recall that RDX contains a pointer to the System Table when ;; our application is called. So rdx + 64 is the address of the ;; pointer to ConOut, and [rdx + 64] is the pointer itself. mov rcx, [rdx + 64]
The Simple Text Output Protocol structure looks like this (page 442):
typedef struct _EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL { EFI_TEXT_RESET Reset; EFI_TEXT_STRING OutputString; EFI_TEXT_TEST_STRING TestString; EFI_TEXT_QUERY_MODE QueryMode; EFI_TEXT_SET_MODE SetMode; EFI_TEXT_SET_ATTRIBUTE SetAttribute; EFI_TEXT_CLEAR_SCREEN ClearScreen; EFI_TEXT_SET_CURSOR_POSITION SetCursorPosition; EFI_TEXT_ENABLE_CURSOR EnableCursor; SIMPLE_TEXT_OUTPUT_MODE *Mode; } EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL;
We are interested in calling the function OutputString
,
which is at offset 8 inside this structure. (Each function pointer is 8
bytes.) This function takes two arguments: a pointer to the Output
Protocol and the string itself (see page 444).
We can load the address of the function into RAX
like so:
;; Now, RCX contains the ConOut pointer. Thus, the address of ;; the OutputString function is at rcx + 8. We'll move this ;; function into RAX: mov rax, [rcx + 8]
FASM has a handy du
instruction, which we can use to
store a 16-bit string. (See the FASM manual
for more information about how du
works.) The string should end with a carriage return and a
line feed. We'll store the string in the .data
section like
so:
section '.data' readable writable string du 'Hello, World!', 0xD, 0xA, 0
The calling conventions (see page 27) specify that the function takes
its first argument in RCX
and its second argument in RDX
. We must also allocate 32 bytes of so-called shadow
space
on the stack before calling the function. Once we have set
everything up, we can call the function in RAX
:
;; We already have the ConOut pointer in RCX. Let's load the ;; string pointer into RDX: mov rdx, string ;; Set up the shadow space. We just need to reserve 32 bytes ;; on the stack, which we do by manipulating the stack pointer: sub rsp, 32 ;; Now we can call the OutputString function, whose address is ;; in the RAX register: call rax ;; Finally, we'll clean up the shadow space and then return: add rsp, 32 ret
And that's our "Hello, World!" program. Now we need to assemble it into an executable.
Step 7 — Assemble "Hello, World!"
Create a file with
the full source code for the assembly program, and save it as hello.asm
:
$ cat > hello.asm format pe64 efi entry main section '.text' executable readable main: ;; Recall that RDX contains a pointer to the System Table when ;; our application is called. So rdx + 64 is the address of the ;; pointer to ConOut, and [rdx + 64] is the pointer itself. mov rcx, [rdx + 64] ;; Now, RCX contains the ConOut pointer. Thus, the address of ;; the OutputString function is at rcx + 8. We'll move this ;; function into RAX: mov rax, [rcx + 8] ;; We already have the ConOut pointer in RCX. Let's load the ;; string pointer into RDX: mov rdx, string ;; Set up the shadow space. We just need to reserve 32 bytes ;; on the stack, which we do by manipulating the stack pointer: sub rsp, 32 ;; Now we can call the OutputText function, whose address is ;; in the RAX register: call rax ;; Finally, we'll clean up the shadow space and then return: add rsp, 32 ret section '.data' readable writable string du 'Hello, World!', 0xD, 0xA, 0
Then, assemble it with FASM, and save the output in drive/hello
:
$ fasm hello.asm drive/hello
With the program in the drive/
directory, we can mount
this directory inside QEMU as a FAT32 formatted drive in the same way
that we did before. And then we can finally run our "Hello, World!"
program!
Step 8 — Run "Hello, World!" in QEMU
You should have drive/hello
from the previous step. Run
QEMU:
$ qemu-system-x86_64 -bios OVMF.fd -net none -drive format=raw,file=fat:rw:drive/
Then, in the UEFI shell execute FS0:\hello
:
Shell> FS0:\hello Hello, World! Shell>
And there you go!
Perhaps you would like to see your "Hello, World!" program running in all its glory on your real, physical hardware. This is actually pretty easy to do. You just need to format a USB drive with a FAT32 file system, and then you can put your file directly on the USB drive. If you have a UEFI shell built-in to your firmware, then you can run the program from there.
Otherwise, you can save the file as \EFI\BOOT\BOOTx64.EFI
on the USB drive; this will make the UEFI firmware think that it's a
bootable device (like a USB drive with an operating system or a
bootloader on it), and let you select that drive from the boot menu. If
you're planning to boot directly into your "Hello, World!" program, you
should change the ret
instruction to jmp $
.
This will cause the program to wait forever so that it doesn't shut down
immediately after booting from it.
Step 9 — Create a bootable USB drive.
Start by replacing ret
with jmp $
so you
can acutally see your program running. Then reassemble it to hello
:
$ sed -i 's/ret$/jmp $/' hello.asm $ fasm hello.asm hello
I'll assume that you have an empty USB drive available as /dev/sdx
. We'll need mkfs.vfat
to format the
USB drive. On Ubunut, you can install this with:
$ sudo apt-get install dosfstools
Now, format the drive as FAT32:
$ sudo mkfs.vfat -F32 /dev/sdx
Then, mount the device:
$ mkdir -p mnt $ sudo mount /dev/sdx mnt
Now, copy the hello
executable to \EFI\BOOT\BOOTx64.EFI
on the device:
$ mkdir -p mnt/EFI/BOOT $ cp hello mnt/EFI/BOOT/BOOTx64.EFI $ sudo umount mnt
Now you should be able to plug your USB drive into your computer and boot from it!
Of course, a "Hello, World!" program isn't all that interesting. But with this, you have a good starting point to make much more intersting applications. In fact, it's possible to make an entire operating system as a UEFI application. If this is something that sounds interesting to you, I'd recommend the OSDev Wiki as an excellent place to get started. Have fun!
Thanks for reading. This article took around 11 hours
to research and write. If you found it useful and want to support me, you
can send a small tip to my Monero
address: 86DgwJ6H15nU4uTtZ1MVNk1EyudtUPEWDcAZg3VSGU6RU44LvzQQcx6Ry8tHqQo5CficxMvNd2i7Ca4GmtJSXwqZ3rnvAwU
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
了不起的Node.js
劳奇 (Guillermo Rauch) / 赵静 / 电子工业出版社 / 2014-1 / 79.00元
本书是一本经典的 Learning by Doing的书籍。它由 Node社区著名的 Socket.IO作者—— Guillermo Rauch,通过大量的实践案例撰写,并由 Node社区非常活跃的开发者—— Goddy Zhao翻译而成。 本书内容主要由对五大部分的介绍组成: Node核心设计理念、 Node核心模块 API、Web开发、数据库以及测试。从前到后、由表及里地对使用 Node......一起来看看 《了不起的Node.js》 这本书的介绍吧!