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