the beginnings of an operating system on aarch64
this is an attempt to collect the information i wish i knew before writing
an operating system.
yes, it runs doom
Table of Contents
im not making an operating system because i have a grand idea
about security or interprocess communication. i just want to
be able to run my programs on a computer and my programs assume
there will be some greater power available to supply things like
memory allocation, threads, futexes, a file system, etc.
what i end up with is perhaps more accuritely called a unikernel.
its one program with the kernel part and the user part bundled together
however, the user part is my compiler and the source of all my example programs,
so it can jit compile the other programs and present something that looks more
like a general purpose operating system.
that said, the building blocks you need at the beginning will the same if you're making
a more classical shape of operating system than this one so this should still be useful.
the high level ideas carry over to other architectures but this is focused on
aarch64 because that's what i have more experience with.
these sections are not a tutorial you can follow in order.
you'll probably want to do simple versions of things and incrementally
progress as you get more debugging tools.
emulators/hypervisors
priveledge levels
system registers
the mrs/msr instructions let you read/write these registers.
the ones i need to use are backend/arm64/bits.fr/L312-L350
writing to these registers has side effects!
some of them are bit flags that control whether user space can use different instructions.
- CPACR_EL1 has a bit to enable floating point instructions
- CNTKCTL_EL1 has a bit to enable reading CNTVCT_EL0/CNTFRQ_EL0 system registers to get high precision timers
- SPSel controls whether userspace and kernelspace share a stack pointer register
some are read only information
- ID_AA64ISAR0_EL1 has a bit that reports if atomic instructions are implemented on this cpu
- CurrentEL has the current privledge level
- ICC_SRE_EL1 has a bit that tells you if interrupts are configured with system registers or a special memory region
- some hold pointers to data structures the cpu uses: page tables, interrupt vector
- some are just extra registers you can use. TPIDR_EL1 conventionally holds a pointer
to the kernel's task data structure so you can tell what's currently running.
linux boot protocol
the linux kernel has very simple rules for how it expects a flat image
to be loaded into memory by the bootloader. if you follow that it's
mostly painless to run in different virtual machines without needing
to figure out what magic start address the system it's pretending to be wants you to use.
linux is ubiquitous enough that everyone needs to make it work on their thing.
see kernel.org spec
and my code for generating it
for more conventional toolchains i assume there's someway to produce that format
with a linker script, luckily i live in a world where i can just put
the bytes in the file so that's none-my-business.
note that this is not an elf file, you don't get to do data relocations
or have sections spread out in virtual memory. there's a header with some
extra information and the first 8 bytes are machine code instructions that
need to jump to the actual entry point of your kernel.
a pointer to the device tree will be in x0 (helpfully the first argument of the standard calling convention).
don't forget to point sp somewhere good for the kernel's stack before jumping to higher level language code.
in my code there's a second header that says how big (the kernel code, the user space init
program, and the initial file system) are, and those are all concatenated together.
my kernel knows to look for that and pass the info to userspace. that's just an arbitrary
choice on my part. a more sane startup mechanism for a real system would probably involve a
seperate disk image with the userspace data, so you don't have to recompile the kernel as much.
1500 milliseconds every time really starts to add up.
device tree
the bootloader/hypervisor/whatever gives you information about what io devices exist.
this lets you have one kernel blob that runs on multiple different hardware configurations.
there's no need to debug blind, you should have a way to print back the structure you parsed as soon as possible!
there's a bootstrapping problem that you might need information in the device tree to know how
to print to the console. good luck with that.
see my code for parsing the blob and displaying the tree.
as an example, see my output when running in apple's virtualization framework.
what your kernel will recieve is a flat binary format.
don't be fooled by examples with textual (.dts) files.
the fields are big endian so don't forget to byteswap.
the header has the size and offset to the structs encoding the tree and the blob of string data.
the structs region is a sequence of nodes, each starting with a tag
(BEGIN_NODE=1, END_NODE=2, PROP=3, NOP=4, END=9).
BEGIN_NODE is followed by an offset in strings of a null terminated field name for that node.
PROP is followed by an offset and a length in strings of the data for that node.
each node can have more BEGIN_NODEs before its closing END_NODE to represent a nested tree structure.
the fields i need for the devices i use in later sections are intc,gic,pl011,pci,memory,cpus.
also qemu's -append cli argument controls the string in chosen.bootargs.
interrupt handlers
- device interrupt like console input, timer, etc.
- syncronous exceptions: illegal instruction, memory fault, etc.
the interupt table is an array of 16 entries, each are 32 instructions.
if you need more than 32 instructions or if you want to handle all the
interrupts in a consistant way you can jump to other code immediatly.
note that which slot of the table was jumped to gives you information
about what type of interrupt it was.
VBAR_EL1 is the system register that holds the address of the table.
it must be aligned to 2048 bytes (also happens to be the total size of the table).
it's important that you save all the registers before doing anything useful because
you're going to return to userspace and it will be expecting all its variables to still be intact.
unlike a normal function call, the user can be interrupted at any time so you can't really
have a calling convention with caller saved registers that the interrupt handler is allowed to stomp on.
of course there are exceptions to that rule.
some system registers (and the condition flags and stack pointer) have seperate versions for user space
and kernel space so you don't have to worry about stomping them.
x18 is the "platform register" which the system (in this case that's you) might declare
is reserved and might be mangled at any time (which happens on macos).
so if that's the abi you want then that's an extra general purpose register you can use
right away in the interrupt handler. make sure the toolchain you use for user binaries agrees with your choice.
there's a system register where you read the intid at the beginning (ICC_IAR1_EL1)
and one where you write intid to acknoledge it when done (ICC_EOIR1_EL1).
it's important to only read ICC_IAR1_EL1 when the interrupt table entry being called is
the one for device interrupts. you can't read ICC_IAR1_EL1 all the time to decide
if you're supposed to be handling an interrupt or you get race conditions where you lose
syncronous exceptions that happen at the same time as a device interrupt.
syscalls
syscalls are just like function calls except that the caller is user code
and the callee is the kernel code. so the userspace needs to set up values in registers
in whatever calling convention the kernel expects (for sanity purposes, i made this
the same as it is for user space calls: x0-x7), and then trigger a trap that makes
the cpu change privledge levels and jump into some handler in the kernel code.
the kernel needs to save the state of all the registers and remember where it came
from so it can return back when the syscall is done. then inspect the contents of registers
and do the action.
in function calls you choose what function is called by jumping to a different address
in memory. syscalls traditionally only have one entry point and you put a magic number
in another register that the handler looks at to decide what action will be performed.
you could imagine writing a normal userspace program with only one function with a giant
switch statement over its first argument where every function call is a recursive call to
that one function with a different argument.
the mechanism for getting to the syscall handler is exactly the same as for other traps
like illegal instructions or accessing invalid memory. the fact that the isa has an instruction
called "syscall" doesn't give any extra power. it just makes it easier to read the
disassembly than if you decided the way you ask for a syscall was to try to jump to some address you know is unmapped.
the important thing is that both sides agree.
if you want an easy time you can choose to use the same syscall numbers (and behaviours) as linux.
then you can compile a statically linked program with someone else's tool chain
and run it on your own operating system (assuming you write an elf loader).
obviously if you want to walk your own path you can choose whatever syscall numbers you want.
linux is unusual in that the syscalls are the stable interface given to third party user programs.
many operating systems ship with some other user space code that provides a stable
interface as function calls (that normal programs dynamically link against)
on top of the kernel's syscalls (on macos and BSDs that's libc).
signals
sometimes you don't want to deal with an interrupt/exception in the kernel,
you just want to send it to userspace and let them decide what to do.
for example, many language runtimes will want to print a nice stack trace on segfaults
or you might want to allow writing device drivers in userspace.
careful about handling nested interrupts and when you tell the cpu you're done handling an interrupt.
generic interrupt controllor
gicd vs gicr.
i use version 3.
sgi vs ppi vs spi.
field bit packing.
timer
the timer is controlled by system registers.
CNTFRQ_EL0 holds the frequency (how many times the timer ticks per second).
CNTV_TVAL_EL0 holds how many ticks until a timer interrupt will be sent (as an i32).
at startup and every time you recive the interrupt, you reset that counter to ask for a new interrupt.
write 1/0 to CNTV_CTL_EL0 to enable/disable the timer.
make sure to set the counter before enabling the timer.
- also set the counter before acknoledging the interrupt
CNTV_TVAL_EL0 is a convience over directly accessing CNTVCT_EL0 (a counter that always goes up)
and CNTV_CVAL_EL0 (a value to compare that counter to for sending a timer interrupt).
it's not clear to me why they give you a shorter way to write
CNTV_TVAL_EL0=x instead of CNTV_CVAL_EL0=CNTVCT_EL0+x.
reading CNTVCT_EL0 directly tells you how long it's been since the core started
(which can be useful when implementing a sleep syscall).
the V in those register names stands for virtual, there's also a P (physical) version of them.
the virtual counters are used for... virtualization... they let a hypervisor efficiently
lie to you about the time by applying some offset.
the important thing is that you won't be allowed to use the physical ones
if you're running in vzf or qemu with -accel hvf.
concurent threads
before you get to real parallelism where you turn on multiple cores that
can run different code at the same time, you can still provide an interface
for spawning threads that appear to run at the same time.
there are two main categories.
coperative scheduling where the user program has some way of explicitly yielding when
it wants to wait for another thread to make progress. preemptive scheduling where
a timer forces a trap that lets another thread run.
inevitably you want a mix of the two. some amount of preemptivness means a program
with an infinite loop can't freeze the whole computer.
some amount of coperativness lets threads efficiently comunicate with each other
instead of burning cpu time uselessly spinning waiting for something to happen.
user space will want some way to create threads and some way to join threads.
futexes
the concerency primitives you want are the ability to sleep a thread and the ability to
signal another sleeping thread that it should wake up.
- dumbest: spin loop until a flag changes
- better but racey: sleep/wake syscall. you could give it a time out but that wastes more time than being precise.
- solution: sleep until the value at a memory location changes.
different systems have different names for this operation:
on linux it's called futex_wake,futex_wait, on macos it's called ulock_wake,ulock_wait, in webassembly its called atomics.wait,atomics.notify.
atomics
there are instructions for many simple read-modifiy-write operations.
the general case is compare-and-swap which does a store that completes
only if a memory location holds a specific value. so you can load, do an arbitrary computation
to choose the new value, and only store it if the memory hasn't been modified
since your original load. the output of the cas instruction tells you if the store succeeded
so you can do it in a loop until you get your update. this is powerful enough
to build all the other rmw atomic operations.
this matters much more with real parallelism but even with peremptive concureny
you can get preempted in the between when you load a value and when you store
back a modified one.
there are also atomic orderings where you give the cpu information about
who you want to prioratize succeeding when there's contention.
something to watch out for: atomic instructions are tend to be much more strict about alignment that normal memory accesses.
many languages require that pointers be well aligned anyway so this doesn't matter.
note that atomics are all in user space / the cpu, the kernel doesn't get a vote.
cas+futex lets you implement higher level concerency things like mutex,condvar,lock free queues.
pl011 console
- less universal than the virtio console but much easier to use for early debugging
- doesn't work on apple's vzf (virtio console does tho)
virtio drivers
see virtio spec and my code for creating virt-queues.
on my system i expose create and poll as syscalls.
there are two ways to find devices: pci, device tree mmio.
qemu lets you use either depending on the cli flags.
vzf only does pci.
there's a separation between the raw transport mechanism used to implement virt-queues
and the higher level protocol for each different device that uses the queues.
its common to have variable sized responses where you put an
uninitialized buffer on the queue, the device writes to part of it,
and then responds with how many bytes it wrote.
readable buffers before writable buffers.
events get interrupts.
pci
it still ends up being memory mapped io. aarch64 doesn't have seperate io instructions like amd64 does.
it's just an extra convoluted way to choose which magic addresses to use.
virtio console
- console (log text to a terminal and get key presses back)
see my code implementing read/write calls with it
virtio gpu
it has a misleading name, you can use it as a simple framebuffer you can write pixels into.
see my code for the command structs
and an example program using them.
setup call sequence:
- ask for the size of the screen
- create a texture resource with a certain pixel format
- attach a region of memory to that texture
- set the texture as the scanout for the screen
loop each frame:
- write your data into the frame buffer
- transfer the resource to the host
- flush the resource to show it on the screen
- sleep until the next frame
when you create a resource, you pick an aritrary u32 resource id that will be used to
identify it in future commands.
the command for attaching memory to the texture takes an array of (physical address, byte length)
tuples so it doesn't need to be contigous in physical memory.
virtio fs
allows you to share directories with the host when running in a vm.
it uses the same FUSE interface as user space file systems on linux.
except that instead of writing the commands as bytes through a pipe,
they're sent as messages over a virt-queue.
see my code for fuse command structs and
code implementing virtual file system operations with it
most of the commands corrisopond directly to linux syscalls that interact with the file system
(stat, mkdir, rename, open, read, write).
in contrast to the virtio-gpu commands, the fs commands never contain pointers.
the ones with variable sized payloads (for a file's contents or name) have the data inlined in the
queue after the command header.
each command has a common header (with a tag saying which command to use), followed by the command
specific struct, followed by any variable sized data. each response has the same shape (with different structs).
when you read from a directory you get back dirent structs.
each dirent is followed by its name, padded to 8 byte alignment.
directories must be opened with the opendir command instead of open.
an open file is represented by a (file handle, node id) pair.
virtual memory
why
- without the mmu on you can't do unaligned memory accesses
- memory doesn't need to be contigous so less fragmentation
- you can isolate different threads/processes so they can't stomp on each other's memory
and you can give memory different read/write/execute permissions so mistakes are less confusing.
each "process" can have its own page table.
- you can overcommit
- more fine control over caching. the default state is that the cpu needs to assume
any memory address could be for memory mapped io which will be slower than if you
promise it will behave like normal memory that reads back the last thing written to it
so its allowed to be cached in a handwavey way.
how
- enable the mmu
- page tables
extra rules
- you're not allowed memory that's executable in el1 and writable in el0 at the same time.
if you care about secuirity that's not something you want anyway but even for
debugging while bringing up your operating system the cpu won't allow it at all.
page table size depends on page size.
different bits in pointer index different levels of page table.
each page table entry is 64 bits and either points to the next level of page table or a physical page to be used at the last level.
page table addresses need to be aligned so extra flags can be stored in the low bits of the page table entry.
see my code
you'll need an allocator that splits up contigous physical memory into pages to hand out when user space requests some.
careful that you know which region isn't taken up by your kernel or the device tree blob or memory mapped io.
the device tree's memory.reg field has the start address and length of physical memory (not accounting for the kernel/devicetree blobs).
recall that you can't do 8 byte loads of those fields before turning on the mmu because they might not be aligned.
Symetric MultiProcessing
fancy word for parallelism where the cores are mostly the same.
- device tree tells you how many cores
- hypervizer call 0xC4000003
- MPIDR_EL1 is the system register that holds the index of the current core in the low byte.
- note that each core has its own version of the system registers so you have to
see my code for waking all cores.
idle task
you want something to do when all the threads are waiting for input/timers/whatever.
wfi instruction so you don't just burn cpu spinning.
power off
- hypervizer call 0x84000008
userspace
the user space part is where you have more interesting choices than endlessly
going down the list in the architecture manual of all the little features needed.
a convenient starting point is doing a boring unix-ish thing where you just look at
a list of functions in libc and implement them somehow. this makes it much easier to
port other programs to the new system.
for example the version of doom i use
in the demo video at the start of this page
is unmodified from one that runs on linux's framebuffer.
it wants to call open(/dev/fb0) to get a file descriptor, pass that to ioctal()
to query the size and colour layout to the screen and then call write to draw pixels on the screen.
so to make that work, my program loader can "just" make its libc imports resolve to
functions that hackily remap those calls on to the virtio-gpu frame buffer.
for style points, the only files the system starts with are the source.
when you exec the doom program, it jit compiles
my c compiler and uses that to jit compile doom.
in my program i took the easy way out on many design decisions because i just wanted
to explore what it takes to make a program run. my virtual file system is entirely in userspace memory,
the initial files (source of example programs) are baked into the kernel.
the concept of thread groups ("processes") and the posix_spawnp function are in userspace
and all my threads share an address space. i use virtual memory for convience not isolation.
the virtio-gpu driver is in user space and since you need to give it physical addresses
for where the frame buffer lives, there's a syscall for address translation (which indirectly
lets you write to any physical address).
this is all clearly a terrible idea if you're running programs you don't trust.
elf loader
if you want to use other people's compilers you probably want to write
an elf loader. the part you actually need to support to get something working is fairly simple.
if you notice a mistake here please tell me about it! i want to learn information!
you can email anything@lukegrahamlandry.ca
prev: amd64 machine code for compiler authors