Cross-target debugging means debugging a program built for a different machine, operating system, or CPU architecture than the one you are sitting at.
Cross-target debugging means debugging a program built for a different machine, operating system, or CPU architecture than the one you are sitting at.
For example, you may write Zig code on an x86_64 laptop, build it for aarch64-linux, copy it to a Raspberry Pi, and debug it from your main computer.
The basic workflow is:
write code on host machine
build for target machine
run program on target machine
connect debugger from host machine
inspect program stateThe host is your development machine. The target is the machine where the program runs.
Why Cross-Target Debugging Exists
Cross-compilation is useful because the target machine may be slow, small, remote, or inconvenient to use directly.
This is common with:
Raspberry Pi boards
ARM servers
embedded Linux devices
containers
virtual machines
remote production-like systems
bare-metal boards
WebAssembly runtimesYou may want to compile on a fast workstation but run on the real target.
That creates a debugging problem: the executable is built on one machine but runs somewhere else.
Build With Debug Information
A debugger needs debug information. Without it, the debugger may only show raw addresses and assembly instructions.
For learning, build in debug mode:
zig build-exe main.zig -target aarch64-linuxDebug mode is the default when you do not pass a release optimization flag.
Avoid this while debugging source-level behavior:
zig build-exe main.zig -target aarch64-linux -O ReleaseFastOptimized builds may reorder code, inline functions, remove variables, and make stepping confusing.
Start with debug builds. Use release builds later when testing performance or final behavior.
A Simple Remote Debugging Model
A common model uses a debug server on the target and a debugger on the host.
host machine target machine
------------ --------------
lldb or gdb <network> debug server + programThe target runs the program under a small debug server. The host debugger connects to it.
For GDB-based workflows, the target may run:
gdbserver :1234 ./mainThen the host connects:
gdb ./main
(gdb) target remote target-device:1234This lets the host debugger control a program running on the target.
The Executable Must Match
The debugger on the host should use the same executable that runs on the target.
If the target runs one build but the debugger reads symbols from another build, line numbers and variables may be wrong.
Good habit:
build once
copy that exact binary to the target
debug using that exact binary on the hostDo not rebuild between copying and debugging unless you copy the new binary too.
Source Paths Matter
Debug information usually records source file paths.
If the debugger cannot find your source files, it may show addresses and function names but not source lines.
This can happen when:
the target path differs from the host path
the build happened in a container
the source tree moved
the debug info contains absolute paths from another machineA debugger can often remap source paths, but the beginner rule is simpler: build from the same source directory you debug from when possible.
Debugging an ARM Linux Program
Suppose you have this program:
const std = @import("std");
fn add(a: i32, b: i32) i32 {
return a + b;
}
pub fn main() void {
const result = add(20, 22);
std.debug.print("result = {d}\n", .{result});
}Build it for 64-bit ARM Linux:
zig build-exe main.zig -target aarch64-linuxCopy it to the device:
scp main user@device:/home/user/mainOn the target device:
chmod +x main
gdbserver :1234 ./mainOn the host machine:
gdb ./mainInside GDB:
target remote device:1234
break add
continueWhen the program reaches add, the debugger stops there.
You can inspect variables:
print a
print b
next
print resultThis is the core debugging loop.
LLDB
LLDB is another debugger. It is commonly used on macOS and with LLVM-based toolchains.
A typical LLDB workflow looks like:
lldb ./mainThen inside LLDB:
breakpoint set --name main
runFor remote targets, LLDB can connect to debug servers, but the setup depends on the target and platform.
The important concept is the same: the debugger needs the binary, symbols, source paths, and a way to control the running process.
Debugging Containers
Containers are another cross-target case.
You may build a Linux binary on your host, then run it inside a container. The operating system boundary is lighter than a separate machine, but debugging still has practical issues.
Common problems include:
debugger not installed in the container
container lacks permissions for ptrace
binary path differs between host and container
source paths differ
shared libraries differFor beginner work, the easiest method is often:
build locally
mount the source directory into the container
run the debugger inside the containerFor more advanced work, run a debug server inside the container and connect from the host.
Debugging WebAssembly
WebAssembly debugging has a different shape.
A Wasm module does not run directly on the operating system. It runs inside a host, such as a browser, Wasmtime, Node.js, or another runtime.
That means you debug two things:
the Wasm module
the host that loads and calls itFor a simple Zig Wasm function:
export fn add(a: i32, b: i32) i32 {
return a + b;
}Problems may occur in the Zig code, but they may also occur at the boundary:
wrong export name
wrong argument types
memory not exported
pointer and length mismatch
host reads memory incorrectly
allocator ownership mismatchFor Wasm, inspect the interface carefully. Many bugs are not algorithm bugs. They are boundary bugs.
Debugging Bare-Metal Targets
Bare-metal debugging is lower-level than Linux or macOS debugging.
There may be no process, no terminal, no filesystem, and no operating system.
You often use hardware debugging tools such as:
JTAG
SWD
OpenOCD
probe-rs
vendor debug probesThe debugger talks to the chip through a hardware probe.
In this world, you may inspect:
CPU registers
memory addresses
hardware registers
stack pointer
program counter
interrupt state
flash memory
RAMSource-level debugging may still be possible, but it depends on correct debug info, linker scripts, startup code, and target support.
Bare-metal debugging requires more setup because the debugger needs to understand the exact chip and how to control it.
Use Logging When Debugging Is Hard
A debugger is useful, but logging is still valuable.
For Linux-like targets, write to stderr:
const std = @import("std");
pub fn main() !void {
const stderr = std.io.getStdErr().writer();
try stderr.print("starting program\n", .{});
const value = 42;
try stderr.print("value = {d}\n", .{value});
}For embedded targets, logging may mean:
UART output
LED blink patterns
debug memory buffers
semihosting
RTT logsA simple log can often find a problem faster than a full remote debugger setup.
Check the Target Triple First
Many cross-target debugging problems start with the wrong target.
For example:
zig build-exe main.zig -target x86_64-linuxwill not run on an aarch64-linux device.
Use the correct target:
zig build-exe main.zig -target aarch64-linuxWhen something fails, verify:
CPU architecture
operating system
ABI
libc choice
dynamic library dependenciesA binary may fail to run because it was compiled correctly for Linux, but for the wrong CPU or wrong libc environment.
Static vs Dynamic Linking
Debugging can be affected by dynamic libraries.
If your program depends on shared libraries, the target machine must have compatible versions installed.
A program may fail before your code even starts if a required library cannot be loaded.
Symptoms may look like:
No such file or directory
error while loading shared libraries
Exec format error
Illegal instructionThese do not all mean the same thing.
Exec format error often means the binary was built for the wrong architecture or operating system.
A shared library error means the dynamic loader could not find or load a dependency.
Illegal instruction may mean the CPU does not support an instruction used by the binary.
Debug Build vs Release Build
Debug builds are easier to inspect.
Release builds are closer to production behavior.
The difference matters.
In debug mode:
variables are easier to inspect
line stepping is clearer
safety checks may be enabled
performance is lowerIn release mode:
code may be inlined
variables may disappear
instructions may be reordered
performance is higher
some safety checks may differWhen finding a logic bug, start with debug mode.
When finding a performance bug, use release mode and profiling tools.
Complete Example: Debug-Friendly Code
This small example is easy to debug because the functions are separated and the values are visible:
const std = @import("std");
fn multiply(a: i32, b: i32) i32 {
return a * b;
}
fn compute(x: i32) i32 {
const doubled = multiply(x, 2);
const adjusted = doubled + 10;
return adjusted;
}
pub fn main() void {
const result = compute(16);
std.debug.print("result = {d}\n", .{result});
}Build for ARM Linux:
zig build-exe main.zig -target aarch64-linuxCopy to target:
scp main user@device:/home/user/mainRun with a debug server on the target:
gdbserver :1234 ./mainConnect from the host:
gdb ./mainInside GDB:
target remote device:1234
break compute
continue
step
print x
print doubled
print adjustedThis lets you inspect the program while it runs on the target machine.
The Practical View
Cross-target debugging is mostly about keeping four things aligned:
the binary
the source code
the target machine
the debuggerThe binary must match the target. The debugger must read the same binary that is running. The source paths must make sense. The target must provide a way to control or observe the program.
For Zig beginners, start with simple debug builds and one target. Then add remote debugging. After that, learn container debugging, Wasm boundary debugging, and bare-metal hardware debugging.