ASMATI・TATSUMIEXPERIMENT NOTE 02
Calling Nim Functions Through a Public C API
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.
| Item | Condition |
|---|---|
| OS | macOS 26.5.2 |
| CPU | Apple Silicon |
| Nim | 2.2.10 |
| C compiler | Apple clang 21.0.0 |
| Memory manager | ORC |
| Build mode | debug (opt: none) |
| Consumer-side C source | C11 |
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.
proc asmati_add_ints(left, right: cint): cint {.exportc, dynlib, cdecl.} =
left + right
proc asmati_half(value: cdouble): cdouble {.exportc, dynlib, cdecl.} =
value / 2.0The 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.
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.
#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.
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.
#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.dylibThe output includes the two functions and the Nim initialization function.
000000000000cc08 T _NimDestroyGlobals
000000000000cc40 T _NimMain
000000000000c9f8 T _asmati_add_ints
000000000000cba4 T _asmati_halfMach-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.
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.
#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.
#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_callerThe program prints the following values.
add(19,23)=42
half(3.5)=1.75Finally, nm -u lists the unresolved symbols that the executable requests from external libraries.
nm -u observed/bin/c_caller_asmati_add_ints
_asmati_half
_printfThe 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.