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-windowsThese names tell Zig what kind of machine code to produce.
ARM vs AArch64
You will often see two broad ARM families:
arm
aarch64arm usually means 32-bit ARM.
aarch64 means 64-bit ARM.
A Raspberry Pi running a 64-bit Linux system may use:
aarch64-linuxAn older 32-bit ARM Linux system may use something like:
arm-linux-gnueabihfAn Apple Silicon Mac uses:
aarch64-macosThe 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-linuxYou can build for Apple Silicon macOS:
zig build-exe main.zig -target aarch64-macosYou can build for 64-bit ARM Windows:
zig build-exe main.zig -target aarch64-windowsThis 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-linuxCopy it to the ARM device:
scp main user@device:/home/user/mainRun it on the device:
chmod +x main
./mainThis 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-freestandingfreestanding 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 allocationInstead, 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 mainIn 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 regionA 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 allocatorsInstead of:
allocate whenever needed
grow data structures freely
depend on a large heapZig’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 buffersFor 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 arrivesInterrupt 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:
0x12345678may be stored in memory as:
78 56 34 12on 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 ReleaseSmallor:
zig build-exe main.zig -target thumb-freestanding -O ReleaseFastReleaseSmall 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.