Mascot based on the Go gopher by Renée French, licensed CC BY 4.0.
A modern, zero-allocation, Go-like programming language built specifically for the Parallax Propeller 2 (P2) multi-core microcontroller.
OctoGo brings the elegance of Go's concurrency model to embedded hardware. It compiles an LL(1) subset of a Go-like language into C99/C11, which is then compiled to native P2 assembly using the industry-standard flexprop toolchain.
There is no software scheduler and no garbage collector. Goroutines map 1:1 to physical silicon.
The repository you are currently viewing might be a mirror. Please review the guidelines below based on where you are viewing this:
| Platform | Role | Contributing Guidelines |
|---|---|---|
| GitLab | Primary Source | This is the canonical repository (cznic/ogo). CI pipelines and main development happen here. |
| GitHub | Mirror | This is a mirror (modernc-org/ogo). We do accept Issues and Pull Requests here for your convenience! Note: PRs submitted here will be manually merged into the GitLab source, so please allow extra time for processing. |
Forget manual state machines and timer interrupts. In OctoGo, spawning a parallel hardware process is as simple as calling go myfunc(). Channel communication uses the familiar <- operator, allowing you to synchronize hardware locks seamlessly.
import "p2"
// blinkWorker runs independently on its own dedicated Cog
func blinkWorker(pin int, rateChan chan int) {
delay := 500
for {
// Non-blocking hardware poll via select
select {
case delay = <-rateChan:
default:
}
p2.PinHigh(pin)
p2.WaitMs(delay)
p2.PinLow(pin)
p2.WaitMs(delay)
}
}
func main() {
// A channel is created by its declaration. OctoGo has no allocator, so
// make(chan int) is rejected: the declaration is what allocates the
// rendezvous cell and acquires its hardware lock.
var rateChan chan int
// Spawns directly to a new hardware Cog!
go blinkWorker(56, rateChan)
// Update the blink rate from the main Cog via hardware-locked channel
rateChan <- 100
}
go install modernc.org/ogo@latestBuilding ogo needs Go 1.25 or newer. It is built and tested on linux/amd64,
linux/arm64, windows/amd64, darwin/arm64 and darwin/amd64 — if you
need another platform, please say so, it helps decide which to do first. Note that the two
halves come apart: ogo build emits an ordinary P2 .binary, so you can compile
on one machine and flash with whatever loader you already have on another.
On Windows, run
ogofromcmd.exeor PowerShell — not a Unix-emulation shell (git-bash, MSYS2, Cygwin). The board-facing commands (run,loadp2) drive the serial port through the native Windows console and are unreliable under those shells: the P2 handshake times out intermittently and the terminal's exit key can stop responding (use Ctrl-C to escape). Building (build,fmt) is unaffected and works in any shell.
That is the whole toolchain. ogo embeds the C backend (flexspin's compiler,
transpiled to Go) and the P2 loader, so there is no flexprop installation, no
separate loader and no SDK path to configure.
ogo <command> [arguments]
The commands are:
| Command | |
|---|---|
build |
compile packages and dependencies |
fmt |
reformat source files |
help |
show help for a command |
loadp2 |
load a program onto a Propeller 2 board (loadp2 passthrough) |
run |
compile and run a program on a connected board |
smith |
output a random program for compiler testing |
test |
test packages |
version |
print the ogo version |
Run ogo help for this list at the terminal, or ogo help <command> — for
example ogo help build — for one command's flags and detail. A typical session:
- Write your
.ogocode. - Run
ogo build blinky/to compile and generate your P2 binary. - Run
ogo run blinky/to compile it, load it onto a connected board and open a terminal.
Seeing garbled serial output?
ogo runsets a precise 200 MHz clock and reads at 230400 baud, soprintlnoutput is readable out of the box. If you load with the rawogo loadp2passthrough instead, it uses loadp2's own defaults — which leave the P2 on its imprecise internal oscillator, so the output is garbled at every baud. Pass a real clock and the matching baud:ogo loadp2 -f 200000000 -b 230400 -t prog.binary(the-fis the key part;200000000assumes the usual 20 MHz P2 crystal — use your board's value if it differs). This is a P2 clocking detail, not an ogo bug.
The language is specified in the package documentation: OctoGo Language Specification. It is a draft and is reconciled with the implementation as the compiler moves; the Status section below is the shorter answer to what works today, and CHANGELOG.md records what changed in each release — including the cases where a new release rejects a program the last one accepted.
OctoGo is designed to be a zero-cost abstraction over the Propeller 2's unique 8-cog architecture.
-
Native Hardware Concurrency: The go keyword transpiles to a scoped block that claims a pooled slot holding the goroutine's stack and its arguments, then invokes _cogstart_C. The pool has one slot per available Cog, so the P2's 8-cog limit is enforced by construction; exceeding it is a runtime panic.
-
Hardware-Backed Channels: Channels (chan) are not software queues. Each is a rendezvous cell in Hub RAM guarded by one of the P2's native hardware locks (0-15), giving atomic, lock-step transfer. A channel is created by its declaration, not by make: there is no allocator to make it with. Past the sixteenth channel the locks are shared rather than exhausted, which costs contention and nothing else — twenty-four channels each completing a rendezvous run correctly on a P2-EDGE.
-
Zero-Allocation & No GC: OctoGo operates without a Garbage Collector. Memory scoping is strict (Hub RAM vs. Cog RAM), and slices are implemented as non-escaping views over fixed arrays.
-
Select Statements: The select statement is transpiled into an efficient polling loop, utilizing flexprop's _waitx yield instructions to prevent bus starvation during non-blocking hardware polling.
-
Implicit Namespaces: To keep the grammar clean and strictly LL(1), there is no package keyword. A package's namespace is implicitly inferred from its directory name, mapping cleanly to a single C translation unit.
OctoGo is a source-to-source compiler (transpiler) written in Go.
-
Frontend: Lexical analysis and parsing are generated via modernc.org/egg using a LL(1) grammar. The AST is represented as a zero-pointer, cache-local flat []int32 slice.
-
Semantic Pass: Uses Go 1.23+ iterators (
func(yield func(node) bool)) to traverse the AST, calculate memory offsets, and resolve scope. -
Transpilation: Emits standard C. The runtime it needs -- bounds and divide-by-zero traps, slice and channel helpers, the goroutine pool -- is emitted into the same file, per program, so only what is used is paid for.
-
Backend Generation: The ogo build command automatically feeds the emitted C into flexprop, delegating register allocation, instruction scheduling, and P2 binary generation to a proven, hardware-aware backend.
OctoGo compiles and runs real programs on real Propeller 2 hardware, and every change is verified by a suite that builds each test program with the real backend, loads it onto a P2 and checks its serial output. It is also unfinished. This section is the honest inventory — please read it before deciding the compiler is broken.
Works (all of it exercised on hardware):
- Integers (sized and unsized, including 64-bit),
byte,rune,bool,string, structs, fixed arrays including multi-dimensional, slices, named types, channels. Structs may refer to themselves, to each other, and to a type declared later, so linked lists, trees and graphs build. A named type behaves as what it is defined over —type Name stringindexes, slices, ranges and compares as a string,type List []intas a slice,type Named Pointhas Point's fields and takes Point's literals — and carries its own methods. That holds for every kind a type can be defined over: a pointer dereferences, a function is called. A literal may name such a type,Row{1, 2, 3}fortype Row [3]int. - A slice literal stands wherever a value may —
sum([]int{1, 2, 3})— its backing array a local of the function that wrote it, with the lifetime that implies. - Slicing, including the capacity bound:
pool[0:0:64]hands out a region of a package-level buffer that appending cannot grow past, which is how you sub-divide storage with no heap to allocate from. - Composite literals: structs positionally (
P{1, 2},P{}, nested) or by field name (P{y: 2}, any subset in any order, the rest zeroed), and arrays and slices positionally, by index or mixed ([4]int{10, 20},[]int{1, 2, 3},[5]int{0: 1, 4: 9}), including multi-dimensional ([2][3]int{{1, 2, 3}, {4, 5, 6}}) and at package scope, which is how you write a lookup table. One place a literal may not appear bare is the top level of anif,fororswitchheader, where{is the block — parenthesize there, as in Go:if p == (P{}) {. That is only for a literal whose type is a bare name;for _, v := range []int{1, 2, 3}needs nothing, a[not being able to begin a block. var(including several names and a value list),constwithiota,type, functions and methods with value or pointer receivers. A package may declare severalinitfunctions; they run in the order they are written, beforemain.- Named and unnamed parameters and results, multiple return values, naked returns.
Several values may be destructured out of a plain call, a method call or an
imported package's call alike,
b, ok := r.pop(). - A declared function used as a value: a variable, parameter, result, array
element or struct field of a function type holds one and a call through it is a
call,
chosen = add; chosen(1, 2). It becomes a C function pointer, so it costs nothing and allocates nothing, and one is always safe to send to another cog — it names code, not the frame it was made in. if/elseandswitchincluding an init statement (if v := f(); v > 0,switch v := f(); v), which may declare several names from one call (if v, ok := m.get(k); ok), allforforms includingrange,switchwith or without a guard,fallthrough,breakandcontinue(including labeled),defer(including in nested blocks, capturing its arguments).- The full operator set, compound assignment, multiple assignment, and equality on
strings, structs and arrays. A multiple assignment writes to anything a single one
can — an element, a field, a pointee — so
xs[i], xs[j] = xs[j], xs[i]swaps. - A call's result may be used directly: a field read off it (
mk().y), a method called on it, or an index into it (mk()[1],mk().d[1]). len,cap,append,copy,clear,min,max,makefor a fixed-capacity slice,panic,print/println.- A predeclared
Builderfor assembling a string at run time without a heap:NewBuilder(back[:])puts a write cursor over a byte array you own, andWriteString,WriteByte,WriteRune,Write,String,LenandResetbehave asstrings.Builder's do, except that a write past the backing is truncated rather than growing it — you chose the size. It is meant to becomestrings.Builderonce there is a standard library to put it in. go,chanandselect, mapped to cogs and hardware locks. A method may be launched too,go w.run(ch), its receiver evaluated and copied where thegostands — including one reached through fields and indexes,go ws[i].run(ch), which is a worker per cog — as may an imported package's function,go driver.Poll(ch). Aselectmultiplexes receives and one send,case ch <- v:. Channels may be declared at package level as well as locally, and the P2's locks are reachable directly through thep2package.- Runtime traps for out-of-range indexing and slicing, division and remainder by
zero, a shift by a negative count, appending past a slice's capacity, and cog
exhaustion. Each prints
panic: <what>and halts the offending cog;--releasereboots the board instead, and--uncheckedomits the checks. - A package is a directory:
ogo buildcompiles every.ogofile in it together, and a program may span several packages. A value of an imported package's struct type is written the way you would expect,geo.Point{1, 2}.
Does not work yet, in rough order of how likely you are to hit it:
- There is no standard library. The
p2package wraps twenty-four intrinsics (pin control, smart pins, timing, the hardware locks) and is the whole of it. Your own packages do import and build; there is just nothing to import yet. - Interfaces, and with them type switches and type assertions. See below.
- A goroutine's stack is a fixed 256 longs and cannot be sized per
gostatement. Recursion works —mainruns on the cog's own stack and a goroutine on its pool slot's — but a deep enough call chain in a goroutine overruns that slot with no diagnostic, this part having no memory protection. Measured on a P2-EDGE, a goroutine recursing 200 deep is fine and one recursing 2000 deep prints nothing at all. ogo testis not implemented._test.ogofiles are recognized and kept out of a build, but nothing runs them yet.- A channel held in a struct field: a send or a receive on one is refused,
whether the field is written
chan Tor a defined type over one. A channel in a variable, a parameter or a package-level declaration is fine. What has to be settled first is where the rendezvous cell lives — a channel variable gets one allocated beside it, and a field cannot without deciding what declaring the struct allocates, and what a copy of it then shares. - Three calls deep,
chooser()(0)(6): the target computes 0 where the host is right, at every optimization level, so this is the backend rather than a flag. Two deep works, and the chain may be broken up —a := chooser(); b := a(0); b(6). A call written directly on an array element of function type,fns[0](8), is refused by the same compiler; bind it first,f := fns[0]; f(8). - A method on a defined type over a channel,
func (c Ch) tag(). Sends, receives and select clauses on such a type all work. - A conversion to a defined array type may not be indexed where it stands,
row(g)[2]; bind it first. C has no cast to an array type. The conversion itself, and every other use of the result, works. - An array literal may not stand as a general value — it initializes a variable,
fills a slot in another composite literal, or is what a
rangewalks, and nothing else, C having no array value for it to become. A slice literal has no such limit; its backing array is a local, so it carries that local's lifetime. - A
selectmay carry at most one send clause, and none alongside adefault— both need a "receiver is ready" signal the rendezvous does not carry. - A function literal and a method value (
t.get); a function with more than one result used as a value; andgothrough a variable holding a function rather than through the function's own name. A declared function used as a value works. - An array as a function result, a slice whose element is an array, and
goto. - A
rangeclause written with=accepts only variables, not an element or a field:for xs[0], a[0] = range xsis refused. Plain variables are fine. - A three-clause
forheader declares one variable:for a, b := f(); a < b;does not parse.ifandswitchheaders take several,if v, ok := f(); ok.
Floating point (float32/float64) is supported, exponent literals included
(1e3, 1.5e-3): the P2's C toolchain provides it,
so float arithmetic, comparison, int conversions and printing all work. Note the
target has no double-precision hardware, so float64 is 32-bit here, same as
float32 (~7 significant digits, not ~15) -- the name is kept for Go source
compatibility but carries no extra precision.
Not planned, because the target does not permit them: a garbage collector, a
heap, maps, closures that capture their environment (a function value is fine —
it is a pointer to code, not to a frame), and runtime string concatenation.
Constant string concatenation folds at compile time; to assemble one at run time,
write into a Builder over storage you own.
Having no heap has one consequence worth knowing before you meet it: a reference
must not outlive what it refers to. Where Go would move the referent to the heap and
say nothing, there is nowhere to move it to, so the program is refused instead —
returning a local's address or a slice backed by a local, storing either in a package
variable, or handing either to another cog as a go argument or through a channel. A
struct holding such a reference counts as one, and the requirement follows a
parameter back to the call sites that chose the storage. Declare the buffer at
package scope and pass a slice of it, which is what the diagnostics ask for:
var buf [64]byte
func main() {
var done chan int
go fill(buf[:], done) // buf outlives every frame
<-done
}
Everything else Go has is meant to be here eventually — anything missing above is work not yet done rather than a decision taken. Generics are the one open question: not supported, not planned, and not ruled out either.
Interfaces are designed but not implemented; the whole-program-optimization strategy behind them is still an open question, and opinions are welcome.
