How it started
I found my old Nintendo 3DS the other day. When I was younger, it did not occur to me, but having worked in software for some time now it became obvious that this is a little computer. Shocking, I know, but young Erik thought these things were simply magic, alright?
So, there’s this fun thing called Custom Firmware (CFW) that you can put on your 3DS to allow it to run custom applications, also known as homebrew. The one I’m using is Luma3DS. For example, there is a custom theme manager and an FTP client. If this sounds interesting to you, do check them out!
Typically, 3DS homebrew applications are written in C, C++, or - occasionally - Assembly (cf. 3DS Homebrew Development). While I have no qualms with C or C++, this struck me as a wonderful opportunity to do something interesting with Zig and showcase some of the gifts it offers!
The devkitPro Toolchain
Giving instructions on setting up a environment for 3DS homebrew development is left to the experts. The quite excellent toolchain is made by devkitPro and I really cannot thank them enough for making it easy to install and use!
Building something on an unfamiliar platform can be a daunting task. If you are interested in writing a homebrew application, I can only recommend looking at the examples provided by the devkitPro team. They’re well written and make it easy to get started!
Before we just go hacking about, let’s look at what happens during the standard build process for a 3DS homebrew application. Having looked into the Makefiles a little bit, we can set the environment variable V=1
to get some more verbose output and see the commands that are actually executed. Let’s take the example for printing “Hello World” to the console, i.e., the top display of our Nintendo 3DS.
arm-none-eabi-gcc -MMD -MP -MF /Users/erik/development/3ds-examples/graphics/printing/hello-world/build/main.d -g -Wall -O2 -mword-relocations -ffunction-sections -march=armv6k -mtune=mpcore -mfloat-abi=hard -mtp=soft -I/Users/erik/development/3ds-examples/graphics/printing/hello-world/include -I/opt/devkitpro/libctru/include -I/Users/erik/development/3ds-examples/graphics/printing/hello-world/build -D__3DS__ -c /Users/erik/development/3ds-examples/graphics/printing/hello-world/source/main.c -o main.o
arm-none-eabi-gcc -specs=3dsx.specs -g -march=armv6k -mtune=mpcore -mfloat-abi=hard -mtp=soft -Wl,-Map,hello-world.map main.o -L/opt/devkitpro/libctru/lib -lctru -lm -o /Users/erik/development/3ds-examples/graphics/printing/hello-world/hello-world.elf
arm-none-eabi-gcc-nm -CSn /Users/erik/development/3ds-examples/graphics/printing/hello-world/hello-world.elf > hello-world.lst
smdhtool --create "hello-world" "Built with devkitARM & libctru" "Unspecified Author" /opt/devkitpro/libctru/default_icon.png /Users/erik/development/3ds-examples/graphics/printing/hello-world/hello-world.smdh
3dsxtool /Users/erik/development/3ds-examples/graphics/printing/hello-world/hello-world.elf /Users/erik/development/3ds-examples/graphics/printing/hello-world/hello-world.3dsx --smdh=/Users/erik/development/3ds-examples/graphics/printing/hello-world/hello-world.smdh
What happens here? There are five main steps:
- compile our code into an object file
main.o
- link our object file with
libctru
into an ELF filehello-world.elf
- (optional) list the symbols of the created file and output them to file
hello-world.lst
- generate some metadata for our homebrew application as an
.smdh
file - bundle everything together into
hello-world.3dsx
So, the only place where our application code is used directly is when compiling it into an object file in step one. This will be important later.
The Zig Programming Language
Zig is a general-purpose programming language and toolchain for maintaining robust, optimal and reusable software.
Zig is currently under rapid development and there may be breaking changes, and rightfully so. My preferred way of avoiding compatibility issues between my code and a potentially upgraded version of Zig, is to use a Nix flake to pin the relevant version. At the time of writing this, I am on version 0.13.0
.
Zig As Your Build System
Zig is not only a language, but a build system as well. We can implement these steps in a build.zig
file; essentially just Zig code which declaratively defines the build steps. Here, we’re being lazy and just copying the output we previously saw from the Makefile and throwing it into a WriteFiles
step.
const std = @import("std");
const builtin = @import("builtin");
pub fn build(b: *std.Build) void {
const wf = b.addWriteFiles();
// step 1
const obj = b.addSystemCommand(&.{"/opt/devkitpro/devkitARM/bin/arm-none-eabi-gcc"});
obj.addArgs(&.{ "-MMD", "-MP", "-g", "-Wall", "-O2", "-mword-relocations", "-ffunction-sections", "-march=armv6k", "-mtune=mpcore", "-mfloat-abi=hard", "-mtp=soft", "-I/opt/devkitpro/libctru/include", "-D__3DS__", "-c", "/Users/erik/development/3ds-examples/graphics/printing/hello-world/source/main.c" });
const obj_out = obj.addPrefixedOutputFileArg("-o", "main.o");
// step 2
const extension = if (builtin.target.os.tag == .windows) ".exe" else "";
const elf = b.addSystemCommand(&(.{ "/opt/devkitpro/devkitARM/bin/arm-none-eabi-gcc" ++ extension }));
elf.setCwd(wf.getDirectory());
elf.addArgs(&.{ "-specs=3dsx.specs", "-g", "-march=armv6k", "-mtune=mpcore", "-mfloat-abi=hard", "-mtp=soft" });
_ = elf.addPrefixedOutputFileArg("-Wl,-Map,", "hello-world.map");
elf.addFileArg(obj_out);
elf.addArgs(&.{ "-L/opt/devkitpro/libctru/lib", "-lctru" });
const out_elf = elf.addPrefixedOutputFileArg("-o", "hello-world.elf");
// step 4
const smdh = b.addSystemCommand(&.{"/opt/devkitpro/tools/bin/smdhtool"});
smdh.setCwd(wf.getDirectory());
smdh.addArgs(&.{ "--create", "hello-world", "Built with Zig, devkitARM, and libctru", "erikwastaken", "/opt/devkitpro/libctru/default_icon.png" });
const out_smdh = smdh.addOutputFileArg("hello-world.smdh");
// step 5
const dsx = b.addSystemCommand(&.{"/opt/devkitpro/tools/bin/3dsxtool" ++ extension});
dsx.setCwd(wf.getDirectory());
dsx.addFileArg(out_elf);
const out_dsx = dsx.addOutputFileArg("hello-world.3dsx");
dsx.addPrefixedFileArg("--smdh=", out_smdh);
const install_3dsx = b.addInstallFileWithDir(out_dsx, .prefix, "hello-world.3dsx");
b.getInstallStep().dependOn(&install_3dsx.step);
}
This is already an improvement. The normal build process generates a number of intermediate files, but we are really only interested in the final .3dsx
. This build.zig
keeps the intermediate files in the .zig-cache
directory and only outputs (installs) the final executable, by default into the zig-out
directory. But we can control the install directory by providing a prefix
to the build command:
zig build --prefix ~/3ds-homebrew
At this point though, this is still a glorified Makefile.
Zig As Your C Compiler
Zig is not only a language and a build system, it is also a drop-in C compiler! This means we can improve our build.zig
by changing the first step by using Zig to compile our object file:
pub fn build(b: *std.Build) void {
const wf = b.addWriteFiles();
// step 1
const optimize = b.standardOptimizeOption(.{});
const target: std.Target.Query = .{
.cpu_arch = .arm,
.os_tag = .freestanding,
.abi = .eabihf,
.cpu_model = .{ .explicit = &std.Target.arm.cpu.mpcore },
};
const obj = b.addObject(.{
.name = "aoc24",
.target = b.resolveTargetQuery(target),
.optimize = optimize,
});
obj.addCSourceFiles(.{
.root = b.path("source/"),
.files = &.{"main.c"},
.flags = &.{ "-MMD", "-MP", "-g", "-Wall", "-O2", "-ffunction-sections", "-march=armv6k", "-mtune=mpcore", "-mfloat-abi=hard", "-mtp=soft", "-D__3DS__"
},
});
obj.addIncludePath(.{ .src_path = .{ .owner = b, .sub_path = "/opt/devkitpro/libctru/include" } });
obj.addIncludePath(.{ .src_path = .{ .owner = b, .sub_path = "/opt/devkitpro/devkitARM/arm-none-eabi/include" } });
// step 2
const extension = if (builtin.target.os.tag == .windows) ".exe" else "";
const elf = b.addSystemCommand(&(.{ "/opt/devkitpro/devkitARM/bin/arm-none-eabi-gcc" ++ extension }));
elf.setCwd(wf.getDirectory());
elf.addArgs(&.{ "-specs=3dsx.specs", "-g", "-march=armv6k", "-mtune=mpcore", "-mfloat-abi=hard", "-mtp=soft" });
_ = elf.addPrefixedOutputFileArg("-Wl,-Map,", "hello-world.map");
elf.addArtifactArg(obj);
//...
}
Everything else can stay the same. Doing this has a couple of advantages; first, we get cross-compilation for our object file. Granted, this is not really relevant for our use-case where we only want to target one architecture, but it is mentioned as something that should not be taken lightly.
It also means that we can maintain our homebrew application in Zig, or even write it from scratch.
Zig As Your Linker
After getting step 1 to work with Zig as our compiler, it would make sense to also use it as our linker in step 2, right? Get rid of our dependency on the devkitPro gcc
. Well, unfortunately, after a few hours of reading documentation, looking at output of zig build --verbose-link
, and banging my head against the wall, I’m ready to admit that I don’t know how to make it work.
It should look something like this:
// step 2
const elf = b.addExecutable(.{
.name = "main.elf",
.target = b.resolveTargetQuery(target),
.optimize = optimize,
});
elf.addObject(o);
elf.linkLibC();
elf.setLibCFile(b.path("libc.txt"));
elf.setLinkerScript(.{ .src_path = .{ .owner = b, .sub_path = "/opt/devkitpro/devkitARM/arm-none-eabi/lib/3dsx.ld" } });
elf.addLibraryPath(.{ .src_path = .{ .owner = b, .sub_path = "/opt/devkitpro/libctru/lib" } });
elf.addLibraryPath(.{ .src_path = .{ .owner = b, .sub_path = "/opt/devkitpro/devkitARM/arm-none-eabi/lib" } });
elf.linkSystemLibrary("ctru");
elf.addCSourceFiles(.{
.root = b.path("source/"),
.files = &.{},
.flags = &.{
"-g",
"-march=armv6k",
"-mtune=mpcore",
"-mfloat-abi=hard",
"-mtp=soft",
"--ignore-missing",
"--defsym=__sync_synchronize=__sync_synchronize_dmb", // from dmb-sync.specs
"-d --emit-relocs --use-blx --gc-sections", // from 3dsx.specs
},
});
//...
// step 5
const dsx = b.addSystemCommand(&.{"/opt/devkitpro/tools/bin/3dsxtool" ++ extension});
dsx.setCwd(wf.getDirectory());
dsx.addArtifactArg(elf);
//...
The libc.txt
tells the linker where to look for the files. In our case it would look something like this:
# The directory that contains `stdlib.h`.
# On POSIX-like systems, include directories be found with: `cc -E -Wp,-v -xc /dev/null`
include_dir=/opt/devkitpro/devkitARM/arm-none-eabi/include
# The system-specific include directory. May be the same as `include_dir`.
# On Windows it's the directory that includes `vcruntime.h`.
# On POSIX it's the directory that includes `sys/errno.h`.
sys_include_dir=/opt/devkitpro/devkitARM/arm-none-eabi/sys/include
# The directory that contains `crt1.o` or `crt2.o`.
# On POSIX, can be found with `cc -print-file-name=crt1.o`.
# Not needed when targeting MacOS.
crt_dir=/opt/devkitpro/devkitARM/arm-none-eabi/lib/armv6k/fpu
# The directory that contains `vcruntime.lib`.
# Only needed when targeting MSVC on Windows.
msvc_lib_dir=
# The directory that contains `kernel32.lib`.
# Only needed when targeting MSVC on Windows.
kernel32_lib_dir=
# The directory that contains `crtbeginS.o` and `crtendS.o`
# Only needed when targeting Haiku.
gcc_dir=/opt/devkitpro/devkitARM/lib/gcc/arm-none-eabi/14.1.0
You can generate such a file with the system’s default locations via zig libc
.
But the linker fails because it can’t find three libraries: ld
, rt
, and util
. It is my understanding that these libraries are often not implemented for systems like gaming consoles, so it may be that the devkitPro linker has some special handling for this. At this point it is, of course, equally likely that I just don’t know how to tell the linker not to look for them. A project for another day.
From C to Zig
At the moment, our hello-world app looks something like this:
// main.c
#include <3ds.h>
#include <stdio.h>
int main(int argc, char **argv)
{
();
gfxInitDefault(GFX_TOP, NULL);
consoleInit("\x1b[16;20HHello World!");
printf("\x1b[30;16HPress Start to exit.");
printf
while (aptMainLoop())
{
();
hidScanInput
= hidKeysDown();
u32 kDown
if (kDown & KEY_START) break;
();
gfxFlushBuffers();
gfxSwapBuffers();
gspWaitForVBlank}
();
gfxExitreturn 0;
}
This is essentially just boilerplate to initialize the libctru
data structures and start up the main event loop. Printing to the console is done via printf
and allows the use of standard VT100 ANSI escape sequences; in this case we use \x1b[{line};{column}H
to set the cursor to the respective line and column, but we could also color the printed text, clear the terminal, and do many more things. This is also most likely how your favorite terminal emulator does things.
But what if it could look more like this?
// main.zig
const std = @import("std");
const ds = @import("3ds.zig");
export fn main(_: c_int, _: [*]const [*:0]const u8) void {
ds.gfxInitDefault();
defer ds.gfxExit();
_ = ds.consoleInit(ds.GFX_TOP, null);
_ = ds.printf("\x1b[16;20HHello World!");
_ = ds.printf("\x1b[30;16HPress Start to exit.\n");
while (ds.aptMainLoop()) {
ds.hidScanInput();
const kDown = ds.hidKeysDown();
if (kDown & ds.KEY_START > 0) break;
ds.gfxFlushBuffers();
ds.gfxSwapBuffers();
ds.gspWaitForEvent(ds.GSPGPU_EVENT_VBlank0, true);
}
}
All that’s required for this to work is importing the C files; in our case we put this in a separate 3ds.zig
file which we then @import
in main.zig
.
// 3ds.zig
pub usingnamespace @cImport({
@cInclude("stdio.h");
@cInclude("3ds.h");
});
That’s it! This generates a cimport.zig
file (in the .zig-cache
directory) which contains all the declarations required to call the functions from both stdio.h
and 3ds.h
.
How it’s going
Okay, look, I know it looks almost identical for a trivial Hello World application, but think of the possibilities! The devil is in the details. We can use pretty much all the facilities that Zig brings to the table for our application code, like defer
, allocators, and comptime
. Then compile it into an object file and pass that on to the devkitPro toolchain.
The only caveat are things that use the operating or file system directly, e.g., we can’t use std.io.BufferedReader
. That said, we can use std.heap.c_allocator
to allocate memory, e.g., for a std.ArrayList
.
I hope to showcase a more interesting application in a future blog post!
Finally, I would like to voice my deep appreciation for everyone working on Luma3DS and devkitPro! Your work has provided me with days of entertainment already and will likely continue to do so for the next weeks and months! Please don’t take this post as me being unhappy with your toolchains. This is just me playing around and seeing what’s possible!