Right, let’s talk about memory. On your laptop, it’s an abundant, lazy river of resources you barely think about. On a microchip like an ATtiny85 with 8KB of RAM, it’s a thimble of water you’re trying to cross the desert with. You will learn to be miserly. You will learn to hate waste. And Go, for all its wonderful abstractions, can be a bit of a spendthrift if you’re not careful. That’s where TinyGo and a new mindset come in.

The first rule of embedded Go club is: you do not use the heap. Well, you can, but you’ll regret it. The second rule of embedded Go club is: you DO NOT use the heap. The heap is where Go’s garbage collector lives, and while TinyGo’s GC is impressively lean, it’s still an unpredictable guest in your tiny memory palace. Its occasional cleaning cycles can introduce latency, and more importantly, heap allocations are just inherently less predictable.

Embrace the Stack and Escape Analysis

The key to smooth performance is keeping your variables on the stack. This is memory that’s automatically allocated and freed when a function is called and returns. It’s fast, predictable, and doesn’t involve the garbage collector. How do you stay on the stack? You let the compiler’s escape analysis do the work.

In simple terms, the compiler checks if a variable’s pointer “escapes” the function it’s declared in. If it doesn’t escape, it’s safe on the stack. If it does, it must be allocated on the heap. You can see this yourself with the -gcflags="-m" flag. Let’s look at a classic pitfall:

// This innocent-looking function is a memory trap.
func getBuffer() *[1024]byte {
    var buf [1024]byte
    return &buf // buf escapes to the heap!
}

func main() {
    buffer := getBuffer()
    // ... use buffer
}

Why does this happen? Because we’re returning a pointer to a local variable. The compiler rightly reasons that the variable must live on after getBuffer returns, so it punts it to the heap. For a one-time use, this is fine. In a tight loop, this will drown your device in GC pressure.

The fix? Stop passing pointers around so much. Either allocate the array in main and pass a slice to functions that need it, or if you must have a factory function, use a function parameter.

// This is the way. The buffer is allocated in main's stack frame.
func fillBuffer(buf []byte) {
    // ... fill the slice
}

func main() {
    var buffer [1024]byte // Lives on main's stack
    fillBuffer(buffer[:])  // Pass a slice of it
}

Pre-Allocation is Your Religion

The most common source of heap allocations in Go is滥用 append and various make calls inside functions. On a microcontroller, you often know your maximum required sizes at compile time. So declare them! Globally. It feels dirty after years of being told to avoid globals, but here, it’s a best practice. You’re not being messy; you’re being efficient.

// Pre-allocate a fixed-size buffer in the data segment (global memory).
// No runtime allocation, no GC overhead.
var serialBuffer [256]byte
var i2cBuffer [32]uint8

func main() {
    // Use a slice that points to our pre-allocated global
    slice := serialBuffer[:]

    for {
        // Read into the slice. No memory is being allocated here.
        n, err := serial.Read(slice)
        // ... process data
    }
}

This code is utterly predictable. The memory is allocated at startup and sits there, ready for use. Zero runtime allocation cost.

Strings and Bytes: The Hidden Cost

Here’s a designer’s choice I’ll call out: Go’s string type is immutable. This is great for thread safety, terrible for memory-constrained devices. Every time you manipulate a string (+, fmt.Sprintf, etc.), you’re creating new allocations.

func logMessage(sensorID int, value int32) {
    // This looks harmless. It is not.
    // Each + operator creates a new string on the heap.
    msg := "Sensor " + strconv.Itoa(sensorID) + ": " + strconv.FormatInt(int64(value), 10)
    println(msg)
}

This will thrash your heap. The solution is to use a byte buffer and append everything to it. Since we pre-allocated a global buffer, we can use it for this too.

var msgBuf [64]byte

func logMessage(sensorID int, value int32) {
    buf := msgBuf[:0] // Create a slice with length 0 but capacity 64
    buf = append(buf, "Sensor "...)
    buf = append(buf, strconv.Itoa(sensorID)...)
    buf = append(buf, ": "...)
    buf = append(buf, strconv.FormatInt(int64(value), 10)...)
    println(string(buf))
}

This is far uglier, I grant you. But it uses zero heap memory for the concatenation (as long as the total length is < 64 bytes). The append calls are just writing into the pre-allocated array. This is the kind of trade-off you make for determinism.

Know Your Data Types

Avoid interface{} like the plague. Interfaces require dynamic dispatch and often involve heap allocation to store the concrete value. Use concrete types everywhere. Also, be mindful of your integer types. Using an int64 on an 8-bit AVR chip is computationally expensive. Use the smallest type that fits your data: uint8, int16, etc. This saves not just RAM but also stack space and CPU cycles.

The mantra is simple: Allocate once, at startup, if at all. Profile with -gcflags="-m", use the size command to analyze your binary, and always, always know where every byte is coming from. It’s not just pedantry; it’s the difference between a blinking LED and a reliable, real-time device.