asopi tech
asopi techIndie Developer

ASMATI・TATSUMIEXPERIMENT NOTE 02

Calling Nim Functions Through a Public C API

Published: Aug 27, 2026Reading time: ~7 min

Asmati Tatsumi — “What High-Level Languages Do at the Low Level,” Part 2

This series explains how the Nim compiler translates Nim programs into C by reading the generated C code. Part 2 compiles two functions (proc declarations) into a dynamic library for C, calls them from a C program that uses the generated header, and follows that path through the public symbols and Nim initialization.

1. Conditions

This experiment builds a macOS dynamic library from Nim and calls two functions from an independent C program. The recorded conditions are listed below.

ItemCondition
OSmacOS 26.5.2
CPUApple Silicon
Nim2.2.10
C compilerApple clang 21.0.0
Memory managerORC
Build modedebug (opt: none)
Consumer-side C sourceC11

2. Two functions exported for C

Nim’s cint corresponds to C int, and cdouble corresponds to C double. Nim 2.2.10 system/ctypes defines them as cint = int32 and cdouble = float64.

The Nim source defines one function (proc declaration) with two cint arguments and another with one cdouble argument.

scalar_api.nim
proc asmati_add_ints(left, right: cint): cint {.exportc, dynlib, cdecl.} =
  left + right

proc asmati_half(value: cdouble): cdouble {.exportc, dynlib, cdecl.} =
  value / 2.0

The function asmati_add_ints adds its parameters left and right and returns the integer result as a cint. The function asmati_half divides its parameter value by 2.0 and returns the halved value as a cdouble. The parameters left and right use cint, while value uses cdouble.

The exportc pragma in the Nim 2.2.10 Manual emits a name that C can use. Exporting a function from a dynamic library combines dynlib with exportc. The cdecl pragma selects the same calling convention as the C compiler.

3. Files produced by the compilation

The following video produces generated C, a C header, and a dynamic library from the Nim source.

The video runs the compilation command and verifies the generated C source, C header, and dynamic library files.

動画を開く

Each option in the command shown in the video produces the following files.

--nimcache:observed/nimcache

This option produces observed/nimcache/@mscalar_api.nim.c and object files. The first file is C generated from the Nim source, and the object files are the compiled results of that C source.

--header:scalar_api.h

This option produces observed/nimcache/scalar_api.h. The header contains the declarations required to call the two functions from C.

--out:observed/bin/libscalar_api.dylib

This option produces observed/bin/libscalar_api.dylib, the dynamic library linked from the compiled object files.

scalar_api.h contains these declarations. The experiment repository also saves the same content as observed/scalar_api.h for inspection.

scalar_api.h
#define NIM_INTBITS 64

#include "nimbase.h"

N_LIB_IMPORT N_CDECL(int, asmati_add_ints)(int left_p0, int right_p1);
N_LIB_IMPORT N_CDECL(double, asmati_half)(double value_p0);
N_LIB_IMPORT N_CDECL(void, NimMain)(void);

The arguments and return value of asmati_add_ints use C int. The argument and return value of asmati_half use C double. Part 1 examined Nim int and float; this source uses cint and cdouble, so the generated header states the C types directly.

The generated scalar_api.h includes the runtime header lib/nimbase.h shipped with Nim 2.2.10. Compiling C source that uses the generated header therefore requires both the directory containing the generated header and the lib directory from Nim 2.2.10 on the include path.

4. Generated C used to build the dynamic library

observed/nimcache/@mscalar_api.nim.c is the C source that the Nim compiler generates from src/scalar_api.nim. Its compiled object file is linked into libscalar_api.dylib.

The following code extracts the two functions from @mscalar_api.nim.c. The file observed/generated_c_excerpt.c in the experiment repository stores this excerpt as text for verification; it is not the dynamic library.

@mscalar_api.nim.c
N_LIB_EXPORT N_CDECL(int, asmati_add_ints)(int left_p0, int right_p1) {
  int result;
  NI temporary;
  result = (int)0;
  if (nimAddInt(left_p0, right_p1, &temporary)) {
    raiseOverflow();
    goto BeforeRet_;
  }
  result = (int)(temporary);
BeforeRet_:
  return result;
}

N_LIB_EXPORT N_CDECL(double, asmati_half)(double value_p0) {
  double result;
  result = ((NF)(value_p0) / (NF)(2.0));
  return result;
}

The public signature of asmati_add_ints accepts two int values and returns an int. In the function body, nimAddInt writes the sum to the local variable temporary, whose type is NI, and calls raiseOverflow when it detects an overflow. The function then casts temporary to int and returns it.

The public signature of asmati_half accepts one double and returns a double. In the function body, both value_p0 and 2.0 are cast to NF before the division, and the result is assigned to the local double variable result. NI and NF are Nim runtime type aliases; under the conditions used here, they expand to int64_t and double, respectively.

Under the C11 and Apple clang conditions used here, the lib/nimbase.h shipped with Nim 2.2.10 selects these definitions.

nimbase.h
#ifdef __cplusplus
#  define NIM_EXTERNC extern "C"
#else
#  define NIM_EXTERNC
#endif

#define N_CDECL(rettype, name) rettype name
#define N_LIB_EXPORT NIM_EXTERNC __attribute__((visibility("default")))

For C11, N_LIB_EXPORT expands to __attribute__((visibility("default"))). N_CDECL(int, asmati_add_ints) becomes int asmati_add_ints, producing a C function with default visibility. When the same header is compiled as C++, NIM_EXTERNC adds extern "C".

5. Public symbols in the dynamic library

The following command lists the public symbols in libscalar_api.dylib.

nm -gU observed/bin/libscalar_api.dylib

The output includes the two functions and the Nim initialization function.

000000000000cc08 T _NimDestroyGlobals
000000000000cc40 T _NimMain
000000000000c9f8 T _asmati_add_ints
000000000000cba4 T _asmati_half

Mach-O nm displays C symbols with a leading underscore. _asmati_add_ints and _asmati_half correspond to the generated C function names asmati_add_ints and asmati_half. The declarations in the header and the public symbols in the dynamic library identify the same two functions.

6. Nim initialization through a library constructor

Before calling the two functions from the C program, we check where the Nim runtime is initialized. The generated C contains the function NimMainInit, which calls NimMain.

@mscalar_api.nim.c
N_LIB_EXPORT N_CDECL(void, NimMain)(void) {
  PreMain();
  NimMainInner();
}

N_LIB_PRIVATE void NIM_POSIX_INIT NimMainInit(void) {
  NimMain();
}

The function NimMain calls PreMain() and NimMainInner() to initialize the Nim runtime and module. The body of NimMainInit calls that NimMain() function.

The lib/nimbase.h shipped with Nim 2.2.10 defines NIM_POSIX_INIT as the following attribute.

nimbase.h
#define NIM_POSIX_INIT __attribute__((constructor))

A function with the constructor function attribute runs automatically before program execution enters main. In this generated dynamic library, NimMainInit runs before main and calls NimMain.

Nim Backend Integration 2.2.10 explains that Nim code requires initialization through NimMain before C or C++ uses it. In this macOS/POSIX dynamic library, the generated library constructor contains that initialization call.

7. Implementing the caller

We implement the caller in src/caller.c using the generated header. This C program invokes both public functions.

caller.c
#include <stdio.h>

#include "scalar_api.h"

int main(void) {
  const int sum = asmati_add_ints(19, 23);
  const double half = asmati_half(3.5);

  printf("add(19,23)=%d\n", sum);
  printf("half(3.5)=%.2f\n", half);

  if (sum != 42 || half != 1.75) {
    return 1;
  }
  return 0;
}

#include <stdio.h> provides the declaration of printf, which displays the results. #include "scalar_api.h" brings the declarations of asmati_add_ints and asmati_half into the caller from the header generated by the Nim compiler. The caller uses that generated header instead of repeating the two function declarations.

The local variable sum receives the return value from asmati_add_ints, while half receives the return value from asmati_half. The main function contains no call to NimMain.

The following clang command compiles src/caller.c, links it against the dynamic library, and creates the executable observed/bin/c_caller. The next command runs that executable.

clang -std=c11 -Wall -Wextra -Werror \
  -Iobserved -I<NIM_LIB> \
  src/caller.c \
  -Lobserved/bin -lscalar_api \
  -Wl,-rpath,@loader_path \
  -o observed/bin/c_caller

./observed/bin/c_caller

The program prints the following values.

add(19,23)=42
half(3.5)=1.75

Finally, nm -u lists the unresolved symbols that the executable requests from external libraries.

nm -u observed/bin/c_caller
_asmati_add_ints
_asmati_half
_printf

The executable observed/bin/c_caller requests the functions asmati_add_ints and asmati_half from the dynamic library. _NimMain is absent from the output, so the caller does not reference NimMain directly. The library constructor examined in the previous section calls NimMain and initializes the Nim runtime.

8. Evidence for a public C API

A Nim function used through a public C API has a C declaration in the generated header and a matching public symbol in the library. Consumer-written C source compiles and links against those artifacts, then invokes the function after the Nim runtime has been initialized.

The steps above trace two functions marked with exportc, dynlib, and cdecl from their Nim definitions through the C program’s execution output. The Nim source, caller source, generated header, generated C excerpt, public symbols, linkage data, execution output, and verification scripts are available in asmati-lab experiment 006.

References