Devblog 0; the "What" and the "Why"

This post has been a long time coming, and a long time of me not writing it. Inspired by a recent reddit thread I participated in on r/gamedev where several users complained how they'd want to see more actual developer content, I decided to dust my project off and make a short write-up. There is no promise of this being a series of posts or continuing into the future, but I won't mind writing up some things from time to time.

Most of this is going to be technical babble about the environment and some design ideas I had. Little real dev work here!

The Themes

I am not a professional gamedev by any means, so any gamedev I do is strictly hobbyist pissing around for fun. This means I can afford myself some more leeway with what choices I make or what targets I aim for.

I am a big strategy gamer, or at least used to be. I grew up playing WarCraft 3 and Command & Conquer: Generals, was absolutely bedazzled when Frozen Throne came out, and spent years chasing that high. Somewhere around 2011 I fell into the Paradox rabbithole, and currently have 10,833.6 hours of Crusader Kings II logged on Steam, and several thousand more outside of it through hacked or old versions. [1]

Nowadays, I mostly spin my wheels playing Dwarf Fortress, kind of wishing there was a game that would cater to my specific narrow interests.

Over the past couple years, I've been toying with the idea of making a strategy game of some kind that would do what I wanted it to do. CK2 was good, it hit me like crack, but it still missed the mark somewhat. I'm really more of a sci-fi guy, and wanted something in that direction. Space-based or sci-fi-themed 4x and grand strategy games never did exactly what I wanted them to do: Stellaris was too arcadey and casual about some things, and didn't let you have fine-grained control over systems or, let alone, planets, and went further in that direction the more time passed (I still miss the old Stellaris pop systems where each pop would have a physical location on the planet map); there was Galactic Civilizations that came close, but it was still too far away; Civ-likes that were set on single planets just weren't it, either. The more-than-excellent Star Dynasties was the closest I got out of the new stuff out there, with its combination of a space-based strategy and a dynastic, character-based system basically directly aping Crusader Kings (nothing wrong with this!), but I didn't like the scale of it: you still operated in terms of whole systems at once on a scale that wasn't fine-grained enough.

Unfortunately, the actual number of things that really got what I wanted was roughly next to zero, from what I could determine. Two games that did scratch the unexplainable itch (or rather one game and one mod) were the WC3 custom map Solar Conquest, where you could individually control units on several different planets and fought for control over each individual planet with the ability to have partial control over things, and the now ancient game Emperor of the Fading Suns which involves dynastic conflict over individual planets under a vacant interstellar throne.

Given that I now guessed that there would be nothing to go into that niche (and I doubt that there's a high enough demand to make someone fill it for me), I figured I might want to do some development myself, knowing full well I'm not likely to release anything any time soon, or ever.

My 'ideal' game, in this case, would be a space-based grand strategy game with a dynastic character focus, primarily or even exclusively dealing with humans and human factors, with things like sub-system and sub-planet granularity of control and a feudal system of interdependencies between characters themselves, and between characters and titles. Space feudalism, really. How has this never been done!?

The game will be 2D, since I just like how maps work in 2D (I even turned off the 3D terrain mode in EU5, I really don't care for the look of it). I will probably make it turn-based, for one reason or another (mostly because turn-based games are more intuitive to me and will be easier to develop) [P]

It's not really that well-developed in sci-fi literature either, for one reason or another. Sure, there's Dune, but even then you get less focus on the diplomacy inherent in feudalism. It's very rare to really stumble across a piece of media doing what's basically A Song of Ice and Fire in space! But I want to try and tackle the problem, even if it doesn't result in me changing that.

The Target

Given that I'm not really limited by commercialisation of my ideas, I could play around with arbitrary tech and target whatever I wanted. Given that I picked up a total shitbox (in today's terms) from a local university getting rid of its tech, I figured why not target that?

The target for my game was going to be, at least for the time being, a machine with the following specs:

↓ thing

­←­-­→

spec ↓

  • CPU

  • Pentium III @ 866MHz

  • RAM

  • 256MB DDR1

  • GPU

  • nVidia GeForce2 MX

  • OS

  • Windows 98 SE

We really are cooking with some exotic computation gear, aren't we!

Beyond that, I also, for some reason, decided I wanted to eventually distribute this game on floppy disks, optimally just one at a time, so that I could distribute it to my friends and go haha guys look how cool this is! You can play my stupid retro game off a stupid floppy disk! This means that I'll have to make sure that my entire game with all its assets fits into somewhat under 1440KB. [2]

Given that the system is running Win98, it is also realistically possible to target MS-DOS on it, given that Win98 is a DOS-based system. This simplifies the programming part a lot, surprisingly, as there are a lot of high-quality libraries and tools targeting DOS that simply don't have a match on the Win95/98 side of things. This also means that I will not have to do much to test my game, as now I can run it just fine locally on DOSBox and expect it to run on hardware as well (and the inverse direction doesn't need to apply).

The GPU this system has isn't that strong, but we're not doing 3D graphics anyway, and just need it to push pixels to the screen. I'm rendering a 1024x768 screen (how huge!) at 16 bits per pixel, which the GeForce2 MX takes like a champ and doesn't stutter a lot. I also don't want to make a 3D game, and not even just because we're on MS-DOS: I think a proper strategy game or wargame can work very well in a 2D scheme, and gains nothing much from going 3D. We'll be doing tiles from a tileset, for this reason.

This set of restrictions feels properly absurd for today, but I've found it really somewhat manageable, and limitations do after all breed innovation.

The Tools

My primary deployment/testing platform is DOSBox-X, which I use to test my builds. It's a really decent DOS emulator that aims for maximal hardware compatibility and tries to make sure it can run a wide array of general-purpose DOS applications and not just games like several other DOSBox forks. It's stable, and it just so happens to emulate the exact damn processor my shitbox has, the Pentium III, down to the 866MHz clock. In retrospect, this was a very good pick, though at first I worried whether I could actually expect matching behaviour. The answer is a solid and resounding yes: everything I've run so far on DOSBox-X has run identically on the shitbox, down to the segfault traces being genuinely byte identical. Isn't emulation neat!

Now, I'm a C++ freak. The hammer I wield is C++, and everything looks like a nail. It's my go-to for scripting, it's my go-to for a Discord bot, it's what I use to solve anything that needs computation done. Thankfully, doing C++ on MS-DOS is genuinely easy and not even disgusting to think about. For this project, I naturally said hey, what if I did this in C++, and unexpectedly the DOS dev ecosystem has been pretty clean to work with. I am compiling code for a 32-bit MS-DOS target using a toolchain built off DJ Delorie's [3] DJGPP, containing GCC 15.2 with C++ support. This means that, yes, structured bindings and lambdas Just Work™ on MS-DOS targets, and that the MS-DOS has a newer compiler than my actual main machine running Linux Mint 22.3.

DJGPP includes Charles W. Sandmann's DOS extender CWSDPMI, which provides the system with virtual memory management and the ability to seamlessly run protected mode code on a real mode OS (such as MS-DOS is, or such as Win98 pretends to be). Memory protection, as annoying as it can be, is not a bad thing, and segfaulting on MS-DOS because you touched a nono array or did a double-free is great for debugging. [4]

DJGPP also includes a few more useful treats:

  • a port of gdb for MS-DOS, running natively on the system (this is excellent)

  • dynamic linker system (dxegen and the associated dxe3res)

  • whole bunch of headers for useful operations (file.h and even a relatively performant regex.h)

It's a surprisingly robust system, and has been around for longer than I have. Did you know that DJGPP was what was used to compile Quake back in the day!?

Another good thing that DJGPP provides us with is the ability to have both a natively-running C/C++ compiler on the MS-DOS itself [5] and one that works on a modern 64-bit host. Cross-compiling for the MS-DOS is much, much, muuuuch faster than doing it natively (something on the order of 50x faster is what I've measured), and it enables me to do a modern type of incremental build, and also just edit while another thread is compiling the game. Lots of stuff you can do!

Finally, a very good cherry on top is that inline assembly and whole-cloth ASM subroutines can get integrated really easily into the codebase without much fanfare; I can just point the compiler at an .s file and it magically gets handled just the way I want it :) [6]

For the rest of the tech, I went with a trustworthy old friend: the game library Allegro, with one of the later versions (but not the very last one) of Allegro 4 still maintaining DOS support. This basically pushed most of the concerns out of my way, since Allegro would handle video mode toggling, input support (mouse and keyboard), timer interrupts (crucial for building an animation), and had support for a number of useful file formats like the .pcx image format. Allegro also provides good support for 16-bit colour [7] and even has hand-tuned assembly functions for drawing a large number of things (lines, circles, pixels) adjusted for 16-bpp colour mode. Allegro also has excellent compatibility with DJGPP.

Given that we're linking a large library such as Allegro into the final game executable, binary size is understandably a bit awful. The liballeg.a object is by itself 1495KB, which is already above what we can afford; aggressive LTO does handle this pretty well, but the resulting executable still ends up hovering at around 1.1MB, dangerously close to the limit, even when compiling with -Os and stripping as much as possible. This isn't a new problem, and developers of the time also encountered it frequently: the natural solution is to do what they did, and use an executable packer. Packers are programs that take an executable program, compress the majority of the code with some algorithm, and replace the entry point with a decompressor stub that would reconstruct external imports and decompress the packed executable and jump into it. Packers of yesteryear weren't quite as sophisticated as what we have today, but upx is pretty fresh, very good, and (most importantly) can work on MS-DOS executables of various formats, including the .EXEs that DJGPP produces. It claims to give up to 50% reduction in most cases; currently, I'm going from 1127KB to 321KB, which is ~28.5% of the initial size.

Digression: Microsoft executables and DJGPP

This digression started as a footnote but it grew way too large to be a footnote (maybe a legnote?). The DJGPP output format is a bit of a nasty beast from our modern point of view.

For a bit of history, there are actually approximately [8] three types of .EXE files in the history of Microsoft. The first executable format on the MS-DOS was the .COM file, which was an image copy of a single 64K segment of memory with a fixed (kind of) entry point at 0x0100 where DOS would load the image and jump into it. This meant that .COM could be up to 0xff00 bytes long (since it must fit into a single memory segment), which limited it a whole lot, and had no relocation info. Microsoft's answer to these limitations was the MZ .EXE, named after Mark Żbikowśki, one of the devs on MS-DOS and one of the format's architects. This MZ executable included some relocation info and allowed executables to span more than a single segment. In fact, MZ .EXE is the ancestor of all its successors, which is why every Windows executable you can run today will have 0x4d5a (ASCII of "MZ") as the magic number in the header.

In any case, the MZ format was decent for MS-DOS, but graphical Windowses ended up needing more cruft :) and so Microsoft produced the now-ancient New Executable which was designed to run on 16-bit OSes, including later versions of MS-DOS, but in a 32-bit memory environment. When they made Windows NT, they also came up with the Portable Executable, which was designed to be started in protected mode on 32-bit OSes and never dip into real or fakereal, but still include a DOS stub that prints the legendary "This program cannot be run in DOS mode" (and which can be replaced with an actual DOS program, resulting in a fat binary!). DJGPP, being a DOS-native toolchain, can't rely on newer executables, so the programs it produces are, technically, presented as MZ .EXE files.

The differences start here.

A now mostly historical object file format of the past forms the basis of both the DJGPP executable and the modern Windows PE. The COFF (common object file format) object file started on Unix as an attempt at developing a new format to replace older limited executables (much like how the MZ .EXE was made to replace .COM with its limits), but was itself quickly replaced by ELF because it was heavily underspecified (basically every compiler and linker vendor produced their own microvariants of COFF files that were mutually incompatible). A variant of the COFF forms the core of the DJGPP executable. The toolchain actually generates a MZ .EXE stub that the OS loads the proper way, but it includes a COFF payload that forms the meat of the program.

The way that a DJGPP .EXE works is that the 16-bit stub allocates memory for a transfer buffer (section of memory where the program talks with the DOS host, located somewhere in 'conventional memory' (the low 640KB of physical RAM)), then tries to set up DPMI services (first by probing for whether it is already under DPMI, and if it isn't then trying to load CWSDPMI either from the current directory or from the system path), reads the COFF header to see how much memory it will need, enters protected mode, loads the executable from the COFF into virtual memory, allocates selectors for conventional memory access (for buffers that don't need to touch the transfer buffer, or for whatever other use that a DOS program might need conventional memory access really), sets up sbrk, brings up the stack (giving you a default of 512K of stack space), hooks signal and interrupt handlers, sets up the program environment, passes the commandline arguments, constructs any object with static storage, and then calls the program's main() from the COFF object.

I assume that because COFF is well-known, and MZ executables aren't that awful to deal with, that upx has decent results tackling the DJGPP executable.

Space-saving strategies

Whenever you Do Development, you basically just play many small games of tradeoffs. The main triangle I had to consider here was the tradeoff between performance, memory and disk use.

With the shitbox being the shitbox it was, I expected that performance would be a pretty big bottleneck. It wasn't. Other than a few times where I was really stupid (such as calling a complex function on one pixel, for every pixel of the framebuffer, for every frame), I haven't really hit any big performance rough spots. The chip is fairly good, really; you have access to fast 32-bit floating point operations with SSE (no hitting the awful slow FPU), to name one thing. I do expect to eventually also just have to do some weird optimisations down the line [9] when I start hitting a performance limit, but for now we're doing just dandy in that regard.

I didn't expect to have memory issues. While 256M is dreadfully small for today's standards, it's positively monstrous for the MS-DOS. I can basically almost do whatever I want, though I'll try to keep my memory use within 64MB if I can. I'm so far away from hitting that limit right now that I can't really imagine how approaching it would even look like.

That, naturally, leaves disk. Like I mentioned before, aiming to hit 1.3MB of disk is already making me do things I've never had to do on a 'modern' system.

One thing that's been fun has been learning about various file formats, compression and decompression methods etc. Even though I said the CPU is pretty beefy, I still have to keep in mind it's a single-core 32-bit chip under a gigahertz, so I can't really do some of the sillier stuff I've done even on a Raspi today. Still, my first default image format, and the one that Allegro supported out of the box and was handled just fine by Aseprite, was the .pcx, which is only very slightly compressed (but decompresses really easily), and my first big space-saving operation was to include a .png decoder (for 17KB of unpacked executable size, I am saving ~50% on my images). When I include text-based configs, I will also look into LZSS since it's not computationally very expensive, which I know since I'm already doing the related LZ77 in the .png decoder and afaik in upx itself, and since Allegro provides built-in support for a LZSS unpacker.

Really, spending some time loading and decompressing or unpacking assets ahead of play is basically free, and was a beloved tool of many a dev until people started shipping gigabyte textures FatHusky but that's a topic for a different post, innit.

I still haven't gotten to the audio, but I expect that I'll ship some small mod playback library (say, libopenmpt to play tracker music) to avoid having to ship actual audio files whenever I can. Given also the limitations of the FAT12 filesystem, I'll want to avoid making many small files (which would fill up the root directory if in the top level directory, or would take up additional space if in a subdirectory), and would perhaps want to bundle all my assets in one large file which I could compress and decompress (hello, reinvention of the tarball), and which would ease loading. Allegro also already provides this in the form of the datafile with a pretty robust interface and set of manipulation functions.

Development process

Honestly, I expected development to be much more painful than it really is.

My actual tech stack is, including some of the pieces I mentioned above, the following:

  • DJGPP toolchain - GCC, G++ 15.2 running on a 64-bit Linux host - native gdb running on MS-DOS host

  • upx packer

  • stb_img for format support

  • make to call compiler and packer

  • Aseprite for graphics

  • VS Code as the IDE (I like having popups telling me what a function wants)

  • DOSBox-X for testing

How does the code look?

Like honest to God normal C++ code. If you don't believe me, just take a look at this:

void planet_t::update_minimap()
{
    if(minimap == nullptr) { return; }
    clear_bitmap(minimap);
    for(uint16_t j = 0; j < PLANET_ROWS; j++)
        for(uint16_t i = 0; i < PLANET_COLS; i++)
        {
            uint16_t colour = 0;
            switch(map[j][i].tileid)
            {
                _CASE(EMTY, EMTYCOL);
                _CASE(WATR, WATRCOL);
                _CASE(GRAS, GRASCOL);
                _CASE(SVNA, SVNACOL);
                _CASE(DEEP, DEEPCOL);
                _CASE(SNOW, SNOWCOL);
                _CASE(MEAT, MEATCOL);
                _CASE(SAND, SANDCOL);
                _CASE(VOID, VOIDCOL);
                _CASE(TOP1, TOP1COL);
                _CASE(TOP2, TOP2COL);
                _CASE(ROCK, ROCKCOL);
                _CASE(MNTN, MNTNCOL);
                _CASE(SWMP, SWMPCOL);

                default:
                    colour = makecol16(0xff, 0x00, 0x00);
                    break;
            }
            uint8_t x = (i * 3);
            uint8_t y = (j * 3);
            if((j % 2) == 0) { x += 1; }
            rectfill(minimap, x, y, x+2, y+2, colour);
        }
    return;
}

You can just do regular idiomatic C++, there's not a lot of gotchas. You don't have everything you would want on a modern system, so things like multithreading aren't in by default (though there exist POSIX thread library ports to DJGPP if you want them for some reason), but most things work just fine. Structured bindings are in, various deductions work fine, constructors and destructors are okay, exceptions work without much of a fuss, etc. With the toolchain it's less inconvenient to do dev for DOS than it is for Windows it feels like.

What's next?

Now, I'm actually a bit along the project, though it's still in the early stages; I work on it whenever I'm feeling particularly bored and there's nothing else to distract me. As I'm loading some configurations from disk, such as the terrain layouts of planets which are constructed from a bitmap/tilemap stored in the assets folder, I've also made some devtools to help me visualise these and work on them 'in-engine', like a micro level editor. I'll probably expand these into a bundle of more proper editing tools that I'll distribute to the end users, to allow them to make their own planets and maybe even events (if somewhen in the far future I get to making an event or scripting system...)

Currently, I have basic screen rendering done, tile mouseover detection is there (it's a bit messy figuring out which tile your mouse is over, actually), a minimap system is there, as is scrolling the planet map. Planets are for now cylindrical: they wrap around going east to west, but are capped off at the poles, and all latitudinal parallels are of equal length. I don't think I'll actually approach making a realistically spherical planet; spheres translate poorly to maps, and I'm a big fan primarily of maps.

If I come back to this devblog series, I'll be writing first about how I built the animation system, how I handled hex coordinates and printing hex tiles, etc. and show some actual screens of the game. They're still horrible.


[P]

Keep in mind that Paradox games are actually all turn-based, just with very fast turns with auto-advancement. Each day or hour or whatever is in reality, mechanically, a turn, and things resolve at turn boundaries. It's very neatly presented in a way that feels like the games are real-time, since the intervals the turns represent are really just sufficiently small that it doesn't feel like each is individually a big deal compared to, say, what Civ-like or Total War games do with year-like turns.

Writing a new backend for ELVM/8cc

Every person will, at some point of their lives, decide to design an ISA, emulate it, and eventually write a toolchain for building software for it. This is one of those immutable laws of the universe, kind of like programmer thermodynamics. Surprisingly, the hardest part of this journey thus far has, for me, been making the emulator, not the surrounding pomp. Turns out it's surprisingly easy to port a C compiler to your device, assuming you're willing to put up with awful code.

Lay of the Land

I won't bore the audience with an elabourate description of the chip. There are eight general-purpose registers, a shadow registry for quick context switching in kernel, a hardware stack pointer, and a 24-bit address space. There is an MMU which pages out the memory space into 12-bit pages and tracks read-write permissions for each. None of that is especially important.

I decided I wanted to port a C compiler to target my chip so I could feel the transient emotional satisfaction of actually compiling a Thing of code for my chip. It's a form of validation: look at it! There it goes, there it works!

But porting a C compiler, or writing a new backend for one is, to put it lightly, a bit of a bother. Some projects are said to be easy to port, needing only a month or two of focused work to emit working code.

I'll show you how to do it in a day.

Our compiler

There are many C compilers out there, certainly dozens and probably reaching into the hundreds. Unlike writing a C++ compiler, which is a sisyphean task that even giants like Microsoft end up having trouble with, you can just sit down and crank out a working (albeit not good) C compiler and be the same age you were when you started it.

Most of them are not meant to be retargetable and so will produce code for only one platform, or will have a very small set of alternatives that are difficult to extend. The one C compiler I've found isn't that hard to work with is the excellent 8cc/ELVM pair. The 8cc compiler serves as the frontend and accepts normal-looking C code (including, surprisingly, itself), and emits ELIR assembly, while the elc backend consumes that and emits various other languages.

The code quality is atrocious. The ELIR architecture has four general-purpose registers and two special ones, only addition and subtraction for number manipulation (so not even bitwise operations), a few conditionals, and char input and output. The compiler is laughably stupid, producing code that would make gcc -O0 feel performant. That's fine, because we're looking for something that is easy to get working.

In the /target folder, you should create a file that will contain your entire backend. I went with enn.c, as the target is the enncpu. At the top you'll want to include the following:

...
#include <ir/ir.h>
#include <target/util.h>
...

These contain some of the basic definitions and things the compiler internals use.

The main datatypes you'll be dealing with are:

typedef enum {
  A, B, C, D, BP, SP
} Reg;

typedef enum {
  REG, IMM
} ValueType;

typedef enum {
  OP_UNSET = -2, OP_ERR = -1,
  MOV = 0, ADD, SUB, LOAD, STORE, PUTC, GETC, EXIT,
  JEQ = 8, JNE, JLT, JGT, JLE, JGE, JMP,
  // Optional operations follow.
  EQ = 16, NE, LT, GT, LE, GE, DUMP,
  LAST_OP
} Op;

typedef struct {
  ValueType type;
  union {
    Reg reg;
    int imm;
    void* tmp;
  };
} Value;

typedef struct Inst_ {
  Op op;
  Value dst;
  Value src;
  Value jmp;
  int pc;
  int lineno;
  char* magic_comment;
  struct Inst_* next;
} Inst;

typedef struct Data_ {
  int v;
  struct Data_* next;
} Data;

typedef struct {
  Inst* text;
  Data* data;
} Module;

The backend driver calls your target through one function, which should return void and take one pointer of type Module*. I named mine void target_enn(Module* module). The single argument is what the driver passes to your target and it contains the entirety of the information extracted from the code. This target is going to be pasted verbatim into the backend driver code and compiled into it, so it has to include a trailing newline (to avoid messing up includes that come after it; yes, I got burnt on this myself).

The ELIR virtual machine is a Harvard-architecture thing, and instruction and data codepaths do not mix. Instructions are not addressable, and all addressing is for the data. The compiler works with 24-bit words, and data is word-addressed. The compiler makes some convenient but wild assumptions (sizeof(char) == sizeof(int)), and this makes it both a bit easier and a bit more complicated to keep track of things.

The dataword stream and the instruction stream are both in the form of a linked list. The module provided by the caller contains pointers to the first members of both of those.

Instructions are the more fun part. Since they aren't actually addressable, the ELVM uses a genuinely horrendous scheme to keep track of where jumps go. Every piece of code under a label has the same pc address (so a sequence of a hundred instructions will all have pc == 34 if they are under the 34th label in the instruction stream!), and jumps go to the first instruction in that stream. It is best to tackle addressing at the end.

Before you implement the instructions, read up on their behaviour and whatnot; it's a very short read, and answers basically everything you need to know.

Emitting an instruction is fairly easy. The Inst struct (see above for its layout) has everything you need to build an instruction out of it. The way I handled it was:

static void enn_inst(Inst* inst)
{
        switch(inst->op)
        {
                case MOV:
                        if (inst->src.type == REG) {
                                emit_enn_mov_reg(inst->dst.reg, inst->src.reg);
                        } else {
                                emit_enn_mov_imm(inst->dst.reg, inst->src.imm);
                        }
                        break;
                case ADD:
                        if (inst->src.type == REG) {
                                emit_enn_add_reg(inst->dst.reg, inst->src.reg);
                        } else {
                                emit_enn_add_imm(inst->dst.reg, inst->src.imm);
                        }
                        break;
        // ...

static void emit_enn_add_reg(Reg dst, Reg src)
{ printf("\tADD   %c, %c\n", dst + 'A', src + 'A'); }

        // ...

Really, that's about all there is to it to emitting instructions. Keep in mind that immediates are ∈ [0, 16777215], and adjust accordingly.

Since labels/pc breaks don't actually appear in the stream but are signified by a change in the inst->pc value, you'll have to do a walk through the linked list to find out where these breaks lie. To avoid calculating the byte addresses of where those jumps go (as was done e.g. in the Armv7 and x86 backends), I exploit the fact that I'm generating assembly and offload the bookkeeping to the assembler. I do one walk through the instruction stream and just emit a string label whenever a break is encountered. Here is the whole of my target callback code:

void target_enn(Module* module)
{
        emit_enn_data_head();

        for(Data* data = module->data; data; data = data->next) {
                printf(".INT24 %d\n", data->v);
        }
        emit_enn_data_tail();

        emit_enn_head();

        int prev_pc = -1;

        for (Inst *inst = module->text; inst; inst = inst->next) {
                if (prev_pc != inst->pc) {
                        printf("@L%d\n", inst->pc);
                }
                enn_inst(inst);
                prev_pc = inst->pc;
        }

        emit_enn_tail();

        return;
} // must have tailing newline

Accordingly, when a jump is encountered, I load the address of the label and jump to it. Here is that bit:

static void emit_enn_jump_reg(Reg jmp)
{ printf("\tJMR   %c\n", jmp + 'A'); }

static void emit_enn_adrl_lbl(int jmp)
{
        printf("\tADRL  G, @L%d\n", jmp);
        printf("\tADRM  G, @L%d\n", jmp);
        printf("\tADRH  G, @L%d\n", jmp);
}

static void emit_enn_jump_imm(int jmp)
{
        emit_enn_adrl_lbl(jmp);
        emit_enn_jump_reg(6);
}

Data loads and stores are a bit more complicated. The ELIR abstraction starts placing the data at address 0, growing upwards, and each word is 3 bytes wide but word-addressed. This means that you need to start placing your data and memory at a known base address and calculate offsets off that. I did it brutishly: data is placed starting at 0x10, after the header, and then all addresses are adjusted based off that. Here is an example:

static void emit_enn_load_reg(Reg dst, Reg src)
{
        printf("\tMOV   G, #3\n");
        printf("\tMULA  %c, G\n", src + 'A');
        printf("\tADD   %c, #16\n", src + 'A');
        printf("\tLDRS  %c, %c\n", dst + 'A', src + 'A');
        printf("\tSUB   %c, #16\n", src + 'A');
        printf("\tDIV   %c, G\n", src + 'A');
}

static void emit_enn_load_imm(Reg dst, int src)
{
        src += 16;
        src *= 3;
        emit_enn_mov_imm(6, src);
        emit_enn_load_reg(dst, 6);
        emit_enn_sub_reg(6, 6);
}

In my case, I know that my base is at #16, and since I set up all the data as .INT24, everything is offset in multiples of three. While I considered padding things out between those to allow for LSHL G, #2 instead of the more convoluted multiplication, it would've eaten up somewhat more space in the data segment, so I decided against it (but will try it like that some time in the future).

Putting it together

To actually have your thing be usable, you have to do a small amount of further bookkeeping.

Your first stop will be the target/elc.c file, where you'll add your target callback. You really can't miss the huge block of function pointer declarations in there, trust me.

The second stop will be going into the Makefile and rummaging around. The ELVM suite is annoying to build because it runs a bunch of testsand samples on every invocation of make. You can change that, and I did, but I won't waste time here on the how since it's immaterial. In the Makefile you'll want to find the ELC_SRCS variable and append your backend target source file to it. After that, you'll want to go to the lists of targets under the comment line # Targets, and add yours. Here's mine:

TARGET := enn
RUNNER := tools/runenn.sh
include target.mk

The tools/runenn.sh file is just an empty bash file because I don't want it running any of those tests. You should then of course hit make -j 8 (or more; and there are a lot of noisy files in there so the console will start sparking and sizzling), and inevitably hit an error because the compilation is passed the unfortunate flags -W -Wall -Wextra -Werror so even an unused variable will break the build and you'll have to do it all over again and so on and so forth.

After this whole song and dance is finished, you'll be left with out/8cc and out/elc. The first is the C compiler frontend which takes one or more C files (there's a linker option that I didn't explore; so one in my case) and produces assembly out of that. You'll invoke it with 8cc {infile}.c -S and it will produce the corresponding {infile}.s (same name) in the same directory (I'm not sure yet how to get it to go anywhere else, but that's the least important thing here). After that, you will invoke the backend driver with elc {infile}.s -enn > outfile.txt (or wherever else you do output; I did mine to stdout). This should, in theory, leave you with a fully working Thing of assembly that targets your custom CPU (in practice: I had to fiddle with it a bit to get it going, and this revealed some bugs in the assembler I didn't know were there). Isn't that neat!

Outro

Realistically, it should take a decent C programmer about a day to actually write a working backend for 8cc using ELVM. The code is absolute gore, and there is so much room for optimisation (such as eliminating dead assignments), but this simplicity and lack of optimisation (though sorely needed) is probably a direct factor in how portable this thing really is. It took me about six hours and the end result was around 400 lines of surprisingly stupid C; some of those hours were wasted on figuring out some un(der)documented things using the x86 and Armv7 backends as reference.

Go do it yourself! Have a go at it, have some fun. I really doubt there's any other C compiler that's this trivial to port and can compile itself (seriously, that's one of the test cases and why it takes so goddamn long to make them).


Under this line you'll find the actual full source of my backend. It's dirty, and I slapped it together in one day. The version here isn't 100% identical to my own code as I shuffled around some newlines and whitespace to make it take up just slightly less space, but the contents are unchanged. Enjoy.

static void emit_enn_add_imm(Reg dst, int src);
static void emit_enn_sub_imm(Reg dst, int src);

static void emit_enn_head()
{
        printf(".SEC %%main\n");
        printf("\tADRL H, @_sec_main\n\tADRM H, @_sec_main\n\tADRH H, @_sec_main\n");
        printf("\tMOVL G, #0x80\n\tLSHL G, #2\n");
        printf("\tADD  H, G\n\tWSP  H\n\tSUB  G, G\n\n");
}
static void emit_enn_tail()
{ printf("%%main\n"); }

static void emit_enn_mov_reg(Reg dst, Reg src)
{ printf("\tMOV   %c, %c\n", dst + 'A', src + 'A'); }

static void emit_enn_mov_imm(Reg dst, int src)
{
        bool neg = false;
        if(src < 0) { src = -src; neg = true; }
        printf("\tMOVL  %c, #0x%02x\n",     dst + 'A', (byte)(src >>  0));
        if(src > 0xff)
                printf("\tMOVM  %c, #0x%02x\n", dst + 'A', (byte)(src >>  8));
        if(src > 0xffff)
                printf("\tMOVH  %c, #0x%02x\n", dst + 'A', (byte)(src >> 16));
        if(neg)
                printf("\tSUB   G, G\n\tSUB   G, %c\n\tMOV   %c, G\n", dst + 'A', dst + 'A');
}

static void emit_enn_add_reg(Reg dst, Reg src)
{ printf("\tADD   %c, %c\n", dst + 'A', src + 'A'); }

static void emit_enn_add_imm(Reg dst, int src)
{
        if(src < 0) { emit_enn_sub_imm(dst, -1 * src); return; }
        else if (src < 64)
                printf("\tADD   %c, #%d\n", dst + 'A', src);
        else
        {
                emit_enn_mov_imm(6, src);
                printf("\tADD   %c, G\n", dst + 'A');
        }
}

static void emit_enn_sub_reg(Reg dst, Reg src)
{ printf("\tSUB   %c, %c\n", dst + 'A', src + 'A'); }

static void emit_enn_sub_imm(Reg dst, int src)
{
        if(src < 0) { emit_enn_add_imm(dst, -1 * src); return; }
        else if (src < 64)
                printf("\tSUB   %c, #%d\n", dst + 'A', src);
        else
        {
                emit_enn_mov_imm(6, src);
                printf("\tSUB   %c, G\n", dst + 'A');
        }
}

static void emit_enn_load_reg(Reg dst, Reg src)
{
        printf("\tMOV   G, #3\n");
        printf("\tMULA  %c, G\n", src + 'A');
        printf("\tADD   %c, #16\n", src + 'A');
        printf("\tLDRS  %c, %c\n", dst + 'A', src + 'A');
        printf("\tSUB   %c, #16\n", src + 'A');
        printf("\tDIV   %c, G\n", src + 'A');
}

static void emit_enn_load_imm(Reg dst, int src)
{
        src += 16;
        src *= 3;
        emit_enn_mov_imm(6, src);
        emit_enn_load_reg(dst, 6);
        emit_enn_sub_reg(6, 6);
}

static void emit_enn_store_reg(Reg dst, Reg src)
{
        printf("\tMOV   G, #3\n");
        printf("\tMULA  %c, G\n", src + 'A');
        printf("\tADD   %c, #16\n", src + 'A');
        printf("\tSTRS  %c, %c\n", dst + 'A', src + 'A');
        printf("\tSUB   %c, #16\n", src + 'A');
        printf("\tDIV   %c, G\n", src + 'A');
}

static void emit_enn_store_imm(Reg dst, int src)
{
        src += 16;
        src *= 3;
        emit_enn_mov_imm(6, src);
        emit_enn_store_reg(dst, 6);
        emit_enn_sub_reg(6, 6);
}

static void emit_enn_exit()
{ printf("\tERR ; \n"); }

typedef enum {
        CEQ, CNE,
        CLT, CGT,
        CLE, CGE,
        NONE
} COMPARISON;

static void emit_enn_cmp_reg(COMPARISON type, Reg dst, Reg src)
{
        switch(type)
        {
                case CEQ: printf("\tCEQ   %c, %c\n", dst + 'A', src + 'A'); break;
                case CNE: printf("\tCNE   %c, %c\n", dst + 'A', src + 'A'); break;
                case CLT: printf("\tCLT   %c, %c\n", dst + 'A', src + 'A'); break;
                case CGT: printf("\tCGT   %c, %c\n", dst + 'A', src + 'A'); break;
                case CLE: printf("\tCGT   %c, %c\n\tCINV\n", dst + 'A', src + 'A'); break;
                case CGE: printf("\tCLT   %c, %c\n\tCINV\n", dst + 'A', src + 'A'); break;
                case NONE: return;
        }
}
static void emit_enn_cmp_imm(COMPARISON type, Reg dst, int src)
{
        if(type == NONE) { return; }
        if (src < 64)
                printf("\tMOVL  G, #%d\n", src);
        else
        {
                emit_enn_mov_imm(6, src);
                printf("\tADD   %c, G\n", dst + 'A');
        }
        switch(type)
        {
                case CEQ: printf("\tCEQ   %c, G\n", dst + 'A'); break;
                case CNE: printf("\tCNE   %c, G\n", dst + 'A'); break;
                case CLT: printf("\tCLT   %c, G\n", dst + 'A'); break;
                case CGT: printf("\tCGT   %c, G\n", dst + 'A'); break;
                case CLE: printf("\tCGT   %c, G\n\tCINV\n", dst + 'A'); break;
                case CGE: printf("\tCLT   %c, G\n\tCINV\n", dst + 'A'); break;
                case NONE: return;
        }
}

static void emit_enn_jmcc_reg(Reg jmp)
{ printf("\tJMR.P %c\n", jmp + 'A'); }
static void emit_enn_jump_reg(Reg jmp)
{ printf("\tJMR   %c\n", jmp + 'A'); }
static void emit_enn_adrl_lbl(int jmp)
{
        printf("\tADRL  G, @L%d\n", jmp);
        printf("\tADRM  G, @L%d\n", jmp);
        printf("\tADRH  G, @L%d\n", jmp);
}

static void emit_enn_jmcc_imm(int jmp)
{ emit_enn_adrl_lbl(jmp); emit_enn_jmcc_reg(6); }
static void emit_enn_jump_imm(int jmp)
{ emit_enn_adrl_lbl(jmp); emit_enn_jump_reg(6); }

static void emit_enn_putc(Reg reg)
{ printf("\tDBGC  %c\n", reg + 'A'); }

static void enn_inst(Inst* inst)
{
        switch(inst->op)
        {
                case MOV:
                        if (inst->src.type == REG) {
                                emit_enn_mov_reg(inst->dst.reg, inst->src.reg);
                        } else {
                                emit_enn_mov_imm(inst->dst.reg, inst->src.imm);
                        }
                        break;
                case ADD:
                        if (inst->src.type == REG) {
                                emit_enn_add_reg(inst->dst.reg, inst->src.reg);
                        } else {
                                emit_enn_add_imm(inst->dst.reg, inst->src.imm);
                        }
                        break;
                case SUB:
                        if (inst->src.type == REG) {
                                emit_enn_sub_reg(inst->dst.reg, inst->src.reg);
                        } else {
                                emit_enn_sub_imm(inst->dst.reg, inst->src.imm);
                        }
                        break;
                case EXIT:
                        emit_enn_exit();
                        break;
                case LOAD:
                        if (inst->src.type == REG) {
                                emit_enn_load_reg(inst->dst.reg, inst->src.reg);
                        } else {
                                emit_enn_load_imm(inst->dst.reg, inst->src.imm);
                        }
                        break;
                case STORE:
                        if (inst->src.type == REG) {
                                emit_enn_store_reg(inst->dst.reg, inst->src.reg);
                        } else {
                                emit_enn_store_imm(inst->dst.reg, inst->src.imm);
                        }
                        break;
                case JMP:
                        if (inst->jmp.type == REG) {
                                emit_enn_jump_reg(inst->jmp.reg);
                        } else {
                                emit_enn_jump_imm(inst->jmp.imm);
                        }
                        break;
                case PUTC:
                        emit_enn_putc(inst->src.reg);
                        break;
                case JEQ:
                        if (inst->jmp.type == REG) {
                                emit_enn_cmp_reg(CEQ, inst->dst.reg, inst->src.reg);
                                emit_enn_jmcc_reg(inst->jmp.reg);
                        } else {
                                emit_enn_cmp_imm(CEQ, inst->dst.reg, inst->src.imm);
                                emit_enn_jmcc_imm(inst->jmp.imm);
                        }
                        break;
                case EQ:
                        if(inst->src.type == REG)
                                emit_enn_cmp_reg(CEQ, inst->dst.reg, inst->src.reg);
                        else
                                emit_enn_cmp_imm(CEQ, inst->dst.reg, inst->src.imm);
                        break;
                case JNE:
                        if (inst->jmp.type == REG) {
                                emit_enn_cmp_reg(CNE, inst->dst.reg, inst->src.reg);
                                emit_enn_jmcc_reg(inst->jmp.reg);
                        } else {
                                emit_enn_cmp_imm(CNE, inst->dst.reg, inst->src.imm);
                                emit_enn_jmcc_imm(inst->jmp.imm);
                        }
                        break;
                case NE:
                        if(inst->src.type == REG)
                                emit_enn_cmp_reg(CNE, inst->dst.reg, inst->src.reg);
                        else
                                emit_enn_cmp_imm(CNE, inst->dst.reg, inst->src.imm);
                        break;
                case JLT:
                        if (inst->jmp.type == REG) {
                                emit_enn_cmp_reg(CLT, inst->dst.reg, inst->src.reg);
                                emit_enn_jmcc_reg(inst->jmp.reg);
                        } else {
                                emit_enn_cmp_imm(CLT, inst->dst.reg, inst->src.imm);
                                emit_enn_jmcc_imm(inst->jmp.imm);
                        }
                        break;
                case LT:
                        if(inst->src.type == REG)
                                emit_enn_cmp_reg(CLT, inst->dst.reg, inst->src.reg);
                        else
                                emit_enn_cmp_imm(CLT, inst->dst.reg, inst->src.imm);
                        break;
                case JGT:
                        if (inst->jmp.type == REG) {
                                emit_enn_cmp_reg(CGT, inst->dst.reg, inst->src.reg);
                                emit_enn_jmcc_reg(inst->jmp.reg);
                        } else {
                                emit_enn_cmp_imm(CGT, inst->dst.reg, inst->src.imm);
                                emit_enn_jmcc_imm(inst->jmp.imm);
                        }
                        break;
                case GT:
                        if(inst->src.type == REG)
                                emit_enn_cmp_reg(CGT, inst->dst.reg, inst->src.reg);
                        else
                                emit_enn_cmp_imm(CGT, inst->dst.reg, inst->src.imm);
                        break;
                case JLE:
                        if (inst->jmp.type == REG) {
                                emit_enn_cmp_reg(CLE, inst->dst.reg, inst->src.reg);
                                emit_enn_jmcc_reg(inst->jmp.reg);
                        } else {
                                emit_enn_cmp_imm(CLE, inst->dst.reg, inst->src.imm);
                                emit_enn_jmcc_imm(inst->jmp.imm);
                        }
                        break;
                case LE:
                        if(inst->src.type == REG)
                                emit_enn_cmp_reg(CLE, inst->dst.reg, inst->src.reg);
                        else
                                emit_enn_cmp_imm(CLE, inst->dst.reg, inst->src.imm);
                        break;
                case JGE:
                        if (inst->jmp.type == REG) {
                                emit_enn_cmp_reg(CGE, inst->dst.reg, inst->src.reg);
                                emit_enn_jmcc_reg(inst->jmp.reg);
                        } else {
                                emit_enn_cmp_imm(CGE, inst->dst.reg, inst->src.imm);
                                emit_enn_jmcc_imm(inst->jmp.imm);
                        }
                        break;
                case GE:
                        if(inst->src.type == REG)
                                emit_enn_cmp_reg(CGE, inst->dst.reg, inst->src.reg);
                        else
                                emit_enn_cmp_imm(CGE, inst->dst.reg, inst->src.imm);
                        break;
                default: printf("something went wrong with insn %d (d:%d, s:%d, j:%d)\n",
                        inst->op, inst->dst.imm, inst->src.imm, inst->jmp.imm);
        }
}

static void emit_enn_data_head()
{
        printf("\n.SEC %%data\n");
        printf("\tADRL  G, @_sec_data\n");
        printf("\tADRM  G, @_sec_data\n");
        printf("\tADRH  G, @_sec_data\n");
        printf("\tJMR   G\n");
}
static void emit_enn_data_tail()
{ printf(".PAD\n%%data\n"); }

void target_enn(Module* module)
{
        emit_enn_data_head();
        for(Data* data = module->data; data; data = data->next)
                printf(".INT24 %d\n", data->v);

        emit_enn_data_tail();
        emit_enn_head();
        int prev_pc = -1;
        for (Inst *inst = module->text; inst; inst = inst->next) {
                if (prev_pc != inst->pc) { printf("@L%d\n", inst->pc); }
                enn_inst(inst);
                prev_pc = inst->pc;
        }
        emit_enn_tail();
        return;
} // must have tailing newline

Thoughts about Memory Management on Bare Metal, pt. 1

Prelude

Recently I've been toying with some weird, cheap SBCs that offer a lot for way too little money. You can actually get a device with a pressure-sensitive 320x240 screen, 4M flash and 512K RAM for under ten bucks Moggers so obviously what I did was that I did get one of those and decided to see what I could do with it.

Enter the Cheap Yellow Display (or CYD for short), name courtesy of Brian Lough, that's got one of those fancy Espressiv IoT SOCs, with a Bluetooth stack and WiFi out of the box, Arduino integration, two cores (one of which will run the RTOS in an Arduino setup by default, but you can hijack it and run other code on it), easy interrupt registering (with per-core control to boot), a decent amount of GPIO pins broken out, and a resistive LCD touchscreen that's trivial to program for.

Its weakest points are its 520K RAM and 4M flash. This is probably the one thing that can limit its usability in general, and there are other ESP32 chips and boards with different configs (8M and 16M flash variants exist, and many boards also integrate an extra 4/8M of PSRAM, absent on the CYD), but this is already more than enough for a lot of things you'd want to do. If you're crazy enough, you can even run Linux on it (though the device in the link has 8M of PSRAM, you can probably trim it and make a godawful, pained, miserable Linux with 512K as well, and it boots just "fine"), but we're not going down this route because that just feels like hubris: yes, the old UNIXes did run on hardware with 64-256K RAM, but that was more than 50 years ago and those machines ate around 9 amps of current at 230V (or at least that's what I'm getting for the PDP-11/20 power supply); the CYD works at a steady 3.3V/115mA, and is a quintillion times faster [1] with a much saner work environment and tools that don't make you want to immediately [2] kill yourself, and you can replace it with basically infinite ease if one dies. Try replacing a PDP-11 on a budget.

Some good things about the lack of PSRAM, though, is that it teaches you to use what you have and not be a pussy ​ has much slower access times and as such you'll want to fall back on fast internal RAM anyhow; PSRAM would be good for memory you'd access intermittently rather than hit every cycle. Or maybe this is all just cope. In any case, 520K RAM it is.

It's also a bit more muddied than that: the ESP32 divides its memory space into two blocks, DRAM for data and IRAM for instructions. These are asymmetric: DRAM covers some 320K and IRAM covers 200K. The docs helpfully state that "[a]ny internal SRAM which is not used for Instruction RAM will be made available as DRAM (Data RAM) for static data and dynamic allocation (heap)", which means that if we need >328K data we can claw it back from IRAM, which helps our case a bit. The ability to execute things from RAM can also help with hot-loading code from external memory (I've looked a lot into this and concluded that just writing a parser for external code and then interpreting it is much, much less painful than hotloading code into IRAM, and we're currently in the middle of me musing how to implement that properly), so you can actually just drive a slim OS in RAM and load programs off an SD card, and one project does just that [3] with quite a bit of charm and polish to it.

The thing is, the ESP32 is powerful enough to run a RTOS (in this case it bundles FreeRTOS [4] by default and uses it for a bunch of shit), and this RTOS reserves some memory for itself, and the WiFi and BT stacks can also eat into this memory for their own purposes (this eats up a whole 64K of RAM in case you're wondering). A default setup with Arduino enabling the use of the Serial library without any screen code has the non-user code reserve 20080 bytes for global variables, "leaving 307600 bytes for local variables". The astute reader will see that this sums up to 327680B, i.e. 320K of RAM. A decent amount of this is going to the RTOS task scheduler and internal states, and a bit is going on facilitating dynamic memory allocation. The docs also tell us that there's a technical limitation to how much we can allocate in what way: we're limited to 160K static allocation, and the remaining 160K "can only be allocated at runtime as heap".

Loss to overhead isn't that bad, though, truth be told. A more full-featured programme with a screen and touch driver and SD card code for both reading and writing takes up 22560B, just under 2.5K more. This is not that awful considering how much RAM we have, but it'd be proper deadly on a smaller device like one of the actual Arduinos that have 2.5K RAM in total.

But this all also means that, if we want to use a lot of RAM in our thing, we have to dynamically allocate more when we start touching the >128K zone. Dynamic memory allocation on microcontrollers sucks, though, and you'll fragment the memory space in basically no time and won't be able to recover from it because there's no actual OS to bail you out. People die when an MCU upredictably returns nullptr from a malloc() call.

One of the solutions is to just allocate everything you can statically ahead of time, and then 'dynamically' allocate during startup and then never clean up or call free() again. The fact that malloc() can only give us blocks up to the size of the largest contiguous memory block and the fact that we're not doing any sophisticated memory mapping on the fly, this really just means figuring out how big our maximum memory blocks are and then allocating up to their size and then writing management code ourselves on top of that. Several tactics for this include memory pools and slab allocation, both of which are pretty neat ideas, and we'll be tapping into their concepts for our purposes. Let's dive in.

Diving In

There's something charming about the CYD. Like I've mentioned above, it's a dual-core system with 520K RAM and whatnot. The ISA the chips are running can be called unusual: it's called Xtensa and was developed by the IP company Tensilica. The exact architecture here is the Xtensa LX6 configured to use 32-bit transfers/data buses, and is little-endian. The CYD has a core without vector extensions, but there are Tensilica cores with SIMD (the ESP32-S3 is one such example, with some weird SIMD features that are genuine nice-to-haves). The chip offers some good features, a large number of pins broken out, several serials, JTAG, all the good stuff. The CYD cuts into this a decent bit by integrating an SD card module, multicoloured LED, a screen, a touch interface for that screen, a micro-USB port, and a buzzer/speaker two-wire/mono plug. The result is a still-decent package with 3 clusters of 4 pins exposed, one of which has a 3.3V line, and one of which doubles as a serial port.

These memory investigations are basically me building up some infrastructure to support an interpreted programming language running behind the scenes on the CYD, as a sort of native BASIC-like. There is no practical use to this, it's just me playing with my toys.

Given that the system has (at least) 320K of SRAM dedicated to data, this is obviously a good place to start. As no more than 160K can be statically allocated, and a decent amount of it will be eaten up by libraries and other code, it becomes "natural" to me to think about the CYD's RAM in terms of 64K blocks. This might be a problem [5] later, but currently it seems fine enough.

The screen itself will also want at least 75K of RAM (320 * 240 = 76800, so if every pixel is one byte that's a clean 75K; though colour performance might suffer obviously), and possibly more (the screen has 16-bit colour, so 150K RAM, eating up almost half of DRAM in one go). If I buffer the screen and expose it as a memory zone, that is; this isn't required in any way, and is anyway not something I care about right now.

In any case, it would be prudent to allocate as much memory as possible for the application, then manage it manually later, writing an own allocator and deallocator. It's also perfectly possible to write a kind of "memory mapper" that would provide a flat contiguous memory space to user code obfuscating the bullshit underneath. One pseudocode example of such a a remapper would be:

#include <vector>

struct memory
{
        std::array< std::array<uint8_t, 64 * 1024>, 4> contents;

        memory()
        {
                for(auto i : contents)
                        i = new std::array<uint8_t, 64 * 1024>;
        }
        ~memory() = delete; // must be static

        uint8_t read(uint32_t address)
        {
                uint8_t  page = (address >> 16) & 0xff;
                uint16_t cell =  address & 0xff'ff;

                return contents[page%4]->at(cell);
        }
        void write(uint32_t address, uint8_t payload)
        {
                uint8_t  page = (address >> 16) & 0xff;
                uint16_t cell =  address & 0xff'ff;

                contents[page%4]->at(cell) = payload;
        }
};

This kind of structure has sub-32 bytes of internal state, manages pages of 64K memory, and translates accesses in a perceived continuous space to accesses in a discontinuous space. Of course, this kind of code is unacceptable for embedded because using the STL in the first place is what is called "rough luck", but it's not the worst thing ever, and the code scaffold is good enough. This is downright primitive, but it serves the purpose of unifying a disparate memory space into one field. A lot more can be added on top of this to make it a proper manager unit that would provide virtual memory with memory safety features. This isn't currently too important.

This feels like the wrong solution for specific problems, though. Some other things I've toyed with include:

  • Hash-maps

  • Slabs and pools (aforementioned)

  • Naïve constrained declaration and no dynamicity

  • other detritus and miscellanea

The hash-map was probably my favourite idea, though.

Static hashing and hashmaps

A hashmap is a really neat data structure: it's an associative array where input keys are used to look up locations in memory easily. These keys are put through a hashing function, transforming them into a hash index or hash code, and this hash will point to where the memory is stored in the corresponding target array. Since with fixed-width input data hashing will 'always' take the same amount of operations, most hashmap designs will feature amortised constant O(1) access, though in practice this isn't really the case.

Since a typical hashmap will not run in an environment where each possible key has a unique location corresponding to it, some kind of hash colision mitigation is unavoidable (thus the average hashing process is not an injective operation), and since allocating the entire space is also frequently uneconomic reallocation can end up being necessary at many points (and, remember, reallocation and dynamic memory stuff is undesirable in bare metal).

The issue here is that we aren't really allowed to grow and shrink at will, and should aim to take up as much memory as possible; that is, the initial storage allocation should aim to give the subsystem the maximum amount of memory it would need during its whole runtime. This becomes the memory ceiling of the datastructure. The hashmap, then, would either have to be statically allocated in the flash binary, or will have to be malloc()'d at startup and never freed.

This still leaves the question of colisions open.

Let's add more context. As I mentioned before, I started thinking about all of these things in the context of developing a BASIC-like running on the ESP32, and so some structures and requirements tend to emerge spontaneously without me having to do much deciding. In a typical BASIC, variables are stored onto a stack or on a table and then every time they're referenced the interpreter trawls this structure (O(n) as a function of table size) to find the variable, and if it's not present it pushes it onto the structure (e.g. in Microsoft BASIC for the 6502 you can use variables without declaring them, which automatically implicitly declares them there). This trawl is repeated every time a variable is referenced, absolutely nuking performance. It's not like it's much of a space-saving thing, either: MS-BASIC used a weird 5-byte format for floating point data (fine), as well as integers (what) that were treated as 16-bit numbers (what?) with 24 padding bits (what!?), plus two bytes for the name, meaning that each variable took up seven bytes of space.

Arrays, as far as I know, need 4+2n bytes of bookkeeping on top of their actual memory footprint: 1 for the name, 2 for the address, 1 for the number of dimensions, and 2n for the length of the dimensions for n dimensions [6] give or take. As you'll probably recognise, arrays go onto the heap of the BASIC, but it's important to note that once you DIM/declare an array, it can't be deallocated or resized (for details, see here), which is a recurring theme in the low-power space. More modern BASICs like the beautiful QB64 allow resizing and reallocating arrays as enabled by running on top of a fat OS that can handle moving a lot of memory around for them [7] without having to think too much about it themselves.

While for arrays this trawl is pretty 'cheap' (it's still O(n) but with n ≤ 255), for variables it can get nasty pretty quick (realistically you can input up to 26*36 = 936 variables, but the interpreter can be reading bytecode that would use 'syntactically inappropriate' variable names going into thousands of combinations; a theoretical maximum of around 4000 variables based on memory constraints is an example of such a large number). This is a big performance sink, but solutions to the problem are tough to come up with. I believe that a statically allocated hashmap is in fact a solution to this.

Simply using a 16-bit key, it's possible to effortlessly index into a 64K table without any further adjustments. This would mean that a 2-character name would just point to the address in the code table without requiring any bookkeeping; this would be an example of a 'perfect hashing table'. My first musings, thus, involved using 2 CJK characters as possible variable names. Encoded as UTF-16 and then somewhat adjusted, this means that both are ∈ [0x0000, 0x6bff] which, when concatenated together, produces a ~30-bit key, which would somehow have to be mapped onto a 64K table in some way; every single possible key would have to compete with ~16K other keys for its slot in the table.

The naïve way to resolve this would be to add the top and bottom hanzi together. This is awful, because it'll make a lot of small collisions easy: let's say that we name one variable 女王 ('queen', 0x5973'738B - the sum of the two hanzi is 0xCCFE); it will immediately collide with 王女 ('princess', 0x738B'0x5973). This extends to all anagrams, of which there is a large amount in e.g. Japanese (本日 'today' vs. 日本 'Japan' is another neat example). While it's tempting to tell the user to Never Ever use anagram variable names, I felt that I needed to mitigate this in some way. So, addition is not a good hashing function.

What I did was use a hash prospector to find good 16-bit hashes and incorporate that into the thing. It spat out quite a few functions; what I settled on was the following:

// bias = 0.0049752954186705221
uint16_t hash1(uint16_t x)
{
    x ^= x >> 11;
    x *= 0x104bU;
    x ^= x >> 9;
    x *= 0xcd1bU;
    x ^= x >> 7;
    x *= 0x42a5U;
    x ^= x >> 11;
    x *= 0x258dU;
    x ^= x >> 9;
    return x;
}

// bias = 0.0046809801814303642
uint16_t hash2(uint16_t x)
{
    x ^= x >> 7;
    x *= 0xb257U;
    x ^= x >> 9;
    x *= 0x8829U;
    x ^= x >> 4;
    x *= 0x0af7U;
    x ^= x >> 12;
    x *= 0x504dU;
    x ^= x >> 6;
    return x;
}

Bias here represents how strongly correlated toggling a bit of input is with specific bits of output (low hash, meaning less correlation, is good). These two are chained together, operating on separate halves of the variable name; a simplified code example:

uint16_t hash(uint32_t input)
{
        uint16_t a, b, c;
        a = hash1(input & 0xff'ff);
        b = hash2(input >> 16);
        c = hash1(a ^ b);

        return c;
}

This provides surprisingly decent hash performance. Using a collision measurement script I wrote, I found that for a table of size n, there is on average fewer than n/2 collision events, and that around half of that is a single collision; iteratively adding mitigations shows that after around 50% population, the hash table starts getting more and more collisions, and that beyond around 80% population, the table practically becomes unusable performance-wise; insertion changes from O(1) in a theoretically optimal table to O(n) in a static one. Average-case performance is still pretty good, but worst-case performance isn't good at all. Still, this beats iterating through a table of variables to extract the named one.

To mitigate collisions during insertion, I simply opted for the following simple algorithm:

  1. If there is no collision, insert; stop.

  2. If there is a collision, rehash c once. If there is no collision, insert; stop.

  3. If there is still a collision, increment c = c+1. If there is no collision, insert; stop.

  4. If there is still a collision, go to #3

In pseudocode:

bool hash_insert(uint32_t input)
{
        uint16_t a, b, c;
        a = hash1(input & 0xff'ff);
        b = hash2(input >> 16);
        c = hash1(a ^ b);

        if(!collides(c))
        {
                insert_at(c);
                return true;
        }
        else
        {
                c = hash2(c);
                if(!collides(c))
                {
                        insert_at(c);
                        return true;
                }
                else
                {
                        for(int i = 0; i < SIZE; i++)
                        {
                                c = c + 1;

                                if(!collides(c))
                                {
                                        insert_at(c);
                                        return true;
                                }
                        }
                        return false;
                }
        }
}

Of course, c should not be allowed to hit the map out of bounds, but otherwise it's a surprisingly simple principle.

Unfortunately, because we mitigate collisions in this way, a table of names must be maintained separately from the data map, so that indexing into the table will tell us if mitigations were applied or not, so we know whether we've found the variable we want or if it's in another castle ​ in a different memory location. This means the table should have a structure like:

struct entry_t
{
        uint32_t name;          // 4
        uint32_t data;          // +4
};

static entry_t name_table[1024];

The astute reader will notice that this is one byte more than the MS-BASIC variable table, without the performance hit of having to loop through an array to find what we want every time a variable is ever used. Such a table would eat 8K of memory for 1K variables (totalling 4K data) with longer names (four ASCII characters, or 2 CJK characters). It feels a bit like defeat having to still store the name in the thing, but it's essential to disambiguating between two identically hashed names; basically, we're using the name as a way to gauge whether the accessor should do a mitigation or not, without wasting too much space and still preserving some onomastics. As the names are stored as one integer made from concatenated characters, checking whether it's the right name is a simple single-instruction comparsion, and we're avoiding complex string routines that would basically be obligatory with a longer variable name. Checking whether anything's been allocated is also pretty easy: since we know names won't have the highest bit set (remember, we have a ~30-bit key), simply setting the name to 0xffff'ffff ensures that it's easy to check for whether a 'spot' is taken by simply doing if(int32_t(name) < 0) { /* ... */ } without having to worry much. Using a larger table than 1024 is likewise pretty easy to a point: at 16K entries, the table reaches 128K of memory use, which is near our upper static allocation limit and should be avoided.

Slabs and pools

I haven't really dug deep into slab and pooling because we really don't have too much memory to work with in general, so doing stuff in this space would not be quite as efficient as it could be if we had more memory to manage. Generally speaking, slab allocation is most effective when you have an OS that manages memory tightly. Since spinning up and destroying pages can be costly, and finding free memory can sometimes be a chore, the kernel instead allocates a bunch of chunks that can fit a certain object type, and then hands that memory out when requested and reclaims it when freed, all by adjusting a bookkeeping structure that shows which parts of slabs are free to use and which occupied. Freeing and allocating memory in this case is an illusion: it means just rewriting the appropriate part of the bookkeeping table. When something is 'destroyed' or reclaimed in a slab allocator, the object structure is kept cached, so that when another instance is requested there is no set-up involved and the cached object can be returned basically intact. The objects are usually also constructed ahead of time and in one go.

Memory pooling is a similar concept, where a large swath of memory is preallocated and then divided up by a pool allocator. There's bookkeeping and all that jazz, but what separates it from a slab (though neither is really extremely well-defined) is that there are no premade objects in pools, and they can store heterogenous data. It's basically a microcosmos of memory management.

Memory pooling could perhaps work here, but in a way the idea of a memory pool is superfluous because there's no real OS for us to request a lot of memory from and then subdivide it to avoid the overhead because we're that OS and we have to manage the memory we have in this manner anyway. That is, all our memory is in memory pools to begin with. Nobody's there to reclaim leaked memory.

The same principles apply, though: we want to keep a small bookkeeping structure that would indicate which parts of memory are free to use and which are taken.

As there's very little memory to go around, a very light bookkeeping structure would be not just optimal, but really necessary. This is balanced out by the need for space savings and very fine memory granularity. For example, we can assume that the smallest unit of memory that can be handed out like this would be 8 bytes; to keep track of every unit, a 64K memory space would need 8K locations keeping storage status. If we use a bitmap, this means the bookkeeping structure would only have an overhead of 1K (8 locations per byte for 8 bytes of space per location) plus anything on top (like a pointer to the structure and whatnot) that would add a handful of extra bytes. Computationally, this would suck to manage a bit, but if space savings were the main concern this would probably be the way to go. Less fine-grained memory allocation would in turn allow for more metadata in the bookkeeping structure while keeping to the same space concerns: if each space manages 16B of data, then 2 bits per location would allow for a total of 4 states to be stored per item, possibly facilitating things like access control; if each space manages 64B of data, then a full byte is available for this purpose.

Another bookkeeping approach would be to keep a table of what's been allocated, a pointer to it, and the length. Several other approaches (such as a linked list) also exist, and are, in my opinion, all better suited for more memory-rich platforms.

Naïve without dynamicity

Most of the stuff an embedded programme will need will be statically allocated ahead of time anyway. In this case, when I say naïve I mean that there's no magic behind the scenes and nothing is playing musical chairs with the memory. Everything is baked in at compilation time and no deviation is allowed. This is how most of the "important data" will live on the chip anyway. A screen buffer, for example, will be statically allocated as 75K or 150K of RAM with no manager, and resources like fonts or images or sprites or whatnot that would be statically burnt to flash are likewise going to just be this kind of memory: it's basically handling unmanaged, unscoped static memory with bare code-hands. There's nothing to talk about in terms of management here, strictly speaking.

What else?

Really, it's just me staring at the screen blarting out words as they come, without a coherent idea of where it's going. I'm going to be designing the BASIC-like over the next few weeks/months (depending on how hard it holds my attention, really), and will probably be writing some kind of static hashmap (more sophisticated than the pseudocode above), a stack (possibly templated for type and depth), and a rudimentary array allocator that would implement a basic (hehe) heap. I'm not actually sure how useful multi-dimensional arrays were back in the olden days, though; I can imagine a 2D or 3D array doing just fine, but do you really need a 37D array when you're not doing some fancy linear algebra churning vectors (which you shouldn't be doing on an ESP32 anyway)?


Kernel Configuration, Part One

It's time to formally come out of the Hello World sphere of things. We've set up the UART, talked to the user in several different ways, and even cobbled together an input system for the user to communicate with the device. We'll be expanding on I/O later, but for now I'm content with what we've got.

Now is the time to start thinking about whether the code we're writing can run on "all" devices in the family, and not just the exact configuration we're running at one specific moment in time. This means that we need to check for processor and device features, and integrate fallbacks wherever possible, and gracefully exit if not.

Fundamentally, this means we can write code for a number of different processors that QEMU will accept for the virt board, and expect more-or-less the same functionality.

Target Analysis

Just because we're bitchin programmers doesn't mean we can just write our code for all the processors out there and expect it to work: we're working strictly in Aarch64 land, and this means we have actually a very limited set of platforms we can ever dare to support without spinning up a whole new codebase and toolkit. Apart from that, we're coding for the virt board (or, well, "board", you know) that has its own quirks and peculiarities that other boards don't have, and in turn the other boards have their kinks to be ironed out (like the Pi actually booting from the GPU and not CPU).

Since we're writing and targetting Aarch64, the ARM CPU core designs we can think of are:

  • ARMv8.1-A: A34, A35, A53, A57, A72, A73

  • ARMv8.2-A: A55, A65, A75, A76, A77, A78, X1, Neoverse N1

  • ARMv8.4-A: A65AE (partial), V1

  • ARMv9.0-A: A510, A710, X2, Neoverse N2

QEMU gives us a number of other options, of which only the Fujitsu A64FX core has any merit—it's an ARMv8.2-A core with the 512-bit SVE extension.

There's also the option of using the QEMU max CPU—a pseudo-core that has the maximum featureset possible implemented in QEMU's codebase. This does not correspond to any core, and has a number of extensions that aren't in any of the currently emulated cores. For example, while the Fujitsu core is emulated with 512-bit SVE, you can set your max core to work with 1024-bit SVE registers 'out of the box'.

The QEMU codebase is, of course, incomplete. Some things are just not emulated yet, but you can get close enough. For example, there's no support for Apple chips, but they're running ARMv8.5-A; there's also no support for ARMv9.0-A cores but their features are more-less completely implemented and we're just waiting for an actual core such as the A510 to be written.

If we are going to write truly board-independent Aarch64 code, that means we're going to have to write peripheral detection routines and whatnot, and that's also a pain in the ass to do compared to just reading the device tree and pointing the CPU in the right direction, so we're avoiding that for now as well. The UART on a virt is in a totally different place compared to the two UARTs on a Pi3, for example, though the setup code is much the same.

But even within our virt garden there's a number of things that can change based on what core we're using. Today we're gonna take a look at two such features, see how they're implemented or left unimplemented, and handle them in two different ways.

Random Numbers

The first feature I wanna take a look at is the hardware RNG that some Arm cores have and some don't. There's a processor feature that determines whether there's a RNG accessible through mrs instructions, and that's FEAT_RNG that's expressed in the top four bits of the ID_AA64ISAR0_EL1 register.

Since we're gonna write a function that returns random numbers on the stack, we're gonna want to set up a check in the bootloader to see whether we have this feature enabled, and if not to rewrite the function address to the alternative code path.

The subroutine which we'll rely on to get random numbers from is going to be called _rng_64, and we'll use this same alias for both the hardware RNG and our fallback implementation.

Somewhere in our code, we're gonna add the following stub:

_rng_64_branch: .quad _rng_64_fallback
_rng_64:
        sub sp, sp, 8
        str x0, [sp, -8]!
        adr x0, =_rng_64_branch
        ldr x0, [x0]
        br  x0
_rng_64_hardware:
        ldr x0, [sp], 8
        ret
_rng_64_fallback:
        ldr x0, [sp], 8
        ret

We're going to use this stub as our jump location, and use the boot code to change the fallback branch to the hardware RNG branch if we have the RNG enabled.

In the boot sequence we'll then add this:

mrs x0, ID_AA64ISAR0_EL1
lsr x0, x0, 60
and x0, x0, 1

cbz x0, _skip_rng_fallback

adr x0, _rng_64_branch
adr x1, _rng_64_hardware
str x1, [x0]

_skip_rng_fallback:

We're overwriting the pointer if the hardwarve feature is enabled, since its default value is _rng_64_fallback. The hardware RNG is then used like this:

_rng_64_hardware:
 mrs x0, s3_3_c2_c4_0           // rndr
 str x0, [sp, 8]
 ldr x0, [sp], 8
 ret

Since we're using the same convention as with other functions, branching into the subroutine using the bl instruction, returning arguments on the stack, here we read the hardware register into x0, store it on the preallocated stack space, restore the x0 register and ret.


the memory map is as follows:

        0x40000000        -- QEMU virt ram and kernel start here
        STK               -- depending on the size of the kernel
                             the bottom of the stack can move around
        STK + 0x00100000  -- top of stack (1mb space), bottom of vectors
        STK + 0x00101000  -- top of vectors, bottom of ramfb config
        STK + 0x00101080  -- top of ramfb config (128b), bottom of ramfb itself
        STK + 0x00501080  -- top of ramfb (4mb space), bottom of heap
        STK + 0x10501080  -- top of heap

Keep in mind that the entire kernel and all memory above it are all rewritable code, and thus we must handle everything with care in order to not rewrite ourselves. We are still working with a flat memory model, and there are no MMU-based safety features; the code is fully privileged since we're still operating in kernel mode. The framebuffer device is basically just a chunk of RAM we dedicate that QEMU will read out of and blit to the screen. Framebuffers are the simplest model of graphical output you can get on any modern hardware, and they're also the least powerful and tend to be performance suckers. To configure the framebuffer, we must first communicate with QEMU itself, by trawling the QEMU config tree in the device's memory, figuring out if we can use the DMA interface, then writing the configuration through DMA to QEMU's config in big-endian order (!!), and only then can we use the framebuffer. Note that the framebuffer requires fw_cfg to have an enabled DMA, so our first step would be to check if DMA is enabled and, if not, to skip framebuffer setup completely and only work with UART down the line. see: https://github.com/qemu/qemu/blob/e93ded1bf6c94ab95015b33e188bc8b0b0c32670/hw/display/ramfb.c#L124 We'll do this by setting a flag if the DMA is not available, and later if we write applications that require the framebuffer we'll check whether it's enabled and gracefully exit informing the user that the functionality isn't available instead of writing to memory that doesn't have any visible effect.

Baremetal Aarch64: Pt 3, Hello World Ultimate

Last time around I said I was gonna do input but then totally forgot about it BroFrustration so today's gonna be a short one on getting input via UART into the machine and then making it dance like a little monkey for us.

UART revisited

The UART interface, like we covered, is dead simple. As with our output code, our input code is going to be fully blocking on the CPU until the UART flags change. Unlike the output, this means that we're going to be eating CPU time until the user provides with input, which could take a while. Cheap, nonblocking IO would take much more of a stack than we have going for us.

Our function will return a single character from input, and take no arguments. We will need to check if the RXFE flag is set (that is, that the receive stack is empty) on the UART, and wait until it's cleared.

Thankfully, the UART is buffered, so we don't need to time our reads from the data register to the exact moment the user writes something.

// our input function
// takes 0 arg on stack, returns 1
// trashes 2 registers
_ugetc:
        str  xzr, [sp, -8]!
        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

        _ugetc_loop1:
                ldr  x1, [x0]
                and  x1, x1, 0x10
                cbnz x1, _ugetc_loop1

        sub  x0, x0, 0x18
        ldr  x0, [x0]
        str  x0, [sp, 16]
        ldp  x0, x1, [sp, 16]!
        ret

We reserved a place on the stack for the return argument, then stored the return there, while popping everything else.

Some fun code to play around with in the main code path:

PLEASE_WRITE:   .asciz "Please input a key and I'll do my best to repeat it and tell you if it's odd or even: "

3:
        ldr x2, =EXAMPLE_STRING
        str x2, [sp, -8]!
        bl _uputs
                // and Hello World, finally!

        ldr x2, =PLEASE_WRITE
        str x2, [sp, -8]!
        bl _uputs

        _parity_loop:
                bl  _ugetc
                ldr x2, [sp]
                and x2, x2, 0xff

                sub x2, x2, 0x40
                cbz x2, 4f
                add x2, x2, 0x40

                bl  _uputc

                sub x2, x2, 0x30
                and x2, x2, 0x1
                cbz x2, _is_even

                        _is_odd:
                        mov x2, 0x4f
                        b _parity_loop_end

                        _is_even:
                        mov x2, 0x45

                _parity_loop_end:
                str x2, [sp, -8]!
                bl _uputc
                b  _parity_loop

4:
        add x2, x2, 0x40
        str x2, [sp, -8]!
        bl _uputc
        b .

This program tests your input whether it's even or odd, prints E or O depending on the case, and halts when it encounters 0x40—that is, @.

To get the input working correctly, I had to pipe it to a serial device, and use PuTTY to link to the other side. If your input isn't working, it's probably due to that. My own experience here is that the code is somehow extremely sluggish; there's up to a full second of delay between my input and the device's response, and I'm not sure if that's a quirk of using virtual COM ports and PuTTY to interface with QEMU or a failing of my code. I might want to write some sort of device driver for input later on to alleviate that and avoid serial TTY, so we'll see.

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:

  1. sometimes the assembler fails to error on unaligned access and will joyfully read instructions one byte off

  2. 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:

mrs x0, SCTLR_EL1
mov x1, 0x1a
neg x1, x1
and x0, x0, x1
msr SCTLR_EL1, x0

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:

    ldr x0, =stack_top
    add x0, x0, 0x4
mov sp, x0

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:

mov x17, 0x48        // the character 'H'
str x17, [sp, -8]!   // our output argument
bl _uputc

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:

mov x0, (0x3 << 20)

// msr cptr_el3, xzr
// msr cptr_el2, xzr
msr cpacr_el1, x1

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 BroFrustration 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.


Baremetal Aarch64: Pt 1, Hello World

If you want to start doing anything at all in baremetal Aarch64 land, you need a chart and a lot of supplies, because the road is wild and unkempt. I'll be assuming you know what the 'path' is and how to install software (duh), but also I'll assume you can learn the Aarch64 ISA and GNU assembler instructions and whatever on your own; don't disappoint me. You'll probably have to adapt these guidelines to your system; I'm working on Windows 10 so all the filenames will be local, but you'll have no issue with the needed changes.

The Tools

We'll be programming in Aarch64, the 64-bit instruction set for Arm CPUs. Specifically, we'll be targetting some variety of Armv8-A instruction set, the specifics of which we'll hone in on a bit down the line. We'll be working on totally fake 'hardware', on an emulated board, since this is just about the most convenient thing we can do: no flashing, no actual electronics work, no cost, nothing but programming. We'll be doing this inside QEMU's emulation suite, since it does a pretty good job of emulating most of the things we'll ever need. It will provide us with peripherals we'll want to use, such as a serial device we'll get data from on the console.

QEMU is easy to install, it comes with a convenient installer for whatever platform you're on. Make absolutely sure you installed the Aarch64 [1] emulator; once you've added it to your path, it should give you something like qemu-system-aarch64.exe and its -aarch64w.exe equivalent. For now, we want the first one; the difference between these two is that the second one doesn't open a console window, and we want one (for now?). You can get it straight from the website there; I'm using QEMU 7 [2] and I suggest you use that too since things like memory offsets might change between versions and you'll be stuck pulling your hair out.

The files you'll want from QEMU include this tiny pair:

  • qemu-system-aarch64.exe

  • qemu-img.exe

We'll also be using a cross compiler. You'll be all on your own setting this one up. The target triplet [3] for our machine will be the fairly specific aarch64-none-elf. [4] You want your compiler to be able to emit object code for aarch64 targets, to have no specific vendor and no OS it produces this object code for (hence the none), and you want it to produce ELF executables because ELF is really cool and awesome.

The GCC tools you'll want are:

  • aarch64-none-elf-ld.exe

  • aarch64-none-elf-as.exe

  • aarch64-none-elf-objcopy.exe

  • aarch64-none-elf-gcc.exe

You probably also want aarch64-none-elf-gdb.exe but honestly I'm not much of a gdb fan and so far in the project I haven't really felt a need for it.

You'll also want the following things:

  • dtc.exe (Device tree compiler)

  • make.exe

  • anything capable of running simple scripts (Batch, Powershell, Bash, ...)

QEMU and Platform

QEMU is pretty good at what it does. What it isn't good at is documentation of pretty much anything inside it. Some parts are amazingly well documented, others just blatantly suck. You'll have to learn to cope with that.

We'll be developing for the fakest of the emulated boards; our emu target is going to be the virt, which is a platform 'which does not correspond to any real hardware; it is designed for use in virtual machines. It is the recommended board type if you simply want to run a guest such as Linux and do not care about reproducing the idiosyncrasies and limitations of a particular bit of real-world hardware', [5] which means it's ideal for us.

QEMU emulates just under 50 Arm-based boards which have a more or less fixed design and component layout, but the virt lets you pick and choose components. I appreciate that a lot, since I'm more or less window shopping for the component that implements what I need in the least obnoxious way (it's much easier to just put a framebuffer down into RAM than handle a VGA interface, for example).

Eventually, we'll fire our system up using the following incantation: qemu-system-aarch64.exe -M virt -cpu cortex-a57 -m 256 -kernel kernel.elf -serial mon:stdio -drive id=first,file=disk.img,format=raw,if=none -device virtio-blk-device,drive=first -device ramfb, but we'll get to all of this slowly.

There is a lot to QEMU, so if you want to quickly get overwhelmed by it, go ahead and type in qemu-system-aarch64.exe --help for a massive wall of pain. I have no idea what half of these even are, but it's good to go through them and read whatever it gives you.

The option -M specifies which machine/board we want QEMU to emulate for us, and so we'll tell it -M virt to give us our desired target. The default CPU of this board is actually going to be the Arm Cortex-A15, which is a 32-bit CPU running on Armv7, and not what we want; so we have to change the default CPU by passing the option -cpu cortex-a57 which is a 64-bit CPU. [6]

We top off our incantation by telling the emulator to give us 256 megabytes of memory (-m 256), that we want to boot from a specific kernel we provide (-kernel kernel.elf), that we don't want graphical output at all (-nographic) and that we want it to redirect the virtual serial port to standard output (-serial stdio). The final thing won't look almost anything alike what I gave you up there, but we're working towards it.

To sum up, our first incantation will be: qemu-system-aarch64.exe -M virt -cpu cortex-a57 -m 256 -kernel kernel.elf -nographic -serial stdio

Congrats, you got your first QEMU error! We have no kernel to boot.

Minimum

First, we want to make sure that our toolchain is in order and that we're assembling our code right. To do this, we'll need to know how to even assemble and link our code. Tough situation. Before we even assemble our first program, the linker needs to know where in memory to put our code, and we need to write a makefile to automate this process. Since the virt board with our chosen CPU supports a MMU and emulates a bunch of memory foolishnesses, we'll actually be putting our kernel in RAM, and so we need to know just where this RAM exactly is.

We'll do this adding the following to our previous invocation: -machine dumpdtb=out.dtb. This will dump a device tree blob to disk, and we'll have to convert it to a readable format using dtc that I mentioned earlier, by invoking it as dtc.exe -I dtb -O dts -o out.txt out.dtb. My own blob generates the following device tree:

/dts-v1/;

/ {
        interrupt-parent = <0x8002>;
        #size-cells = <0x02>;
        #address-cells = <0x02>;
        compatible = "linux,dummy-virt";

        psci {
                migrate = <0xc4000005>;
                cpu_on = <0xc4000003>;
                cpu_off = <0x84000002>;
                cpu_suspend = <0xc4000001>;
                method = "hvc";
                compatible = "arm,psci-1.0\0arm,psci-0.2\0arm,psci";
        };

        memory@40000000 {
                reg = <0x00 0x40000000 0x00 0x10000000>;
                device_type = "memory";
        };
        .
        .
        .

Of course, I trimmed it since the output is ~9kb long, but we have what we need right now right at the start of the tree. We see that our device's memory starts at the 1GB mark (0x40000000), which means this is where we'll put our kernel.

We are going to need a loader script, and I'm using this stub for the time being:

ENTRY(_reset)
SECTIONS
{
        . = 0x40000000;
        .startup . : { kernel.o(.text.startup) }
        .text : { *(.text) }
        .data : { *(.data) }
        .bss : { *(.bss COMMON) }
        . = ALIGN(16);
        . = . + 0x100000; /* 1MB of stack atop of BSS */
        stack_top = .;
}

Did I mention you'll have to wrangle with writing your own linker scripts? You'll have to wrangle with writing yur own linker scripts. The above script will suffice for now, though we might want to expand it later. I've also taken the liberty of naming our entry point _reset; I particularly like using underscores for label names and allcaps for constants, but your mileage may vary. Save it as kernel.ld for now.

For our makefile, we have the most rudimentary thing known to man for now:

CROSS=aarch64-none-elf-

all: kernel.elf

kernel.o: kernel.s
        $(CROSS)as.exe -g -c $< -o $@

kernel.elf: kernel.o
        $(CROSS)ld.exe -Tkernel.ld $^ -o $@

This will produce an object file out of the assembly source, and then use the linker script to produce an ELF executable with the proper section offsets.

All we have left is to write our first assembly file and we're good to go. The most barebones stub we'll be using for now is:

.section .text.startup
.global _Reset
_Reset:
    mov x0, 0x40
    b .

Save that as kernel.s and finally run the makefile. This should produce the file kernel.elf, as we specified in the makefile, and you almost certainly won't be able to run this executable natively on your machine. That's alright. Run it through QEMU (remember the error you got last time?) and it should freeze instead of error out! This is actually good, because it means the code we wrote is actually doing what we wrote (we told the CPU to do an infinite loop).

If you want to see the CPU's current instructions, you can pass a further commandline argument -d in_asm and it'll blit out the instructions to the console as they're being executed. For our trashfire kernel up there, it generates the following:

IN:
0x40000000:  d2800800  movz     x0, #0x40
0x40000004:  14000000  b        #0x40000004

----------------
IN:
0x40000004:  14000000  b        #0x40000004

Which is exactly what we told it to do. If you noticed movz there instead of plain mov, that's just because there's no such mov instruction in Aarch64 to begin with, it's just an assembler mnemonic, and movz is one of the instructions that replaces it.

At this point, I'd make the following files:

run.bat

qemu-system-aarch64.exe -M virt -cpu cortex-a57 -m 256 -kernel kernel.elf -nographic -serial stdio

run-debug.bat

qemu-system-aarch64.exe -M virt -cpu cortex-a57 -m 256 -kernel kernel.elf -nographic -serial stdio -d in_asm

run-dtb.bat

qemu-system-aarch64.exe -M virt -cpu cortex-a57 -m 256 -machine dumpdtb=out.dtb

These three files will make up most of my commandline use (other than, of course, calling make), and I feel that's what you'll also need yourself for evaluation, debugs and dumps.

Hello, World?

If you've read the previous post, you'll have had it drilled into your head that nothing comes for free in baremetal land. There is no kernel and no bootloader: you are the kernel and bootloader and the kitchen sink, too. There is no BIOS to handle your syscalls like you'd get in x86 territory. You will not be able to do anything until you write more drivers than you really thought was possible in a week. Paradoxically, thus, to get anything like the legendary Hello, World!, the simplest of programs, working on bare metal, you'll have to first write a driver for whatever device gets us serial output.

For the virt board, your first output will be passed through the UART device [7] and since we redirected serial output to the console, this means that anything the UART device passes from the CPU will be passed to the console. This is the way we'll handle the first print.

The UART is the simplest possible device that we can use for this purpose. Literally all you have to do is perform a tiny setup dance and it's ready to go blitting bytes to your console like it means business. The specific model that the virt board implements is the PrimeCell UART module PL011, which is also one of the UARTs that are present on the Raspi3b. It's a dead simple device that offers both serial and queued input and output modes, but we have to ensure it's serial and set up for both transmission and reception. Like actually everything on the board, it's a memory mapped device, which means the MMU has conveniently remapped the UART's internal registers to pretend-RAM locations.

To find out where the device got mapped to in memory, we need to go revisit the device tree and scroll quite a bit down. Remember we're looking for the 'PL011', and in my device tree the memory map is like so:

pl011@9000000 {
        clock-names = "uartclk\0apb_pclk";
        clocks = <0x8000 0x8000>;
        interrupts = <0x00 0x01 0x04>;
        reg = <0x00 0x9000000 0x00 0x1000>;
        compatible = "arm,pl011\0arm,primecell";
};

The device tree is telling us that our UART device, called PL011, starts at memory location 0x09000000 and is 4kb (0x1000) long. Reads from and writes to this area get remapped to the device. To figure out how it works, we need to hit the specs and slough through a lot of boring shit.

I'll sum the important bits up for you:

  1. PL011 UART has a controllable FIFO buffer (that we want to disable)

  2. it can generate maskable and aggregate interrupts

  3. there are signal and error bits in its status/flag registers (e.g. busy bit)

  4. you must disable the UART when reprogramming control registers [8]

  5. some memory areas are reserved, some are turbo reserved

  6. the forbidden areas are at 0x0080x014, and at 0x1c; writing to them bricks the device

  7. the UART should be enabled manually, because you can't guarantee it's enabled on reset

  8. control registers should also be set manually because their values can be unpredictable

We don't actually care about most of the functionality of this device and some of it is actively detrimental to our needs, so we have to deactivate them just in case.

The relevant registers of the PL011 UART:

Register Offset
Data +0x0
Flags +0x18
Control +0x30
FIFO Interrupts +0x34
Interrupt clear +0x44

With this in mind, we start by clearing the control register, then setting the transmit mode, enabling the UART, clearing all interrupts and disabling FIFO interrupts. Even though the PL011 schematics say that, on reset, the value in the control register is 0x0300 (p47 of the schematic PDF), we do this for extra safety reasons. Never hurts to be safe. The code we'll be running:

.section .text.startup
.global _Reset
_Reset:
    b 1f
    .skip 8

//      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:
        ldr x0, =0x09000030
        // we're not actually loading these from memory
        // though we probably should; for now we just want output

        mov x1, 0x1
        str x1, [x0]            // set bit 0 = enable
        mov x1, 0x101
        str x1, [x0]

        add x0, x0, 0x4
        mov x1, 0x03ff
        str x1, [x0]            // disable FIFO interrupts

        add x0, x0, 0x10
        str xzr,[x0]            // clear all interrupts

        sub x0, x0, 0x14
        mov x1, 0x301
        str x1, [x0]            // and finally enable the receive flag

        // now the UART should be barebones functional
        // we can test it by blasting its memory location w bytes

        mov x1, 0x48

        sub x0, x0, 0x44
        2:
        str x1, [x0]            // and we see if it works!
        // you should be seeing a wall of H on your console right about now
        b 2b

And a whole lot of bullshit later, you've got single character output to the console, repeatedly! It's probably still quite buggy, you might need to hit a key to get output to display or whatever if it jams, but we'll handle that later when we start writing actual printing procedures down the line.

What's next?

With the UART enabled, the next part should be writing actual print routines, figuring out how we're gonna call procedures, and getting input into our machine (interactivity woo).


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:

  1. figure out how the fake MMU maps devices

  2. learn about the dtb (device-tree blob)

  3. learn how the UART device works in the real world

  4. look at UART set-up code for the Raspi3

  5. translate this into the virt UART specs

  6. figure out the location of the virt UART

  7. ???

  8. 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:

  1. to set up the image, the framebuffer device needs to be set up

  2. the way to set up the device is using QEMU's wonderfully underdocumented fw_cfg

  3. you need to also verify whether your CPU has something called the 'dma' via the magic number 0x51454d5520434647

  4. it works by going through the configuration zone looking for a honest to God string value

  5. when you find it, you then write a bunch of shit to the config object

  6. specifically, you need the dimensions, format, pitch and address

  7. 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 BroFrustration

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.