Building OctoGo: A Go-like Language Where Goroutines Are Cores

The last few posts here have been about pieces of a toolchain without saying what they were for. egg, the LL(1) parser generator. The flat AST and the ergonomics of walking one when you have decided that allocation is the enemy. And underneath both, ccgo, which turns C into Go.

This post is what they were (also) for.

OctoGo is a Go-like language for the Parallax Propeller 2. egg generates its parser. Its AST is a flat []int32. Its backend is a C compiler that ccgo transpiled into the binary you install. And the thing that makes it worth writing about is not any of that plumbing - it is that on this target, a goroutine is not a scheduler entry. It is a core.

The target, briefly

Most readers will not have met a Propeller 2, so: it is an eight-core microcontroller with no interrupts in the usual sense, no MMU, and no operating system. The cores are called cogs. There are eight of them, they are symmetric, and they run genuinely in parallel. There is 512 KB of shared hub RAM, each cog has 512 longs of private memory, and the silicon provides 16 hardware locks.

That last sentence is the whole design brief. Eight cores and sixteen locks are not an implementation detail to abstract over - they are the resource budget, and a language for this chip should make them visible rather than pretend they are infinite.

So OctoGo has no garbage collector, no heap, and no scheduler. What it has is a mapping:

  • go f() starts a real cog.
  • A chan is a rendezvous cell guarded by a real hardware lock.
  • Running out of either is a runtime panic, not a queue.

Everything below follows from that.

LL(1) as a design force

egg accepts LL(1) grammars and rejects everything else, with detailed diagnostics. It was not softened for OctoGo. The grammar lives in a doc comment in specs.go, gets extracted to EBNF, and egg turns it into the parser. If a language feature is not LL(1), it does not go in.

Most language designers would call that a bad trade. You give up real expressiveness to keep the parser simple. What it actually costs is worth spelling out, because the answer is more interesting than “you lose some syntax.”

Sometimes it costs nothing and you just have to be clever. Consider parsing a for header. After for, an identifier could begin a condition (for x {) or an init statement (for i := 0; ...). One token of lookahead cannot tell them apart. The fix is to stop trying: parse a leading Expression, then let the next token decide what you were parsing. A { means it was a condition; a ; or an assignment operator means it was an init. The same trick handles switch guards. LL(1) holds, and the language does not lose anything - the only cost is that the condition is no longer a direct child of the for-statement, so the checker has to go find it.

And sometimes it looks like it costs something real, right up until it doesn’t.

Composite literals are Go’s classic parsing ambiguity. After a type name, a { might open a literal or might open a block:

p := Point{1, 2}   // literal
if x {             // block

One token of lookahead sees the same thing in both. Go’s own parser resolves it with a parser-level flag tracking whether a composite literal is currently permitted - precisely the context-sensitivity an LL(1) grammar cannot express. For a long time OctoGo simply had no composite literals, and I assumed that was the price of the constraint.

It isn’t, and the way out is worth the detour. Go’s actual rule - as opposed to its implementation - is that a composite literal may not appear at the top level of an if, for or switch header unless you parenthesize it. That rule is perfectly context-free. It just has to be said in the grammar rather than in the parser. So there are two expression grammars:

Factor           = identifier [ CompositeLit | FactorSuffix ] | ...
CompositeLit     = "{" [ ExpressionList ] "}" .

HeaderExpression = HeaderSimpleExpr { RelOp HeaderSimpleExpr } .
HeaderFactor     = identifier [ FactorSuffix ]      // no CompositeLit
                 | "(" Expression ")"               // ... but parentheses restore it
                 | ...

The ordinary chain allows composite literals. The header chain is the same grammar with that one production removed, and it is what if, for and switch headers parse. Inside parentheses you are back to the full expression, which is where Go’s escape hatch comes from for free: if p == (P{}) { works, for exactly the reason it works in Go. egg accepted the whole thing without a single new conflict.

The obvious objection is that duplicating five productions means duplicating every consumer of them, and the checker and emitter switch on node kinds all over the place. That turned out to be a non-problem, because the two chains are structurally identical - the header one is a strict subset. So the AST iterator maps the header productions onto the ordinary ones as it yields them:

func baseSym(s Symbol) Symbol {
	switch s {
	case HeaderExpression:
		return Expression
	case HeaderFactor:
		return Factor
	// ...
	default:
		return s
	}
}

Nothing outside the parser knows the distinction exists. The entire checker and the entire emitter were untouched by this change; the whole test suite went from red to green the moment that function was added. The duplication buys LL(1) and then disappears.

That is the shape of the constraint in practice. It does not usually forbid things. It forbids saying them in a particular way, and often the rewrite is clearer about what the language actually means than the original would have been - Go’s composite-literal rule is real, and here it is written down instead of being an emergent property of a parser flag.

The payoff for all this discipline is that the frontend is boring in the way you want a frontend to be boring. The grammar is machine-checked. The parser is generated. When the language changes, the doc comment is edited and the parser regenerated, and egg reports immediately if the new grammar is ambiguous.

Concurrency compiled to silicon

Here is a complete OctoGo program:

func worker(ch chan int, n int) {
	ch <- n * 10
}

func main() {
	var ch chan int
	go worker(ch, 1)
	println(<-ch)
}

It compiles to a 16 KB P2 binary. When it runs, worker is executing on a different physical core than main, and the two meet through a hardware lock.

Two details in that program are not Go, and both are forced by the target.

There is no make(chan int). The checker rejects it. There is no allocator to make a channel with, so the declaration is what creates it: var ch chan int allocates the rendezvous cell and acquires its hardware lock, and the lock’s lifetime is tied to the variable’s. This bothered me for about a day and then stopped bothering me. On a chip with sixteen locks, a channel is a scarce named resource, and declaring one should look like declaring one.

The cell itself is unglamorous:

typedef struct { int lock; volatile int full; volatile int taken; volatile int val; } ogo_chan_int;

full says a value is waiting. taken counts how many values have been consumed - which is there for a specific reason. A sender needs to know that its own handoff completed. If it watched full alone, then with several senders sharing a channel it could see another sender’s deposit and conclude its own value had been received. Counting consumed values lets a sender snapshot taken when it deposits and wait for that number to change.

go cannot fail to be bounded. The goroutine machinery is a statically allocated pool with one slot per available cog:

typedef struct { int used; int done; int cog; long args[OGO_ARG_LONGS]; long stack[OGO_STACK_LONGS]; } ogo_cog_slot;

Each slot holds both the goroutine’s stack and its marshalled arguments. That is not tidiness - it is necessary. The launched cog reads its arguments after the go statement has returned, so they cannot live in the launching function’s frame. Putting the stack and the args in the same pooled slot makes their lifetime the goroutine’s by construction.

Because the pool has exactly one slot per cog, “out of slots” and “out of cogs” are the same condition, and the check is one comparison:

if (slot < 0) { ogo_panic("out of cogs"); }

Note what this buys: go inside a loop is legal in OctoGo. It cannot run away, because the silicon bounds it and exhaustion is reported. defer inside a loop, by contrast, is a compile error - that one really is unbounded, since each deferred call would need to be recorded somewhere with no upper limit.

The whole toolchain is inside the binary

go install modernc.org/ogo@latest gives you a complete cross-toolchain for the P2. No flexprop installation, no separate loader, no environment variables pointing at an SDK.

That works because of ccgo. The backend is flexspin’s C compiler, transpiled from C to Go: 456,215 lines of it, about 12 MB of generated source, compiled into the ogo binary as a library rather than shelled out to. The P2 loader, loadp2, is transpiled the same way. And the P2 include tree - headers, libc sources, libc.a - is packed into a 1.1 MB go:embeded tarball that gets unpacked at runtime.

The result is a 13 MB binary that contains a C compiler, a loader, and a P2 standard library, and the pipeline is:

.ogo → check → emit C → flexcc (in-process) → .binary → loadp2 → board

ogo build runs the first four stages; ogo run adds the last one. Both are a single Go binary talking to a serial port.

This matters beyond convenience. Vendoring a toolchain this way means the compiler under test is the compiler users get, pinned to a specific upstream revision, with no “works on my machine, I have flexprop 7.4 installed.” For a project with one maintainer, that removes an entire category of bug report.

The bug that only silicon could find

Corrected 20 July 2026. This post originally attributed one of the hangs below - a program with several channels and goroutines stopping at a rendezvous - to a bug in flexspin’s FCACHE. It was a livelock in OctoGo’s own rendezvous, and the paragraph saying otherwise has been rewritten. The fix is in v0.2.2.

Now the part that actually matters.

OctoGo’s test suite compiles every emitted program with the host C compiler and runs it, checking the output. Concurrency is covered too, via a small shim that backs cogs with pthreads and hardware locks with mutexes, at the real eight-cog and sixteen-lock limits. It is a good suite. It catches real bugs.

Unnamed function parameters landed recently - Go’s func f(int, int), where a parameter has no name because the body does not use it. The emitted C leaves the parameter unnamed too. gcc accepts that in a definition, flexcc accepts it, and the host suite went green across the board.

Then it ran on the board:

func mix(_ int, b bool, c byte) int {
	if b {
		return int(c)
	}
	return 0
}

// mix(9, true, 65) should return 65.

It returned 1.

Not garbage, not a crash - 1. Which is a suspiciously meaningful number, and if you stare at the call for a moment you can see where it came from. true is 1. The function was returning int(c) where c had somehow received the value of b.

flexcc had compiled the definition with the unnamed leading parameter by dropping its argument slot entirely and shifting everything after it. b got argument 0 (9, nonzero, so the branch was taken), c got argument 1 (true, which is 1), and the 65 fell off the end. The generated code was internally consistent and completely wrong, and it produced no diagnostic from either compiler.

gcc handles that same C correctly. So no amount of host testing - not more cases, not more assertions, not a better shim - could ever have caught it. The divergence was between two C compilers, and only one of them was going to run on the hardware.

The fix is small: give every unnamed parameter a synthetic name so flexcc allocates its slot like any other, and emit a (void) reference so the forced name does not trip -Wunused-parameter on the host:

int mix(int _ogo_unused0, _Bool b, uint8_t c) {
	(void)_ogo_unused0;
	...
}

The lesson is not the fix. It is this: a cross-compiler that is verified only on the host is not verified. I had been treating the host suite as ground truth and the board as a demo. It is the other way around. So OctoGo now has a second suite - the same table of programs, but built with the real backend, loaded onto a real P2, and checked against the serial output. Thirty-seven programs, a little over half a minute, and it is the suite that actually means something.

That suite has since earned its keep twice over. Both times the shape was the same: a program that ran correctly under gcc and hung on silicon. The first was a receive used as a call argument - println(<-ch) deadlocked where v := <-ch ran, because flexcc miscompiled the rendezvous loop when it inlined it into an argument. The second took most of a day to corner, because it moved whenever code was added around it: a program with a few channels and a few goroutines would hang at the first rendezvous. I decided that one was not our bug either. FCACHE - flexspin’s optimization that lifts a hot loop into Cog RAM - looked like it was stopping the poll from observing another Cog’s writes to a Hub cell, volatile on the cell did not prevent it, and builds went out with --fcache=0.

That was wrong, and the bug was mine. The poll asked for the hub lock on every turn, which re-takes it faster than the Cog on the other side can ever win it: both sides live, neither progressing. A livelock, not a stale read. FCACHE only made the loop fast enough to reach the threshold. What settles it is that varying only the backoff moves the hang - one cycle stops after two exchanges of four, eight after three, sixty-four completes - which a load being served from stale Cog RAM could not do. The fix is to read the flag before asking for the lock, and ask only when there is plausibly something to take. --fcache=0 is gone with it, so compiled programs get loop caching back rather than having it switched off everywhere to hide three bad loops. The corrected reproducer demonstrates the fix and reproduces the hang under -DLIVELOCK. Thanks to Wuerfel_21 on the Parallax forum, who said it was a bug in the logic exposed by faster timings, and was right.

So: one compiler bug, and one of my own that I blamed on the compiler. Neither was reachable from the host suite. Both were found by the board suite within minutes of a test existing that exercised them.

There is a coda. The first version of that harness killed the loader with SIGKILL as soon as it matched the expected output. That works fine - until it does not, and the board stops responding entirely, and no amount of retrying brings it back without physically power-cycling the thing. An abruptly killed loader leaves the serial port in a state the next load cannot recover from.

The fix was to stop killing it. loadp2 prints its own escape hatch when it enters terminal mode: “Press Ctrl-] to exit.” So the harness now writes 0x1d to the loader’s stdin, and the loader shuts the port down properly and exits 0. Five consecutive full runs, a hundred-odd loads, no wedging. SIGKILL survives only as a last resort for a genuinely hung load.

I spent a while assuming the board was flaky hardware. It was my test harness.

Where it is

OctoGo compiles and runs real programs on real Propeller 2 hardware today. Structs and composite literals, methods with value and pointer receivers, slices and fixed arrays, the full control-flow set including switch, defer, iota, multiple return values named and unnamed, append, goroutines, channels and select. A package is a directory, so a program can be spread over as many files as it wants. There are 123 semantic-check specs, and 37 programs are built with the real backend and run on silicon on every change.

It is also unfinished, in ways worth stating plainly. ogo test is a stub. An array or slice literal can only initialize a variable, never appear mid-expression. A program is one package in one directory: only import "p2" resolves. And the p2 package wraps nine intrinsics, which is enough for blinky and not a standard library.

Interfaces are designed but not built, and the whole-program-optimization strategy behind them is genuinely undecided - bounded tagged unions, strict monomorphization, or static vtables, each with a different bargain between Go compatibility and the memory budget. That is the question I would most like opinions on, along with whether the zero-allocation constraints feel workable once you try to write something real, and whether a channel that is created by its declaration rather than by make reads naturally or just reads odd.

One practical note: the toolchain is built and tested on linux/amd64, and that is the only platform it ships for today. Which one do you need next? That is a real question rather than a polite one - each port means regenerating the transpiled backend for that target, so I would rather spend the effort where it is actually wanted than guess. In the meantime the two halves come apart cleanly: ogo build emits an ordinary P2 .binary, so it can be built in a VM, on a Pi (once linux/arm64 support lands) or under WSL and flashed with whatever loader is already installed. (WSL is fine for building; its USB passthrough makes ogo run more trouble than it is worth.)

go install modernc.org/ogo@latest

The source is at https://gitlab.com/cznic/ogo. If you have a P2 and any interest in writing Go-shaped code for it, I would like to hear what breaks.

Comments

Popular posts from this blog

Taming the Flat AST: Ergonomics in the Age of Zero Allocations

Building a High-Performance JSON Parser in Go with Egg

Taming the Regex Monster: Optimizing Massive Literal Alternations