The C ABI: Using Assembly with Zig
TweetThu 14 May 2026
I am currently reading Modern x86 Assembly Language Programming by Daniel Kusswurm. The book provides examples in C++, NASM, and MASM, but since I'm more interested in Zig, I’ve decided to follow along using it as my primary language.
As we'll see in this post, the C ABI (Application Binary Interface) is the cornerstone of language interoperability. You can find the code on GitHub.
C++ example
The first example in the book shows us how to write an assembly function AddSubI32_a
and call it on C++ side.
Here is a NASM implementation nasm/ch02_01.asm.
section .text
global AddSubI32_a
; Calculates (a + b) - (c + d) + 7.
AddSubI32_a:
add edi, esi ; edi = edi + esi = a + b
add edx, ecx ; edx = edx + ecx = c + d
sub edi, edx ; edi = edi - edx
add edi, 7 ; edi = edi + 7
mov eax, edi ; eax = edi
ret ; return to caller
The global assembly directive makes the AddSubI32_a symbol visible to the C++ linker.
This is how the function is declared and called in the C++ program cpp/main.cpp.
#include <iostream>
// Calculates (a + b) - (c + d) + 7.
extern "C" int AddSubI32_a(int a, int b, int c, int d);
int main() {
int r = AddSubI32_a(1, 2, 3, 4);
std::cout << "C++ says " << r << std::endl;
return 0;
}
Let's assemble the NASM file to create the object file ch02_01.o:
nasm -f macho64produces Mach-O x86-64 file. If you're on Linux, you would needelf64format, seenasm -hf.nm ch02_01.oshows a symbol table of the object filech02_01.o. The capitalT(text section symbol) inT AddSubI32_atells us that the function is global (local symbols are lower caset).
﹩ nasm -f macho64 ./nasm/ch02_01.asm -o ch02_01.o
﹩ nm ch02_01.o
0000000000000000 T AddSubI32_a
The extern "C" keyword instructs the C++ compiler to adhere to the C ABI
when dealing with this function, e.g.,
use C-style naming (no name mangling)
so the same AddSubI32_a symbol is used consistently in C++ and assembly.
Let's compile the C++ file into an object file main.o without linking it yet:
- the capital
UinU _AddSubI32_astands for Undefined global symbol, i.e., themain.oknows that the function exists but doesn't know where its machine code is T _mainsays that the_mainsymbol is global and present in the text section of this object file
﹩ g++ -c ./cpp/main.cpp -o main.o
﹩ nm main.o
000000000000019c s GCC_except_table3
U _AddSubI32_a
...
0000000000000000 T _main
The linker should produce the executable file main since it can resolve
the undefined symbol from main.o to the one defined in ch02_01.o.
﹩ g++ main.o ch02_01.o -o main
Undefined symbols for architecture x86_64:
"_AddSubI32_a", referenced from:
_main in main.o
ld: symbol(s) not found for architecture x86_64
Unfortunately, it failed to link because the symbols are different: U _AddSubI32_a vs T AddSubI32_a.
The main.o file expects the symbol to have an underscore on macOS, i.e., _AddSubI32_a.
Once the function is renamed from AddSubI32_a to _AddSubI32_a in nasm/ch02_01.asm
we finally get our program working:
﹩ nasm -f macho64 ./nasm/ch02_01.asm -o ch02_01.o
﹩ nm ch02_01.o
0000000000000000 T _AddSubI32_a
﹩ g++ main.o ch02_01.o -o main
﹩ ./main
C++ says 3
Note, you don't need to add an underscore if you're on Linux.
Zig example
Zig can compile C and C++ code for different architectures and operating systems
because it comes with Clang/LLVM embedded inside it.
Here is how we can link main.o and ch02_01.o to get the executable file main.
﹩ zig c++ main.o ch02_01.o -o main
These examples compile C, C++ code and link with ch02_01.o object file that we built with NASM.
﹩ zig cc ./c/main.c ch02_01.o -o main
﹩ zig c++ ./cpp/main.cpp ch02_01.o -o main
Of course we can write a Zig equivalent of C/C++ program, see zig/main.zig.
Just like C/C++, Zig supports extern and callconv(.c) keywords
to use the C ABI when calling AddSubI32_a().
The extern defaults to the C calling convention, so we can actually omit callconv(.c) keyword.
const std = @import("std");
// Calculates (a + b) - (c + d) + 7.
extern fn AddSubI32_a(a: i32, b: i32, c: i32, d: i32) callconv(.c) i32;
pub fn main() void {
const r = AddSubI32_a(1, 2, 3, 4);
std.debug.print("Zig says {d}\n", .{r});
}
As we can see, it's compiled and linked with ch02_01.o no problem.
﹩ zig build-exe ./zig/main.zig ch02_01.o --name main
﹩ ./main
Zig says 3
Since Zig includes Clang/LLVM, we can use it to build everything (including our assembly code).
﹩ zig build-exe ./zig/main.zig ./llvm/ch02_01_att.s --name main
The caveat is that we have to use LLVM Assembler which mimics GAS (GNU Assembler),
check out llvm/ch02_01_att.s example.
.text
.global _AddSubI32_a
# Calculates (a + b) - (c + d) + 7.
_AddSubI32_a:
addl %esi, %edi # edi = edi + esi
addl %ecx, %edx # edx = edx + ecx
subl %edx, %edi # edi = edi - edx
addl $7, %edi # edi = edi + 7
movl %edi, %eax # eax = edi
ret # return to caller
GAS prefers .s file extension instead of .asm, and you've probably noticed that
the assembly code looks quite different from NASM:
- directives start with a dot (
.globalvsglobal) - comments start with
#instead of; - GAS uses AT&T-style syntax, so operand order is reversed,
registers have
%prefix, instructions have size suffixes (linaddlmeans long), and scalars have$prefixes, e.g.,addl $7, %edi(AT&T) vsadd edi, 7(Intel)
The .intel_syntax noprefix directive helps us keep the code changes minimal.
It tells the LLVM Assembler to use Intel-style syntax like in NASM, for example llvm/ch02_01_intel.s:
.intel_syntax noprefix
.text
.global _AddSubI32_a
# Calculates (a + b) - (c + d) + 7.
_AddSubI32_a:
add edi, esi # edi = edi + esi = a + b
add edx, ecx # edx = edx + ecx = c + d
sub edi, edx # edi = edi - edx
add edi, 7 # edi = edi + 7
mov eax, edi # eax = edi
ret # return to caller
Let's build our Zig program with that Intel-style assembly code,
but this time we'll leverage build.zig.
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "main",
.root_module = b.createModule(.{
.root_source_file = b.path("zig/main.zig"),
.target = target,
.optimize = optimize,
}),
});
exe.root_module.addAssemblyFile(b.path("llvm/ch02_01_intel.s"));
b.installArtifact(exe);
}
The zig build will make the main program just like zig build-exe did.
﹩ zig build
﹩ ./zig-out/bin/main
Zig says 3
C ABI
We instructed C++ and Zig to adhere to the C ABI when calling AddSubI32_a() function.
But what does it mean for the assembly side that implements it?
The C ABI defines a set of calling conventions and data layouts specific
to a given CPU architecture and operating system.
This implies that the assembly code for AddSubI32_a(a: i32, b: i32, c: i32, d: i32) i32
will look different on Linux x86-64 vs Windows x86-64.
Here is NASM and MASM code to quickly demonstrate System V AMD64 ABI (Linux/macOS) and Microsoft x64 ABI (Windows) basics:
- on Linux/macOS we pass the
a,b,c,d32-bit arguments inedi,esi,edx,ecxregisters, and return the 32-bit integer value fromeaxregister - on Windows we use
ecx,edx,r8d,r9dinstead
; NASM
AddSubI32_a:
add edi, esi ; edi = edi + esi = a + b
add edx, ecx ; edx = edx + ecx = c + d
sub edi, edx ; edi = edi - edx
add edi, 7 ; edi = edi + 7
mov eax, edi ; eax = edi
ret ; return to caller
; MASM
AddSubI32_a proc
add ecx, edx ; ecx = ecx + edx = a + b
add r8d, r9d ; r8d = r8d + r9d = c + d
sub ecx, r8d ; ecx = ecx - r8d
add ecx, 7 ; ecx = ecx + 7
mov eax, ecx ; eax = ecx
ret ; return to caller
AddSubI32_a endp
Since I am writing this post on macOS (Intel i5-10600), I'll stick to System V AMD64 ABI. Wikipedia outlines it as follows:
- integer arguments are passed in
rdi,rsi,rdx,rcx,r8, andr9. The 8, 16, and 32-bit integers are passed in the low-order bits (the high-order bits are undefined). - floating point arguments go in
xmm0-xmm7(bits[31..0]for float,[63..0]for double, all other bits are undefined) - the return value go in
rax/rdxfor integer, andxmm0/xmm1for floating point. If a return value is larger than 16 bytes, the caller allocates a return struct on its stack. That struct address is passed inrdiregister, so the first argument has to go inrsi. Upon returning theraxmust contain the memory address it got fromrdiregister. For example,rdi=&r,rsi=a,rdx=bwhen a function is called as followsr = f(a, b). - additional arguments are pushed onto the stack in reverse order
- for variadic calls such as
printf, thealregister must be set to the number of usedxmmregisters, set it zero if none are used - caller-saved (volatile, can be used freely):
rax,rcx,rdx,rdi,rsi,r8-r11 - callee-saved (non-volatile, the function must restore them):
rbx,rsp,rbp,r12-r15 - bits
[255..128]of registersymm0-ymm15are volatile, - registers
zmm16-zmm31are volatile RFLAGS.DF(direction flag for string instructions) andMXCSR.RC(rounding mode control flag) are non-volatile. IfRFLAGS.DFis set, it must be zeroed before a function call or return. IfMXCSR.RCis modified, the original rounding mode must be restored.- the stack must be aligned to a 16-byte boundary before calling a function,
i.e.,
rspmust be a multiple of 16 - leaf functions can use 128-byte area (the red zone) below
rspfor temporary data without moving the stack pointer (this works only in user-space) - struct members must be aligned to addresses that are multiples of their size, and a struct's total size must be a multiple of its largest member's alignment
| register | volatile? | usage |
|---|---|---|
| rax | yes | 1st int return value |
| rbx | no | long-lived local variables |
| rcx | yes | 4th int argument |
| rdx | yes | 3rd int argument, 2nd int return value |
| rsi | yes | 2nd int argument |
| rdi | yes | 1st int argument |
| rbp | no | stack frame pointer or scratch |
| rsp | no | stack pointer |
| r8 | yes | 5th int argument |
| r9 | yes | 6th int argument |
| r10 | yes | scratch (temporary) |
| r11 | yes | scratch |
| r12-15 | no | long-lived local variables |
| xmm0 | yes | 1st float argument, return value |
| xmm1 | yes | 2nd float argument, return value |
| xmm2 | yes | 3rd float argument |
| xmm3 | yes | 4th float argument |
| xmm4 | yes | 5th float argument |
| xmm5 | yes | 6th float argument |
| xmm6 | yes | 7th float argument |
| xmm7 | yes | 8th float argument |
| xmm8-15 | yes | scratch |
| zmm16-31 | yes | scratch |
If we follow the aforementioned rules when writing an assembly code (or any code really),
we can use our function with any language as long as it supports this calling convention.
For instance, AddSubI32_a can be implemented in Zig and called from C++ using the C ABI.
We just need to use the export keyword instead of extern, see zig/ch02_01.zig.
export fn AddSubI32_a(a: i32, b: i32, c: i32, d: i32) callconv(.c) i32 {
return (a + b) - (c + d) + 7;
}
Let's build it and use in the C++ program.
﹩ zig build-obj zig/ch02_01.zig -O ReleaseFast -femit-bin=ch02_01.o
﹩ nm ch02_01.o
0000000000000000 T _AddSubI32_a
0000000000000000 t _ch02_01.AddSubI32_a
﹩ g++ ./cpp/main.cpp ch02_01.o -o main
﹩ ./main
C++ says 3
The static library can also be created with the following build.zig.
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const lib = b.addLibrary(.{
.linkage = .static,
.name = "ch02_01",
.root_module = b.createModule(.{
.root_source_file = b.path("zig/ch02_01.zig"),
.target = target,
.optimize = optimize,
}),
});
b.installArtifact(lib);
}
It produces zig-out/lib/libch02_01.a archive that contains an object file libch02_01_zcu.o.
﹩ zig build -Doptimize=ReleaseFast
﹩ nm zig-out/lib/libch02_01.a
libch02_01_zcu.o:
0000000000000000 T _AddSubI32_a
0000000000000000 t _ch02_01.AddSubI32_a
﹩ g++ ./cpp/main.cpp ./zig-out/lib/libch02_01.a -o main
﹩ ./main
C++ says 3
To sum up, we have successfully compiled the Zig and assembly code into a static library and linked it across C, C++, and Zig. It all worked seamlessly thanks to the C ABI!
References:
- Modern x86 Assembly Language Programming by Daniel Kusswurm
- wiki/Name_mangling
- wiki/X86_calling_conventions
Category: Zig Tagged: assembler zig