Accidentally becoming a bootloader dev
Figured I'd start off my first proper blogpost with the problem
I recently found myself developing around. Over the years I've been
getting into more and more esoteric shit; I started programming
for real using some spinoff dialect of BASIC, then hit it off
seriously with QB64 (another, more mainstream dialect), and then
jumped into the deep end with just raw C++ and then C and a
smattering of other languages.
And you know how it goes, one thing leads to another and you get
caught up in the most fucked up of shit [1] known to programming
man just because you weren't careful enough to know better. Over
time (say, over the last 18 months) I settled into a weird cycle
of going back and forth between C and assembly language, and now
I'm feverishly coding in Aarch64.
So, when a boy meets the unbridled and toxic power of a programming 'language' that is geared towards pain and bitbanging, it's inevitable that he fall in love, and that love breeds monsters.
The First Sin
Anyone who's ever programmed in the kernel knows that it's a bit
of a pickle to work with. Unlike in user-space, you're very very
close to the metal, you're working with raw memory addresses,
you're fucking around and abusing the alloca() function [2] and unwinding your
stacks manually to save three cycles on some signal return code
that your compiler foolishly tried to play it safe with. This is
all really cool stuff that you can tell your friends and they'll be
really impressed (if they're absolute geek fucks who have never seen
a titty in their lives), and you'd be the star of the evening.
After three good mouthfuls of whisky you're starting to feel a bit doozy, and you notice the man in the corner of the room. Who is he? How did he get here? If you had any bitches, he'd be scaring them away right this fucking now. You don't have hoes, and the booze has made sure you definitely have no inhibitions either, so you come up to him and strike up a conversation.
'Hey, kiddo', he goes, 'do you want to learn about the dark dark arts, or are you just forever gonna be a cuck and live in the high-level lands?'
The way he says it oozes with testosterone and the smell of stale sweat, and you just know you've sold your soul to the devil.
Calling conventions
An operating system is no good if it can run only one process at any one time inside only one function. To facilitate the generation of function intercommunication, compiler vendors figured out that they should make it so that each individual snippet of code could be compiled independently after the preprocessor was done with its thing. All code should have an agreed-upon way of dealing with the stack and registers the CPU provides, and this means that a form of convention for calling a function came to be. A calling convention is basically the agreed-upon way that a caller and its subroutine deal with memory. On the calling side, the procedure has to deal with registers and passing arguments when calling a subroutine, how it gets them returned from the subroutine, and so on. On the called side, the subroutine handles receiving the args, managing the registers that its caller did not save, and then returning the result, and all this in a very rigid very consistent way.
Apart from there necessarily being a calling convention for every
architecture your code runs on (duh), the funny part is that there's
a calling convention for each compiler, vendor and operating system
you're compiling for. This is one of the reasons that, in the old
days of the Wild DOS West, calling a function compiled with a different
compiler than yours would mysteriously return garbage or nonsense
values [3] even though you did absolutely nothing wrong (as far as
you knew). One hairpull trigger for people working on Linux-Windows
interop is also calling convention mismatch: Linux kernel code uses the
SystemV calling convention expecting the order of arguments to
be RDI, RSI, RDX, R10, R8, R9 and Windows uses Microsoft's x86-64
calling convention that goes RCX, RDX, R8, R9, and handles
the stack differently (every function call always wastes 32 bytes on Windows).
But calling conventions are a myth. You don't have to use them, you don't have to care about them, you don't have to respect who saves what.
But how?
Assembly programming within an operating system
A good and polite compiler like GCC not only looks away when you shoot yourself in the foot, it politely gives you more ammo to do it when you run out. One of those footgun bullets is every major compiler letting you blast raw assembly inline into your code. For example, GCC pretends you're writing strings for your assembler, and lets you do the following shitfest: [4]
int foo(char *p, int a, int b) { int t1,t2; // dummy output spill slots int r1,r2; // dummy output tmp registers int res; asm ("# operands: %0 %1 %2 %3 %4 %5 %6 %7 %8\n\t" "imull $123, %[b], %[res]\n\t" "mov %[res], %[spill1]\n\t" "mov %[a], %%ecx\n\t" "mov %[b], %[tmp1]\n\t" // let the compiler allocate tmp regs, unless you need specific regs e.g. for a shift count "mov %[spill1], %[res]\n\t" : [res] "=&r" (res), [tmp1] "=&r" (r1), [tmp2] "=&r" (r2), // early-clobber [spill1] "=m" (t1), [spill2] "=&rm" (t2) // allow spilling to a register if there are spare regs , [p] "+&r" (p) , "+m" (*(char (*)[]) p) // dummy in/output instead of memory clobber : [a] "rmi" (a), [b] "rm" (b) // a can be an immediate, but b can't : "ecx" ); return res; // p unused in the rest of the function // so it's really just an input to the asm, // which the asm is allowed to destroy }
Furthermore, and what's really the funniest jokermode part of this, is that
you can just call (well, jmp to) functions from within your assembly,
and these other functions can be other assembly, and this lets you bypass the
pesky calling convention, ignore setting up the stack and saving registers
and, the actual Apple of Eden here, gives you a taste of how life is like
when even C is a high-level, restrained language.
This still means you have to abide by the rules the OS has set out
for you, since the kernel does a lot of scheduling, doesn't let you touch
memory pages that you 'should avoid' and 'are sensitive', and in general
just gets in your way. If you're a Linux bro, you can 'just' go into the
kernel, modify the guards you need or don't need, and rebuild the shit from
source. If you're in Windows land, half the things are locked up tighter
than a nun's cunt, so you're stuck with hacky injections and disassembling
Microsoft's .dll files.
The real benefits of this approach is when you're really juicing blood from a stone and trying to optimise the last cycle out of code. Sometimes this does pay off [5] and sometimes the compiler is really, truly smarter than you and the best case code is, in fact, the dumb shit it bleeted out. Admit defeat.
Assembly programming deep in kernel land
Nobody does this one, and for a reason. Code this deep down gets really
nasty and convoluted (at least judging from Linux internals), and you're
doing away with hundreds of manyears of tradition and whatnot. You know
that there's no data types in assembly? It's just chains of bytes, in
an orientation you don't actually know (is it big or little endian?),
without anything to actually tell you what's what. You can just pop a float
from the stack into an integer register, and treat it like a really funky
int. Nobody will know, nobody will care, and you can do the Quake
square root without any typecasts in mind.
The people who live in this layer are usually the real chads of code dev. You can tell by how few Rustaceans actually poke their head below the '''systems''' layer. There really isn't any fun to be had here, and the main places the Linux kernel uses inline assembly over C code is when doing syscalls, for checking register states, doing atomic operations and all sorts of nasty barriers that tell a CPU to stop being smart about its memory or execution order and to do the things we told it to do in the exact order we tell it to.
The Second Sin
But this wasn't enough. I lived a bit in assembly land but generally
avoided it because I hated recalling the exact sequence of jumping
through hoops every time I wanted to do anything useful with my assembly.
Did you know that inline assembly from within C++ used what's realistically,
but not theoretically, a different calling convention than C code, except
in the most trivial of cases? [6] Did you also know that the OS will also
execute your code in a nondeterministic way? [7]
Calling printf() was a chore, using BIOS interrupts to write text with
was a different chore (who knew that int 10h was so messy on x86?),
and don't you even dare think about doing anything graphical with
modern hardware.
No, my second sin was getting a simpler piece of hardware—I got an Arduino Due and, unfortunately, decided to play with it in assembly. Things got really fun and I ended up absorbing more knowledge about the board specs than I really should have (who knew that it was that easy to use clock mismatches as a RNG that's better than the one provided by the manufacturer?).
The Due is a 'simple' board controlled by a ATSAM3X8E chip based off
the Arm Cortex-M3 core, running on the ARMv7-M instruction set. The 32-bit ARM
instruction set comes with a whole bunch of goodies that you wouldn't see
the x86 be caught dead with: all instructions work with all registers
(cf. the x86 trying to do mul), instructions are fixed-width
(meaning you can reason about code size even without assembling!),
and conditional code does not require any jumps since practically
all the instructions have a large number of conditional variants.
But to program the Due you needed practically no fancy magic, you
can just get the board, write absolutely tiny code (as in, your programs
will rarely pop a couple of kilobytes in C mode), flash it with
your junk code, and just power it up. Unlike the x86 platform,
the startup sequence quite literally is 'start at 0x0, execute
code, interrupt vectors are in the words immediately after the start position'.
There's no juggling with real, unreal or protected mode, no boot sectors,
no trying to claw your way out of 16-bit space to 32-bit.
The compiler the development suite gives you handles most of the work for you admittedly, but if you pilfer the headers for a few magic numbers and the correct sequence of memory positions to blast, you can actually get the Arduino going doing genuine work off an assembly file.
But getting output going beyond 'turn LED on' and input beyond
'button was pressed' was both going to be an electrical chore to wire,
and a bit expensive (screens and whatnot compatible with the
Arduino didn't come cheap at the time), so in my majestic
foolishness I decided I wanted to do Arm assembly programming
on my x86 desktop. This necessitated downloading, installing,
using QEMU.
The Third Sin
So, QEMU is an emulator suite for a bunch of architectures, and a bunch of computers based off them. In the Arm family it emulates a couple dozen boards, all of them imperfectly and partially, and all of them in a very underdocumented way: to get your bearings you have to read things like the actual source comments (usually outdated), the RedHat mailing list (—''—), schematics of the boards (sometimes just missing), of the devices those boards include (...) etc.
So I decided that the next best thing would be to do raw, bare-metal
programming on the virt board—a fake QEMU platform whose components
you can pick and choose yourself, and that's guaranteed to have the best
emulation experience because it's not tied to actual hardware demands
(did you know that the Raspi boards are booted through their black-box
GPU that runs a binary blob kernel that we have no idea as to how it works?)
and you can just pick and choose parts and QEMU will try and make them work.
The virt board comes with some predetermined parts as well so you
don't have to do all the picking and choosing, which is cool.
And then you realise, again, that there is absolutely no documentation for anything you want to do, that nobody's publicly written about what they did to enable the things you want, and that there is between 'practically no' code and 'no' code out there that does what you want to do.
So the first thing I wanted to do was to write a sort of Hello World to see any output being displayed. This meant any sort of output whatsoever from the board to the 'outside world', which for now meant getting it to write to console.
At this point I was still oblivious.
The way that these kinds of boards communicate with the outside
world is, at its most basic level, just getting a hose of bytes and
blasting a memory location in a specific way until something you want
to happen happens. To get the virt to shit out text, I had to
talk to the UART device which QEMU helpfully semiautomatically
maps to stdout / the console. So you go and read the documentation,
realise you're out of your depth, the links slowly turn from blue
to purple, and it takes you three days to realise what a mess it all is.
The UART is a memory-mapped input-output device (MMIO) that's mapped
to a special memory address somewhere between the flash space (0x0) and
start of RAM (here that's 0x40000000) and the MMU pretends that
it's a real memory location and not a fake cop-out redirection. So,
to get the thing going I had to:
figure out how the fake MMU maps devices
learn about the
dtb(device-tree blob)learn how the UART device works in the real world
look at UART set-up code for the Raspi3
translate this into the
virtUART specsfigure out the location of the
virtUART???
profit?
One of the first iterations of this code looked give-or-take like this (magic numbers not included):
ldr x0, =AUX_ENABLE ldr x1, [x0] and x1, x1, 0x1 str x1, [x0] ldr x0, =AUX_MU_CNTL str xzr, [x0] ldr x0, =AUX_MU_MCR str xzr, [x0] ldr x0, =AUX_MU_IER str xzr, [x0] mov x1, 0x3 ldr x0, =AUX_MU_LCR str x1, [x0] mov x1, 0xc6 ldr x0, =AUX_MU_IIR str x1, [x0] mov x1, 0x48 mov x0, =AUX_MU_BAUD str x1, [x0] ldr x1, =GPFSEL1 mov x2, 0x3F000 and x1, x1, x2 mov x2, #1152 mov x3, #64 mul x2, x2, x3 orr x1, x1, x2 ldr x0, =GPFSEL1 str x1, [x0] ldr x0, =GPPUD str xzr, [x0] mov x2, 0xA0 _loop_1: sub x2, x2, #1 nop cbnz x2, _loop_1 mov x1, 0xC000 mov x0, =GPPUDCLK0 str x1, [x0] mov x2, 0xA0 _loop_2: sub x2, x2, 0x1 nop cbnz x2, _loop_2 str xzr, [x0] mov x1, 0x3 ldr x0, =AUX_MU_CNTL str x1, [x0]
Basically, you 'have' to make sure the device
is enabled and ready to accept data from you,
you need to map its 'pins' to MMIO addresses
the MMU exposed to you, etc etc etc. This specific
snippet crashed and burned, so I spun the wheels for like a week
and eventually settled on like 3x the amount of code
only to get the ability to blit a single byte at a
time at this device, passing its contents to the stdout
in the dumbest and most unsafe possible way since
I think I just stopped caring about things at that point:
mov x0, 0x40 ldr x1, =AUX_MU_IO str x0, [x1] add x0, x0, #1 str x0, [x1] add x0, x0, #1 str x0, [x1] b.
No checking whether the device is ready to accept another byte, no checking if it's written or has errored, no gods no kings.
I was still oblivious, yeah?
So having enabled the UART and gotten the proverbial Hello World out (though ofc I hadn't wrtten a string printer just yet, it would've been trivial though), I figured might as well figure out the other devices and see how they get set up.
The first one on the list was the framebuffer. Basically, the dumbest possible way to blit pixels onto a surface is to set up a framebuffer device with the appropriate dimensions, pitch (how many bytes per row of pixels), pixel format (i.e. bits per colour and order of colours) and give it a chunk of RAM to read from to the screen.
The setup for this was even more hellish than the above, since the documentation was actually totally absent, so I had to resort to the aforementioned RedHat mailing list, and reading the source for SeaBIOS and the U-Boot bootloader, and then read some more QEMU source code (why does the documentation actually suck so much?) etc etc. In short:
to set up the image, the framebuffer device needs to be set up
the way to set up the device is using QEMU's wonderfully underdocumented
fw_cfgyou need to also verify whether your CPU has something called the 'dma' via the magic number
0x51454d5520434647it works by going through the configuration zone looking for a honest to God string value
when you find it, you then write a bunch of shit to the config object
specifically, you need the dimensions, format, pitch and address
voilà! works magically
This took like a week I think. But then, I could just write raw bytes to the designated RAM space and things ~just worked~, and I got my dumb little images to display with actually no difficulty at all. Godless magic, and I still didn't see the error of my ways.
Moment of Truth
The moment where I cracked was when I tried to get persistent storage set up, and then I realised what a fucking fool I'd been for genuine weeks then.
The way I wanted to do storage was via a virtio-blk-device
which was a simple, abstract interface device for reading and
writing to an image file for board emulator developers, and the
interface it provides is, allegedly, a very decent scheme that
gives bootloader coders another option for storage schemes.
As a wise man once said: 'The intent of virtio devices is to be
implemented by hypervisors (such as QEMU). They simplify things a bit,
so that it’s easier and more efficient for hypervisors and guests to
communicate, without having to emulate any quirks of real hardware devices.' [8]
It reads and writes in 512-byte sectors, is of course also
memory-mapped in its own specific funny way, and to get the device
set up you need to first discover its secret!! location o: by
checking more magic numbers (in this case we want 0x74726976)
and then the device type, and then finally you have to
actually talk to the device to tell it that it's been
discovered and that you have a driver for it, and then you
have to negotiate (yes, that's apparently the term) with it
to see what intersection of features is supported by both
your driver and the device ![]()
And this is where I broke. After a knee-deep slough through the code in the SeaBIOS repository, again, I ended up on the fucking OSDev bootloader page, which is where I realised that the monkey business I've been blindly doing has, all this time, been writing drivers in a primitive retarded bootloader that I never wanted or needed.
And all I ever fucking wanted was a simple platform to write
some funny armasm and get haha clown results back, not this
level of convoluted hoopjumping that once again revealed how genuinely
rancid software development is once you get to talk to your devices
on your own terms. The amount of fucky code I had to write, bin,
rewrite over the past months, and the amount of hoops I had to
jump and will have to jump if I want persistent storage, is
making me start to reconsider writing at least some of this
code in C—but then what's the point?
I started this to avoid doing that, so I'm at the fucked up crossroads of damned if I do, damned if I don't.
Specifically, the C++ compiler passes
a hidden pointer to this in the first available
register, meaning that now instead of RDI your
first argument is passed in through RSI if you're
on Linux.
You can use the task scheduler as a funky RNG if you're feeling wicked enough.
from https://brennan.io/2020/03/22/sos-block-device/, which I wish I'd read before spending three days on this in vain.