Tprrt's Blog

Embedded Linux, Zephyr RTOS, open-source hardware, Linux gaming, retro gaming, and competitive fitness

Oct 15, 2026

Floored Twice: Chasing a Soft-Float Tax in GBA Text Rendering

Introduction

The earlier article on cycle-level GBA optimization covered the software 3D rasterizer, where two "obviously correct" changes measured as regressions once link-time optimization's inlining was counted. Here the same thing happens in the text renderer, with two more wrong turns that have nothing to do with LTO.

Note

framer-engine is a personal side project. The source code will be made publicly available once the engine reaches a sufficient level of maturity.

Two framer-engine examples with a lot of on-screen text ran far below the 60 Hz target, both idle, nothing moving, no input needed to reproduce: input_tester (76 of a 96-slot text sprite budget active) at roughly 18 FPS, and wordle, a word cloud of rotated labels, at roughly 14.5. phase_probe.py, the gdbstub cycle-bracketing script used in earlier articles, put about 69% of a frame's cost inside one function, gba_text_render_system. Which part of it?

Finding where the cost lives

The function processes a handful of text entities, each with one or more lines, each line made of several hardware sprites (one GBA OAM entry per glyph group). Three plausible places for the cost to hide: the glyph layout math, the screen-space projection that turns a world position into a pixel coordinate, or the OAM attribute writes themselves, once per glyph and so the most frequent of the three.

arm-none-eabi-objdump -dS ruled out the OAM writes: they are inlined integer stores with no library calls. The projection math was different: cx/cy (an entity's anchor in screen space) and py_base/px (where each line starts) were float, and the ARM7TDMI has no FPU, so every subtract, add, multiply, floorf() and float-to-int conversion goes through a software float routine.

Static inspection shows that a call exists, not how often it runs. Two breakpoints measured that: one at the function's entry, one at the first line of the innermost per-sprite loop body. The delta between consecutive hits of the second breakpoint is one sprite's own cost when it stays on the same line as the sprite before it, and the same delta plus a line's or entity's own setup when it doesn't. In a steady frame of input_tester, a same-line sprite-to-sprite step cost about 515 cycles. A step that also crossed into a new line or a new entity cost 7400 to 17000, roughly fifteen times as much. Those transitions are rarer than sprites but frequent enough to dominate a frame. The per-entity and per-line projection math was the cost, not the per-sprite work.

Attempt 1: fixed-point that rounded the wrong way

The engine's software 3D pipeline already uses fix_t, a Q12 fixed-point type, where one hardware multiply (fix_mul) replaces a chain of soft-float calls. Reusing it for text looked like the obvious fix: convert the camera position, ortho extent, scale and entity position to fix_t and project in integer math.

It looked right in every example. Before trusting that, I wrote a host-side sweep test comparing the fixed-point result with the float one across a grid of realistic camera, ortho, scale and position values. 16 of 192 combinations disagreed, every one by exactly one, in the same direction:

mismatch: cam=0.0 ortho=5.0 scale=28.8 pos=0.0 expected=144 actual=143
mismatch: cam=0.0 ortho=5.0 scale=28.8 pos=2.5 expected=72  actual=71

The existing fix_from_float() helper (shared with the 3D rasterizer, which depends on its exact behavior) truncates toward zero. Four conversions chained together — camera, ortho extent, scale, position — each truncate a little short, and on values that land exactly on an integer in float math, the accumulated shortfall floors to one below the right answer. Text at 144.0 pixels would have shifted by a pixel on specific camera positions, with no visible sign.

Attempt 2: the float path that measured slower

Instead of fixing the rounding, I kept everything in float and replaced (int)floorf(x) with a hand-rolled version. objdump showed the floorf() body at 74 instructions, handling NaN, infinity and subnormals that bounded screen-space input never produces. A truncate-then-adjust version is exact for any finite value and looks much cheaper:

static inline int gba_fast_floor(float x)
{
    int i = (int)x;
    return (float)i > x ? i - 1 : i;
}

It is exact, verified like the first attempt, and shorter. cycle_probe.py disagreed: the steady-state average on input_tester went from 727556 to 748028 cycles/frame, a ~2.8% regression. The original (int)floorf(x) is two soft-float calls (floorf, then the float-to-int conversion). The replacement is three (truncate, convert back to float, compare), and each call pays its own call/return/Thumb-interworking overhead. floorf()'s large body is cheaper than one extra call.

What actually worked

Fixed-point, with the rounding fixed: a local gba_fix_from_float_rounded() that rounds to nearest. The shared fix_from_float() stays untouched because the 3D rasterizer depends on its truncation. The sweep test now passes, including the exact-integer cases and the narrowest float margin around an integer boundary.

The second change mattered as much: every frame-constant term (camera position, ortho extent, screen-space scale) is converted to fix_t once per call, not once per entity. Only the entity's own position is converted per entity. fix_mul is one hardware multiply, and flooring a fix_t to a pixel coordinate is an arithmetic right shift, with no library call.

A rotated text entity still needs a float cx/cy for the sprite-rotation math. Computing it from scratch next to the fixed-point version made a rotated entity pay for both, which was slower than before for that case; cycle_probe.py on wordle's word cloud confirmed it. Deriving the float value from the fix_t one (one int-to-float conversion and a multiply by a constant) halved that overhead. A rotated entity still computes both.

Checking it

Cycle counts do not show correctness, and a projection rewrite can be off by one pixel without looking wrong. The check is the flicker article's OAM sampling, made byte-exact: connect to the emulator's GDB stub, let a few frames pass so startup settles, read the 1 KB of hardware sprite memory, and compare it with the same read from the unmodified ROM.

IDENTICAL: 1024 bytes of OAM match exactly

This holds for both input_tester (unrotated text) and wordle (rotated): all 128 sprite entries have the same position, tile, palette bank and affine flags in both ROMs.

The numbers

Steady-state averages, cycle_probe.py against framer_gba_wait_vblank:

Example Before After Change
input_tester (unrotated text) ~684800 cycles/frame ~660650 cycles/frame ~3.5% faster
wordle (rotated word cloud) ~810400 cycles/frame ~814470 cycles/frame ~0.5% slower

input_tester is the representative case: a HUD-style scene with static, unrotated labels. wordle's word cloud is a rotation showcase and the one case where the change gives a little back. Closing that gap would need two code paths, fixed-point when nothing is rotated and float when something is. I did not add them: more branching in a dense function, for a demo scene rather than the 18 FPS HUD case.

What is left

~3.5% is not the fix for an 18 FPS scene. The per-entity layout call and framer_color_bank_acquire() were never bracketed like the projection math, and either may hold more cost. Next step: the same two-breakpoint measurement on them.

posted at 22:50  · 6 min read  ·   ·  gba  performance  optimization  embedded  retro  gamedev  fixed-point  c  article

Oct 07, 2026

Dropping Sprites on Purpose: Flicker Rotation on GBA and NDS

Introduction

The article about bullet pools ended with a number: on the Game Boy Advance, a pool of bullets stops being fully visible at 121 of them, and on the Nintendo DS at 126. Both consoles have 128 hardware sprite entries (OAM), and a few of them always go to the player, the target and the text. Past that number, the renderer runs out of entries and has to stop drawing.

Which bullets stop being drawn? The engine's first answer was the last ones in the array, which is wrong for a reason unrelated to performance. The second answer is cheaper and older, and an emulator can check that it works.

Note

framer-engine is a personal side project. The source code will be made publicly available once the engine reaches a sufficient level of maturity.

The first answer: the same ones, every frame

A sprite batch is drawn in array order, and the loop stops as soon as there is no OAM entry left. With 200 bullets and room for 121, the same 79 bullets, the ones at the end of the pool, are skipped in every frame. They are never drawn, and a bullet that is not drawn still moves and still hits the player. The player dies to something that was never on the screen.

The engine already had one tool for this, and it is the right one for most of the problem: a priority per batch. A batch with a lower priority number is drawn first and so gets its OAM entries first; the batches that matter least lose theirs. On the GBA, two stationary pools of 100 bullets that together overflow OAM behave as expected: the one created second but given priority 0 kept all 100 of its sprites, and the one created first, with priority 200, kept 22.

/* The enemy's bullets decide whether the player lives. */
framer_sprite_batch_set_priority(world, enemy_pool, 0);
/* The player's own shots and the sparks come after. */
framer_sprite_batch_set_priority(world, shot_pool, 200);

Priority decides between batches. It says nothing about what happens inside the one that does not fit, and the pool that loses is still cut off at the same element every frame.

The second answer: start where you stopped

The consoles' own games had this problem first. A Famicom can draw eight sprites on a scanline and sixty-four in all, and the shoot-'em-ups of that era did not pretend otherwise: when a boss made of many segments crossed a line, some of its sprites were missing. Players know the fix as flicker: the missing sprites change from one frame to the next. Every sprite is on screen on some frames, and at 60 frames a second the eye reads a dim sprite rather than a hole. Recca is a well-known example. The hardware did not do this; the games did, by rotating the order they wrote their sprites in.

The same rotation fits a batch in a few lines. Each frame, a batch that has opted in starts drawing at the element where the previous frame stopped, and wraps around to the front when it reaches the end:

int n = framer_sprite_batch_count(batch);
int start = framer_sprite_batch_start(batch);

for (k = 0; k < n; k++) {
    i = start + k < n ? start + k : start + k - n;
    q = framer_sprite_batch_get(batch, i);

    if (s_next_oam_slot >= GBA_OAM_HW_SLOT_COUNT) {
        framer_sprite_batch_resume_at(batch, i);
        return;
    }
    /* ... write the OAM entry for q ... */
}
framer_sprite_batch_resume_at(batch, 0);

When the whole batch fits, the loop finishes, the resume point goes back to zero and nothing rotates. When it does not fit, the element that was refused is the one the next frame starts with. The first frame draws elements 0 to 120 of 200, the second draws 121 to 199 and then 0 to 41, the third starts at 42, and so on. Every element is drawn about 60% of the frames, and which ones are missing keeps moving.

Where the index lives

The resume index has to survive from one frame to the next, and the obvious place, a field of the batch's own component, does not work: a render system is handed a copy of the component, so a write to it is lost. A second component would work, but the static ECS this engine runs on GBA and NDS has a fixed table of components, and on the GBA that table is nearly full (the article about it explains the budget). So the index sits in a small table in sprite_batch.c, keyed by the address of the batch's elements and sized for the sixteen batches a frame can draw. A batch that does not find a slot is simply not remembered, and draws from the front each frame, which is what a batch without flicker does anyway. The only change to the component itself is one byte, the flag.

It is off by default, with one call to turn it on:

framer_sprite_batch_set_flicker(world, shot_pool, 1);

The desktop backends ignore it, since they have no OAM and draw everything.

Not for the enemy's bullets

Flicker is the right trade for effects, sparks and the player's own shots, where a missing sprite on one frame costs nothing. It is the wrong one for the bullets that can kill the player. A bullet that blinks is harder to read than one that is steadily absent from a crowd, and at 60% visibility it still hits on the frames it is invisible. Those bullets belong in a batch that always fits, at the lowest priority number. That advice is written next to the API in the engine's docs, because the mechanism makes it easy to get wrong.

Checking it

The claim to test is narrow: with flicker on, every bullet is drawn on some frame, and with it off, the same bullets never are. A screenshot cannot show that, since one frame looks like any other, so the check reads the OAM.

The test program is the bullets example with a pool of 200 stationary bullets laid out in a grid of 20 by 10, which never move. A script connects to the emulator's GDB stub, lets the program run for a random fraction of a second, stops it, reads the 1 KB of OAM, and picks out the bullets, which are the 8 by 8 sprites that share the most common tile. It does that dozens of times and counts how many different positions it ever saw.

On the GBA, in mGBA, with room for 121:

Flicker Samples Bullets drawn per sample Distinct positions ever drawn
off 60 121 to 128 122
on 60 121 200

The "off" row is noisier than it should be: the per-sample count reached 128 and 122 positions were seen, where 121 is the most that fits. I did not track down the difference. It is small next to 122 against 200, but the sampler is not exact on the GBA.

On the DS, in melonDS 1.1, with room for 126, the program toggles the flag itself every 180 frames, so one launch gives both rows, and the script reads the current flag from a global at each stop:

Flicker Samples Bullets drawn per sample Distinct positions ever drawn
off 82 126 126
on 78 126 200

In both cases the number drawn at any moment is unchanged: flicker changes which sprites are drawn, not how many. The figure shows three GBA samples per setting, taken a second or so apart, with OAM positions mapped back onto the 20 by 10 grid:

Two rows of three 20 by 10 grids of dots. In the top row, labelled flicker off, the same rows of dots are filled in all three samples and the top three and a half rows are always empty. In the bottom row, labelled flicker on, a different block of dots is empty in each of the three samples.

Filled dots are bullets present in OAM, hollow ones are bullets that were not drawn. With flicker off, the same 79 are missing in every sample. With it on, the gap moves.

All of this ran on emulators (mGBA, melonDS), none on hardware, and the per-scanline limits were not checked.

What is left

The rotation only deals with the whole-OAM limit, the 128 entries. Both consoles also limit how many sprites can be on one scanline, and that limit can be hit by a wide row of bullets before the 128 total, so it is next. Also planned: a way to report how many sprites a frame used, so a game can see its budget without a debugger, and a priority for single sprite entities and text, which today are always drawn before every batch.

posted at 22:10  · 7 min read  ·   ·  sprites  oam  gba  nds  flicker  shmup  embedded  retro  gamedev  c  article

Oct 02, 2026

Wireframe Ran at 20 FPS, Solid at 60: Counting ARM9 Cycles to Find Out Why

Introduction

The GBA cycle-level optimization article and the one on faking point lights on the DS GPU share a premise: these CPUs have no floating-point unit, and every vertex costs something somewhere. The GBA pays in rasterizer cycles. The DS has a hardware 3D engine, so it pays elsewhere, in a part of the frame I had not looked at.

Note

framer-engine is a personal side project. The source code will be made publicly available once the engine reaches a sufficient level of maturity.

The framer-engine spinning_shapes example puts a cube, a sphere and a cone on screen, each rotating, and a button toggles between drawing them solid and drawing them as wireframes. On the DS it ran at 60 FPS in solid and about 20 in wireframe. Wireframe should be cheaper: fewer pixels filled, no lighting. The bug report was that a debug view ran three times slower than the real thing. A second question followed: how do you measure a frame on a platform you can only run in an emulator?

How wireframe gets drawn on a GPU that has no lines

The DS 3D engine draws triangles and quads, and nothing else: libnds' glBegin() takes only triangles and quads (and their strips), with no line primitive. The hardware has a wireframe polygon mode, selected by a polygon alpha of zero, but it outlines a polygon's own edges. The engine's meshes keep a separate list of the edges to draw, and for a sphere that list leaves out the diagonals that split each patch into two triangles; the hardware mode would draw them. (I reasoned this out and never tried the mode.) So wireframe here is a thin, flat, unlit quad along every edge.

The quad has to be widened perpendicular to the edge, in a direction that makes sense for a convex mesh centered on its origin. The code takes the direction from the origin to the middle of the edge and removes the part along the edge:

/* per edge, every frame */
float edge_len = sqrtf(edge[0]*edge[0] + edge[1]*edge[1] + edge[2]*edge[2]);
float mid_len  = sqrtf(mid[0]*mid[0] + mid[1]*mid[1] + mid[2]*mid[2]);
dir[k]    = edge[k] / edge_len;          /* k = 0..2 */
radial[k] = mid[k]  / mid_len;
dot       = radial[0]*dir[0] + radial[1]*dir[1] + radial[2]*dir[2];
width[k]  = radial[k] - dot * dir[k];    /* perpendicular to the edge */
width_len = sqrtf(width[0]*width[0] + width[1]*width[1] + width[2]*width[2]);
width[k]  = width[k] / width_len * half_width;
/* then four vertices: a -/+ width, b +/- width, as 4.12 fixed point */

On a PC this is unremarkable. On the DS's ARM9 it is three square roots, nine divides, roughly fifty multiplies and adds and twelve float-to-fixed conversions per edge, every frame, each a call into a software floating-point routine.

Why not just look at the frame rate

Reading the emulator's frame counter tells you least. melonDS' speed varied a lot between runs in earlier sessions, so wall-clock time and its frame counter say more about the host than the console. The emulated hardware timers hold still: the same ROM executes the same instructions and the timers count them the same way every run.

libnds exposes two of them chained into a 32-bit counter through cpuStartTiming() and cpuEndTiming(), which return ticks of the hardware timer clock (about 33.5 MHz, the bus clock; the ARM9 itself runs at twice that). A throwaway build wraps the 3D render system and stores the result in a global per render mode:

volatile uint32_t g_diag_ticks[2];   /* [0] solid, [1] wireframe */
volatile uint32_t g_diag_count[2];

static void nds_hw3d_render_system(framer_iter_t *it)
{
    int w = framer_render_mode_get_override() ==
            FRAMER_RENDER_MODE_FORCE_WIREFRAME;

    cpuStartTiming(0);
    nds_hw3d_render_system_impl(it);   /* the real system, unchanged */
    g_diag_ticks[w] = cpuEndTiming();
    g_diag_count[w]++;
}

A copy of the example toggles the render mode itself every 120 frames, so one emulator launch produces both numbers, and the globals are read over melonDS' GDB stub, with their addresses taken from nm on the ARM9 ELF:

ticks = struct.unpack('<2I', bytes(rsp.read_mem(sock, tick_addr, 8)))
count = struct.unpack('<2I', bytes(rsp.read_mem(sock, count_addr, 8)))

Two runs agreed to within a few ticks, which makes the figures usable:

Mode Ticks per frame Time
Solid 317,950 9.5 ms
Wireframe 1,414,850 42 ms

A 60 FPS frame is 16.7 ms. Solid fits with room to spare. Wireframe takes 42 ms in the 3D pass alone, more than two vertical blanks, so the frame lands on the third and the screen updates every 50 ms: 20 FPS.

Where 1.4 million ticks go

The meshes are not small. The sphere has 20 sectors and 10 stacks, which is 430 edges and 360 triangles; the cone adds 40 edges and 40 triangles and the cube 12 and 12. A wireframe frame therefore builds 482 quads, and a solid frame submits 412 triangles. Dividing:

  • wireframe: 1,414,850 / 482, about 2,900 ticks per edge, roughly 5,900 ARM9 cycles for the arithmetic above;
  • solid: 317,950 / 412, about 770 ticks per triangle, which is a normal and three vertices converted to fixed point and handed to the GPU, with no square root and no divide anywhere in it.

The GPU did not set the cost in either mode. The CPU did, and in wireframe it spent 42 ms of every frame, two and a half frame budgets, on arithmetic whose answer is the same every frame.

The data never changes

The cube is the same cube every frame. Only the model matrix changes, and the GPU applies that itself from a matrix stack. Every square root and divide in that loop recomputes the same four vertices as 1/60th of a second earlier.

The DS has a mechanism for exactly this: a display list, a block of pre-packed geometry-engine commands that glCallList() sends to the GPU FIFO by DMA. Its layout is the hardware's own FIFO layout, documented in GBATEK: a first word with the number of words that follow, then groups made of one word holding up to four command ids, one per byte, followed by the parameter words of those commands in order. A quad with four vertices comes out as:

word 0   count
word 1   BEGIN_VTXS | VTX_16 | VTX_16 | VTX_16     (0x23232340)
word 2   1                                          (GL_QUADS)
word 3   x0 | y0 << 16                              vertex 0
word 4   z0
word 5   x1 | y1 << 16                              vertex 1
word 6   z1
word 7   x2 | y2 << 16                              vertex 2
word 8   z2
word 9   VTX_16 | NOP | NOP | NOP                   (0x00000023)
word 10  x3 | y3 << 16                              vertex 3
word 11  z3

The packer is about eighty lines of plain C with the command ids defined next to it (BEGIN_VTXS is 0x40, NORMAL 0x21, VTX_16 0x23). It has no libnds dependency, so it is unit-tested on the desktop, with the layout above as one test. libnds documents one trap: END_VTXS does nothing and is reported to cause trouble in a DMA'd list, so lists never contain it.

void gx_dl_cmd(struct gx_dl *dl, uint8_t id, const uint32_t *params, int n)
{
    if (dl->slot == 4) {                 /* open a new command word */
        dl->cmd_at = dl->len++;
        dl->words[dl->cmd_at] = 0;
        dl->slot = 0;
    }
    dl->words[dl->cmd_at] |= (uint32_t)id << (8 * dl->slot);
    dl->slot++;
    for (int i = 0; i < n; i++)
        dl->words[dl->len++] = params[i];
}

The renderer bakes a mesh the first time it is drawn in a given mode: the edge quads for wireframe, the triangles with their flat normals for solid. After that a draw is a colour, a glCallList() and nothing else. Only what is identical on every draw goes into a list; the colour, the material, the polygon format and the model matrix stay with the caller, so the same list serves every object that shares a mesh. Two kinds of object keep the old triangle-by-triangle path because they vary something a list would have to hold: objects with a colour per face, since the material changes between faces, and textured ones, since their texture coordinates are scaled by the texture's size. The old immediate wireframe code also stays, as the fallback for a mesh that cannot be baked: more than eight different meshes in a program, or no memory left for the list.

Results

Same scene, same instrumented build, measured the same way:

Mode Before After Change
Solid 317,950 18,500 17 times cheaper
Wireframe 1,414,850 24,450 58 times cheaper

The wireframe pass goes from 42 ms to 0.73 ms and solid from 9.5 ms to 0.55 ms. The two modes now cost about the same, which is what the original bug report had assumed all along.

The picture must stay the same. The figure shows the top screen before and after, captured in the same runs as the numbers. The shapes are at different points of their rotation because the demo keeps spinning; shading and lines are the same.

Top screen of spinning_shapes on melonDS, a blue cube, a red sphere and a green cone, drawn solid (above) and as wireframe (below), before the change on the left and after it on the right, looking the same

Solid and wireframe, before (left) and after (right).

Three caveats:

  • The GPU still has to consume the list. glCallList() flushes the list from the data cache, starts the DMA and waits for it to finish, and the DMA only completes once the geometry FIFO has accepted the data. melonDS models the geometry engine's timing only roughly, so on a real console that wait will be longer than 0.7 ms. It is waiting on the GPU, not computing, and nothing was measured on real hardware.
  • The first draw pays the old price once. Baking runs the original math one time per mesh and mode, so the first frame in wireframe still does what every frame used to. It is a one-off hitch rather than a rate, and an unmeasured one.
  • Lists cost memory. About 15 KB for the sphere's wireframe and 12 KB for its solid list, allocated from the heap and held for the life of the program.

A footnote on quitting a debugger session

A script that interrupts melonDS' GDB stub target, reads what it needs and closes the socket leaves the emulated CPU halted, and the stub keeps a dead connection in CLOSE-WAIT. The emulator can no longer be quit from its window, a new connection times out, and SIGTERM has no effect (with the Flatpak build at least; I suspect the sandbox's first process lacks default signal handling for outside signals, but did not verify it). Always send continue before disconnecting, and stop a stuck instance with flatpak kill rather than kill -9.

What is still on the slow path

Objects with per-face colours and textured objects still submit triangle by triangle, so a scene dominated by them gets none of this. Both can be baked: a per-texture-size list for the textured case, one list per colour run for the face-coloured one. They are next, measured the same way.

posted at 21:30  · 9 min read  ·   ·  nds  gpu  performance  optimization  embedded  retro  gamedev  c  article

Sep 25, 2026

One Entity, Many Bullets: Fitting a Projectile Pool Into the ECS

Introduction

The previous article described the static, no-heap Entity Component System framer-engine runs on GBA and NDS: fixed tables of component slots and entities, sized for a machine with no malloc(). With today's component set the table has room for 64 real entities. A caravan-style vertical shoot-'em-up, the engine's first playable milestone, wants several hundred bullets on screen in a dense pattern. The entity table cannot grow to fit that, and the pattern cannot cap itself at 64 shots.

Note

framer-engine is a personal side project. The source code will be made publicly available once the engine reaches a sufficient level of maturity.

The usual answer is not to make every bullet an entity. The simulation, a flat array of structs advanced by one system, was easy. Drawing that array without opening a second path in the renderer took two attempts. The first shipped, worked, and was wrong in a way no test would catch. The second was smaller, matched a pattern the engine already had, and surfaced three bugs across three rendering backends, none about bullets.

The first answer: a queue that isn't an entity

Hundreds of short-lived things, updated and drawn every frame and never addressed individually, suggest a per-frame queue: a flat array of draw commands (position, sprite, frame), reset each frame, appended to by whatever wants something drawn and consumed once by the renderer. A bullet pool fits exactly, pushing one entry per live bullet after the physics step, and needs nothing from the ECS: no entity, component or query.

That was the problem. Everything else the engine draws (a sprite, a tilemap, a HUD label) is an entity with a component, and every renderer's draw phase is systems iterating queries. A queue fed by application code and drained by the renderer is a second path. The entity-inspection API cannot see it, capability queries do not know it exists, and the debugging tools built on "the ECS is the whole scene" miss it. Bullets appeared in the right place at the right speed, but elements without an entity bypass the engine's core model. It is a different design that happens to render the same pixels.

The second answer: an entity that stands for many

framer-engine already had a component for this class of problem: a Tilemap is one entity whose component points at a grid the owner keeps, and the renderer draws the whole grid from that entity. Nothing in "many drawn things, one external memory, one component pointing at it" is specific to tiles. The rewrite generalizes it as SpriteBatch, and the bullet pool becomes one more caller instead of a bespoke path:

struct framer_batch_sprite {
    int32_t x, y;       /* center, world units, fixed point */
    uint32_t texture;
    uint8_t frame;
    uint16_t size;       /* drawn size, world units, fixed point */
};

struct sprite_batch {
    const void *first;   /* owner's element array */
    const uint16_t *count; /* owner's live-element count */
    uint16_t stride;      /* bytes between elements */
};

A framer_bullet has its own struct framer_batch_sprite as its first field, so the pool's storage array is the SpriteBatch element array, with no copy and no second buffer to keep in sync. framer_bullet_pool_entity_create() gives the pool's one entity a BulletPool component, stepped each frame by a physics system, and a SpriteBatch component built from the same array, drawn each frame by the renderer. Every draw phase still iterates a query and draws what it finds. The bullets stay out of the 64-slot entity table, and everything drawn is reachable from an entity.

Every renderer backend has to learn to draw the new component independently, which gave each one a chance to get it wrong.

Bug 1 (Vulkan): a guard that was checking the wrong component

Each backend's sprite system is imported once per world, so it needs a guard against importing twice. Comparing the stored world pointer with the current one breaks when a different world is allocated at the same address, which every test here does by tearing a world down and creating a fresh one. The convention in the renderer imports is to also check that a component the import function itself registers is present on the world.

The check written for SpriteBatch tested the wrong component:

/* Wrong: Sprite is registered by the renderer's own import, before
 * this function ever runs -- it says nothing about whether THIS
 * function has run on this particular world. */
if (s_world == world && FRAMER_COMPONENT_IS_REGISTERED(world, Sprite))
    return;

On a fresh world reusing a previous world's address, Sprite is already registered by the caller, earlier in the same framer_sprite_system_import(), before the guard runs. The check reports "already set up" on the first call and the function returns without registering SpriteBatch or its draw system. Sprites drew; batches never did.

A test caught it. The new pixel-readback test for SpriteBatch (draw an element, read the pixel back, expect it filled) passed on the software and OpenGL backends and failed on Vulkan, whose suite happened to run a batch test on a world at an address a prior test had used. The fix checks the component this function registers:

if (s_world == world && FRAMER_COMPONENT_IS_REGISTERED(world, SpriteBatch))
    return;

The OpenGL and software backends had the identical guard, found by inspection before either failed a test. Both got the same fix and were verified.

Bug 2 (OpenGL): reading back a write that hasn't happened yet

The OpenGL backend compiles its default shaders lazily, on first request, and caches them in the world's Shader singleton component:

const struct shader *shaders = FRAMER_GET(world, FRAMER_ID(Shader), Shader);
if (!shaders) {
    unsigned int sprite = framer_shader_create(...);
    /* ... compile the other three default shaders ... */

    FRAMER_SET(world, FRAMER_ID(Shader), Shader, .sprite_shader = sprite, /* ... */);
    shaders = FRAMER_GET(world, FRAMER_ID(Shader), Shader);
    if (!shaders)
        return 0;
}

switch (type) {
case SHADER_TYPE_SPRITE: return shaders->sprite_shader;
/* ... */
}

This assumes a component write takes effect immediately. It does not: a write issued inside a running system is staged and merged once every system in that step has finished. A second system in the same step can see it, but the call that made the write cannot read it back. That is an ordinary property of this ECS. Every other caller of framer_shader_get_default() came after something upstream (loading a level, spawning a player) had already asked for a shader on an earlier frame, so the singleton existed.

The SpriteBatch draw system is the first thing in a new scene with nothing upstream: no mesh, no plain sprite, just a bullet pool's entity. On the first frame its render system is the first caller of framer_shader_get_default(). The write lands, the read-back in the same call finds nothing, the function returns 0, and that frame renders every batch element with no shader bound. The ECS timing is fine; the fix stops reading back what was just staged:

struct shader created = {.sprite_shader = sprite, /* ... */};

framer_component_set(world, FRAMER_ID(Shader), FRAMER_ID(Shader), &created);
return shader_of_type(&created, type);

Bug 3 (all three desktop backends): drawn correctly, in the wrong order

The first two bugs were "nothing drew". In the third, every bullet drew at the right position, size and texture, but the entity that fired them disappeared behind its own bullets once enough piled up at its position.

SpriteBatch and the per-entity Sprite draw are separate systems in the same ECS phase, and nothing said which goes first. On GBA and NDS the hardware settles it: OAM sprites carry a priority, and entity sprites are drawn at a higher priority than batch elements, so an entity is in front of a batch whatever the system order. Sampling the OAM through mGBA's debug stub mid-run confirms it: the emitter (the round blue sprite at the center) sits in front of the spiral it fired:

The bullets example running under mGBA, OAM contents captured over its debug stub -- a blue emitter sprite is clearly visible on top of a spiral of orange bullet sprites radiating from it

The bullets example's spiral pattern, OAM contents read directly off a running GBA — the emitter draws in front of every bullet it fires, by hardware priority, regardless of draw order.

The three desktop backends (OpenGL, Vulkan, software) have no such guarantee: draw order is their only ordering mechanism, and the two systems registered in whatever order the import code called them, which put SpriteBatch after the entity sprite system. Sampling framebuffer pixels at the emitter's position every fifty frames of a Vulkan run shows the failure: the emitter's blue for hundreds of frames, then, once enough live bullets reach that pixel, whatever bullet is passing through it:

frame  350: 2850c8 5a96ff 5a96ff 5a96ff 2850c8   (emitter blue)
frame  750: 2850c8 5a96ff 5a96ff 5a96ff 2850c8   (emitter blue)
frame  800: c81e28 ff7828 ffdc50 ff7828 c81e28   (bullet red/orange)
frame 1200: c81e28 ffdc50 fffff0 ff7828 c81e28   (bullet, still)

The fix declares the order explicitly instead of relying on registration order, on every desktop backend:

static const struct framer_system_dep batch_deps[] = {
    {.before = "sprite_render_system", .after = NULL}};

framer_system_register(world, "sprite_batch_render_system",
                        FRAMER_PHASE_ON_UPDATE,
                        sprite_batch_render_system, bq, batch_deps, 1);

A new renderer test pins it down: a red entity sprite and a white batch element on the same pixels, asserting the pixel reads back red.

Verification: one bug per backend, so one check per backend

The design is not backend-specific, but each backend had its own bug, so each was checked separately. The desktop pixel-readback tests (software, OpenGL, Vulkan) ran the same three scenes. examples/bullets was cross-compiled for GBA and NDS and its OAM sampled through mGBA's debug stub, the technique the dithering article used to catch a hardware-only bug. Every GBA example ROM was booted headlessly to check that a related fix did not regress startup: one more component pushed the fixed-stride storage layout past EWRAM, so component storage is now pooled.

One result was the same everywhere I checked: with the spiral pattern running continuously, the live bullet count settles in the high 90s on GBA, OpenGL and Vulkan alike.

Where this goes next

FRAMER_STATIC_MAX_COMPONENTS (40) and FRAMER_STATIC_MAX_ENTITIES (104, leaving 64 for game use) both grew slightly to fit the new component without pushing the pooled storage past EWRAM on GBA. What remains for the bullet pool is measuring and documenting the per-target bullet ceiling. The capacity constants (examples/bullets uses 128 on GBA/NDS, 1024 on desktop) were picked before there were numbers to size them against.

posted at 21:40  · 8 min read  ·   ·  ecs  rendering  vulkan  opengl  gba  nds  embedded  retro  gamedev  c  article

Aug 13, 2026

Sixteen Offsets, Two Patterns: Debugging GBA's Ordered-Dither Translucency

Introduction

The two previous GBA articles, on cycle-level optimization and the no-heap ECS underneath it, were about working around hardware with no float unit and no polygon fill. This one is about an older constraint on the same hardware: the GBA's Mode 4 framebuffer is 8bpp indexed, and its palette is a single unbuffered 256-entry table with no double-buffering and no VBlank gating on writes. Reassigning a palette slot recolors every pixel already on screen that uses it, immediately, mid-frame if the timing is unlucky.

That rules out blending per pixel and allocating the result a palette slot on the fly. Blending would also be too slow. framer-engine's GBA backend rasterizes 3D faces in software on a 16.78 MHz ARM7TDMI with no hardware alpha unit, and the earlier optimization article spent its length shaving cycles off this rasterizer's inner loop. Blending means mixing source and destination colors and resolving the result to a palette index for every covered pixel of every translucent face, every frame. Dithering avoids that: the per-pixel decision is one threshold compare against a precomputed matrix, with no blend math and no palette search. Translucent faces are drawn to roughly the fraction of screen cells their alpha implies, using a fixed Bayer matrix, so the density of drawn pixels approximates the alpha.

Note

framer-engine is a personal side project. The source code will be made publicly available once the engine reaches a sufficient level of maturity.

That part worked the first time. What failed, twice and in two different ways, both visible only on real hardware, was making overlapping translucent objects dither differently enough from each other to stay visible. The second bug is a quieter version of the first, and the fix that stuck came from building three ROMs and looking at them, not from a smarter formula.

The setup: one matrix, one offset per object

The dithering is a small pure function. Given a matrix of thresholds and a per-object level (roughly alpha out of 16), a pixel draws if the matrix value at its screen coordinate is below the level:

static const unsigned char DITHER_BAYER_4X4[4][4] = {
    {0,  8,  2, 10},
    {12, 4, 14,  6},
    {3, 11,  1,  9},
    {15, 7, 13,  5},
};

static inline bool dither_should_draw(int x, int y, int level)
{
    return DITHER_BAYER_4X4[y & 3][x & 3] < level;
}

That is enough for one translucent object, not for two. examples/translucent_cubes draws three overlapping cubes (red, green, blue) at the same alpha, back to front. With no per-object variation they test the same screen cells against the same level, so they draw to the same pixels, and the nearest cube overwrites the farther ones where they overlap instead of blending with them. Each object needs its own dithering phase: a draw-set that differs from its neighbors' while still covering the right fraction of pixels.

Bug 1: shifting the coordinate collapses sixteen phases into two

The obvious way to give each object a phase is to shift the sampled coordinate before indexing the matrix: object i looks up BAYER[(y + py) & 3][(x + px) & 3] for some per-object (px, py). That gives sixteen (px, py) pairs and, apparently, sixteen phases. It passed review and looked right in the emulator.

It did not look right in the screenshots from hardware. With three overlapping equal-alpha cubes, the middle one, green, was almost invisible wherever it overlapped blue.

examples/translucent_cubes running in mGBA with the coordinate-shift bug — only red and a dark, almost-black region are visible, no green or blue

examples/translucent_cubes at the commit that shipped the coordinate-shift phase. Green and blue have collapsed into the same draw-set as another object and vanished.

Enumerating all sixteen shifts against the 4x4 matrix at that alpha level showed the cause: only two distinct draw-sets exist among the sixteen shifts. The 4x4 Bayer matrix has translational symmetry, so every shift is equivalent, cell for cell, to another shift with the same parity of (px + py). Two objects in the same parity class draw to identical cells, and the nearer one erases the other. Three objects and two parity classes guarantee a collision.

The fix: rotate the compared value, not the sampled coordinate

The working version leaves the coordinate alone and rotates the value read from the matrix by a per-object offset before comparing it with the level:

static inline bool dither_should_draw(int x, int y, int level,
                                       int offset)
{
    return (int)((DITHER_BAYER_4X4[y & 3][x & 3] + offset) & 15) <
           level;
}

DITHER_BAYER_4X4 is a bijection: its sixteen cells hold the values 0 to 15 once each. The cells with (value + offset) mod 16 < level form a level-sized contiguous arc of that range, and as offset goes from 0 to 15 the arc's start visits every residue once. Sixteen offsets give sixteen distinct draw-sets at every level, by construction and not just for the alpha I tested, as sixteen Caesar shifts give sixteen distinct ciphertexts. This shipped and the green cube reappeared. The next report from hardware was different: not invisible, but rougher than the other two.

examples/translucent_cubes running in mGBA after the value-rotation fix — all three cubes visible, but the third one's dither pattern looks visibly rougher than the other two

The demo after the value-rotation fix. All three cubes are visible, but the thinnest sliver, drawn last and forced onto a worse offset, already reads rougher than its neighbors.

Bug 2: only two offsets out of sixteen are smooth

"Rougher" was exact. At the alpha level examples/translucent_cubes uses, offset 0 produces a perfect alternating checkerboard:

offset=0                     offset=1
#.#.#.#.                     #.#.#.#.
.#.#.#.#                     .#.#.#.#
#.#.#.#.                     #.#.#.#.
.#.#.#.#                     .#.#.#.#

Only one other offset (8, half the matrix's sixteen values away) produces the other perfect checkerboard, disjoint from offset 0's. Every offset in between is the same pattern with a few cells flipped, less regular the farther it sits from 0 or 8. Three overlapping objects need three distinct offsets, which the previous fix gave, but with only two perfect offsets in a 16-value matrix the third object is forced onto an irregular one. That is a structural property of a small matrix, not a bug in the value rotation.

A bigger matrix buys a finer gradient, not an exemption

An 8x8 matrix (64 distinct values) does not remove the "only two perfect offsets" property; the argument above does not depend on matrix size. It gives a much finer gradient around the two perfect points, so a third offset near either one looks close to perfect. The caller contract is unchanged: levels still run from 0 to 16, and the comparison is rescaled by 4 to match the 8x8 matrix's 0-63 range:

static inline bool dither_should_draw(int x, int y, int level,
                                       int offset)
{
    return (int)((DITHER_BAYER_8X8[y & 7][x & 7] + offset) & 63) <
           level * 4;
}

The third cube went from "noticeably wrong" to "close to right". That still did not fix the problem that mattered.

The bug the roughness metric couldn't see

The offsets came from a 64-entry table ranked by how close each offset's own pattern is to a perfect checkerboard: offset 0 first, offset 32 (the 8x8 matrix's other perfect point) second, then the next closest. For three visible objects the table handed out 0, 32, 1.

Offset 1 is individually an excellent choice: one flipped cell out of sixty-four. The ranking never asked how much offset 1's draw-set overlaps offset 0's. The answer is 31 of 32 drawn cells identical. The third object was again drawing to almost the same pixels as the first, which is the original bug in a better disguise: "individually smooth" and "well separated from neighbors" are different properties, and the ranking had conflated them.

Testing three sequences instead of guessing

Fixing the separation analytically hit a trade-off. Offsets chosen only to maximize separation, spread evenly over the 64-value range, land on values like 16 and 48, which render as regular vertical or horizontal stripes instead of checkerboard noise:

offset=1 (roughness-ranked)      offset=16 (separation-first)
#.#.#.#.                         #.#.#.#.
.#.#.#.#                         #.#.#.#.
#.#.#.#.                         #.#.#.#.
.#.#.#.#                         #.#.#.#.
#.#.#.#.                         #.#.#.#.
.#.#.#.#                         #.#.#.#.
#.#.#.#.                         #.#.#.#.
#..#.#.#                         #.#.#.#.

The stripe pattern shares 16 of 32 cells with its neighbors instead of 31, a large improvement by the numbers. But axis-aligned stripes are a textbook case of a dither that is more noticeable than diagonal checkerboard noise. More computation could not say whether that trade looks better than the near-miss checkerboard.

So I built three complete 64-entry offset sequences: the original roughness-ranked table; a separation-maximizing one, a bit-reversal permutation (a Van der Corput sequence, where entry i is the 6-bit binary reversal of i and every prefix is evenly spread over the offset range); and a capped-overlap middle ground. I compiled each into a translucent_cubes.gba ROM and ran them in mGBA. The separation-maximizing sequence won, stripes included.

static const unsigned char GBA_DITHER_OFFSET_SEQUENCE[64] = {
    0,  32, 16, 48, 8,  40, 24, 56, 4,  36, 20, 52, 12, 44, 28, 60,
    2,  34, 18, 50, 10, 42, 26, 58, 6,  38, 22, 54, 14, 46, 30, 62,
    1,  33, 17, 49, 9,  41, 25, 57, 5,  37, 21, 53, 13, 45, 29, 61,
    3,  35, 19, 51, 11, 43, 27, 59, 7,  39, 23, 55, 15, 47, 31, 63,
};
examples/translucent_cubes running in mGBA with the bit-reversal offset sequence — all three cubes visible, dither density looking even across all three

The demo with the bit-reversal sequence: three distinct, evenly separated offsets, with no object drawing to nearly the same cells as another.

Verifying a change with nothing to count

The GBA performance article relied on cycle_probe.py, a deterministic cycle count diffed between builds. It does not apply here: this is an output correctness question, and the claims are combinatorial. I checked each of them by construction in Python before writing the C: the sixteen distinct draw-sets of the value rotation, the exact pixel count at every level and offset of the 8x8 matrix, and the even prefix spread of the bit-reversal sequence. Each check enumerated every offset, level and pair. Every change also got the full desktop test suite, a GBA cross-compile and a headless mGBA run of examples/translucent_cubes. For the final sequence choice I looked at the rendered result, the only step that caught what the math could not.

Where this goes next

The bit-reversal sequence is one fixed table shared across every scene. It optimizes pairwise separation along the sequence, not for the specific set of objects overlapping in a given frame. For a small, fixed handful of translucent objects that is enough. A scene with many more would exhaust the best-separated entries and reach ones no better than the original bug. Assigning offsets jointly across the objects in use each frame is the next step if that happens.

posted at 11:00  · 8 min read  ·   ·  gba  rendering  dithering  embedded  retro  gamedev  article

Jul 07, 2026

Only Parallel Light Sources Are Supported: Faking Point Lights on the DS GPU

Introduction

The previous two articles, on cycle-level optimization and the no-heap ECS underneath it, were about the Game Boy Advance: an FPU-less CPU with no polygon fill, where every trick avoids soft-float calls and VRAM writes. The Nintendo DS backend in framer-engine is the opposite kind of target. It has a fixed-function 3D GPU with hardware transform, lighting and rasterization. That should make lighting the easy part, but the constraint only moves from "the CPU can't do float math" to "the GPU can't do what you ask and renders something else instead, without telling you."

This article covers a rendering bug, a hardware surprise from GBATEK, and a fix that gets closer to point-light falloff without leaving the GPU's lighting hardware for a software fallback.

Note

framer-engine is a personal side project. The source code will be made publicly available once the engine reaches a sufficient level of maturity.

The hardware constraint

Every light in framer-engine (directional, point or spot) is the same struct light component, and every backend (GBA's software rasterizer, desktop OpenGL/Vulkan/software, and this one) is expected to shade point lights with distance falloff: brighter when closer, fading to nothing at the light's range. The DS's hardware lighting docs rule that out. libnds's glLight():

void glLight(int id, rgb color, v10 x, v10 y, v10 z)
Only parallel light sources are supported on the DS

There are four hardware light slots, each a color and a direction vector, with no position, range or falloff term. The GPU computes per-vertex Lambertian shading (N·L) against those four directions in fixed-function hardware at no CPU cost per triangle, which is excellent for a directional light. A point light can only be an approximation: pick a direction, accept that the lighting unit does not know about distance, and improve the approximation where possible. The alternative is per-vertex shading on the ARM946E-S, which would throw away the reason to use the GPU's fixed-function path.

Bug 1: an ambient floor that was zero on purpose

Before the point-light work, the hardware 3D path had a simpler problem. GL_AMBIENT, the DS material property for the color of a face catching no light, was set to black as soon as any Light entity existed:

/* what shipped, briefly */
glMaterialf(GL_AMBIENT, RGB15(0, 0, 0));

glLight() lights a face only if its normal faces toward one of the four active directions. Any other face gets no diffuse contribution, and with GL_AMBIENT at black, no ambient either, so every face pointing away from all active lights rendered flat black. The fix looked simple: sum the color of every ambient and directional light each frame and feed that into GL_AMBIENT instead of a hardcoded zero:

s_ambient_r += gl->r;
s_ambient_g += gl->g;
s_ambient_b += gl->b;
/* ...clamped to 1.0 after the loop, then: */
glMaterialf(GL_AMBIENT,
    RGB15((int)(r * s_ambient_r * 31.0f), /* ...g, b... */));

It rendered correctly with one active light and not with two.

The hardware surprise: ambient is summed once per active light

Adding a second point light turned every previously correct face white: blown-out, clipped white, on geometry that had looked right a commit earlier.

The cause is in GBATEK's description of the DS polygon lighting equation, and nothing in libnds's glMaterialf() doc comment mentions it. The hardware does not compute MaterialAmbient once and add it to the result. It computes MaterialAmbient × LightColor for every active light and sums those. Diffuse and specular should work that way, since that is how colored lights combine. Ambient is conceptually the floor when nothing lights the face and should not scale with the number of lights, but the DS does not make that distinction. Set GL_AMBIENT to the floor you want, and with three active lights the hardware adds three times that to every face.

The first fix was to divide by the number of active lights before setting GL_AMBIENT, so the hardware's sum would reconstruct roughly the intended value:

/* looked right, wasn't */
float ambient_scale = 1.0f / (float)s_num_lights;

This fixed the overexposure with two point lights. It also made a sphere lit by one dim ambient light and two much brighter point lights far too dark, because a flat 1/N cannot know the three lights are not equally bright. It divides a lone dim ambient contribution by as much as it divides two unrelated point lights. A cube's few flat faces looked fine anyway, since whichever face caught a point light's direction was bright from diffuse. A sphere's curved surface, mostly facing none of the three directions, was left with almost only the over-divided ambient term and rendered close to black again, from a different arithmetic mistake.

The actual fix reconstructs the target ambient exactly. Track the per-channel sum of every active light's color, not just the count, and divide by that:

/* MaterialAmbient * sum(LightColor) == target once hardware sums it
 * back, regardless of how many lights are active or how bright any
 * one of them is. */
float ambient_scale_r = 1.0f / fmaxf(s_light_color_sum_r, 0.05f);
glMaterialf(GL_AMBIENT,
    RGB15((int)(r * amb_r * ambient_scale_r * 31.0f), /* ...g, b... */));

The hardware computes MaterialAmbient × sum(LightColor), so setting MaterialAmbient = target / sum(LightColor) makes its sum land on target for any number of lights of any brightness. The fmaxf(..., 0.05f) floor covers an edge case: if every active light has an exact 0 in one color channel (a pure green light has no red or blue), that channel's sum is zero and no MaterialAmbient can make the hardware produce a nonzero result from a color that has none. The floor does not fix that case, since the hardware cannot recover an ambient floor for a channel no light contributes to. It only keeps the reconstruction from returning inf and corrupting the result.

RGB555 and "bright enough"

With the arithmetic fixed, faces pointing away from every light were no longer wrong, but still indistinguishable from unlit black in a screenshot. RGB555 has 5 bits per channel, 32 steps. A first ambient floor of 0.18 on a 0.8-albedo material gives roughly 0.8 * 0.18 * 31 ≈ 4/31: nonzero, but lost to display gamma and contrast. Raising the floor to 0.45 gives roughly 11/31, about 35%, enough for a person to see that the math is right. Getting the equation right and getting the number right were two separate bugs.

Per-object falloff, without leaving hardware lighting

With the ambient floor behaving, the remaining problem was the one in the introduction. The point light's direction was computed once per frame, from the world origin, not per object:

/* once per frame, not once per object */
float lx = gl->x, ly = gl->y, lz = gl->z; /* the light's position */
/* ...normalize (lx, ly, lz) as a direction from the origin... */
glLight(s_num_lights, hw_color, dx, dy, dz);

Every object, wherever it sat relative to the light, saw the same direction and brightness with no falloff, so a point light behaved like a directional one. That is worse than the hardware's real limit requires. The DS cannot do per-pixel falloff, or per-vertex falloff within one object, but it can use a different, correct direction and brightness for each object if something recomputes them per object.

So the glLight() upload moves from once per frame to once per object, right before that object's triangles submit, with direction and falloff computed from that object's world position:

static void nds_upload_lights_for_object(const float pos[3])
{
    for (int i = 0; i < s_num_lights; i++) {
        /* ...ambient/directional lights reuse the once-per-frame
         * direction/color computed earlier -- no position to react
         * to, so recomputing per object would be pure waste... */
        float lx = gl->x - pos[0], ly = gl->y - pos[1], lz = gl->z - pos[2];
        float d2 = lx * lx + ly * ly + lz * lz;
        float atten = 1.0f - d2 * gl->inv_range2; /* same falloff every backend uses */
        /* ...clamp atten to [0, 1], normalize (lx, ly, lz), scale color by atten... */
        glLight(i, hw_color, dx, dy, dz);
    }
}

An object near the light renders bright, one near the edge of its range dim, and one outside the range gets nothing. This is per-object point-light falloff through the same hardware N·L calculation as before. Triangle count, vertex count and rasterization work do not change; only the values written to two hardware registers that were already written once per object.

It is not free. A point light's direction needs a square root to normalize, now once per (point light, object) pair instead of once per frame. But it uses the same fixed-point sqrtf32/divf32 primitives as the per-frame version, on a scene with a handful of lights and objects: a few more fixed-point operations, no new soft-float cost, no extra vertex to transform or pixel to fill. Against the cost of submitting an object's geometry it does not register.

Two smaller wins from the same pass

Neither changes a triangle.

GL_ANTIALIAS was enabled at startup and doing half its job. The DS blends an edge under antialiasing only where the polygon ID changes across it, and every object defaulted to ID 0, so edge smoothing applied only where an object's silhouette met the background. Two overlapping objects with the same ID got a hard edge between them. Giving each object its own ID (an index mod 63, since glClearPolyID(63) reserves 63 for the backdrop) applies the blending between objects too. It is a different bitmask OR'd into a glPolyFmt() call that already happens once per object.

The built-in sphere/cone mesh was generated at 12 sectors × 6 stacks (120 triangles), sized for a target this GPU is not. A comment in the code gives the DS's per-frame budget as "on the order of a couple thousand polygons". A 360-triangle sphere at 20×10 is a rounding error against that and visibly rounder, and triangle count was never this target's constraint.

Update: the tessellation win had a hidden cost

The rounder sphere had a cost this article missed at the time. examples/lighting ran at exactly 30 fps on NDS timing (melonDS) instead of 60, and the cause traced back to this pass.

nds_submit_tri(), which issues each triangle's vertices, also recomputed every triangle's GL_AMBIENT/GL_DIFFUSE material state, including the ambient-reconstruction division from the sections above. For a single-color object that is waste: the material state is identical across its triangles and needs setting once per object. A 12×6 (120-triangle) sphere issued 120 redundant glMaterialf() pairs into the GX FIFO every frame even before this article's change; the 20×10 sphere (360 triangles) issued 360. With the lighting example's four active lights, each re-triggering the reconstruction on every call, frame time went just past one VBlank period.

swiWaitForVBlank() does not degrade gracefully. It waits for the next VBlank interrupt after the frame's work completes, so a frame either makes a VBlank or waits for the one after. Missing one by any margin, even a fraction of a scanline of GX FIFO backlog, halves the frame rate: 60 fps to exactly 30, not 59 or 58. The tessellation bump caused no gradual slowdown. It sat under the deadline until it crossed it.

The fix leaves tessellation, lighting and the ambient math unchanged. It changes when the material state is written: once per object (or once per face, for the rare object whose faces have different colors) immediately before its triangles submit, not with every triangle. The same GL_AMBIENT/GL_DIFFUSE values reach the GPU and nothing drawn changes; the identical FIFO command is issued fewer times.

The GBA backend is unaffected. Its software rasterizer computes each face's final color in gba_lit_color_face() and has no separate material state to reissue.

Confirmed on melonDS timing: back to a solid 60 fps. The shape of the bug is the one this article keeps hitting: the GPU does what it is asked at whatever cost that carries, and two reasonable changes (a rounder sphere, per-object point-light falloff) compounded into a cost neither would have caused alone.

Verifying a change with nothing to count

The GBA articles used cycle_probe.py, a deterministic emulator cycle counter diffed between frames. It does not apply here: this work runs on the GPU's fixed-function pipeline, not the ARM9's cycles, and there is almost no CPU-side cost to isolate with a breakpoint. Verification was the build and test suite (native, GBA and NDS cross builds, all passing unchanged throughout), plus looking at the frame in melonDS to confirm that the sphere in examples/lighting is shaded by two differently colored, differently positioned lights instead of one flat approximation.

Where this goes next

The DS GPU has capabilities this backend does not use: hardware texture sampling from VRAM, a specular shininess table, per-vertex fog. None are CPU cost questions; they are GPU features sitting idle. Diffuse texturing is next, as the highest-value use of unused hardware. If the ambient bug is a guide, it will hide its own GBATEK surprise.

posted at 21:14  · 10 min read  ·   ·  nds  gpu  lighting  embedded  retro  gamedev  article

Jun 25, 2026

A Static Micro-ECS for the Game Boy Advance

Introduction

The previous article was about squeezing cycles out of framer-engine's GBA renderer. This one covers what has to be settled before that work is possible: how to run an Entity Component System, the pattern the engine is built around, on a console with 32KB of fast RAM, 256KB of slow RAM, no operating system and no usable malloc().

framer-engine answers with a second, much smaller ECS implementation behind the same interface as the desktop one. Component definitions, systems and queries are identical between a PC build and a GBA build. Only one file differs: which world.c is linked.

Note

framer-engine is a personal side project. The source code will be made publicly available once the engine reaches a sufficient level of maturity.

Two ECS backends, one interface

The default backend is Flecs, a full-featured ECS used for desktop and handheld targets with a real OS (PSP, 3DS, Switch, PC). It provides archetypes, queries, observers and relationships, and all of that bookkeeping assumes a heap that grows and shrinks as entities and component types come and go. The GBA has 32KB of IWRAM and 256KB of EWRAM, both fixed-size and fully accounted for from boot, with no MMU and no OS to page anything in. There is no heap.

A second full-featured ECS sized to that budget would be a lot to maintain for a problem that does not need it. GBA-class scenes hold a handful of objects (examples/simple_cube uses one mesh and a camera, examples/spinning_shapes three), not thousands, and the component types are fixed at compile time. src/ecs/static/world.c, about 550 lines of plain C, is sized to that instead of to Flecs:

/* Entities */
static bool s_alive[FRAMER_STATIC_MAX_ENTITIES];
static uint64_t s_comp_mask[FRAMER_STATIC_MAX_ENTITIES];

/* Component data store: row = component slot, column = entity slot.
 * Each entity slot is FRAMER_STATIC_MAX_COMPONENT_SIZE bytes wide. */
static uint8_t s_store[FRAMER_STATIC_MAX_COMPONENTS]
                       [FRAMER_STATIC_MAX_ENTITIES *
                        FRAMER_STATIC_MAX_COMPONENT_SIZE];

Every array is a fixed-size global sized by compile-time constants. Nothing in this file can fail by running out of memory unpredictably. It can only run out of array slots, a number known before the program starts.

Both backends sit behind the same include/framer/ecs.h: framer_world_t, framer_component_register(), framer_query_create(), framer_system_register() and the FRAMER_GET/FRAMER_SET/ FRAMER_FIELD macros. A component defined with FRAMER_COMPONENT_DEFINE(Velocity) and a system registered with framer_system_register() compile and run unchanged on either. A single Meson option selects the backend, -Decs=flecs or -Decs=static. GBA's cross file pins it to static because Flecs needs an OS, and the build refuses to configure otherwise.

Entities are array slots

An entity here is a 1-based index into those parallel arrays. framer_entity_create() claims the lowest dead slot, first-fit:

framer_entity_t framer_entity_create(framer_world_t *world)
{
    int i;

    for (i = 0; i < FRAMER_STATIC_MAX_ENTITIES; i++) {
        if (!s_alive[i]) {
            s_alive[i] = true;
            s_comp_mask[i] = 0;
            if (i + 1 > s_entity_high)
                s_entity_high = i + 1;
            return EID(i);
        }
    }
    return 0; /* pool exhausted */
}

That is the whole allocator. There is no free list and no fragmentation, since every slot has the same size and a compile-time address. The price is deliberate: destroying entity 5 and creating a new one immediately returns ID 5 for a different logical entity, and there is no generation counter to tell them apart. For a handful of objects that mostly live for a whole level, that is acceptable. test_entity_slot_recycled_first_fit pins it down so it stays a known property.

Components: a bitmask and a flat array

Each entity slot carries one uint64_t bitmask with one bit per registered component type. framer_component_set() is a memcpy into a fixed-stride row of s_store plus a bit set:

void framer_component_set(framer_world_t *world, framer_entity_t e,
                          framer_id_t id, const void *data)
{
    int ei = EIDX(e);
    int ci = cidx(id);
    /* ...bounds and liveness checks elided... */
    memcpy(&s_store[ci][ei * FRAMER_STATIC_MAX_COMPONENT_SIZE], data,
           s_comp[ci].size);
    s_comp_mask[ei] |= ((uint64_t)1u << ci);
    s_any_mask |= ((uint64_t)1u << ci);
}

A query is a precomputed mask built from the component ids it asks for, and matching an entity is one AND and one comparison ((s_comp_mask[ei] & mask) == mask). The uint64_t is also the ceiling on distinct component types in a world: 64. That is plenty for a retro scene, and it stops anyone building something Flecs-shaped on top.

A silent NULL deref vs. a loud abort()

The most important property of this backend is what happens when a limit is hit. framer_component_register() originally returned 0 when a component was too big or the registry was full, like the rest of the API. A 0 component id then does damage downstream: framer_query_create() skips it when building the mask, and the system that registered the query calls FRAMER_FIELD() on a field that was dropped, dereferencing NULL on its next matching entity. This happened on a real GBA build: a Text component of 288 bytes against a 64-byte cap, and 17 component types against a cap of 12. The crash was a NULL-pointer SIGSEGV inside a render system, two layers away from the mistake, not "your component is too big".

The fix removes the silent path:

if (size > FRAMER_STATIC_MAX_COMPONENT_SIZE) {
    fprintf(stderr,
        "framer_component_register: \"%s\" is %lu bytes, "
        "exceeding FRAMER_STATIC_MAX_COMPONENT_SIZE (%d)\n",
        name ? name : "?", (unsigned long)size,
        (int)FRAMER_STATIC_MAX_COMPONENT_SIZE);
    abort();
}

It uses abort() instead of assert() or a GCC/Clang builtin because this backend also has to compile under cc65 and SDCC for 8-bit targets, and C89 abort() is the one failure primitive guaranteed everywhere. The tests check the contract, not just the arithmetic: test_ecs_static_limits.c forks a child, registers one component past the limit, and asserts the child died of SIGABRT. That proves the fail-loud path fires.

On a backend made of fixed-size arrays, every hard limit is a wall. The choice is hitting it with a clear message at the offending call, or with a corrupted query and a crash three calls away. This backend fails loudly wherever a hard cap exists.

Sizing the pools, and a second silent-failure bug

Each target's meson.build picks FRAMER_STATIC_MAX_COMPONENTS, FRAMER_STATIC_MAX_ENTITIES and FRAMER_STATIC_MAX_COMPONENT_SIZE for that platform. No universal default fits, because the engine registers its full set of core components (Transform, Velocity, Sprite, Light, Text, Camera and so on, 19 today) regardless of what an example uses:

# Embedded (system == 'none'):
-DFRAMER_STATIC_MAX_COMPONENTS=19
-DFRAMER_STATIC_MAX_ENTITIES=96
-DFRAMER_STATIC_MAX_COMPONENT_SIZE=64

MAX_ENTITIES=96 has its own bug behind it. Each registered component type, not each entity created, reserves one sentinel slot from the same entity pool, so the entities available to a scene are MAX_ENTITIES minus the number of component types. At MAX_ENTITIES=64 and 19 components that left 45 usable slots, enough for examples/simple_cube but one short of examples/input_tester's 48 (47 on-screen panel entities plus a camera). The failure was silent again: framer_entity_create() returned 0 once the pool filled, and the caller wanting one more entity for a gamepad-axis label never got it. The text did not appear and nothing in the logs said why. Raising the cap to 96 (77 free after the 19 sentinels) fixed it. On GBA that costs about 544 bytes of IWRAM (three per-entity arrays scale with the cap), which I checked against the build's free IWRAM margin before landing. On a 32KB budget you measure.

As in the performance article, arm-none-eabi-size -A on a current GBA build shows where this backend's memory goes. The flat component store (19 × 96 × 64 bytes ≈ 114KB) goes in EWRAM's .sbss section: too big for IWRAM, and zero-initialized by the startup code at no ROM cost:

#ifdef GBA
#define _FRAMER_STORE_ATTR __attribute__((section(".sbss")))
#else
#define _FRAMER_STORE_ATTR
#endif

static uint8_t _FRAMER_STORE_ATTR
    s_store[FRAMER_STATIC_MAX_COMPONENTS]
           [FRAMER_STATIC_MAX_ENTITIES * FRAMER_STATIC_MAX_COMPONENT_SIZE];

Everything else (alive flags, masks, the per-frame iterator's entity list) is small enough for IWRAM, the GBA's fast 32KB memory, where the CPU wants its hot data. On the current examples/simple_cube build that is roughly 19.5KB of 32KB used. That leaves room for a component or two, and the number needs rechecking whenever the component count grows, as the 96-entity change did.

The scan loop

framer_world_progress() walks every registered system in phase order and, for each, scans entity slots up to the high-water mark (s_entity_high, one past the highest slot any entity or component sentinel has occupied) looking for bitmask matches. The sticky s_any_mask check skips the scan entirely for systems whose component type no entity has ever had. That was the biggest win in the previous article's performance work, covered there. The design point here is that this is a linear scan over a flat array, not a sparse-set or archetype lookup. That is right at GBA scene sizes (tens of entities) and wrong at thousands, where you would reach for Flecs.

What this design gives up

Two backends exist so that these trade-offs are made once, explicitly, per target:

  • No entity generations. IDs are recycled immediately and look identical to the entity that held them before.
  • No archetypes, no sparse sets. Matching is a linear scan with a bitmask test, not a cache-friendly iteration over exactly the matching entities. Fine at dozens of entities, wrong past that.
  • 64 component types in total, for the whole world. Not per query: every component type in the engine, shared across every system. The current 19 leaves room, but adding a component means someone must recheck that ceiling.
  • No relationships, hierarchies or observers. Flecs has all three. This backend has entities, components and queries.

Flecs has these capabilities and this backend does not, and they are the features that cost a heap, dynamic bookkeeping and memory use unknowable at compile time, none of which a bare-metal ROM can afford.

Where this goes next

Every retro target on the roadmap has the same constraints: no OS, no heap, a fixed memory map and a known component set. The 32-bit-era consoles mentioned at the end of the previous article, starting with the PlayStation 1, have far more RAM and a real GPU but still no OS and no desktop heap. I expect them to use this static backend with much larger pool constants, not Flecs and not a third ECS. Whether that holds on a real PS1 build is for a later article.

posted at 23:30  · 9 min read  ·   ·  gba  ecs  embedded  retro  gamedev  c  article

Jun 24, 2026

Squeezing Cycles: Optimizing a Software 3D Renderer for the GBA

Introduction

framer-engine is a small cross-platform ECS game engine I am building, with backends from desktop OpenGL/Vulkan down to bare software rendering on 8/16-bit consoles. The Game Boy Advance backend renders textured and shaded 3D meshes (cubes, cones, spheres) through a CPU-only software rasterizer on a 16.78MHz ARM7TDMI with no FPU and no hardware polygon fill. Every float operation and every divide is a library call, and every pixel is a CPU read-modify-write into VRAM.

Note

framer-engine is a personal side project. The source code will be made publicly available once the engine reaches a sufficient level of maturity.

examples/simple_cube running in mGBA — a single shaded, rotating cube

examples/simple_cube, captured from mGBA. The simple_cube numbers below are measured on this demo.

Every number below comes from scripts/debug/gba/cycle_probe.py, a script that sets a breakpoint on the engine's vblank-wait function inside headless mGBA and reads the emulator's cycle counter on every hit. mGBA's CPU emulation is deterministic: the same ROM with the same inputs gives bit-identical cycle counts every time, so a claim is "frame N now costs X fewer cycles", not "it looked smoother". I discard the first ~100 frames as warm-up (caches, lazy first-frame setup) and average the steady-state window after that.

That discipline matters more than any single trick below, because twice an optimization that was obviously correct measured as a regression. Those are at the end.

The hardware constraint

The GBA display is locked to the LCD's scanout rate. A frame takes 280896 system clock cycles to display whether or not your CPU work fits in it. If it does not, you display every other frame or worse, and the frame rate quantizes to 59.73 / n for whatever integer multiple of the budget a frame costs. There is no GPU to defer to and no graceful way to miss the deadline. The goal is a CPU-side frame cost under, or close to, 280896 cycles.

Two limits drive every technique:

  1. No FPU. Any float/double multiply, divide, sqrtf(), sinf()/cosf()/tanf() compiles to a call into ARM's soft-float runtime: a function call plus a software algorithm per operation.
  2. No hardware rasterizer. Mode 4's bitmap layers are VRAM written by the CPU. Every pixel a triangle covers is computed and stored by the ARM7TDMI.

Technique 1: fixed-point math instead of float

The most basic change: the hot path (per-vertex transform, per-pixel rasterization) uses Q12 fixed-point integers through a small fix_t type (src/backends/renderer/common/sw3d_fixed.h):

typedef int32_t fix_t;

#define FIX_SHIFT 12
#define FIX_ONE   (1 << FIX_SHIFT)   /* 4096 == 1.0 */

static inline fix_t fix_mul(fix_t a, fix_t b)
{
    return (fix_t)(((int64_t)a * (int64_t)b) >> FIX_SHIFT);
}

The int64_t intermediate looks expensive, but on ARM it lowers to a single hardware SMULL (signed multiply, 64-bit result), with no library call. A float * float on this target is a soft-float call doing mantissa and exponent bookkeeping in software.

Division still hurts, because the ARM7TDMI has no hardware divider either way: a fixed-point divide costs an integer library call (__aeabi_idivmod and friends) instead of a float one. The perspective divide exploits a property of that specific division to cut the cost:

/* fix_div()'s general implementation widens to a 64-bit intermediate to
 * stay correct for arbitrary numerators, but the perspective divide's
 * numerator is always FIX_ONE, so FIX_ONE << FIX_SHIFT never exceeds
 * 32 bits. */
static inline fix_t fix_reciprocal(fix_t b)
{
    return (fix_t)(((int32_t)FIX_ONE << FIX_SHIFT) / b);
}

Replacing the general 64-bit fix_div() with this 32-bit reciprocal at the one call site whose numerator is always FIX_ONE saved ~50,000 cycles/frame on examples/spinning_shapes.

Technique 2: a LUT instead of sinf()/cosf()

framer_transform_get_matrix(), the shared cross-platform transform code, builds rotation matrices with cglm's glm_rotate_{x,y,z}(), which call cosf()/sinf(). On desktop that is a couple of FPU instructions. On the GBA it is a soft-float libm round trip per axis, per object, per frame.

examples/spinning_shapes running in mGBA — a cube, octahedron, and cone rotating on all three axes

examples/spinning_shapes, captured from mGBA: three objects rotating on all three axes every frame. Every spinning_shapes number here comes from this demo.

The GBA backend carries its own 256-entry sine table (sw3d_raster.c), reads cosine from the same table at a quarter-turn offset, and interpolates linearly between samples:

static const fix_t gba_sin_lut[256] = { /* ... */ };

static void gba_fast_sincosf(fix_t angle_turns_256, fix_t *s, fix_t *c)
{
    int idx = angle_turns_256 & 0xff;
    int cidx = (idx + 64) & 0xff; /* cos(x) == sin(x + tau/4) */

    *s = gba_sin_lut[idx];
    *c = gba_sin_lut[cidx];
}

256 entries is ~1.4° between samples, far finer than a 240x160 screen shows, so the interpolation error never appears as jitter. Swapping it in for the float sin/cos chain, measured A/B (git stash plus identical build and measure commands) on spinning_shapes (3 objects rotating on all 3 axes every frame): 1,294,320 → 1,234,341 cycles/frame, ~4.6% less, from removing one class of soft-float call.

A follow-up went further. cglm builds the rotation matrix with up to three generic 4x4 matrix multiplies, one per nonzero Euler axis, each a 64 multiply-add matmul although most entries of a pure-axis rotation matrix are 0 or 1. Instead, the 9 nonzero 3x3 entries of the combined Rz·Ry·Rx product are expanded by hand from the three angles' sin/cos (still from the LUT) and folded into the output with one glm_mat4_mul:

/* out = Rz * Ry * Rx, 9 nonzero entries expanded by hand instead of
 * three generic 4x4 matmuls. */
out[0][0] = cy * cz;
out[0][1] = cy * sz;
out[0][2] = -sy;
out[1][0] = sx * sy * cz - cx * sz;
out[1][1] = sx * sy * sz + cx * cz;
out[1][2] = sx * cy;
out[2][0] = cx * sy * cz + sx * sz;
out[2][1] = cx * sy * sz - sx * cz;
out[2][2] = cx * cy;

I verified it against the three-matmul path with NumPy differential tests over all 8 zero/nonzero axis combinations and thousands of random angle triples (max absolute error ~1e-16) before it touched the renderer. The gain was only 1,309,080 → 1,306,224 cycles/frame, ~0.22%, far less than the operation count suggests, because the optimizer already folds away most of the zero and one multiplies when each Rz/Ry/Rx factor starts from an identity-seeded matrix. Hand-expanding math only pays once you have checked what the compiler already does. A branch-free variant that skipped the matrix multiply altogether (translation column copy plus per-column scale) measured worse in every iteration than this one-matmul version, and I discarded it.

Technique 3: Quake III's fast inverse square root

Triangle shading needs each surviving triangle's world-space normal, normalized, once per shaded triangle per frame, in the renderer's hottest loop. cglm's glm_vec3_normalize() calls sqrtf() and divides by the result: two soft-float calls per triangle.

The fix is the well-known bit-hack:

static float sw3d_fast_inv_sqrt(float number)
{
    union { float f; uint32_t i; } conv = { .f = number };

    conv.i = 0x5f3759df - (conv.i >> 1);
    conv.f *= 1.5f - (0.5f * number * conv.f * conv.f); /* one Newton-Raphson step */
    return conv.f;
}

A magic-constant shift gives a rough inverse square root straight from the float's IEEE bit pattern, with no sqrt call, and one Newton-Raphson step sharpens it until it is visually indistinguishable for lighting. It replaces both the sqrt and the divide at every site in the GBA backend that called glm_vec3_normalize() (face-normal lighting in the renderer and the rasterizer's triangle-normal centroid computation), removing two soft-float calls per triangle.

Technique 4: an ECS dispatch early-out

Not every win is in the renderer. framer_world_progress(), the ECS scheduler's per-frame loop, walked every registered system's full entity range every frame, including systems whose query needs a component type that no entity in the scene has ever had. simple_cube registers collider/velocity/rigidbody systems on every platform (component import is unconditional), so most systems scanned entities every frame to match none.

The fix tracks a sticky OR of every component bit ever set in the world and skips a system's scan entirely, in O(1) with no entity walk, when its query's required mask includes a bit outside that set, since it can never match:

/* s_any_mask: sticky OR of every component bit ever set across the
 * world's lifetime. A query whose mask requires a bit outside this set
 * can never match any entity — skip the per-entity scan entirely. */
if ((q->mask & world->s_any_mask) != q->mask)
    continue;

This is the largest single win in the project: simple_cube 308650 → 288629 cycles/frame, spinning_shapes 757806 → 744701 cycles/frame (steady-state averages over frames 101-150). It is a scheduler fix, not a renderer trick, found the same way: measure where the cycles go.

Technique 5: making divides Bresenham-shaped

The scanline rasterizer (sw3d_fill_triangle()/sw3d_fill_quad()) originally tested every pixel in each triangle's bounding box against all three edge functions. The replacement computes each row's [lo, hi] x-span per edge, incrementally. That is Bresenham's line algorithm applied to "x as a function of y" along a triangle edge:

/* Incrementally tracks bound(y) = floor((b0 + (y - y0) * d) / a) for a
 * fixed positive `a`, one row at a time, with zero divisions after
 * init. The GBA's ARM7TDMI has no hardware divider, so trading one
 * division per edge (at init) for what used to be a same-sign test on
 * every bounding-box pixel is the whole point. */
struct row_bound {
    long val, step, rem, err, a;
};

"One division-equivalent test per candidate pixel" becomes "one division per triangle edge plus an integer add per row", a large change on a CPU with no divider.

It also produced an unusual micro-optimization. The one division still needed per edge (floordiv_pos()) is built on a / b and a % b in C, which GCC should fuse into a single __aeabi_idivmod call. Disassembly showed the fusion on one branch (a > 0) but not the other (a < 0, which negates both operands first): a redundant __aeabi_idiv next to the __aeabi_idivmod for the same division. Restructuring the C source gave byte-identical codegen, so I could not fix it from the C side. The fix is to call the library function directly and unpack its packed 64-bit r0:r1 quotient/remainder by hand, so the compiler cannot make the wrong fusion choice:

extern long long __aeabi_idivmod(long numerator, long denominator);

static long floordiv_pos(long a, long b)
{
    long long qr = __aeabi_idivmod(a, b);
    long q = (long)(uint32_t)qr;
    long r = (long)(qr >> 32);

    if (r != 0 && a < 0)
        q--; /* C truncates toward zero; floor() needs a -1 correction */
    return q;
}

This saved roughly 25,000-30,000 cycles/frame on spinning_shapes, by removing one redundant library call on one branch that no compiler flag could remove.

Technique 6: let the hardware scale a smaller image

The GBA has no hardware polygon fill, so every covered pixel is a CPU read-modify-write into VRAM. That is the floor under every other optimization here: once every avoidable divide and float operation is gone, you are still bound by how many pixels the CPU touches.

The way around it is not a CPU optimization. Mode 4's BG2 layer supports affine transforms even though it is a flat bitmap, the trick behind GBA games that faked SNES Mode 7 scaling. The renderer draws only a 120x80 corner of the framebuffer (a quarter of the 240x160 screen's pixels) and BG2's affine matrix stretches it across the screen at scanout, in hardware, at no CPU cost:

#if GBA_RENDER_SCALE == 1
static inline void gba_clear_buffer(vu16 *base) { /* full-res clear */ }
#else
static inline void gba_clear_buffer(vu16 *base)
{
    /* only clear the GBA_RENDER_WIDTH x GBA_RENDER_HEIGHT corner that's
     * actually sampled by BG2's affine matrix — the rest of the page is
     * never displayed, so clearing it is wasted work. */
}
#endif

On spinning_shapes this took the steady-state cost from ~1.55M to ~1.28M cycles/frame, roughly 10.8fps → 13.1fps, ~17% less, at the price of visibly blockier 2x nearest-neighbor edges. It is opt-in (-Dgba_half_res), because unlike every other technique here it is a visible quality trade, not a free win.

How the measurements work

None of the numbers above are estimates. scripts/debug/gba/cycle_probe.py drives headless mGBA, breaks on the engine's vblank-wait call (the one point every frame passes through exactly once), and reads the emulator's cycle counter on every hit. mGBA's CPU core is deterministic, unlike real silicon, so the same ROM, breakpoint and warm-up discard give bit-identical cycle counts on every run. "Did this help?" becomes a yes/no question: rebuild, re-run the probe, diff the number.

That is also what caught the two optimizations that were obviously correct and were not.

Failed attempt 1: caching screen-space half-extents that never change

The camera's screen-space half-width and half-height, once converted to fixed-point, do not change from frame to frame unless the projection changes. Hoisting the conversion out of the per-vertex projection loop and caching it looked like a free win: same values, computed once instead of once per vertex.

It measured as a regression.

The likely cause, from inspecting the generated assembly: the project builds with link-time optimization (LTO) and -Doptimization=3, and LTO's inlining heuristics are sensitive to function and loop size in ways the C source does not show. A cache check, even a cheap one, in an already-hot inlined loop changed the cost/benefit the inliner used elsewhere in the same translation unit, and the loss of more valuable inlining outweighed the arithmetic saved. The hoist was right about the math and wrong about the measured outcome.

Failed attempt 2: skipping integration work for a zero velocity

The same thing happened independently in velocity_integration_system(). Most entities in simple_cube have a Velocity that is exactly zero every frame. A zero-vector early-out before the glm_vec3_scale/glm_vec3_add calls is mathematically a no-op, so it looked like free cycles for every entity that was not moving:

/* tempting, and wrong on this build */
if (glm_vec3_isvalid(v->linear) && glm_vec3_norm2(v->linear) == 0.0f &&
    glm_vec3_norm2(v->angular) == 0.0f)
        continue;

Measured: +112 cycles/frame on simple_cube, +312 on spinning_shapes, a regression from a change with no behavior difference. Same root cause: the early-out added code size and a branch to a hot loop, LTO's inlining shifted, and the inlining lost elsewhere cost more than the skip saved. I reverted it in the same session.

The takeaway is not that loop-invariant hoisting or early-outs are untrustworthy; both are standard and usually right. Once a build relies on LTO and aggressive optimization levels, the compiler's decisions are part of the system you are optimizing, and they do not always move the way your model of the code predicts. The only check is the one used for every win here: change one thing, measure, keep it only if the number goes down.

Failed attempt 3: quantizing colors in the wrong number system

The GBA backend's shaded sprites and triangles go through gba_palette_index(), a linear scan (up to 256 entries, with a nearest-color distance calculation once full) that maps a computed RGB555 value onto BG_PALETTE, since Mode 4 is 8bpp paletted. A one-entry cache handles two consecutive calls requesting the same value. But continuous per-frame lighting (examples/lighting, added since this article's original techniques, orbits two colored point lights around a static sphere and cube) makes each shaded triangle request a slightly different value almost every frame. That defeats the cache and saturates the palette within seconds.

The obvious fix was to round each lit color channel to a coarser step, 16 buckets instead of RGB555's 32, before the palette lookup. A gradually changing light then collapses onto fewer, more repeated values: more cache hits and a palette that stays truer to intent for longer. The first implementation worked in float space, where the lit color already was a float:

/* looked free, measured otherwise */
static inline float gba_quantize_channel(float v)
{
    const float steps = 16.0f;
    return (float)(int)(v * steps + 0.5f) * (1.0f / steps);
}

Measured with cycle_probe.py on the same orbiting-lights demo, 150 frames, steady-state average over frames 101-150: 646528 cycles/frame with no quantization versus 658869 with it, a ~1.9% regression.

This is still the FPU-less ARM7TDMI. v * steps + 0.5f, the cast and * (1.0f / steps) are three more soft-float calls on each of the three channels, for every shaded triangle every frame, which cost more than the palette-scan time saved. An optimization aimed at this hardware's constraint had ignored that constraint.

The fix rounds in integer space, using a value the code already computes. f_to_5bit(), the existing float→RGB555-channel helper, produces a clamped 0-31 integer, and masking off its low bit gives the same 16 buckets for one AND:

static inline u16 f_to_5bit_quantized(float v)
{
    return (u16)(f_to_5bit(v) & ~1u);
}

Same demo, same measurement: 642455 cycles/frame, a modest ~0.63% improvement over the no-quantization baseline. The idea was sound; it had to be expressed in a number system this CPU handles cheaply.

Where this leaves things

examples/simple_cube now sits at 288074 cycles/frame. 16777216 / 288074, the same ratio cycle_probe.py reports for every measurement here, gives ~58.24fps, against a 60fps budget of 280896 cycles (~59.73fps). That is about 2.5% over budget, down from roughly 7-8% before this round of work. spinning_shapes, three shaded objects rotating on all three axes, a heavier scene by design, sits at 741378 cycles/frame, ~22.63fps. These are ceilings for these demo scenes on cycle-accurate emulation, not estimates; more triangles or lights move the frame cost accordingly.

Closing the rest of the gap on simple_cube means riskier changes: caching ECS query results across frames (beyond the existence check from Technique 4), or pre-converting mesh vertex data to fixed-point ahead of time instead of per-vertex at raster time. The latter is complicated because the same mesh struct is also filled through the engine's public float-only custom-mesh API, so caching would mean changing that API or building a cache-on-first-use scheme. Both are bigger than "swap a divide for a multiply", and I am stopping here rather than chasing harder-to-verify returns from more soft-float removal.

What's next

The GBA backend was the first test of whether framer-engine's approach (an ECS with 3D, software-rendered, no FPU) holds up on constrained hardware. The next targets step up in capability: 32-bit-era consoles like the PlayStation 1, and handhelds with 3D acceleration (PSP, Nintendo DS, 3DS). The goal is an engine that is pleasant for small demos and indie games on real retro hardware, GBA included.

At least one of them, probably the PSP with its conventional FPU-plus-GPU setup, is there for another reason. Every technique here exists because the GBA has no FPU and no hardware rasterizer. On a platform with both, those tricks do not apply, and the question becomes how far the engine and hardware go together. That work is closer to traditional real-time 3D budgeting (draw calls, vertex throughput, fill rate) than to soft-float avoidance, and needs the same measurement discipline aimed at a different bottleneck. Whether any of these tricks carry over is for later articles.

Jun 23, 2026

llm-companion: A Self-Hosted AI Coding Assistant

I did not want my code to leave my network. An agentic coding session sends file contents, project structure and half-finished thoughts to whatever model answers the prompts. I did not want that going to a third-party API by default: it costs money for routine work, it stops when the LAN or VPN has no route to the internet, and it teaches me nothing about serving an LLM on constrained hardware. So I built llm-companion, a rootless Ollama stack for Fedora Server and Debian. It runs on a spare machine at home, OpenCode points at it, and cloud providers are only an explicit fallback.

What llm-companion Is

At its core, llm-companion is a single Kubernetes Pod manifest (kube/stack.yml) deployed by Ansible, running five containers that share one network namespace:

Internet / LAN / VPN
        │
     :8080  ← firewalld / ufw opens only this port
        │
┌────────────────────────────────────────────────────────┐
│  llm-companion Pod  (shared network namespace)         │
│                                                        │
│  ┌──────────────────────────────────────────────────┐  │
│  │  caddy  :8080 (hostPort)                         │  │
│  │  Bearer token auth on /ollama/api/* /ollama/v1/* │  │
│  │  Passes /searxng/* to SearXNG (Bearer token)     │  │
│  │  Passes / to Open WebUI                          │  │
│  └───────────────────┬──────────────────────────────┘  │
│                      │ localhost                       │
│  ┌───────────────────▼──┐  ┌───────────────────────┐   │
│  │  ollama :11434       │  │  open-webui :3000     │   │
│  │  (internal)          │  └────────┬──────────┬───┘   │
│  └──────────────────────┘           │          │       │
│                              ┌──────▼──┐  ┌────▼──────┐│
│                              │ searxng │  │   open-   ││
│                              │  :8888  │  │ terminal  ││
│                              │         │  │  :8000    ││
│                              └─────────┘  └───────────┘│
└────────────────────────────────────────────────────────┘
  • Ollama serves the models.
  • Open WebUI is the browser chat interface. It keeps its own user accounts and history, and takes document uploads for RAG without a separate vector store.
  • SearXNG is a metasearch engine that gives the chat agent web search without sending the query to a single third party.
  • Open Terminal gives the agent a sandboxed shell, reachable from the browser.
  • Caddy is the reverse proxy, the only container exposed to the host network, and the only place a Bearer token is enforced. It is also the only one that would need TLS, so adding HTTPS later is a Caddyfile change.

Two use cases: chatting through Open WebUI, and routing OpenCode sessions through Ollama's OpenAI-compatible API.

Why It Is Built This Way

One Pod, one exposed port. All five containers share a single network namespace and talk to each other over localhost, not DNS names. Only Caddy publishes a hostPort. The firewall rule is one line (8080/tcp) and authentication lives in one place, the Caddyfile. Open WebUI and Open Terminal are never reachable directly, even from the LAN.

Bearer auth at the proxy, not in each service. Ollama and SearXNG have no authentication of their own. Caddy checks a Bearer token before forwarding to /ollama/api/*, /ollama/v1/* or /searxng/*. Open WebUI keeps its own login. Rotating the key (generate-api-key.sh) touches one Kubernetes Secret, not three services' configs.

A hardware-aware model picker instead of a fixed model list. pull-models.sh detects architecture (x86_64/aarch64), accelerator (CPU, AMD ROCm, NVIDIA CUDA) and available RAM/VRAM, then picks the best model that fits for each category (coding, vision, general, embedding). That is a 1.5B coding model and a 1.7B reasoning model on a 2 GB ARM64 board, Devstral Small 2 24B on a 16 GB+ GPU. --list shows the plan before pulling anything.

Quadlet over a bare ``podman run``. A Quadlet .kube unit gives the pod normal systemd semantics: systemctl --user restart llm-companion, restart on failure, and AutoUpdate=registry so a podman auto-update timer pulls newer pinned images. It runs rootless, with loginctl linger so the user service survives without a login session.

Deploying It

The fastest way to see the stack end-to-end is vm.sh, which provisions a QEMU/KVM VM running the exact same Ansible playbook and kube/stack.yml used on real hardware:

sudo dnf install qemu-kvm qemu-img wget curl genisoimage
sudo usermod -aG kvm $USER && newgrp kvm

git clone https://github.com/tprrt/llm-companion
cd llm-companion

./scripts/vm.sh build           # one-time provisioning (~golden image)
./scripts/vm.sh start           # boots in ~2 minutes from there on

I iterate on the stack this way: rebuild the golden image, boot, check the services, tear down.

For an actual deployment, copy the example inventory and point it at your server:

cp ansible/inventory/hosts.yml.example ansible/inventory/hosts.yml
$EDITOR ansible/inventory/hosts.yml
all:
  children:
    llm_companion:
      hosts:
        my-server:
          ansible_host: 192.168.1.100
          ansible_user: fedora
          ansible_ssh_private_key_file: ~/.ssh/id_ed25519

Then run the playbook:

ansible-playbook -i ansible/inventory/hosts.yml ansible/site.yml

In order, it creates the directories and enables linger (common), opens port 8080 via firewalld or ufw (firewall), installs Podman and builds the Ollama image (podman), then generates the API key, installs stack.yml and starts the systemd service (llm-stack). It is idempotent: re-run it after changing the inventory or pulling new code.

Pull models sized to your hardware:

./scripts/pull-models.sh --list    # dry run: show what would be pulled
./scripts/pull-models.sh           # pull the best model per category

On an AMD GPU host, re-run Ansible with -e "ollama_build_target=rocm" first to build the ROCm image and deploy stack-rocm.yml instead, which grants the container access to /dev/kfd and /dev/dri.

Wiring Up OpenCode

On the client machine, point OpenCode at the server through its OpenAI-compatible provider config (~/.config/opencode/opencode.json):

{
  "$schema": "https://opencode.ai/config.json",
  "model": "ollama/qwen3-8b-16k",
  "provider": {
    "ollama": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "Ollama",
      "options": {
        "baseURL": "http://<server-ip>:8080/ollama/v1",
        "headers": { "Authorization": "Bearer sk-ollama-<your-key>" }
      },
      "models": {
        "qwen3-8b-16k": { "name": "Qwen3 8B — coding/vision/general (16k)", "tools": true }
      }
    }
  }
}

The key is printed at the end of the Ansible run and stored in ~/.config/ollama/api-key.env on the server. Switch models at any time with /models inside OpenCode — no restart needed.

Cloud providers (Anthropic, GitHub Copilot) can sit alongside the ollama provider in the same config and are selected with the same /models command. That is the fallback: local by default, cloud when I am travelling, the model is too large for the box, or the service is down.

Lessons Learned

Rootless GPU access was the hard part. ROCm needs /dev/kfd and /dev/dri inside the container, which needs securityContext.privileged: true; I found no narrower rootless path to those device nodes. The ROCm variant therefore gives up some of the isolation the CPU variant has. It is a separate manifest (stack-rocm.yml), not a flag on the default one, and the host should be dedicated to it.

The model picker mattered more than I expected. Picking a quantization by hand works for one machine. It breaks when the same playbook must run unchanged on a 2 GB ARM64 board, an 8 GB CPU-only Fedora box and a 16 GB GPU desktop. With the RAM/VRAM gates in one script, the Ansible roles, the Quadlet unit and the Caddy config do not need to know which tier they run on.

May 26, 2026

Capitole du Libre 2026: the CFP is open

Capitole du Libre is one of France's largest community-driven Free Software and Open Source events. It is held every year in Toulouse, and I have spoken there in 2018, 2019, and 2022. The 2026 edition takes place on November 14–15 at INP-N7, and the Call for Proposals is now open.

Submission deadline: July 20, 2026 at 23:59 (Europe/Paris).

Formats

Five submission types are accepted:

  • Short conference — 25 minutes, Q&A included
  • Full conference — 55 minutes, Q&A included
  • Lightning talk — 5 minutes, no Q&A
  • Workshop — 1 hour 55 minutes
  • Associative village booth — 2-day presence

You may submit multiple proposals, but the programme committee will accept a maximum of two presentations per speaker. Each proposal must feature no more than two presenters.

Topics

All submissions must relate to Free Software or Open Source. The committee welcomes proposals on:

  • Tools and technologies, especially hands-on return-of-experience talks
  • Privacy, digital sovereignty, and the societal implications of emerging tech
  • Self-hosting and DIY practices
  • Community building in Free Software projects

Purely commercial pitches and topics unrelated to Free Software are not accepted.

Travel reimbursement

Conference and workshop speakers can claim up to €150 in travel and accommodation expenses (receipts required, advance payment available on request). Booth operators are not eligible for reimbursement.

The committee encourages first-time speakers and beginner-friendly proposals.

Submit your proposal: cfp.capitoledulibre.org/cdl-2026/cfp.

RIOT-OS 2026.04 "Fire Rizzlease" is out

RIOT-OS 2026.04, codenamed Fire Rizzlease, was released on May 6, 2026. RIOT is a multi-threading operating system targeting microcontrollers found in the Internet of Things — from 8-bit and 16-bit MCUs to lightweight 32-bit processors — with a focus on energy-efficiency, soft real-time capabilities, and a small memory footprint.

This release spans 84 days of development, 125 merged pull requests composed of 229 commits, and contributions from 27 people. 1,141,523 lines were deleted against 25,007 inserted, mostly from a vendor code cleanup.

Codeberg mirror

RIOT is now synced to Codeberg (#21997), a non-profit community forge, alongside GitHub.

Massive EFM32 vendor code removal

Over one million lines of vendor code have been removed from the EFM32 family, replaced by a pkg/gecko_sdk dependency (#22040). This accounts for most of the deletions and shrinks the codebase for Silicon Labs EFM32-based boards.

Raspberry Pi Pico 2 / RP2350 improvements

RP2350 support was reworked (#21753):

  • Unified abstractions between the RISC-V and ARM cores of the RP2350.
  • Added the XH3IRQ interrupt controller.
  • Updated UART driver.
  • Added RISC-V support.

The scope of this work was large enough to inspire a bachelor's thesis at HAW Hamburg.

New board and CPU support

Three new targets join the supported hardware list:

  • pro-micro-nrf52840 (#22089) — a popular nRF52840-based Pro Micro form-factor board.
  • slstk3301a (#22069) — Silicon Labs EFM32 Tiny Gecko starter kit.
  • STM32H7 (#21978) — high-performance STM32 family, with additional peripheral support for the nucleo-h753zi (#22076).

New device drivers

  • AMG88xx (#22104) — infrared array sensor (thermal camera) from Panasonic.
  • ADS1X1X (#21694) — family of Texas Instruments I²C ADCs.

Guide site and documentation

The RIOT Guide Site is the entry point for new users and is replacing Doxygen for prose documentation. This release adds:

  • More tutorials.
  • An experimental Supported Boards section.
  • Unit tests in tutorials (#22042).
  • Updated Astro v6 framework (#22145).

The Doxygen API reference remains available at api.riot-os.org.

Networking improvements

Several additions to the GNRC networking stack:

  • New gnrc_pktshark module to pretty-print network traffic (#21284).
  • gnrc_ipv6_nib_dyn_lladdr_get() API (#22013).
  • ABR (Authoritative Border Router) now run-time configurable (#21081).
  • Generic UDP shell command (#22049).

Notable bug fixes

43 bugs were fixed in this release, including:

  • ESP8266 crashes on reboot and startup (#22014, #22010).
  • Potential buffer overflow in the atwinc15x0 driver (#22041).
  • NanoCoAP message corruption in coap_build_reply() (#22094).
  • Wrong byte order for gyro and accelerometer reads in the MPU-9x50 driver (#22135).
  • LVGL configuration and SDL issues on native (#22005, #22139).
posted at 12:00  · 2 min read  ·   ·  riot  rtos  embedded  iot  article

May 13, 2026

Yocto Project 6.0 "wrynose" is out

The Yocto Project 6.0, codenamed wrynose, was released on May 13, 2026. This is the new Long-Term Support (LTS) release, succeeding 5.0 "scarthgap". My name is in the contributors list for this release.

If you are migrating from 5.0, make sure to read the migration guides for the intermediate releases: 5.1 (styhead), 5.2 (walnascar), and 5.3 (whinlatter).

Major component upgrades

Toolchain and core component versions:

  • Linux kernel 6.18
  • GCC 15.2
  • glibc 2.43
  • LLVM/Clang 22.1.3
  • Python 3.14.4
  • systemd 259.5
  • Go 1.26.2, Rust 1.94.1
  • QEMU 10.2.0
  • U-Boot 2026.01

Over 300 recipe upgrades in total.

Rust in the Linux kernel

One of the most significant additions is first-class Rust support for building the Linux kernel and out-of-tree kernel modules:

  • A new kernel-yocto-rust class adds the required dependencies to build Rust components of the kernel.
  • A new module-rust class supports building out-of-tree Rust kernel modules. A skeleton example is available under meta-skeleton/recipes-kernel/rust-out-of-tree-module.
  • Enabling Rust in the kernel is now as simple as adding rust to KERNEL_FEATURES in a recipe that inherits kernel-yocto.

Security improvements on by default

Several security and hardening features that were previously opt-in are now enabled by default in the nodistro setup:

  • security_flags.inc — adds security-related compiler and linker flags.
  • no-static-libs.inc — disables most static libraries.
  • uninative — allows reuse of native sstate built on one distro on another, also enabled by default now.
  • OpenSSL now disables TLS 1.0/1.1 by default.

New SBOM and CVE tooling

CVE analysis and SBOM generation changed substantially:

  • A new sbom-cve-check class replaces the old cve-check class for post-build CVE analysis of images. It uses the new python3-sbom-cve-check tool internally.
  • A new sbom-cve-check-recipe class allows CVE analysis at the recipe level, without building the full software.
  • SPDX 2.2 support has been dropped; the project now supports SPDX 3 only.
  • New variables SPDX_INCLUDE_KERNEL_CONFIG and SPDX_INCLUDE_PACKAGECONFIG allow exporting richer metadata into SPDX documents.

BitBake and tooling improvements

bitbake-setup changes:

  • Non-destructive update behavior: local commits and modifications are preserved during bitbake-setup update.
  • Shared State is now shared between builds by default (SSTATE_DIR and BB_HASHSERVE_DB_DIR set in site.conf).
  • VSCode configuration files are now generated by default.
  • The unpack_update() function allows updating a Git repository in-place.

WIC is now an external project

The WIC image creator tool has been extracted from OpenEmbedded-Core and is now maintained as a standalone project. The recipe in OE-Core now builds from this external source. A new wicenv image type was also added.

devtool IDE integration

devtool ide-sdk improvements:

  • gdbserver attach mode support.
  • GDB pretty-printing for C++ STL types.
  • Kernel module development support.

What's next

Wrynose will receive long-term maintenance. If you are on scarthgap (5.0), plan your migration with the Yocto Project documentation.

posted at 12:00  · 3 min read  ·   ·  yocto  openembedded  embedded  linux  oe-core  article

Dec 31, 2025

Open-Source Game Engines for Retro Consoles

8-bit and 16-bit Consoles

Game Boy Color (GBC)

GB Studio

GB Studio is a visual game editor for making Game Boy games without writing code. Its drag-and-drop interface covers RPGs, adventure games, platformers and shooters.

Key Features:

  • Visual scene editor
  • Built-in sprite and background editors
  • Integrated music tracker
  • Event system for complex game logic
  • Exports to actual GB/GBC ROMs that run on real hardware
  • Cross-platform support (Linux, Windows, macOS)

License: MIT

Links: GitHub | Website | Documentation

GBDK-2020

GBDK-2020 is a fork of the Game Boy Development Kit that adds C99 support and a newer toolchain.

Key Features:

  • C99 compiler
  • ROM banking support for large games
  • Libraries for sprites, backgrounds, and sound
  • Compatible with both Game Boy and Game Boy Color

License: Various (mostly permissive)

Links: GitHub | API Documentation

Game Boy Advance (GBA)

Butano

Butano is a C++17 game engine built on devkitARM. It abstracts the hardware while leaving the system's capabilities reachable.

Key Features:

  • Sprite management with affine transformations
  • Regular and affine background layers
  • Audio support (DMG and DirectSound)
  • Scene management system
  • GBA-optimized math utilities

License: zlib License

Links: GitHub | Documentation

Tonclib

Tonclib is the long-standing GBA library. It is less actively developed but stable, and comes with the Tonc tutorial, one of the best-documented resources for GBA development.

Key Features:

  • Advanced sprite and background management
  • Mode 7 (affine) support for pseudo-3D effects
  • Built-in text rendering

License: MIT-like (custom permissive)

Links: GitHub | Tonc Tutorial

Nintendo DS (NDS)

NightFox's Lib

NightFox's Lib is a high-level 2D game library built on libnds.

Key Features:

  • Sprite engine with rotation and scaling
  • Tiled background support
  • Collision detection
  • 2D and 3D text rendering
  • Sound and MOD music playback
  • File system access

License: MIT

Links: GitHub

libnds + devkitARM

libnds is the official devkitPro library, with low-level access to all DS features. It requires knowing the hardware.

Key Features:

  • Complete hardware access to both screens
  • 2D and 3D graphics support
  • Touchscreen and button input
  • WiFi networking support
  • FAT file system access
  • Audio subsystem control

License: zlib License

Links: GitHub | Documentation | Examples

Nintendo 3DS

citro2d / citro3d

The citro libraries are the official devkitPro solution for 3DS development, with hardware-accelerated 2D and 3D graphics.

Key Features:

  • Hardware-accelerated rendering via PICA200 GPU
  • 2D sprite batching (citro2d)
  • Full 3D graphics pipeline (citro3d)
  • Shader support
  • Stereoscopic 3D rendering
  • Text rendering

License: zlib License

Links: citro3d | citro2d | Documentation | Examples

Super Nintendo (SNES)

PVSnesLib

PVSnesLib is a C library for the Super Nintendo.

Key Features:

  • Sprite management (OAM)
  • Background and tilemap support
  • Mode 7 support for rotation and scaling
  • Sound driver integration
  • Gamepad input handling
  • DMA and HDMA operations

License: MIT

Links: GitHub | Wiki

libSFX

libSFX is a macro assembler framework for SNES development.

Key Features:

  • Assembly-first with C support
  • Super FX (GSU) support
  • Music and sound effects
  • Steeper learning curve

License: MIT

Links: GitHub | Wiki

Sega Mega Drive / Genesis

SGDK (Sega Genesis Development Kit)

SGDK is the most widely used framework for Mega Drive homebrew, with an active community and extensive documentation.

Key Features:

  • Sprite engine with hardware scrolling
  • Multiple background plane support
  • VDP (video display processor) management
  • Z80 sound driver with XGM music format
  • DMA operations
  • Built-in collision detection
  • ResComp resource compiler for assets

License: MIT

Links: GitHub | Wiki | Forums

Neo Geo

NGDK (Neo Geo Development Kit)

NGDK brings C development to the Neo Geo arcade platform and AES home console.

Key Features:

  • C framework for Neo Geo development
  • Sprite system management
  • Background and fix layer handling
  • Input handling for arcade controls
  • Sound support (Z80 + YM2610)
  • Asset conversion tools
  • Example games included

License: Custom permissive

Links: GitHub | Wiki

PC Engine / TurboGrafx-16

HuC (Hudson C Compiler)

HuC is the classic PC Engine C compiler, still maintained by the community.

Key Features:

  • C compiler for PC Engine
  • Support for HuCard and CD-ROM²
  • PSG sound support
  • Sprite management
  • Background and tilemap support
  • ADPCM audio for CD games
  • Standard C library subset

License: BSD-like

Links: GitHub

Squirrel (HuDK)

Squirrel (HuDK) is a newer alternative to HuC.

Key Features:

  • CD-ROM support

License: Open source

Links: GitHub

Fifth and Sixth Generation Consoles

Sony PlayStation 1 (PS1)

PSn00bSDK

PSn00bSDK is a lightweight open-source PS1 SDK, a free alternative to the old Psy-Q SDK.

Key Features:

  • Hardware 3D graphics (GTE) support
  • 2D sprite and primitive rendering
  • CD-ROM file system access
  • SPU sound support with ADPCM and XA audio
  • Memory card management
  • Controller input (standard and analog)
  • Serial I/O support

License: MPL 2.0

Links: GitHub | Wiki | Examples

Sega Saturn

Jo Engine

Jo Engine is a high-level 2D and 3D game engine for the Saturn.

Key Features:

  • High-level API for 2D and 3D
  • Sprite engine with scaling and rotation
  • 3D model support with converter tools
  • Audio support (PCM, CD audio)
  • Save game management
  • Collision detection
  • Map and tilemap support
  • USB dev cart support for rapid testing
  • Video tutorials available

License: MIT

Links: GitHub | Website | Wiki

Yaul

Yaul is a replacement for the old Sega Basic Library, aimed at developers who want low-level Saturn access.

Key Features:

  • VDP1 and VDP2 support
  • SCU DMA operations
  • CD block support
  • SCSP (sound) support
  • USB dev cart support

License: BSD

Links: GitHub | Documentation

Nintendo 64

libdragon

libdragon is the open-source N64 SDK, an alternative to the official Nintendo one.

Key Features:

  • 3D graphics via RDP/RSP
  • Audio subsystem support
  • Controller input
  • ROM file system
  • Hardware sprites

License: Unlicense (public domain)

Links: GitHub | Documentation

Sega Dreamcast

KallistiOS (KOS)

KallistiOS is the de facto standard for Dreamcast homebrew.

Key Features:

  • Complete OS-like framework
  • 2D and 3D graphics (PowerVR)
  • Network support (modem, broadband adapter)
  • VMU (Visual Memory Unit) support
  • Input device support
  • CD-ROM file system (ISO9660)
  • AICA SPU audio support
  • Threading and multitasking
  • USB development support

License: BSD-style

Links: GitHub | Documentation | Forums

Additional KOS libraries include GLdc (OpenGL-like API) and SDL ports, making cross-platform development easier.

Sony PlayStation 2 (PS2)

PS2SDK

PS2SDK gives access to the PlayStation 2 hardware.

Key Features:

  • Graphics Synthesizer (GS) support for 2D/3D
  • Emotion Engine and I/O Processor access
  • Vector Unit (VU) programming
  • Sound library (audsrv)
  • USB and network support
  • Memory card management
  • DVD file system access

License: BSD/Academic Free License

Links: GitHub | Website | Examples

Nintendo GameCube / Wii

devkitPPC + libogc

The devkitPro toolchain for GameCube and Wii, with libogc.

Key Features:

  • Official devkitPro toolchain
  • Full hardware access for both systems
  • GX 3D graphics library
  • ASND audio library
  • Controller support (PAD/WPAD)
  • Network library
  • USB and SD card storage
  • DVD reading
  • Homebrew Channel integration (Wii)

License: Various (permissive)

Links: GitHub | Documentation | Examples | devkitPro

Sony PlayStation Portable (PSP)

PSPSDK

PSPSDK is the homebrew SDK for the PSP.

Key Features:

  • 3D graphics (GU library) with hardware acceleration
  • 2D sprite rendering
  • Multi-format audio support
  • WiFi and networking
  • USB support
  • Memory Stick access
  • Save data management
  • MP3, AAC playback

License: BSD/GPL

Links: GitHub | Forums | Examples

PlayStation Vita

Vita SDK

Vita SDK is the homebrew SDK for Sony's Vita.

Key Features:

  • OpenGL ES-like graphics
  • Touch screen support
  • Accelerometer and gyroscope
  • Camera support
  • Network and WiFi
  • Trophy system support
  • Save data management
  • Multi-format audio

License: Various

Links: GitHub | Website | Documentation | Examples

Xbox (Original)

nxdk

nxdk is a clean-room Xbox SDK with no Microsoft code.

Key Features:

  • Direct3D 8-like graphics API
  • Audio support
  • Controller input
  • Network support
  • Hard drive access
  • SDL port available

License: Various (LGPL/MIT)

Links: GitHub | Wiki | Examples

Development Tools and Workflow

DevkitPro Toolchain

Many frameworks (GBA, DS, 3DS, GameCube/Wii) require the devkitPro toolchain, which runs on Linux:

  • Website
  • Getting Started Guide
  • Includes devkitARM, devkitPPC, and associated libraries
  • Available via pacman (devkitPro package manager) on Fedora

Graphics Tools

These tools are free, open-source and run on Linux:

Pixel Art Editors:

  • Pixelorama (MIT): Modern pixel art editor with animation support, built with Godot. An Aseprite alternative. Website
  • LibreSprite (GPL v2): Fork of old GPL Aseprite with familiar interface. Website
  • GrafX2 (GPL v2): Inspired by Deluxe Paint, excellent for retro graphics. Website
  • Piskel (Apache 2.0): Web-based and offline pixel art editor. Website

Tilemap Editor:

  • Tiled (GPL v2/BSD): Industry-standard tilemap editor. Website

General Graphics:

  • GIMP (GPL v3+): Full-featured image editor. Website

Music and Sound Tools

All tools below are free, open-source and run on Linux:

Chiptune (Hardware Chip Emulation):

  • Furnace (GPL v2+): Multi-system chiptune tracker supporting 60+ sound chips (NES, SNES, Genesis, Game Boy, etc.). Available on Flathub. GitHub

Module Trackers (Sample-based):

  • MilkyTracker (GPL v3): FastTracker II-inspired tracker for MOD/XM formats. Website
  • Schism Tracker (GPL v2): Impulse Tracker clone for S3M/IT formats. Website

NES/Famicom Specific:

  • FamiStudio (MIT): DAW-style NES/Famicom music editor with expansion chip support. Available on Flathub. Website

Audio Editor:

  • Audacity (GPL v2/v3): Multi-track audio editor and recorder. Website

Emulators for Testing

All emulators below are open-source and Linux-compatible:

  • mGBA: Game Boy Advance - Website
  • DeSmuME: Nintendo DS - Website
  • Citra: Nintendo 3DS - Website
  • bsnes: Super Nintendo - GitHub
  • Genesis Plus GX: Sega Mega Drive - GitHub
  • Mednafen: Multi-system (PC Engine, PS1, Saturn, etc.) - Website
  • DuckStation: PlayStation 1 - GitHub
  • PCSX2: PlayStation 2 - Website
  • Dolphin: GameCube/Wii - Website
  • PPSSPP: PlayStation Portable - Website
  • Vita3K: PlayStation Vita - Website
  • Flycast: Sega Dreamcast - GitHub
  • Mupen64Plus: Nintendo 64 - Website
  • xemu: Original Xbox - Website

Recommendations by Experience Level

Beginner-Friendly

8-bit/16-bit:

  • GB Studio (GBC): Visual editor, no coding required
  • GBDK-2020 (GBC): Simple C development
  • SGDK (Mega Drive): Excellent documentation and community

Fifth/Sixth Generation:

  • PSn00bSDK (PS1): lightweight API
  • Jo Engine (Saturn): High-level engine with tutorials
  • PSPSDK (PSP): stable, documented

Intermediate

8-bit/16-bit:

  • Butano (GBA): C++17, documented
  • PVSnesLib (SNES): Comprehensive library
  • NightFox's Lib (DS): High-level 2D development

Fifth/Sixth Generation:

  • KallistiOS (Dreamcast): Mature ecosystem
  • devkitPPC (GC/Wii): Official toolchain
  • Vita SDK (Vita): Active community

Advanced

8-bit/16-bit:

  • libSFX (SNES): Assembly-first, highly optimized
  • citro3d (3DS): Direct hardware access
  • libnds (DS): Low-level control

Fifth/Sixth Generation:

  • PS2SDK (PS2): Complex but powerful
  • Yaul (Saturn): Modern low-level library
  • libdragon (N64): RDP/RSP programming
  • nxdk (Xbox): Direct3D 8 development

Community Resources

General Communities:

  • NESDev Forums: Multi-platform retro development - Forums
  • GBAtemp: DS/3DS homebrew - Website
  • devkitPro Discord: Nintendo handheld development

Platform-Specific:

  • GBADev: Game Boy Advance - Website
  • PSXDev: PlayStation 1 - Website
  • PS2Dev Forums: PS2, PSP - Forums
  • DCEmulation: Dreamcast - Website
  • SegaXtreme: Saturn, Mega Drive - Website
  • N64brew: Nintendo 64 - Website
  • GC-Forever: GameCube/Wii - Website
  • r/vitahacks: PS Vita homebrew

Conclusion

Everything here is free and open-source, from the SDKs to the pixel editors and music trackers, and runs on Linux. The range goes from a no-code Game Boy puzzle game in GB Studio to assembly-optimized code for the PlayStation 2's Emotion Engine.

May your sprites never flicker.

posted at 10:00  · 9 min read  ·   ·  gamedev  retro  homebrew  console  open-source  article

Mar 11, 2025

Retro Console Hardware Comparison

CPU and Memory Architecture

Processor Specifications

Console CPU Clock Speed
Game Boy Custom Sharp LR35902 4.19 MHz
Game Boy Color Custom Sharp Z80 8 MHz
NES Ricoh 2A03 (MOS 6502) 1.79 MHz (NTSC) / 1.66 MHz (PAL)
SNES Ricoh 5A22 (65C816-based) 3.58 MHz (max)
PC Engine HuC6280 (MOS 6502-based) 7.16 MHz
Neo Geo Motorola 68000 + Zilog Z80 12 MHz + 4 MHz
Game Boy Adv. ARM7TDMI 16.78 MHz
Nintendo DS ARM946E-S + ARM7 67 MHz + 33 MHz
Nintendo 3DS Dual-Core ARM11 MPCore 268 MHz
Wii IBM PowerPC "Broadway" 729 MHz
PSP MIPS R4000-based CPU 333 MHz
Switch NVIDIA Tegra X1 (ARM Cortex-A57) 1.02 GHz

Memory Configurations

Console RAM
Game Boy 8 KB
Game Boy Color 32 KB + 16 KB VRAM
NES 2 KB + 2 KB VRAM
SNES 128 KB + 64 KB VRAM
PC Engine 8 KB + 64 KB VRAM
Neo Geo 64 KB + 68 KB VRAM
Game Boy Adv. 256 KB + 96 KB VRAM
Nintendo DS 4 MB + 656 KB VRAM
Nintendo 3DS 128 MB + 6 MB VRAM
Wii 88 MB (24 MB + 64 MB GDDR3)
PSP 32 MB (PSP-1000) / 64 MB (PSP-2000+)
Switch 4 GB LPDDR4

Key Observations:

RAM grew from kilobytes to gigabytes, roughly a million-fold. The NES had 2 KB of main RAM; the Switch has 4 GB.

2D Graphics Capabilities

Early consoles used dedicated 2D hardware with hardware sprites and tile-based rendering.

Color Depth and Palette

Console Graphics Processor Displayable Colors
Game Boy Custom Sharp LR35902 4 shades of gray
Game Boy Color Custom Sharp Z80 32,768, 56 max
NES PPU (2C02 or 2C03) 52, 25 max
SNES S-PPU 32,768, 256 max
PC Engine HuC6270A VDC 512, 482 max
Neo Geo Custom LSPC2-A2 65,536, 4,096 max
Game Boy Adv. Custom 2D Core 32,768, 512 max
Nintendo DS 2D/3D Graphics Engine 32,768, 4,096 max
Nintendo 3DS PICA200 GPU 16.8 million
Wii ATI Hollywood GPU 16.8 million
PSP Sony CXD2962GG + Media 16.8 million
Switch NVIDIA Tegra X1 16.8 million

Sprite Capabilities

Console Sprite Size Max Sprites on Screen
Game Boy 8x8 or 8x16 px 40 sprites, max 10 per line
Game Boy Color 8x8 or 8x16 px 40 sprites, max 10 per line
NES 8x8 or 8x16 px 64 sprites, max 8 per line
SNES Up to 64x64 px 128 sprites, max 32 per line
PC Engine 16x16 px 64 sprites, max 16 per line
Neo Geo Up to 16x512 px 380 sprites, no strict limit
Game Boy Adv. Up to 64x64 px 128 sprites, max 32 per line
Nintendo DS Up to 64x64 px 128 sprites, max 32 per line
Nintendo 3DS Variable Sprite handling via 3D engine
Wii Variable Sprite handling via 3D engine
PSP Variable Sprite handling via 3D engine
Switch Variable Sprite handling via 3D engine

Key Observations:

Sprite-per-line limits constrained 8-bit and 16-bit consoles, and developers had to manage sprite placement to avoid flicker. The Neo Geo's large sprites (up to 16x512 pixels) and high sprite count suited arcade-style action games.

Video Output Specifications

Display Characteristics

Console Resolution Refresh Rate Aspect Ratio
Game Boy 160x144 59.7 Hz 10:9
Game Boy Color 160x144 59.7 Hz 10:9
NES 256x240 60 Hz (NTSC) 50 Hz (PAL) 4:3
SNES 256x224 512x448i 60 Hz (NTSC) 50 Hz (PAL) 4:3
PC Engine 256x224 59.94 Hz 4:3
Neo Geo 320x224 59.18 Hz 4:3
Game Boy Adv. 240x160 59.7 Hz 3:2
Nintendo DS 256x192 (per screen) 59.8 Hz 4:3
Nintendo 3DS 400x240 (top) 320x240 (bottom) 60 Hz 5:3 (top) 4:3 (bottom)
Wii 640x480 60 Hz 4:3 or 16:9
PSP 480x272 60 Hz 16:9
Switch 1280x720 (Handheld) 1920x1080 (Docked) 60 Hz 16:9

Key Observations:

Resolution went from the Game Boy's 160x144 to 1920x1080 on a docked Switch. Most classic consoles targeted NTSC's 60 Hz or PAL's 50 Hz. The shift from 4:3 to 16:9 came around the PSP/Wii generation.

Audio Capabilities

Sound Architecture

Console Sound Channels Sample Rate
Game Boy 4 (2 square, 1 wave, 1 noise) ~8 kHz
Game Boy Color 4 (same as GB) ~8 kHz
NES 5 (2 pulse, 1 triangle, 1 noise, 1 DPCM) ~21.3 kHz (NTSC) ~17.3 kHz (PAL)
SNES 8 PCM 32 kHz
PC Engine 6 PCM ~7.16 kHz to ~20 kHz
Neo Geo 4 FM, 3 PSG, ADPCM-A, ADPCM-B ~15.7 kHz (ADPCM-A) ~18.5 kHz (ADPCM-B)
Game Boy Adv. 6 (2 direct PCM + 4 PSG) 32 kHz
Nintendo DS 16 PCM 32 kHz
Nintendo 3DS 24 PCM 32 kHz
Wii 64 PCM 48 kHz
PSP 32 PCM 44.1 kHz
Switch 32 PCM 48 kHz

Audio Output

Console Audio Processor Audio Output
Game Boy Custom Sharp LR35902 Mono
Game Boy Color Custom Sharp Z80 Mono
NES Ricoh 2A03 (NTSC) / Ricoh 2A07 (PAL) Mono
SNES Sony SPC700 + DSP Stereo
PC Engine HuC6280 PSG Mono
Neo Geo Yamaha YM2610 Stereo
Game Boy Adv. Custom 2D Core Stereo
Nintendo DS 2D/3D Graphics Engine Stereo
Nintendo 3DS PICA200 GPU Stereo
Wii ATI Hollywood GPU Stereo / DPL II
PSP Sony CXD2962GG + Media Stereo
Switch NVIDIA Tegra X1 Stereo / DPL IIx

Key Observations:

The SNES had 8-channel PCM audio at 32 kHz. Output went from mono to stereo in the 16-bit generation, and the Wii and Switch support Dolby Pro Logic surround encoding.

Special Graphics Features

Hardware Effects by Console

Game Boy / Game Boy Color:

  • No special graphics modes beyond basic tile and sprite rendering

NES:

  • Attribute Tables (Limited Tile Coloring)
  • CHR-ROM for Tile-Based Graphics

SNES:

  • Mode 7: Affine transformations for scaling and rotation, enabling pseudo-3D effects (used in games like F-Zero and Super Mario Kart)
  • Windowing Effects: Variable transparency regions
  • HDMA (Horizontal Direct Memory Access): Per-scanline effects
  • Color Math: Hardware addition/subtraction for transparency and lighting effects

PC Engine:

  • No special graphics modes beyond standard tile/sprite capabilities

Neo Geo:

  • Hardware Scaling for sprites
  • Line Scroll: Independent line offsets for parallax effects
  • Raster Effects: Per-scanline modifications

Game Boy Advance:

  • Affine Transformation: Mode 7-like scaling and rotation
  • Mosaic Effect: Hardware pixelation for special effects
  • Alpha Blending: Multi-layer transparency
  • Object Priority: Hardware Z-ordering for sprites and backgrounds

Nintendo DS:

  • 3D Rendering: Hardware-accelerated 3D graphics engine
  • Extended Affine Transformations: Advanced 2D rotation and scaling
  • Fog Effects: Depth-based atmospheric effects
  • Multiple Background Layers: Up to 4 background layers with independent scrolling

Nintendo 3DS:

  • Stereoscopic 3D: Glasses-free autostereoscopic 3D display
  • Advanced Shader Support: Programmable vertex and fragment shaders
  • GPU-Accelerated Rendering: PICA200 graphics processor

Wii:

  • GPU Effects: Programmable shaders, bloom, motion blur
  • Texture Mapping: Advanced texture filtering and mipmapping
  • Bump Mapping: Per-pixel lighting simulation
  • Hardware Anti-Aliasing: Multi-sample anti-aliasing (MSAA)

PSP:

  • Hardware Transform & Lighting (T&L): Vertex processing on GPU
  • Texture Compression: Efficient VRAM usage
  • Advanced Alpha Blending: Complex transparency effects

Switch:

  • Advanced Shaders: Physically-Based Rendering (PBR)
  • Hardware-Accelerated Global Illumination: Realistic lighting
  • HDR (High Dynamic Range): Expanded color and brightness range
  • Post-Processing Effects: Depth of field, screen-space ambient occlusion (SSAO), temporal anti-aliasing

Key Observations:

SNES Mode 7 gave 2D hardware pseudo-3D effects. The move from fixed-function 2D hardware to 3D GPUs came around the Nintendo DS/PSP generation, and the Switch supports physically-based rendering and post-processing comparable to a gaming PC.

Conclusion

From the Game Boy's 4.19 MHz processor and 8 KB of RAM to the Switch's 1+ GHz quad-core CPU and 4 GB of RAM, each generation gained roughly an order of magnitude. For homebrew developers, the per-system limits (sprite counts, scanline restrictions, memory budgets) decide what is feasible and shape how the code is written, from a Game Boy game with 40 sprites and 4 shades to a Switch title with advanced shaders.

posted at 10:00  · 7 min read  ·   ·  hardware  retro  console  comparison  technical  article

Aug 06, 2024

Secure Boot with AHAB on i.MX93

Secure Boot ensures that only authenticated and authorized code runs on a device, from the first instruction. It works as a chain of trust: each component verifies the integrity of the next. This article covers the first link on the NXP i.MX93, using AHAB, NXP's mechanism for authenticating the initial boot stages.

Several mechanisms must be used to authenticate each element of this chain, but the mechanism for authenticating the first boot stages depends on the target SoC. The i.MX93 series uses NXP's Advanced High Assurance Boot (AHAB) to secure the first boot stages.

For subsequent stages, you can implement mechanisms such as:

  • Using U-Boot's "verified boot" feature to sign the kernel,
  • Using the default environment (cf. USE_DEFAULT_ENV_FILE), and restricting write access to only a few environment variables (cf. ENV_WRITEABLE_LIST), which are necessary for writable access, such as for OTA updates,
  • Using DM-verity to authenticate the root filesystem,
  • And finally, using OverlayFS combined with DM-crypt to mount encrypted, writable subfolders.

This article covers authenticating the bootloader with AHAB on the i.MX93 in single-boot mode, generating the signing keys, and a short introduction to AHAB.

Note: AHAB also provides an encryption feature to protect the confidentiality and integrity of data, whereas secure boot verifies the integrity and authenticity of the boot process. Encryption is not covered here.

AHAB Architecture

The AHAB authentication mechanism is based on public key cryptography using asymmetric keys.

On the i.MX93, AHAB support is provided by a security co-processor, the EdgeLock enclave (ELE), which handles the authentication of binaries signed with one or more private keys. This co-processor contains fuses that must be burned with the hash of the public keys.

AHAB Containers

Since multiple boot stages (e.g., TF-A, OP-TEE, U-Boot, etc.) and firmwares are required to boot i.MX93 platforms, these binaries are packed into containers using the imx-mkimage tool:

bl31.bin
lpddr4_dmem_1d_v202201.bin
lpddr4_dmem_2d_v202201.bin
lpddr4_imem_1d_v202201.bin
lpddr4_imem_2d_v202201.bin
mx93a1-ahab-container.img
tee.bin
u-boot.bin
u-boot-spl.bin

In i.MX93 single-boot mode, the bootloader image contains at least three containers:

  • mx93a1-ahab-container.img: Contains the ELE Firmware.
  • u-boot-atf-container.img: Contains at least the SPL.
  • flash.bin: Contains TF-A, OP-TEE, and U-Boot.
        *start ----> +---------------------------+ ---------
                     |   1st Container header    |   ^
                     |       and signature       |   |
                     +---------------------------+   |
                     | Padding for 1kB alignment |   |
*start + 0x400 ----> +---------------------------+   |
                     |   2nd Container header    |   |
                     |       and signature       |   |
                     +---------------------------+   |
                     |          Padding          |   |  Authenticated at
                     +---------------------------+   |  ELE ROM/FW Level
                     |           ELE FW          |   |
                     +---------------------------+   |
                     |          Padding          |   |
                     +---------------------------+   |
                     |       Cortex-M Image      |   |
                     +---------------------------+   |
                     |         SPL Image         |   v
                     +---------------------------+ ---------
                     |   3rd Container header    |   ^
                     |       and signature       |   |
                     +---------------------------+   |
                     |          Padding          |   | Authenticated
                     +---------------------------+   | at SPL Level
                     |            TF-A           |   |
                     +---------------------------+   |
                     |           OP-TEE          |   |
                     +---------------------------+   |
                     |           U-Boot          |   v
                     +---------------------------+ ---------

These containers are signed offline using NXP Code-Signing Tools (CST), which also allow the creation of an OEM private key infrastructure (PKI) and the generation of the associated public keys (SRK) table, which is burned into the fuses. The CST can also be used with the PKCS#11 standard to access cryptographic services from tokens or devices such as HSM, TPM, and smart cards.

The first container is signed with NXP keys and is authenticated by the ELE ROM, while the other containers are signed with OEM keys.

AHAB Boot Flow

In single boot mode, the Cortex-A55 ROM reads data from the selected boot device, loading all containers in the chosen boot image set one by one. All images within each container (e.g., EdgeLock secure enclave firmware, Cortex-M33 firmware, A55 firmware, OP-TEE, and U-Boot) are loaded, and the EdgeLock secure enclave (ELE) is tasked with authenticating them. The ELE firmware is authenticated by the ELE ROM, and images in the second container are verified by the ELE firmware.

If the bootloader image contains more than two containers, the third and subsequent containers are authenticated by the SPL instead of the ELE.

PKI Generation

To authenticate the bootloader, we need to generate keys. These keys can be created with the CST. The private key will be used to sign the bootloader, and the public key will be burned into the i.MX93 fuses to authenticate the bootloader during boot.

Follow these steps to generate the keys:

cd cst-3.4.1/keys
echo 00000001 > serial

Write the passphrase for the certificate (replace "fooahabcert" with your choice) in two lines, separated by \n. It is important to store this passphrase securely with backups:

echo -e "fooahabcert\nfooahabcert" > key_pass.txt

Generate a P384 ECC PKI tree with a subordinate SGK key on CST:

./ahab_pki_tree.sh
[...]
Do you want to use an existing CA key (y/n)?: n

Key type options (confirm targeted device supports desired key type):
Select the key type (possible values: rsa, rsa-pss, ecc)?: ecc
Enter length for elliptic curve to be used for PKI tree:
Possible values p256, p384, p521:  p384
Enter the digest algorithm to use: sha384
Enter PKI tree duration (years): 10
Do you want the SRK certificates to have the CA flag set? (y/n)?: n

Generate the Signing Root Keys (SRK) Table and SRK Hash for 64-bit Linux machines:

cd ../crts/
../linux64/bin/srktool -a -d sha256 -s sha384 -t SRK_1_2_3_4_table.bin \
    -e SRK_1_2_3_4_fuse.bin -f 1 -c \
    SRK1_sha384_secp384r1_v3_usr_crt.pem,\
    SRK2_sha384_secp384r1_v3_usr_crt.pem,\
    SRK3_sha384_secp384r1_v3_usr_crt.pem,\
    SRK4_sha384_secp384r1_v3_usr_crt.pem

Do not enter spaces between the commas when specifying the SRKs in the "-c" or "--certs" option. Otherwise, the certificates specified after the first space will be excluded from the table.

Regenerate the SRK HASH (SRK_1_2_3_4_fuse.bin) using SHA256 with the SRK_1_2_3_4_table.bin:

openssl dgst -binary -sha256 SRK_1_2_3_4_table.bin

Optionally, verify that the sha256sum of SRK_1_2_3_4_table matches the SRK_1_2_3_4_fuse.bin:

od -t x4 SRK_1_2_3_4_fuse.bin
0000000 29eec727 eaed9aa7 c7e53bc0 36835f78
0000020 6901bc47 b244753c f78d3162 27ae36b9
0000040

Bootloader Signature

The CST uses CSF description files to sign (and encrypt) containers generated by imx-mkimage with OEM keys. When imx-mkimage generates containers, it also specifies the block offsets to be used in the CSF description files. For example, imx-mkimage returns the following values for your bootloader:

CST: CONTAINER 0 offset: 0x0
CST: CONTAINER 0: Signature Block: offset is at 0x190
CST: CONTAINER 0 offset: 0x400
CST: CONTAINER 0: Signature Block: offset is at 0x490

Where 0x190 is the block offset for the second container header and 0x490 is the block offset for the third container header.

The CSF description file used to sign a container contains three sections:

  • [Header]: Information about the HAB version to use for signing.
  • [Authenticate Data]: Information about the key used to sign.
  • [Install SRK]: Information about the container being signed.

The following CSF description files were used to sign the u-boot-atf-container.img in our example:

[Header]
Target = AHAB
Version = 1.0

[Install SRK]
# SRK table generated by srktool
File = "SRK_1_2_3_4_table.bin"
# Public key certificate in PEM format
Source = "SRK1_sha384_secp384r1_v3_usr_crt.pem"
# Index of the public key certificate within the SRK table (0 .. 3)
Source index = 0
# Type of SRK set (NXP or OEM)
Source set = OEM
# bitmask of the revoked SRKs
Revocations = 0x0

[Authenticate Data]
# Binary to be signed generated by mkimage
File = "u-boot-atf-container.img"
# Offsets = Container header  Signature block (printed out by mkimage)
Offsets = 0x0 0x190

The following CSF description files were used to sign flash.bin in our example:

[Header]
Target = AHAB
Version = 1.0

[Install SRK]
# SRK table generated by srktool
File = "SRK_1_2_3_4_table.bin"
# Public key certificate in PEM format
Source = "SRK1_sha384_secp384r1_v3_usr_crt.pem"
# Index of the public key certificate within the SRK table (0 .. 3)
Source index = 0
# Type of SRK set (NXP or OEM)
Source set = OEM
# bitmask of the revoked SRKs
Revocations = 0x0

[Authenticate Data]
# Binary to be signed generated by mkimage
File = "flash.bin"
# Offsets = Container header  Signature block (printed out by mkimage)
Offsets = 0x400 0x490

The first step is to generate a u-boot-atf-container.img, then copy the block offsets into the CSF description file to sign it:

make SOC=iMX9 REV=A1 dtbs=imx93-11x11-evk.dtb u-boot-atf-container.img

Next, sign it with the following command and replace the unsigned version:

cst -i u-boot-atf-container.img.csf -o u-boot-atf-container.img.signed
mv u-boot-atf-container.img.signed u-boot-atf-container.img

Then generate a flash.bin containing the signed u-boot-atf-container.img:

make SOC=iMX9 REV=A1 V2X=NO dtbs=imx93-11x11-evk.dtb flash_singleboot

Finally, sign the resulting flash.bin:

cst -i flash.bin.csf -o flash.bin.signed

Burn Fuses

Once the signed flash.bin is flashed, you need to burn the public keys used to sign the bootloader into the i.MX93 fuses to finalize AHAB secure boot. This requires using a U-Boot that provides AHAB functionalities, such as checking ELE events during bootloader authentication and securing the device.

Program SRK

The following commands enable AHAB secure boot by programming the SRK_HASH[255:0] fuses on i.MX93, ensuring that only bootloaders signed with keys matching the SRK hash programmed into the fuses will be accepted:

fuse prog -y 16 0 0x29eec727
fuse prog -y 16 1 0xeaed9aa7
fuse prog -y 16 2 0xc7e53bc0
fuse prog -y 16 3 0x36835f78
fuse prog -y 16 4 0x6901bc47
fuse prog -y 16 5 0xb244753c
fuse prog -y 16 6 0xf78d3162
fuse prog -y 16 7 0x27ae36b9

Close the Device

Once the SRK fuses are programmed, you can "close" the device to allow only the bootloader signed with keys matching the SRK table to boot:

ahab_close

Before closing the device, you can verify that the fuses have been written correctly by checking that no ELE events are raised:

ahab_status
Lifecycle: 0x00000008, OEM Open

No Events Found!
=>
Lifecycle: 0x00000008, OEM Open

No Events Found!

Once the device is closed, the ahab_status command will show OEM closed:

ahab_status
Lifecycle: 0x00000020, OEM closed

No Events Found!
=>
Lifecycle: 0x00000020, OEM closed
No Events Found!

As long as OEM Open appears in the status, the device is not secured and can still execute unsigned bootloaders or those signed with invalid keys.

Conclusion

With the SRK hash fused and the device closed, the i.MX93 boots only bootloader containers signed with the OEM keys. Until then, an OEM Open device still runs unsigned images.

posted at 19:21  · 8 min read  ·   ·  security  embedded  imx93  secure-boot  ahab  nxp  article

Jul 29, 2022

Zephyr Device Tree Guide

Introduction

The Zephyr project, hosted by the Linux Foundation since 2016, provides a real-time operating system (RTOS), under the Apache 2.0 license, for connected devices too small for Linux or acting as companions to a Linux core. It targets microcontrollers and IoT devices, is modular, and is written in C (C11, with C++17 supported).

It can be configured to run on devices with as little as 10 KB of memory. It supports 32-bit and 64-bit architectures (Cortex-A, Cortex-M, Cortex-R, RISC-V, x86-64, etc.), many boards (Feather, nRF52840, ST Discovery, ST Nucleo, ESP32, etc.), connectivity such as Bluetooth, Ethernet, Wi-Fi and LoRa, and network protocols including IPv4, IPv6, UDP, TCP, CoAP, LWM2M, MQTT and DNS.

Like Linux, Zephyr uses Kconfig, and its device model is mainly based on device tree.

Device tree

Device trees are tree data structures that describe the hardware components and their relationships in a system. They are stored in text files, the device tree sources (*.dts), which developers write to describe the hardware of SoCs and boards. The operating system uses them to determine how to initialize and interact with the hardware.

Each node describes a device of the system, has properties that describe its characteristics, and has exactly one parent (except the root node).

Each device driver is associated with a specific device tree node, which represents a hardware component in the system. The device driver provides the necessary code and data to control the behavior of the hardware component.

test_i2c_bme280: bme280@6 {
        compatible = "bosch,bme280";
        reg = <0x6>;
};

In the Linux kernel, device tree sources are compiled to device tree binaries (dtb) that the bootloader stages (U-Boot, TF-A...) and the kernel parse at boot, so the same binaries can support several hardware configurations.

In Zephyr, device tree sources are instead turned at build time into a devicetree_generated.h C header. It contains the macros and data structures that let drivers read the hardware description, such as a device's memory mapping, pin assignments and IRQ numbers:

#define DT_COMPAT_HAS_OKAY_bosch_bme280 1
#define DT_N_INST_bosch_bme280_NUM_OKAY 1
#define DT_FOREACH_OKAY_bosch_bme280(fn) fn(DT_N_S_soc_S_i2c_40005400_S_bme280_77)
#define DT_FOREACH_OKAY_VARGS_bosch_bme280(fn, ...) fn(DT_N_S_soc_S_i2c_40005400_S_bme280_77, __VA_ARGS__)
#define DT_FOREACH_OKAY_INST_bosch_bme280(fn) fn(0)
#define DT_FOREACH_OKAY_INST_VARGS_bosch_bme280(fn, ...) fn(0, __VA_ARGS__)
#define DT_COMPAT_bosch_bme280_BUS_i2c 1

Where:

  • DT_COMPAT_HAS_OKAY_bosch_bme280: indicates that there is at least one instance of BME280
  • DT_N_INST_bosch_bme280_NUM_OKAY: defines the number of BME280 instances that are marked okay
  • DT_FOREACH_OKAY_bosch_bme280: allows you to apply a function fn to each instance of the BME280
  • DT_FOREACH_OKAY_VARGS_bosch_bme280: also allows you to apply a function fn to each instance of the BME280, but with additional arguments
  • DT_FOREACH_OKAY_INST_bosch_bme280: allows you to apply a function fn to each instance of the BME280, passing the instance number as an argument
  • DT_FOREACH_OKAY_INST_VARGS_bosch_bme280: is similar to the previous macro, but this one allows for additional arguments
  • DT_COMPAT_bosch_bme280_BUS_i2c: indicates that the BME280 device is connected to an I2C bus.
  • DT_N_S_soc_S_i2c_40005400_S_bme280_77: refers to a specific node in the device tree, here it refers to the BME280 sensor connected to the I2C controller with the base address 0x40005400 within the SoC. The sensor's address on this I2C bus is 0x77.

In addition, device tree sources can be extended or overridden, for example to connect additional devices to a board, or to disable board devices which will not be used:

/ {
        aliases {
                bme280 = &bme280;
        };
};

&spi1 {
        status = "disabled";
};

&i2c1 {
        status = "okay";
        bme280: bme280@77 {
                compatible = "bosch,bme280";
                reg = <0x77>;
        };
};

Binding

The content of device tree sources is described in binding files, written in YAML. They are also used to validate device tree sources against the YAML description.

description: BME280 integrated environmental sensor

compatible: "bosch,bme280"

include: [sensor-device.yaml, i2c-device.yaml]

Device driver

A Zephyr driver reads the properties of its device tree node through macros defined in C headers. For example, this code initializes a BME280 sensor using properties from the device tree:

#include <device.h>
#include <drivers/i2c.h>
#include <devicetree.h>
#include <zephyr.h>

// Define the node identifier for the BME280 sensor
#define BME280_NODE DT_N_S_soc_S_i2c_40005400_S_bme280_77

// Function to initialize the BME280 sensor
static int bme280_init(const struct device *dev)
{
    // Check if the node is available
    if (!device_is_ready(dev)) {
        printk("Device %s is not ready\n", dev->name);
        return -ENODEV;
    }

    // Retrieve the I2C device associated with the BME280 node
    const struct device *i2c_dev = DEVICE_DT_GET(DT_BUS(BME280_NODE));

    if (!device_is_ready(i2c_dev)) {
        printk("I2C device not ready\n");
        return -ENODEV;
    }

    // Write some initialization code here, such as configuring registers

    printk("BME280 sensor initialized\n");
    return 0;
}

// Initialize the BME280 sensor at boot time
SYS_INIT(bme280_init, APPLICATION, CONFIG_APPLICATION_INIT_PRIORITY);

Conclusion

Anyone who has written a BSP or driver for Linux will find this familiar. It is a steeper step for people coming from bare microcontroller development.

posted at 20:32  · 4 min read  ·   ·  zephyr  device-tree  rtos  embedded  article

Sep 27, 2020

Build RIOT-OS with Podman

Summary

This tip shows how to build a RIOT-OS application with Podman and the official build container, with a short introduction to both.

Podman

Some Linux distributions, like Fedora, officially support only Podman instead of Docker, for several reasons:

  • It is daemonless container engine.
  • It is rootless.
  • It follows Open Container Initiative (OCI) standards.
  • It is safer than the Docker engine.
  • It introduces the notion of Pods: a group of container(s) that share storage or network resources.

Podman can also use images built by the Docker engine and stored in a Docker registry.

Most Podman commands are identical to Docker's, so alias docker=podman is often enough. But because Podman is rootless, additional security parameters are sometimes needed.

RIOT-OS

RIOT-OS is a memory-constrained RTOS, such as Contiki, that provides real-time and multithreading abilities, and it runs on processors from 8bits to 32bits.

It was designed for IoT devices with low power consumption in mind, and provides three network stacks supporting protocols such as:

The RIOT-OS project also provides some useful tools including a build container (riotdocker).

The RIOT-OS Makefile builds an application in this container when the variable BUILD_IN_DOCKER is set to 1: the prebuilt image is downloaded and instantiated to run the make command.

By default it uses the Docker engine, but variables in the build environment can be overridden to use a custom prebuilt image, another engine or custom engine parameters. Here they select Podman with the required parameters.

Example

In the following example, we build the Helloworld application for a STM32 Discovery board. To do that we specify the engine by setting the variable DOCKER to the value podman. The variable DOCKER_USER is set empty because in the variable DOCKER_RUN_FLAGS the parameter --userns is set to keep-id to map the uid:gid of the current rootless user (from host) with the values that will be used into the container.

export BUILD_IN_DOCKER=1
export DOCKER="podman"
export DOCKER_USER=""
export DOCKER_RUN_FLAGS="--rm -i -t --security-opt seccomp=unconfined --security-opt label=disable --userns=keep-id"
export DOCKER_MAKE_ARGS="-j$(nproc)"

make BOARD=stm32l476g-disco
Launching build container using image "riot/riotbuild:latest".
podman run --rm -i -t --security-opt seccomp=unconfined --security-opt label=disable --userns=keep-id -v '/usr/share/zoneinfo/Europe/Paris:/etc/localtime:ro' -v '/home/tperrot/dev/tprrt/pwm-ramp-gen/RIOT:/data/riotbuild/riotbase:delegated' -e 'RIOTBASE=/data/riotbuild/riotbase' -e 'CCACHE_BASEDIR=/data/riotbuild/riotbase' -e 'BUILD_DIR=/data/riotbuild/riotbase/build' -v '/home/tperrot/dev/tprrt/pwm-ramp-gen:/data/riotbuild/riotproject:delegated' -e 'RIOTPROJECT=/data/riotbuild/riotproject' -e 'RIOTCPU=/data/riotbuild/riotbase/cpu' -e 'RIOTBOARD=/data/riotbuild/riotbase/boards' -e 'RIOTMAKE=/data/riotbuild/riotbase/makefiles'     -v '/home/tperrot/dev/tprrt/pwm-ramp-gen/.git:/home/tperrot/dev/tprrt/pwm-ramp-gen/.git:delegated' -e 'BOARD=stm32l476g-disco'  -w '/data/riotbuild/riotproject/' 'riot/riotbuild:latest' make 'BOARD=stm32l476g-disco'   -j8
Building application "hello-world" for "stm32l476g-disco" with MCU "stm32".

[INFO] cloning stm32cmsis
fatal: not a git repository: /data/riotbuild/riotbase/../.git/modules/RIOT
Cloning into '/data/riotbuild/riotbase/cpu/stm32/include/vendor/cmsis/l4'...
remote: Enumerating objects: 364, done.
remote: Counting objects: 100% (364/364), done.
remote: Compressing objects: 100% (71/71), done.
remote: Total 364 (delta 309), reused 344 (delta 289), pack-reused 0
Receiving objects: 100% (364/364), 709.56 KiB | 561.00 KiB/s, done.
Resolving deltas: 100% (309/309), done.
HEAD is now at e442c72 Release v1.6.1
[INFO] updating stm32cmsis /data/riotbuild/riotbase/cpu/stm32/include/vendor/cmsis/l4/.pkg-state.git-downloaded
echo e442c72651e8d4757f6562acc14da949644944ce   > /data/riotbuild/riotbase/cpu/stm32/include/vendor/cmsis/l4/.pkg-state.git-downloaded
[INFO] patch stm32cmsis
"make" -C /data/riotbuild/riotbase/boards/stm32l476g-disco
"make" -C /data/riotbuild/riotbase/core
"make" -C /data/riotbuild/riotbase/cpu/stm32
"make" -C /data/riotbuild/riotbase/drivers
"make" -C /data/riotbuild/riotbase/sys
"make" -C /data/riotbuild/riotbase/cpu/cortexm_common
"make" -C /data/riotbuild/riotbase/cpu/stm32/periph
"make" -C /data/riotbuild/riotbase/drivers/periph_common
"make" -C /data/riotbuild/riotbase/cpu/stm32/stmclk
"make" -C /data/riotbuild/riotbase/sys/auto_init
"make" -C /data/riotbuild/riotbase/cpu/cortexm_common/periph
"make" -C /data/riotbuild/riotbase/cpu/stm32/vectors
"make" -C /data/riotbuild/riotbase/sys/malloc_thread_safe
"make" -C /data/riotbuild/riotbase/sys/newlib_syscalls_default
"make" -C /data/riotbuild/riotbase/sys/pm_layered
"make" -C /data/riotbuild/riotbase/sys/stdio_uart
   text    data     bss     dec     hex filename
   8900     112    2300   11312    2c30 /data/riotbuild/riotproject/bin/stm32l476g-disco/hello-world.elf
posted at 13:01  · 4 min read  ·   ·  container  podman  riot-os  article

Sep 08, 2020

How the Busybox's chrt applet works

Introduction

This article dissects the chrt applet from Busybox 1.32.0. The command reads or modifies the scheduling attributes of a process.

chrt -m
SCHED_OTHER min/max priority    : 0/0
SCHED_FIFO min/max priority     : 1/99
SCHED_RR min/max priority       : 1/99
SCHED_BATCH min/max priority    : 0/0
SCHED_IDLE min/max priority     : 0/0
SCHED_DEADLINE min/max priority : 0/0

pidof firefox
6987 6851 6825 6816 6800 6771 6767 6761 6720 6611

chrt -p 6987
pid 6987's current scheduling policy: SCHED_OTHER
pid 6987's current scheduling priority: 0

sudo chrt -f -p 1 6987
chrt -p 6987
pid 6987's current scheduling policy: SCHED_FIFO
pid 6987's current scheduling priority: 1

The Busybox applet is ten times smaller once compiled than the util-linux binary, with some limitations.

The dissection

The applet is implemented in util-linux/chrt.c, whose main function calls several helpers and has three parts:

  • the first parses the command options
  • the second prints the scheduler's information
  • the last one, to apply scheduler changes in case of a set

At start of main, the character string containing the options are parsed to obtain a bitfield easier to use:

opt = getopt32(argv, "^"
                "+" "mprfobi"
                "\0"
                /* only one policy accepted: */
                "r--fobi:f--robi:o--rfbi:b--rfoi:i--rfob"
);

If the (-m) is set then the min and max valid priorities for each scheduling policies are shown and the command exits:

if (opt & OPT_m) { /* print min/max and exit */
        show_min_max(SCHED_OTHER);
        show_min_max(SCHED_FIFO);
        show_min_max(SCHED_RR);
        show_min_max(SCHED_BATCH);
        show_min_max(SCHED_IDLE);
        fflush_stdout_and_exit(EXIT_SUCCESS);
}

The function show_min_max uses the Posix functions sched_get_priority_max and sched_get_priority_min from the standard C library to send a syscall to the kernel in order to obtain the min and max values accepted by each policy:

max = sched_get_priority_max(pol);
min = sched_get_priority_min(pol);
if ((max|min) < 0)
    fmt = "SCHED_%s not supported\n";

Otherwise the required options and arguments to show or to apply real-time attributes of a process:

//if (opt & OPT_r)
//  policy = SCHED_RR; - default, already set
if (opt & OPT_f)
    policy = SCHED_FIFO;
if (opt & OPT_o)
    policy = SCHED_OTHER;
if (opt & OPT_b)
    policy = SCHED_BATCH;
if (opt & OPT_i)
    policy = SCHED_IDLE;

argv += optind;
if (!argv[0])
    bb_show_usage();
if (opt & OPT_p) {
    pid_str = *argv++;
    if (*argv) { /* "-p PRIO PID [...]" */
            priority = pid_str;
            pid_str = *argv;
    }
    /* else "-p PID", and *argv == NULL */
    pid = xatoul_range(pid_str, 1, ((unsigned)(pid_t)ULONG_MAX) >> 1);
} else {
    priority = *argv++;
    if (!*argv)
            bb_show_usage();
}

Then the applet uses the Posix function sched_getscheduler provides by the standard C library to obtain the scheduling attributes of the process specified by the pid.

print_rt_info:
    pol = sched_getscheduler(pid);
    if (pol < 0)
            bb_perror_msg_and_die("can't %cet pid %u's policy", 'g', (int)pid);

Finally, when chrt modifies scheduling attributes, the Posix function sched_setscheduler applies them and the new attributes are shown:

if (sched_setscheduler(pid, policy, &sp) < 0)
    bb_perror_msg_and_die("can't %cet pid %u's policy", 's', (int)pid);

if (!argv[0]) /* "-p PRIO PID [...]" */
    goto print_rt_info;

The function sched_setscheduler and sched_getscheduler will send a syscall to the scheduler subsystem of the kernel Linux. This subsystem also exposes this information from /proc:

cat /proc/6987/sched
WebExtensions (6987, #threads: 23)
-------------------------------------------------------------------
se.exec_start                                :       4421312.640001
se.vruntime                                  :        344438.942254
se.sum_exec_runtime                          :         38238.466094
se.nr_migrations                             :                 6811
nr_switches                                  :                49452
nr_voluntary_switches                        :                21749
nr_involuntary_switches                      :                27703
se.load.weight                               :              1048576
se.runnable_weight                           :              1048576
se.avg.load_sum                              :                 3415
se.avg.runnable_load_sum                     :                 3415
se.avg.util_sum                              :              3497621
se.avg.load_avg                              :                   74
se.avg.runnable_load_avg                     :                   74
se.avg.util_avg                              :                   74
se.avg.last_update_time                      :        4421312640000
se.avg.util_est.ewma                         :                   75
se.avg.util_est.enqueued                     :                   75
policy                                       :                    0
prio                                         :                  120
clock-delta                                  :                   89
mm->numa_scan_seq                            :                    0
numa_pages_migrated                          :                    0
numa_preferred_nid                           :                   -1
total_numa_faults                            :                    0
current_node=0, numa_group_id=0
numa_faults node=0 task_private=0 task_shared=0 group_private=0 group_shared=0

Limitations

Limitations I observed in this applet:

Resetting scheduling policy

The chrt applet doesn't offer an option (-R) to specify if the scheduling policy should be applied or reset when a process forks to create children. This feature, introduced since Linux 2.6.32, can be only enabled or disabled at the build of busybox and it is applied on all scheduling attributes modifications done with this applet.

Deadline support

The chrt applet doesn't provide the required scheduling options (-d, -T, -P and -D) to set the deadline scheduling attributes of a process.

posted at 19:20  · 4 min read  ·   ·  busybox  chrt  dissection  beginner  article

Jun 27, 2020

Build an embedded Linux in less than 15 minutes

Introduction

I had not built an embedded Linux without a framework like Open Embedded from the Yocto project for years, so this guide builds a minimal one from scratch to boot a target. The examples boot a virtual Qemu target and can be adapted to a real one. The build environment is bootstrapped with a prebuilt glibc cross-toolchain from Bootlin.

Setup the environment

First, install the packages needed to use the cross-toolchain, compile the host tools and run Qemu:

  • The Ncurses libraries are only required to execute the command make menuconfig.
  • The certificates and wget will be used to download the prebuilt toolchain.
  • In the same way, git will be used to checkout the source of Busybox and Linux.
  • The Qemu packages emulate the target system and run static aarch64 binaries on the x86-64 host.
apt update
apt install -y --no-install-recommends \
    bc \
    build-essential \
    ca-certificates \
    cpio \
    file \
    flex \
    git \
    ipxe-qemu \
    libncurses5-dev \
    libncursesw5-dev \
    libssl-dev \
    qemu \
    qemu-system-aarch64 \
    qemu-user-static \
    wget

Download and extract the prebuilt toolchain:

mkdir ~/src
cd ~/src
wget https://toolchains.bootlin.com/downloads/releases/toolchains/aarch64/tarballs/aarch64--glibc--stable-2020.08-1.tar.bz2
tar xvjf aarch64--glibc--stable-2020.08-1.tar.bz2

Then set the environment variables needed to cross-compile:

  • PATH: extended so the cross-tools are available
  • CROSS_COMPILE: the prefix used by the cross-tools
  • ARCH: the architecture of the target platform
ls ~/src/aarch64--glibc--stable-2020.08-1/bin/*gcc
~/src/aarch64--glibc--stable-2020.08-1/bin/aarch64-linux-gcc

export PATH=~/src/aarch64--glibc--stable-2020.08-1/bin:$PATH
export CROSS_COMPILE=aarch64-linux-

The cross-tools can now be called from the shell:

aarch64-linux-gcc -v
Using built-in specs.
COLLECT_GCC=~/src/aarch64--glibc--stable-2020.08-1/bin/aarch64-linux-gcc.br_real
COLLECT_LTO_WRAPPER=~/src/aarch64--glibc--stable-2020.08-1/bin/../libexec/gcc/aarch64-buildroot-linux-gnu/9.3.0/lto-wrapper
Target: aarch64-buildroot-linux-gnu
<...>
Thread model: posix
gcc version 9.3.0 (Buildroot 2020.08-14-ge5a2a90)

Build the Linux kernel

Fetch and build the latest stable branch of the Linux kernel:

git clone git://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git
cd linux
git checkout -b local/linux-5.4.y origin/linux-5.4.y
# git show HEAD

export ARCH=arm64

make defconfig
  HOSTCC  scripts/basic/fixdep
  HOSTCC  scripts/kconfig/conf.o
  HOSTCC  scripts/kconfig/confdata.o
  HOSTCC  scripts/kconfig/expr.o
  LEX     scripts/kconfig/lexer.lex.c
  YACC    scripts/kconfig/parser.tab.[ch]
  HOSTCC  scripts/kconfig/lexer.lex.o
  HOSTCC  scripts/kconfig/parser.tab.o
  HOSTCC  scripts/kconfig/preprocess.o
  HOSTCC  scripts/kconfig/symbol.o
  HOSTLD  scripts/kconfig/conf
*** Default configuration is based on 'defconfig'
#
# configuration written to .config
#

# make menuconfig

make -j$(nproc)
  <...>
  AR      drivers/net/ethernet/built-in.a
  AR      drivers/net/built-in.a
  AR      drivers/built-in.a
  GEN     .version
  CHK     include/generated/compile.h
  LD      vmlinux.o
  MODPOST vmlinux.o
  MODINFO modules.builtin.modinfo
  LD      .tmp_vmlinux.kallsyms1
  KSYM    .tmp_vmlinux.kallsyms1.o
  LD      .tmp_vmlinux.kallsyms2
  KSYM    .tmp_vmlinux.kallsyms2.o
  LD      vmlinux
  SORTEX  vmlinux
  SYSMAP  System.map
  Building modules, stage 2.
  MODPOST 531 modules
  OBJCOPY arch/arm64/boot/Image
  GZIP    arch/arm64/boot/Image.gz

make defconfig applies the default configuration for the target platform (ARCH=arm64) and make -j$(nproc) compiles.

git show HEAD and make menuconfig are optional:

  • git show HEAD checks that the head is the latest tag of the linux-5.4.y branch.
  • make menuconfig customizes the kernel configuration.

NB. The Linux kernel, Busybox and other projects use Kbuild to manage build options.

Populate the sysroot

The easiest way to bootstrap a sysroot is Busybox, a size-optimized single executable providing common UNIX tools. A few configuration files complete the sysroot. Pulling and building Busybox works like the kernel.

git clone git://git.busybox.net/busybox
cd busybox
git checkout -b local/1_32_stable origin/1_32_stable
# git show HEAD

export ARCH=aarch64
export LDFLAGS="--static"

make defconfig
# make menuconfig
make -j$(nproc)

make install

LDFLAGS forces static linking of Busybox; make menuconfig with CONFIG_STATIC=y does the same. A static executable can be tested with Qemu:

qemu-aarch64-static busybox echo "Hello!"
Hello!
qemu-aarch64-static busybox date
Sat Jun 27 15:06:41 UTC 2020

qemu-aarch64-static runs a binary built for another architecture on the host, here the aarch64 Busybox on an x86-64 host.

make install created a tree in the _install directory, used to populate the sysroot:

ls -l _install
total 4
drwxr-xr-x. 1 tperrot tperrot 974 Nov 30 15:22 bin
lrwxrwxrwx. 1 tperrot tperrot  11 Nov 30 15:22 linuxrc -> bin/busybox
drwxr-xr-x. 1 tperrot tperrot 986 Nov 30 15:22 sbin
drwxr-xr-x. 1 tperrot tperrot  14 Nov 30 15:22 usr

ls -l _install/bin
<...>
lrwxrwxrwx. 1 tperrot tperrot       7 Nov 30 15:22 umount -> busybox
lrwxrwxrwx. 1 tperrot tperrot       7 Nov 30 15:22 uname -> busybox
lrwxrwxrwx. 1 tperrot tperrot       7 Nov 30 15:22 usleep -> busybox
lrwxrwxrwx. 1 tperrot tperrot       7 Nov 30 15:22 vi -> busybox
lrwxrwxrwx. 1 tperrot tperrot       7 Nov 30 15:22 watch -> busybox
lrwxrwxrwx. 1 tperrot tperrot       7 Nov 30 15:22 zcat -> busybox

To finish the sysroot, create an rcS init script:

mkdir _install/proc _install/sys _install/dev _install/etc _install/etc/init.d
cat > _install/etc/init.d/rcS << EOF
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
/sbin/mdev -s
[ ! -h /etc/mtab ]  && ln -s /proc/mounts /etc/mtab
[ ! -f /etc/resolv.conf ] && cat /proc/net/pnp > /etc/resolv.conf
EOF
chmod +x _install/etc/init.d/rcS

Build the filesystem

This step packages the sysroot tree into a filesystem the kernel can mount, either a ramfs or a rootfs:

  • the ramfs is a simple filesystem the kernel creates in RAM from an archive.
  • the rootfs is a filesystem the kernel mounts from a non-volatile device.

See the kernel documentation for the difference between them.

Build a ramfs

Build the ramfs with cpio and gzip after fixing the ownership:

mkdir _rootfs
rsync -a _install/ _rootfs
chown -R root:root _rootfs
cd _rootfs
find . | cpio -o --format=newc > ../rootfs.cpio
cd ..
gzip -c rootfs.cpio > rootfs.cpio.gz

Build a rootfs

For the rootfs, create an empty image, format it as ext3 through a loop device, then copy the tree and fix the ownership.

dd if=/dev/zero of=rootfs.img bs=1M count=10
mke2fs -j rootfs.img
mkdir _rootfs
mount -o loop rootfs.img _rootfs
rsync -a _install/ _rootfs
chown -R root:root _rootfs
sync
umount _rootfs

Boot the target

The qemu commands to boot the system:

# With the ramfs
qemu-system-aarch64 -nographic -no-reboot -machine virt -cpu cortex-a57 -smp 2 -m 256 \
    -kernel ~/src/linux/arch/arm64/boot/Image \
    -initrd ~/src/busybox/rootfs.cpio.gz \
    -append "panic=5 ro ip=dhcp root=/dev/ram rdinit=/sbin/init"

# With the rootfs
qemu-system-aarch64 -nographic -no-reboot -machine virt -cpu cortex-a57 -smp 2 -m 256 \
    -kernel ~/src/linux/arch/arm64/boot/Image \
    -append "panic=5 ro ip=dhcp root=/dev/vda" \
    -drive file=~/src/busybox/rootfs.img,format=raw,if=none,id=hd0 -device virtio-blk-device,drive=hd0

The target boots to a shell:

[    0.000000] Booting Linux on physical CPU 0x0000000000 [0x411fd070]
[    0.000000] Linux version 5.10.0-rc5 (tperrot@27ea4a863f61) (aarch64-linux-gcc.br_real (Buildroot 2020.08-14-ge5a2a90) 9.3.0, GNU ld (GNU Binutils) 2.33.1) #1 SMP PREEMPT Mon Nov 30 14:40:05 UTC 2020
[    0.000000] Machine model: linux,dummy-virt
<...>
[    0.858346] Sending DHCP requests ., OK
[    0.870558] IP-Config: Got DHCP answer from 10.0.2.2, my address is 10.0.2.15
[    0.870909] IP-Config: Complete:
[    0.871199]      device=eth0, hwaddr=52:54:00:12:34:56, ipaddr=10.0.2.15, mask=255.255.255.0, gw=10.0.2.2
[    0.871566]      host=10.0.2.15, domain=, nis-domain=(none)
[    0.871825]      bootserver=10.0.2.2, rootserver=10.0.2.2, rootpath=
[    0.871866]      nameserver0=10.0.2.3
[    0.872389]
[    0.875863] ALSA device list:
[    0.876151]   No soundcards found.
[    0.879353] uart-pl011 9000000.pl011: no DMA platform data
[    0.920237] Freeing unused kernel memory: 5952K
[    0.921223] Run /sbin/init as init process

Please press Enter to activate this console.
posted at 13:01  · 7 min read  ·   ·  busybox  embedded  intermediate  linux  qemu  article

May 28, 2020

My blog opening

I closed my last blog seventeen years ago. This new one is for sharing what I know and my small experiments with embedded open source. It will mainly cover embedded Linux, plus open firmware, RTOSes and related topics such as virtualization and security.

posted at 16:50  · 1 min read  ·  blog  article