The C ABI: Using Assembly with Zig

Thu 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 macho64 produces Mach-O x86-64 file. If you're on Linux, you would need elf64 format, see nasm -hf.
  • nm ch02_01.o shows a symbol table of the object file ch02_01.o. The capital T (text section symbol) in T AddSubI32_a tells us that the function is global (local symbols are lower case t).
﹩ 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 U in U _AddSubI32_a stands for Undefined global symbol, i.e., the main.o knows that the function exists but doesn't know where its machine code is
  • T _main says that the _main symbol 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 (.global vs global)
  • comments start with # instead of ;
  • GAS uses AT&T-style syntax, so operand order is reversed, registers have % prefix, instructions have size suffixes (l in addl means long), and scalars have $ prefixes, e.g., addl $7, %edi (AT&T) vs add 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, d 32-bit arguments in edi, esi, edx, ecx registers, and return the 32-bit integer value from eax register
  • on Windows we use ecx, edx, r8d, r9d instead
; 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, and r9. 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/rdx for integer, and xmm0/xmm1 for 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 in rdi register, so the first argument has to go in rsi. Upon returning the rax must contain the memory address it got from rdi register. For example, rdi=&r, rsi=a, rdx=b when a function is called as follows r = f(a, b).
  • additional arguments are pushed onto the stack in reverse order
  • for variadic calls such as printf, the al register must be set to the number of used xmm registers, 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 registers ymm0-ymm15 are volatile,
  • registers zmm16-zmm31 are volatile
  • RFLAGS.DF (direction flag for string instructions) and MXCSR.RC (rounding mode control flag) are non-volatile. If RFLAGS.DF is set, it must be zeroed before a function call or return. If MXCSR.RC is modified, the original rounding mode must be restored.
  • the stack must be aligned to a 16-byte boundary before calling a function, i.e., rsp must be a multiple of 16
  • leaf functions can use 128-byte area (the red zone) below rsp for 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:

Category: Zig Tagged: assembler zig

comments


Hello World in avo 🥑

Tue 02 December 2025

Let's learn together how to write some Go assembly using avo aka writing assembly-like Go code to generate assembly. To make it more clear, here is an avo program add/asm.go.

package main

import asm "github.com/mmcloughlin/avo/build"

func main() {
    asm.TEXT("Add", asm.NOSPLIT, "func(x …

Category: Go Tagged: assembler golang

comments

Read More

Linux process

Tue 31 January 2023

Being curious about BPF, I studied source code of several programs from the BCC libbpf-tools. BPF performance tools book aided me to navigate BPF C code. For example, it explained that a BPF program has to use helpers because it can't access arbitrary memory (outside of BPF) and can't call …

Category: Infrastructure Tagged: architecture linux

comments

Read More

API based on Flask

Mon 09 December 2013

Here I want to consider implementation of API best practices which usually don't follow Fielding's REST strictly. Example Flask project is on GitHub.

API Versioning

Interfaces are changed hence versioning is mandatory in order to not annoy your users. You might need to add new resource or field to particular …

Category: Python Tagged: python flask api

comments

Read More

Preparation to Python Interview

Fri 02 November 2012

I decided to collect a little more information and experience during preparation to Python developer interview. These are some information and links which seemed important to me. Maybe it will be helpful.

How does it usually go?

What kind of projects did you participate in?

What did you do at …

Category: Python Tagged: python interview

comments

Read More

Django TODO: тестирование во время конструирования

Fri 29 June 2012

Тестирование, выполняемое разработчиками -- один из важнейших элементов полной стратегии тестирования.

Тестирование может указать только на отдельные дефектные области программы -- оно не сделает программу удобнее в использовании, более быстрой, компактной, удобочитаемой или расширяемой.

Цель тестирования противоположна целям других этапов разработки. Его целью является нахождение ошибок. Успешным считается тест, нарушающий работу ПО …

Category: Python Tagged: python django django-todo testing

comments

Read More

Django TODO: конструирование системы

Fri 29 June 2012

При работе над проектом конструирование включает другие процессы, в том числе проектирование. Формальная архитектура дает ответы только на вопросы системного уровня, при этом значительная часть проектирования может быть намеренно оставлена на этап конструирования. Проектирование -- это "постепенный" процесс. Проекты приложений не возникают в умах разработчиков сразу в готовом виде. Они развиваются …

Category: Python Tagged: python django django-todo construction

comments

Read More

Django TODO: проектирование архитектуры системы

Fri 29 June 2012

Следующим этапом разработки системы является проектирование архитектуры.

Архитектура должна быть продуманным концептуальным целым. Главный тезис самой популярной книги по разработке ПО "Мифический человеко-месяц" гласит, что основной проблемой, характерной для крупных систем, является поддержание их концептуальной целостности. Хорошая архитектура должна соответствовать проблеме [1].

Разделение системы на подсистемы на уровне архитектуры, позволяет …

Category: Python Tagged: python django django-todo architecture

comments

Read More

Django TODO: выработка требований к системе

Fri 29 June 2012

После прочтения Макконелла захотелось спроецировать его советы на Django. Для этого я взял за основу разработку системы Django TODO. Итак, первый этап -- выработка требований к системе.

Требования подробно описывают, что должна делать система. Внимание к требованиям помогает свести к минимуму изменения системы после начала разработки. Явные требования помогают гарантировать, что …

Category: Python Tagged: python django django-todo requirements

comments

Read More

Соглашения по разработке на Python/Django

Fri 29 June 2012

Во время разработки я часто сверяюсь с известными мне соглашениями, стараюсь следовать рекомендациям. Цитировать их не имеет смысла -- лучше приведу ссылки.

PEP 8 -- Style Guide for Python Code.

Code Like a Pythonista: Idiomatic Python. В нем я нашел ответы на вопросы форматирования длинных строк:

expended_time = (self.finish_date() - self.start_date
                 + datetime …

Category: Python Tagged: python django best practices

comments

Read More

Разделение настроек в Django

Fri 29 June 2012

В Django wiki собраны различные способы разделения настроек. Мне нравится вариант, описанный в блоге Senko Rašić:

settings/
├── __init__.py
├── base.py
├── development.py
├── local.py
└── production.py

base.py содержит общие настройки для development.py и production.py, например:

ADMINS = ()
MANAGERS = ADMINS

TIME_ZONE = 'Asia/Yekaterinburg'
# ...

production.py содержит настройки для …

Category: Python Tagged: python django settings

comments

Read More

Краткий обзор инфраструктуры для разработки reusable Django приложений

Wed 13 June 2012

Начиная впервые разрабатывать веб-приложения на новом фреймворке программист зачастую сталкивается с некоторыми трудностями. При разработке отчуждаемых веб-приложений на Django к этим проблемам необходимо отнести организацию файлов в проекте, обнаружение тестов, вопросы пакетирования приложений и организации автоматизированного тестирования. В данной статье приведены пути решения этих проблем.

Важно знать различия между двумя …

Category: Python Tagged: python django infrastructure

comments

Read More

Вычислительные методы одномерной оптимизации

Wed 06 October 2010

На третьем курсе по предмету методы оптимизации делали лабораторную работу на тему «Вычислительные методы одномерной оптимизации». Задача заключалась в поиске безусловного минимума функции f(x) = pow(x, 3) – x + pow(e, -x) на начальном интервале [0, 1] с точностью 0.00001.

Вычисления производились через:

  • пассивный метод;
  • равномерные блочные методы;
  • метод …

Category: Misc Tagged: php mathematical optimization

comments

Read More

Определение нажатия комбинации клавиш средствами BIOS на ассемблере

Thu 03 December 2009

По учебе понадобилось написать программу на ассемблере, которая должна распознать нажатие «горячей» комбинации клавиш LeftCtrl+RightShift+F3 и реагировать на него звуковым сигналом. Информации/примеров по этой теме маловато, по этому решил опубликовать свою программку.

masm
.model small
.stack 256
.data
    Msg_about db 'Распознать нажатие «горячей» комбинации клавиш', 0Ah, 0Dh …

Category: Misc Tagged: assembler

comments

Read More

Моделирование одноканальной СМО с отказами

Sat 30 May 2009

Дана одноканальная система массового обслуживания с отказами. В нее поступают заявки через промежуток времени n, где n – случайная величина, подчиненная равномерному закону распределения. Время обслуживания заявки системой m также является случайной величиной с показательным законом распределения. Если к моменту прихода заявки канал занят, заявка покидает систему необслуженной.

Изначально код был …

Category: Misc Tagged: python modeling single-channel queue

comments

Read More