Baremetal Aarch64: Pt 2, Hello World Advanced
So we set up the UART device and we are going to use it as the main way to talk to the outside world for now. The UART is dead simple to set up and use, especially when compared with writing a driver for more conventional communication methods. Apart from console output, which we tested last time, we're also going to use it as our (unbuffered) input method. I'm not gonna go through the effort of writing a PS/2 or USB stack for a Hello World kind of deal, UART is sufficient.
Cleaning up
Last time I cut some corners in that I hardcoded the UART memory address but now we're gonna properly load it from a label that we can conveniently change if, somewhere down the line, the UART base changes or if something else happens that we gotta take care of. The start of label 1 will now be:
UART_BASE: .word 0x09000000 // UART_DATA: .byte 0x00 // UART_FLAG: .byte 0x18 // UART_CNTL: .byte 0x30 // UART_FIFO: .byte 0x34 // UART_INTC: .byte 0x44 .section .text 1: adrp x0, UART_BASE add x0, x0, :lo12:UART_BASE ldr w0, [x0] add x0, x0, 0x30 // UART_CNTL
Not much has changed, except we're now loading the address.
You'll note that it's a federal fucking issue to load from
memory in Aarch64. We're first loading the page address of
where the UART_BASE label is located into x0 via the
adrp instruction; this loads the higher 56 bits of the
address into the register, which then forces us to add the
offset separately using add and the :lo12:(name)
specifier, where the assembler helpfully extracts the lower
12 bits (hence the name) of the label for us and generates
a normal add instruction with the offset as an immediate.
Two pits I fell into while playing with this:
sometimes the assembler fails to error on unaligned access and will joyfully read instructions one byte off
ldr x0, [x0]was a big headache since reading from a word into a dword register reads the word twice, once in low and once in high bits
The second one I keep forgetting about every now and then, I'm 100% sure I'll encounter it again. It's not mentioned in the official online docs, and I don't even see it mentioned in the 5200 (!) page architecture reference manual, though that might be my own search fuckup.
When you make and run this, it should do the same thing
as before. We're not gonna load the offsets from memory because
they're tiny (meaning it's much more of a chore to load them and
add them to the base) [1] and, more importantly, they're guaranteed
to never change relative to base and each other. We're also going
to set UART up only once and won't change anything in the
control register in the foreseeable future.
UART Safety, Printing and You!
The PL011 UART is equipped with a flag register that can tell us
what the UART is doing right now and how it's going, so we can
poll it to see whether it's ready to take a byte from us to print
or not. From a quick look at the docs we see that we need to
test for whether UARTFR & 0x28 returns true and wait until
it isn't.
We'll also want to compartmentalise the print into a procedure
or function. This is going to mean we'll have to think about
how to pass arguments to functions, since our putchar()
lookalike will consume a single byte that its caller will pass
into it. Remember that spiel about calling conventions?
We'll call the routine _uputc, standing for "U(ART)-put-c(har)".
We can write the function out in high-level pseudocode to see exactly what's going to have to happen:
void _uputc(byte x) { REGISTER a; REGISTER* b = &UARTDR; b += 0x18; do { a = *b; } while (a & 0x28); b -= 0x18; *b = x; return; }
This immediately tells us we need three registers to store
the UART flags (a), the UART address (b), and the
argument to send to the UART data register (x).
Aarch64 specifics
Though massively simpler than the clusterfuck that is x86, Aarch64 has a lot of very unfunny specifics that are not documented in any user-friendly way. Just the Arm A-profile architecture reference manual is (as of right now) 11 530 A4 pages, and going through dozens of thousands of pages looking for relevant info is not the easiest thing, especially if you have to repeat it.
We're working with a more sophisticated Arm processor than the
average embedded board has. This means there are significant
memory and exception safety features that can be toggled on
or off, and you can do about the same things in a Cortex-A that
you can with x86 privilege rings. There are four rings in
the standard, and not all devices need to implement all the
levels—a CPU needs to have at least EL0 and EL1.
The higher the number, the more privilege we have executing
code and shit. We'll be staying in Exception Level One (EL1),
which is the highest QEMU provides normally. [2]
One of the main funnies here is that Aarch64 really
wants to enforce stack alignment to sixteen bytes, and if
you try to access the SP while it's unaligned you will,
in most cases, brick your CPU for the time being since
you can't recover from exceptions. To make sure this
doesn't happen, you can either always align to 16 bytes, or
disable this. We want to disable memory alignment fault
exceptions in general, and we do this by playing with the
SCTLR_EL1 system register.
The instruction that reads from a system reg is mrs and
the one that writes to a system reg is msr.
Each takes one named register and one numbered
general-purpose register as arguments, in a destination-source
order.
To disable these fault exceptions, we will use:
This disables unaligned stack access faults, and register load/store misalignment faults, which makes working with the stack and with the heap quite a bit less headache-inducing.
Unaligned stack access faults are especially annoying because this funny processor feature means that storing a single register on stack means you unknowingly get to brick the CPU very very easily. Programmer wisdom and developer references from the likes of Apple and wise guys from StackOverflow even tell you that you must keep the stack aligned and that there's no way around this, which is totally untrue. I don't know who thought this was a good idea.
We can't use the stack out of the box, though. Upon cold reset, the value of the SP should be zero, and on a warm reset it's 'an architecturally UNKNOWN value' as per the manual. This means that you just gotta set the stack up yourself every time you boot—though you have to do that in x86 as well so it's no big deal.
Configuring the stack is not really that Big of a Deal.
If you recall our linker script, we allocated an extra
0x100000 bytes above our .data and .bss sections
and assigned that to stack_top. To set the stack pointer up
we need to load this location's address into memory, then
add four (since we will predecrement on push and postincrement
on pop) to ensure we push to the very top of the space. This
new value we'll then load into the sp register, and the rest
is handled by the assembler. The code is as simple as it gets:
We gave the kernel stack 1MB of memory to do what it wants with it. This is a relatively crude approach, but it gets the job done for now.
Though for now it doesn't matter, we have to be careful
with memory management way down the line. Because we're not
using the MMU to its full capacity, our memory model is essentially
flat as far as code and data are concerned. Aside from the memory-mapped
devices below the 0x40000000 address, where the device
has mapped all the fancy stuff like UARTs and whatever,
the MMU gave us the memory device (RAM) starting at
address 0x40000000 and going on for 0x10000000 bytes
(configured through the commandline), and the flash device
starting at 0x0 and ending at 0x04000000, which should
store ROM but we're not using it. Our program code
and data are all in the same flat memory space, with no
mem management or segmentation, which means our code is
writable, which means pushing too much onto the stack will mean
actually overwriting the kernel.
There's several ways of solving this, such as putting the stack below the kernel or at the top of RAM growing downwards into the heap, but neither of those are necessary right now really, because we won't be getting a megabyte of stack exhausted any time soon anyway.
Back to _uputc(byte x)
Fact is that, if we want to do any kind of compartmentalisation and shunting code off into subroutines, we have to be aware that we're now responsible for making a generic subroutine that will work independently of what called it, and that the code doesn't disrupt its caller's operation in any way.
When you're generating a bunch of assembly automatically, this is where calling conventions come in handy: the compiler has a list of what it must, can and cannot do when getting two chunks of code to interact.
Generally, calling conventions prioritise passing data through the register bank, since that's much faster than using memory for data transfers. When you run out of registers, you usually have to spill data onto the stack, and getting data to and from the stack is a lot slower than just working with RAM. Sometimes you see this in professional code (the Go compiler didn't know about register transfer until like 2020 I think), but generally you spill only when you have to.
This convention starts mattering a little bit less when you're
hand-writing both the callers and the subroutines: you can tune
the data transfer mechanism per pair very accurately to reflect
your needs in a way that compilers (still?) can't. Perhaps
this one function will use registers x7 through x11, while
another will want q0 through q3 because of their larger
size.
The one constant is that the subroutine will always have to be called in a specific way. If you want to change how it's called, you have to either write boilerplate that maps this new call to what the subroutine understands, or write a new routine wholesale.
This is a sacrifice that you can make very easily, and the way this works out for you is much like how C does it: you don't get overloading, name-mangling and resolution like in C++, and you'll have to give separate names to routines with the same functionality but different parameter schemes.
On the other hand, we can just pass things in and out of routines using the stack. This simplifies the register saving dance at the expense of losing at least a few cycles here and there. Accessing cache is pretty fast, but still slower than accessing registers (I feel it's a factor of 4 kind of deal), so we gotta make it worth it.
Luckily for us, Aarch64 gives us an unusually meaty powerful tool
here. Despite actually lacking dedicated push and pop instructions
that are around in 32-bit Arm, Aarch64 lets us load and store
registers in pairs in a single instruction. We're thus gonna
be using ldp and stp (and their variants where necessary)
for this, in addition to using ldr and str for the generic
register storage option.
The load and store pair instructions work on all the registers,
both integers (e.g. stp x1, x2, [sp, #-16]!) and vectors/floats
(so, ldp q1, q2, [sp], #32). This lets us transfer up to 256
bits of memory in one go (assuming your CPU uses 128-bit NEON and
not the bigger SVE registers that can go beyond 1024 bits), and
not lose (m)any cycles doing it if the chunk is in cache. [3]
So for this subroutine, we'll be saving the trashed registers to the stack and getting the argument from the stack.
Let's revisit the pseudocode:
void _uputc(byte x) { REGISTER a; REGISTER* b = &UARTDR; b += 0x18; do { a = *b; } while (a & 0x28); b -= 0x18; *b = x; return; }
For this, we'll obviously need two registers to do the logic work for us, and one to store our argument. Since we have to save three registers, stack alignment has to be off—but we've luckily disabled it.
In our caller code we'll have:
Essentially, it doesn't matter which register we get the argument from, so that the subroutine will always see the argument on the stack and the caller won't have to bend over backwards to get the argument into a specific register.
The subroutine will then look like this:
// our print function // takes 1 arg on stack, returns 0 // trashes 3 registers _uputc: stp x0, x1, [sp, -16]! str x2, [sp, -8]! ldr x2, [sp, 24] // this is where we first pushed // the argument in the caller adrp x0, UART_BASE add x0, x0, :lo12:UART_BASE ldr w0, [x0] // now x0 has the UART_BASE location add x0, x0, 0x18 // UART_FLAG address _uputc_loop1: ldr x1, [x0] // read from UART_FLAG and x1, x1, 0xff // 0010 1000 = busy & transmit full bic x1, x1, 0xc0 // but we gotta do it the long way bic x1, x1, 0x10 bic x1, x1, 0x07 cbnz _uputc_loop1 sub x0, x0, 0x18 // back to BASE / DATA str x2, [x0] ldr x2, [sp], 8 ldp x0, x1, [sp], 16 ret
You'll note that Aarch64 is especially retarded when it
comes to immediates for bitwise operations, in that they have
to be a mask with a bit pattern of some sort, and not actual
immediates like in arithmetic operations. The block of four
logical ops basically performs the check and x1, x1, 0x28
but that will not fit into the immediate field here. The
bic instruction is basically a form of a & ~b that's
convenient for toggling some specific bits off.
We can actually do some optimisation here and save one register, though the stack will still be unaligned:
// our print function // takes 1 arg on stack, returns 0 // trashes 2 registers _uputc: stp x0, x1, [sp, -16]! adrp x0, UART_BASE add x0, x0, :lo12:UART_BASE ldr w0, [x0] // now x0 has the UART_BASE location add x0, x0, 0x18 // UART_FLAG address _uputc_loop1: ldr x1, [x0] // read from UART_FLAG and x1, x1, 0xff // 0010 1000 = busy & transmit full bic x1, x1, 0xc0 // but we gotta do it the long way bic x1, x1, 0x10 bic x1, x1, 0x07 cbnz _uputc_loop1 sub x0, x0, 0x18 // back to BASE / DATA ldr x1, [sp, 16] str x1, [x0] ldp x0, x1, [sp], 16 add sp, sp, 8 // pop the argument off the stack ret
An optimisation assembly affords us that we can't intentionally get in C is the ability to destructively reuse registers when their data's lifetime's up. A half-decent C compiler would do this optimisation for you automatically, but this isn't something you can specify through the language itself.
Nested function calls
A peculiarity of the Aarch64 ISA is that the bl instruction
lets us do branches to subroutines without any memory access,
which is extremely good. The way it works is that it stores
the address of the instruction it's accessing + 4 into the
register x30, then jumping to the label we give it.
Correspondingly, we return from the subroutine we jumped
to using the ret instruction, which jumps to the address
stored in x30 (i.e. it's an alias).
Predictably, this means that you can only bl exactly once
before you trash your original return address.
This pair of instructions has its advantages over the x86
call and ret pair—no stack access is performed and
things are kept fast, but you have to write the nesting code yourself
and that gets kind of nasty kind of quickly.
We can thus absolutely avoid having to use the stack for subroutine calls that are one-deep, which saves on memory at the expense of losing a register.
The next function we'll write will have to call another function and so we'll have to write nesting code.
Extending into _uputs(byte* x)
Printing one byte at a time is definitely something we can
but shouldn't do. It's inconvenient on its own, really. To
exemplify function call nesting and to clear up this mess, we
will write a _uputs function—that is, "U(ART)-put-s(tring)".
The function will take one argument, a zero-terminated C-style
string of bytes from memory, and print it out byte by byte until
we reach zero.
We'll try to minimalise memory accesses as much as possible, which means doing funny shit with registers. For example, the following pseudocode would be a good scheme to implement:
void _uputs(byte* x) { REGISTER a = *x; REGISTER b; do { b = a & 0xff; _uputc(b); a = a >> 8; if(a == 0) { x += 8; a = *x; } } while(b != 0x00); return; }
We'll be reading memory in 8-byte chunks
at a time, meaning we'll need to do only one
access per 8 characters (remember, the UART is
an 8-bit interface). If we haven't
reached a zero character, we call the _uputc
and let it handle the print on its own, abstracted
away from our eyes.
In assembly, we have some more things to think of,
such as storing the x30 on the stack alongside
the other registers in use. We don't have to
think about how long our string is, though.
Reading 64-bit chunks of memory at once could be "potentially dangerous" i.e. we could be leaking secret memory or whatever if someone checks register states when they're not supposed to, but since the memory model is flat and unprotected, there won't be any hardware faults to think about too hard.
Our code should now look like this:
// handler for string printing // takes 1 arg on stack, returns 0 // trashes 4 registers _uputs: stp x0, x1, [sp, -16]! stp x2, x30, [sp, -16]! ldr x0, [sp, 32] // the string address _uputs_loop1: ldr x1, [x0] // the initial memory read cbz x1, _uputs_loop1_end and x2, x1, 0xff // extract byte cbz x2, _uputs_loop1_end str x2, [sp, -8]! bl _uputc asr x1, x1, 8 and x2, x1, 0xff cbz x2, _uputs_loop1_end str x2, [sp, -8]! bl _uputc asr x1, x1, 8 and x2, x1, 0xff cbz x2, _uputs_loop1_end str x2, [sp, -8]! bl _uputc asr x1, x1, 8 and x2, x1, 0xff cbz x2, _uputs_loop1_end str x2, [sp, -8]! bl _uputc asr x1, x1, 8 and x2, x1, 0xff cbz x2, _uputs_loop1_end str x2, [sp, -8]! bl _uputc asr x1, x1, 8 and x2, x1, 0xff cbz x2, _uputs_loop1_end str x2, [sp, -8]! bl _uputc asr x1, x1, 8 and x2, x1, 0xff cbz x2, _uputs_loop1_end str x2, [sp, -8]! bl _uputc asr x1, x1, 8 and x2, x1, 0xff cbz x2, _uputs_loop1_end str x2, [sp, -8]! bl _uputc add x0, x0, 8 // shift pointer by 8, and loop b _uputs_loop1 _uputs_loop1_end: ldp x2, x30, [sp], 16 ldp x0, x1, [sp], 16 add sp, sp, 8 ret
Instead of reading a byte at a time, we're
reading eight, and then doing a call on each
byte of the register at a time. This reduces
the number of memory accesses by 7 (every eighth
step instead of every step of the loop), which
is helpful when _uputc already does
at least 6 memory ops (and potentially more) every single call.
I even unrolled a loop manually to save
a register that would otherwise have
gone to waste as a counter. You don't need
to do this, but it saves us a few instructions
and, despite being longer code, will run in fewer
cycles because we avoided two more stack accesses.
This function is called more or less the same as the previous one, except now you need to get the address instead of just passing the argument raw on the stack:
EXAMPLE_STRING: .asciz "Hello, world! " .align 8 ... ldr x2, =EXAMPLE_STRING str x2, [sp, -8]! bl _uputs
You can avoid this nested call by writing the
_uputs() function so that it itself
writes to UART without having to call a
subroutine to do that work. This would both
be more efficient and go faster (think of it
like inlining a function manually), but I feel
like it suffers from readability.
Ultimately, we're not squeezing cycles from a dry stone, there's much more we could do to optimise these things that we aren't doing because the tradeoffs are too severe.
Extra features
There's a few more things we can do to make the environment a bit more fully-featured. For example, the floating-point unit is by default disabled on boot, so we have to enable it by fiddling with the built-in system control registers:
We don't have EL3 and EL2 support in our CPU, it only goes up to EL1, but I'm including the higher EL code anyway for your convenience for when you play with real hardware.
In QEMU, you can now type info registers to see
a whole new table of vector regs has just appeared
and is initialised to zero.
One other interesting feature that the Aarch64 platform provides in Cortex-A CPUs, mostly the newer ones, is the ability to generate random numbers in the hardware.
To see whether you have RNG capability or not you're
supposed to read from the ID_AA64ISAR0_EL1 register.
If mrs x10, ID_AA64ISAR0_EL1; and x10, x10, 0x1000000000000000
returns true, your CPU has the registers RNDR and RNDRRS
implemented. The CPU will then provide you with a
random number when you do mrs x10, RNDR—or at least, it
theoretically should!
But
the GNU binutils don't even fucking work,
they don't recognise some register names and error out for
no fucking reason, so you have to actually poll that register
using its internal encoding name, which in this case is going to be
mrs x10, s3_3_c2_c4_0. This should provide you with a
random number in x10, which QEMU in turn sources from the OS.