0 Setting up the development environment
Every listing in this book was produced by running the commands you see, on the tools described in this chapter. If your tools differ, the output you get will differ too: addresses move, instructions change, and a reader who cannot match what is on the page to what is on the screen quickly loses confidence. The first edition of this book was written against Ubuntu 16.04 and gcc 5.4; readers who tried to follow it a few years later found that the same command produced a different program, and had no way to know whether they or the book were at fault. This chapter exists so that this never happens to you. Spend half an hour here before starting chapter 1, and the rest of the book will behave exactly as printed.
0.1 The tools
We need surprisingly few tools to write an operating system, and all of them are free software that has been around for decades:
gcc, the GNU C compiler, together with binutils: the assembler
as, the linkerld, and the inspection toolsobjdump,readelf,nmandobjcopy. Chapters 4 and 5 are built almost entirely onobjdumpandreadelf.nasm, the Netwide Assembler. The bootloader in chapter 7 is written in nasm syntax, which is the Intel syntax used throughout the Intel manuals.
gdb, the GNU debugger. Chapter 6 is devoted to it, and from chapter 7 on it is our only window into a machine that has no operating system to tell us what went wrong.
QEMU, a machine emulator. It plays the role of the physical computer. It boots our disk image exactly as a real BIOS would, and it exposes a gdb stub so that we can stop the emulated CPU at any instruction, including the very first one the BIOS executes.
GNU Make, to automate the build. Its syntax is summarized in Appendix B.
A text editor of your choice, and a terminal. The book never depends on an IDE.
The book targets 32-bit x86. On a 64-bit Linux this means gcc must be able to produce 32-bit code, which most distributions package separately under a name such as multilib. This is the single most common installation problem, so check it first (see the smoke test below).
0.2 Option A: install the tools on Linux
On Debian or Ubuntu:
$ sudo apt install build-essential gcc-multilib nasm gdb qemu-system-x86 make git
On Fedora:
$ sudo dnf install gcc glibc-devel.i686 libgcc.i686 nasm gdb qemu-system-x86 make git
On Arch Linux, after enabling the multilib repository in
/etc/pacman.conf:
$ sudo pacman -S base-devel lib32-glibc lib32-gcc-libs nasm gdb qemu-system-x86 make git
The exact versions do not matter much for Part I and Part II, with one important exception discussed in the next section: modern compilers produce position-independent executables by default, and the book’s commands include flags to turn that off. If your machine is not an x86 computer at all, for example a laptop with an ARM processor, your gcc cannot produce x86 code and you should use option B.
0.3 Option B: the container (recommended)
The repository of the book ships a container image definition,
tools/Dockerfile, that installs exactly the tool versions
used to produce the listings. It works on Linux, on Windows (through WSL
2 and Docker Desktop) and on macOS (Docker Desktop, including Apple
silicon machines, where the x86 tools are emulated). This is also what
the continuous integration of the repository runs, so if the book builds
and boots there, it will build and boot for you.
Build the image once:
$ git clone https://github.com/tuhdo/os01.git
$ cd os01
$ docker build -t os01 tools/
Then start a shell in it whenever you want to work, with the
repository mounted at /work:
$ docker run --rm -it --user "$(id -u):$(id -g)" --security-opt seccomp=unconfined -v "$PWD":/work -w /work os01
The --user option makes the files you create belong to
you rather than to root; the -v option shares
the current directory with the container;
--security-opt seccomp=unconfined lets gdb turn off
address-space randomization for the programs it debugs in chapter 6
(Docker’s default system-call filter forbids it, and gdb would print a
warning at every run). Everything else in the book is the
same whether you are inside the container or on a Linux installation of
your own.
The versions inside the image at the time of writing:
| tool | version |
|---|---|
| gcc | 14.2.0 (Debian) |
| binutils | 2.44 |
| nasm | 2.16.03 |
| gdb | 16.3 |
| QEMU | 10.0 |
| make | 4.4.1 |
0.4 Why the book’s
gcc commands carry extra flags
Throughout Part I you will compile small C programs and look at the machine code gcc produced for them. The commands look like this:
$ gcc $BOOKFLAGS hello.c -o hello
where BOOKFLAGS is a shell variable that the container
already defines, and that you should define yourself if you installed
the tools natively (put the line in your ~/.bashrc):
$ export BOOKFLAGS="-m32 -no-pie -fno-pie -fno-asynchronous-unwind-tables -fcf-protection=none -O0"
That is a lot of flags for a Hello World, and each one is there for a reason. Here is what happens without them. Take this program:
hello.c
#include <stdio.h>
int main(int argc, char *argv[])
{
printf("Hello World!\n");
return 0;
}Compile it the simple way and disassemble main (the
objdump command is explained in chapter 4; for now just
look at the shape of the output):
$ gcc -m32 hello.c -o hello
$ objdump -M intel -d hello
0000118d <main>:
118d: 8d 4c 24 04 lea ecx,[esp+0x4]
1191: 83 e4 f0 and esp,0xfffffff0
1194: ff 71 fc push DWORD PTR [ecx-0x4]
1197: 55 push ebp
1198: 89 e5 mov ebp,esp
119a: 53 push ebx
119b: 51 push ecx
119c: e8 28 00 00 00 call 11c9 <__x86.get_pc_thunk.ax>
11a1: 05 53 2e 00 00 add eax,0x2e53
11a6: 83 ec 0c sub esp,0xc
11a9: 8d 90 14 e0 ff ff lea edx,[eax-0x1fec]
11af: 52 push edx
11b0: 89 c3 mov ebx,eax
11b2: e8 89 fe ff ff call 1040 <puts@plt>
...
Now compile with the book’s flags:
$ gcc -m32 -no-pie -fno-pie -fno-asynchronous-unwind-tables -fcf-protection=none -O0 hello.c -o hello
$ objdump -M intel -d hello
08049166 <main>:
8049166: 8d 4c 24 04 lea ecx,[esp+0x4]
804916a: 83 e4 f0 and esp,0xfffffff0
804916d: ff 71 fc push DWORD PTR [ecx-0x4]
8049170: 55 push ebp
8049171: 89 e5 mov ebp,esp
8049173: 51 push ecx
8049174: 83 ec 04 sub esp,0x4
8049177: 83 ec 0c sub esp,0xc
804917a: 68 08 a0 04 08 push 0x804a008
804917f: e8 bc fe ff ff call 8049040 <puts@plt>
8049184: 83 c4 10 add esp,0x10
8049187: b8 00 00 00 00 mov eax,0x0
804918c: 8b 4d fc mov ecx,DWORD PTR [ebp-0x4]
804918f: c9 leave
8049190: 8d 61 fc lea esp,[ecx-0x4]
8049193: c3 ret
The two programs do the same thing, but the second one is what the rest of this book expects you to see:
-no-pie -fno-pie: the first program is a position-independent executable. Its code starts at the tiny address0x118dbecause the operating system will move it to a random address every time it runs, a security measure against exploits. To be movable, the code cannot embed any absolute address: look at the call to__x86.get_pc_thunk.axfollowed by anaddand alea; that sequence computes “where am I?” at run time so that the string"Hello World!"can be found relative to the current instruction. The second program lives at the fixed address0x8049166and simply pushes the address of the string,0x804a008. On bare metal there is no operating system to relocate anything, and fixed addresses are precisely what we want to learn to control; the extra code only gets in the way. Most distributions configure gcc with--enable-default-pie; you can check withgcc -v.-fno-asynchronous-unwind-tables: by default gcc emits a.eh_framesection describing how to unwind the stack at every instruction, used by C++ exceptions and debuggers. It is useless to us and clutters the ELF file we will dissect in chapter 5.-fcf-protection=none: on recent gcc builds every function starts with anendbr32instruction, a marker for Intel’s control-flow enforcement technology. It is harmless but mysterious to a beginner, so we turn it off.-O0: no optimization, so that the generated assembly follows the C source line by line. Chapter 4 relies on this to show how each C construct maps to machine code.-m32: produce 32-bit code. The kernel we write in Part III runs in 32-bit protected mode; keeping Part I in 32-bit as well means you only have to learn one register set and one calling convention. Chapter 15 explains what changes in 64-bit mode.
When a listing needs debugging information, -g is added
explicitly. When a listing was produced with other flags, the command is
printed next to it. If you ever get different output from what is
printed, the first thing to check is the flags, the second is the tool
versions in the table above.
0.5 Smoke test
Before moving on, verify that everything works end to end. The
repository contains the code of every chapter under code/.
From the top of the repository (inside the container if you chose option
B):
$ make -C code/chapter7/os
$ make -C code/chapter7/os test
The first command assembles the bootloader of chapter 7, creates a floppy-disk image and writes the bootloader into its first sector. The second boots that image in QEMU without a display, attaches gdb to the emulated machine and checks that the CPU reaches the code that the bootloader loaded. You should see:
boot-test: ok, stopped at 0x500
Then try the interactive version. In one terminal:
$ make -C code/chapter7/os qemu
A QEMU window opens and stays black: the machine is stopped at its first instruction, waiting for a debugger. In a second terminal:
$ cd code/chapter7/os
$ gdb
The .gdbinit file in that directory connects to QEMU and
sets breakpoints, exactly as chapter 7 will explain. Type c
to let the machine run, and the QEMU window shows the BIOS booting from
the floppy. Type q to leave gdb, and close QEMU. If any of
these steps fails, fix it now; the error messages of a missing 32-bit
library or a missing QEMU are far easier to understand here than halfway
through chapter 8.
If gdb refuses to load the .gdbinit file,
it is because recent versions only auto-load scripts from directories
you have declared safe. Add this line to ~/.gdbinit (the
container already has it):
add-auto-load-safe-path /
0.6 A note on Windows and macOS
The book uses Linux because its tools are the ones the x86 world was built with, and because the hosted programs in Part I are ELF files, the same format our kernel will use. Windows produces PE files and macOS produces Mach-O files, so even the compiler steps of chapter 4 would look different there. The container gives you a complete Linux environment in a few minutes; use it rather than trying to replicate the tools natively.
Exercise 0.1. Run gcc -v on your
machine and find the --enable-default-pie option in its
configuration line. Then compile hello.c once with and once
without -no-pie -fno-pie, and run file hello
on each result. One is reported as pie executable, the
other as executable.
Exercise 0.2. The linker has a built-in default
script that it uses when none is given. Display it with
ld --verbose and find the line that places the first byte
of code at 0x08048000 (32-bit) or 0x400000
(64-bit). You will write your own version of this script in chapter
8.
Exercise 0.3. Run
qemu-system-i386 -machine help. The book uses the
q35 machine, described in chapter 3. Find it in the list,
together with the older pc machine that QEMU uses by
default.
0.7 Check your understanding
Why does the book compile every hosted program with
-no-pie -fno-pie? What does position independence buy a hosted program, and why is it in our way?The two listings of
maindo the same thing, yet the first one calls__x86.get_pc_thunk.ax. What does that call compute, and why does the second listing not need it?Your machine is 64-bit. Why does the book insist on
-m32for the hosted programs of Part I rather than teaching 64-bit code from the start?What is the difference between the roles of QEMU and gdb in the smoke test? What would you lose with only one of them?
Why does the container run with
--security-opt seccomp=unconfined? What goes wrong in chapter 6 without it?A listing you reproduce shows different addresses from the book. In what order should you check things, and why is “the book is wrong” the last hypothesis?
gcc exists on Windows and on macOS. Why can the book’s steps still not be followed natively there?