Skip to content

ARM and Embedded Targets

ARM is a CPU architecture family used in phones, tablets, laptops, routers, Raspberry Pi boards, microcontrollers, servers, and many embedded devices. When you write Zig for...

ARM is a CPU architecture family used in phones, tablets, laptops, routers, Raspberry Pi boards, microcontrollers, servers, and many embedded devices. When you write Zig for ARM, you are usually writing for a specific machine, not just for “ARM” in general.

For beginners, the first idea is this: a target is more than an operating system. It includes the CPU architecture, the operating system, the ABI, and sometimes the C library.

Examples:

aarch64-linux
arm-linux-gnueabihf
thumb-freestanding
aarch64-macos
aarch64-windows

These names tell Zig what kind of machine code to produce.

ARM vs AArch64

You will often see two broad ARM families:

arm
aarch64

arm usually means 32-bit ARM.

aarch64 means 64-bit ARM.

A Raspberry Pi running a 64-bit Linux system may use:

aarch64-linux

An older 32-bit ARM Linux system may use something like:

arm-linux-gnueabihf

An Apple Silicon Mac uses:

aarch64-macos

The CPU architecture matters because machine code for one architecture cannot run directly on another.

Cross-Compiling to ARM

Zig can build ARM binaries from another machine.

For example, on an x86_64 Linux laptop, you can build for 64-bit ARM Linux:

zig build-exe main.zig -target aarch64-linux

You can build for Apple Silicon macOS:

zig build-exe main.zig -target aarch64-macos

You can build for 64-bit ARM Windows:

zig build-exe main.zig -target aarch64-windows

This is useful because embedded devices may be slow, small, or inconvenient to build on directly. You can compile on a powerful development machine and copy the binary to the target device.

Native ARM Linux Example

Here is a simple Zig program:

const std = @import("std");

pub fn main() void {
    std.debug.print("Hello from ARM!\n", .{});
}

Build for 64-bit ARM Linux:

zig build-exe main.zig -target aarch64-linux

Copy it to the ARM device:

scp main user@device:/home/user/main

Run it on the device:

chmod +x main
./main

This workflow is common for Raspberry Pi, small servers, ARM development boards, and edge devices.

Embedded Does Not Always Mean Linux

Some embedded devices run Linux. Others do not have an operating system at all.

A Raspberry Pi running Ubuntu or Raspberry Pi OS is an embedded-like ARM Linux system.

A microcontroller such as an ARM Cortex-M chip is usually bare metal. There may be no filesystem, no process model, no virtual memory, and no normal terminal.

That difference is huge.

On ARM Linux, this may work:

const file = try std.fs.cwd().openFile("data.txt", .{});

On a bare-metal microcontroller, there may be no filesystem, so that code has no meaning.

Freestanding Targets

For bare-metal and embedded work, you may see targets using freestanding:

thumb-freestanding
arm-freestanding
aarch64-freestanding

freestanding means Zig should not assume a normal operating system.

That affects your program design. You may not have:

files
processes
environment variables
standard input
standard output
dynamic libraries
normal heap allocation

Instead, you often work directly with memory-mapped registers, interrupts, startup code, linker scripts, and hardware manuals.

Memory-Mapped I/O

Embedded programs often control hardware through memory-mapped I/O.

That means a hardware register appears at a fixed memory address. Reading or writing that address talks to the device.

A simplified example:

const gpio_addr: usize = 0x4002_0000;
const gpio = @as(*volatile u32, @ptrFromInt(gpio_addr));

pub fn main() void {
    gpio.* = 1;
}

This says: treat address 0x4002_0000 as a pointer to a volatile 32-bit register, then write 1 to it.

The word volatile matters. It tells the compiler that reading or writing this memory has effects outside normal program memory. The compiler must not casually remove or reorder it as if it were an ordinary variable.

This example is simplified. Real hardware code depends on the exact chip, register layout, clock setup, and board design.

Why volatile Matters

Consider this ordinary variable:

var x: u32 = 0;
x = 1;
x = 2;

The compiler may notice that x = 1 is overwritten and remove it.

But with hardware registers, every write may matter. Writing 1 may turn on a device. Writing 2 may change a mode. The compiler must preserve those operations.

That is why embedded register pointers usually use volatile.

const reg = @as(*volatile u32, @ptrFromInt(0x4000_0000));
reg.* = 1;
reg.* = 2;

Both writes are meaningful.

Startup Code

On a desktop program, the operating system starts your process and calls into your program.

On bare metal, there may be no operating system. Something still has to happen before your main logic runs.

Startup code may need to:

set the stack pointer
initialize memory
zero the BSS section
copy initialized data into RAM
configure clocks
set up interrupt vectors
call main

In a normal beginner Zig program, you do not see this. In embedded Zig, you may need to provide it or use a board support package that provides it.

Linker Scripts

A linker script tells the linker where code and data should go in memory.

Embedded devices often have separate memory regions:

flash memory
RAM
memory-mapped peripherals
bootloader region

A program may need code placed in flash and variables placed in RAM.

A linker script describes that layout. Without the right memory layout, the binary may build but fail to boot.

This is one reason embedded programming is more hardware-specific than ordinary desktop programming.

Allocators in Embedded Programs

On small embedded systems, heap allocation may be limited or avoided.

This means you may prefer:

fixed arrays
static buffers
ring buffers
arena allocators
fixed buffer allocators

Instead of:

allocate whenever needed
grow data structures freely
depend on a large heap

Zig’s explicit allocator model helps here. If a function needs memory, it usually asks for an allocator. On embedded systems, you can pass a fixed buffer allocator instead of a general-purpose heap.

Example:

const std = @import("std");

pub fn main() !void {
    var backing: [1024]u8 = undefined;
    var fba = std.heap.FixedBufferAllocator.init(&backing);
    const allocator = fba.allocator();

    const buffer = try allocator.alloc(u8, 128);
    defer allocator.free(buffer);

    _ = buffer;
}

This program allocates from a fixed 1024-byte buffer. It does not need an operating system heap.

No Standard Output

On a microcontroller, this may not work:

std.debug.print("hello\n", .{});

There may be no terminal.

Embedded programs often output through:

UART serial
semihosting
JTAG or SWD debugger
LED blinking
logging over USB
custom debug buffers

For beginners, the classic embedded “hello world” is often blinking an LED, not printing text.

Interrupts

Embedded systems often respond to hardware events through interrupts.

An interrupt can happen when:

a timer fires
a button is pressed
data arrives on UART
an ADC conversion finishes
a network packet arrives

Interrupt code must be careful. It may run at unexpected times. It should usually be short, predictable, and avoid heavy work.

Zig can be used for this kind of programming, but interrupts require target-specific setup and hardware knowledge.

Endianness

ARM systems are commonly little-endian, but low-level code should still understand endianness.

Endianness describes byte order for multi-byte values.

For example, the 32-bit value:

0x12345678

may be stored in memory as:

78 56 34 12

on a little-endian system.

This matters when reading binary protocols, hardware registers, files, and network packets.

Use standard library helpers or explicit conversions when byte order matters.

Alignment

Some ARM systems care strongly about alignment.

A 32-bit value may need to be read from an address divisible by 4. Misaligned access may be slower, unsupported, or faulting depending on the CPU and configuration.

Zig makes alignment part of pointer types. This helps you express and check memory assumptions.

For embedded code, alignment is not a detail. It can be the difference between a working program and a hard fault.

Build Modes

Debug builds are useful while learning, but embedded systems often have limited memory and timing constraints.

You may build with:

zig build-exe main.zig -target thumb-freestanding -O ReleaseSmall

or:

zig build-exe main.zig -target thumb-freestanding -O ReleaseFast

ReleaseSmall focuses on smaller code size.

ReleaseFast focuses on speed.

For embedded targets, code size can matter as much as performance because flash memory may be limited.

Practical Beginner Path

A sensible learning path is:

First, build native Zig programs on your computer.

Then cross-compile a simple program to ARM Linux, such as Raspberry Pi.

Then learn fixed buffers and allocator control.

Then study bare-metal basics: memory maps, startup code, linker scripts, volatile registers, and interrupts.

Then target a specific microcontroller board.

Do not try to learn all embedded topics at once. Embedded Zig is a mix of language knowledge, compiler knowledge, hardware knowledge, and debugging skill.

Complete Example: Fixed Buffer Allocation

This example does not depend on Linux, macOS, or Windows behavior. It shows a style that is useful for embedded systems:

const std = @import("std");

fn fill(buffer: []u8, value: u8) void {
    for (buffer) |*byte| {
        byte.* = value;
    }
}

pub fn main() !void {
    var memory: [256]u8 = undefined;
    var fba = std.heap.FixedBufferAllocator.init(&memory);
    const allocator = fba.allocator();

    const data = try allocator.alloc(u8, 64);
    defer allocator.free(data);

    fill(data, 0xaa);

    std.debug.print("filled {d} bytes\n", .{data.len});
}

On a desktop system, this prints a message. On a real bare-metal system, you would replace the print call with board-specific output.

The important part is the memory style. The program uses a known fixed buffer instead of assuming an unlimited heap.

The Practical View

ARM support in Zig is useful because Zig makes cross-compilation, memory control, and low-level access part of the normal language experience.

For ARM Linux, Zig feels close to ordinary Linux programming.

For bare-metal embedded targets, Zig becomes a systems language for direct hardware control. You must understand the chip, the board, the memory map, the linker setup, and the startup path.

Start with ARM Linux if you are new. Move to bare metal after you are comfortable with targets, pointers, fixed memory, and the standard library boundaries.