feat: CBOR-free abi=c C bindings (-d:targetLang=c_abi) (#109)

This commit is contained in:
Gabriel Cruz 2026-07-06 13:48:08 -03:00 committed by GitHub
parent c91287ba22
commit 5eaa799b7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1570 additions and 34 deletions

View File

@ -168,6 +168,14 @@ jobs:
fi
nimble test_cpp_e2e -y
# The CBOR-free abi=c C binding (issue #105). Linux-only: the generated
# header is platform-checked by the check-bindings job, and the runtime
# path only needs one OS to exercise the flat-struct dispatch.
- name: Run abi=c C e2e test
if: matrix.label == 'Linux'
shell: bash
run: nimble test_c_abi_e2e -y
check-bindings:
# Single OS is enough — codegen output is platform-independent; the Nim
# matrix catches version-sensitive output (the PR #39 drift class).

View File

@ -101,3 +101,6 @@ jobs:
- name: Run C++ e2e tests (${{ inputs.sanitizer }})
run: nimble test_cpp_e2e_sanitized -y
- name: Run abi=c C e2e test (${{ inputs.sanitizer }})
run: nimble test_c_abi_e2e_sanitized -y

View File

@ -62,9 +62,22 @@ All notable changes to this project are documented in this file.
- `c` (flat C-struct) ABI **codec**: every `{.ffi: "abi = c".}` type gets a
`<T>_CWire` companion plus `cwirePack` / `cwireUnpack` / `cwireFree`. This
first slice covers the flat path — POD scalars and `string` (as `cstring`);
composite fields follow. The `c` proc-dispatch path and the foreign (C++ /
Rust) generators are still pending, so `c` remains rejected on
proc/ctor/dtor/event annotations for now.
composite fields follow. (The `c` proc-dispatch path and its CBOR-free C
generator landed later in this release — see the `-d:targetLang=c_abi` entry
below; `c` events remain CBOR-only.)
- **CBOR-free `abi = c` C binding generator** (`-d:targetLang=c_abi`): emits a
single self-contained `<lib>.h` whose flat `_CWire` structs *are* the C ABI,
so the C consumer passes native structs and links no CBOR at all (contrast
the CBOR `-d:targetLang=c` backend). The `c` proc-dispatch path is now wired
end-to-end: the generated exported wrappers `cwireUnpack` the request into a
Nim object, reuse the existing CBOR thread transport internally, and a Nim
reply trampoline `cwirePack`s the response back into a flat struct for the
caller's typed callback. `abiCodegenImplemented` now accepts `c` for
proc/ctor/dtor annotations (events remain CBOR-only). New
`examples/echo/c_abi_bindings/` (checked in beside the CBOR `c_bindings/` for
comparison), `nimble genbindings_c_abi_echo` / `check_bindings_c_abi` /
`test_c_abi_e2e` / `test_c_abi_e2e_sanitized` tasks, and a `tests/e2e/c_abi`
ctest harness ([#105](https://github.com/logos-messaging/nim-ffi/issues/105)).
- `tests/bench/bench_codec.nim` (+ `nimble bench_codec`): a single-process
microbenchmark comparing the `cbor` and `c` codecs across payload shapes,
isolating codec cost from the (identical) thread/callback round-trip.

View File

@ -0,0 +1,72 @@
cmake_minimum_required(VERSION 3.14)
project(echo_c_abi_bindings C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
# The CBOR-free `abi = c` binding links no TinyCBOR the flat structs in the
# generated header are the ABI. Only the Nim dylib is built.
set(_search_dir "${CMAKE_CURRENT_SOURCE_DIR}")
set(REPO_ROOT "")
foreach(_i RANGE 10)
if(EXISTS "${_search_dir}/ffi.nimble")
set(REPO_ROOT "${_search_dir}")
break()
endif()
get_filename_component(_search_dir "${_search_dir}" DIRECTORY)
endforeach()
if("${REPO_ROOT}" STREQUAL "")
message(FATAL_ERROR "Cannot find repo root (no ffi.nimble in any ancestor)")
endif()
# Extra `nim c` arguments (e.g. a `-d:` that flips a shared example source to
# `abi = c`). A library that declares `defaultABIFormat = "c"` needs none.
set(NIM_FFI_EXTRA_ARGS "" CACHE STRING "Extra nim c args when building the dylib")
find_program(NIM_EXECUTABLE nim REQUIRED)
if(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
set(NIM_LIB_FILE "${REPO_ROOT}/libecho.dylib")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
set(NIM_LIB_FILE "${REPO_ROOT}/echo.dll")
else()
set(NIM_LIB_FILE "${REPO_ROOT}/libecho.so")
endif()
get_filename_component(NIM_SRC "${CMAKE_CURRENT_SOURCE_DIR}/../echo.nim" ABSOLUTE)
add_custom_command(
OUTPUT "${NIM_LIB_FILE}"
COMMAND "${NIM_EXECUTABLE}" c
--mm:orc
-d:chronicles_log_level=WARN
--app:lib
--noMain
"--nimMainPrefix:libecho"
${NIM_FFI_EXTRA_ARGS}
"-o:${NIM_LIB_FILE}"
"${NIM_SRC}"
WORKING_DIRECTORY "${REPO_ROOT}"
DEPENDS "${NIM_SRC}"
COMMENT "Compiling Nim library libecho (abi = c)"
VERBATIM
)
add_custom_target(echo_nim_lib ALL DEPENDS "${NIM_LIB_FILE}")
add_library(echo SHARED IMPORTED GLOBAL)
set_target_properties(echo PROPERTIES IMPORTED_LOCATION "${NIM_LIB_FILE}")
add_dependencies(echo echo_nim_lib)
find_package(Threads REQUIRED)
add_library(echo_headers INTERFACE)
target_include_directories(echo_headers INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}")
target_link_libraries(echo_headers INTERFACE echo Threads::Threads)
target_compile_definitions(echo_headers INTERFACE _POSIX_C_SOURCE=200809L)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/main.c")
add_executable(echo_example main.c)
target_link_libraries(echo_example PRIVATE echo_headers)
add_dependencies(echo_example echo_nim_lib)
endif()

View File

@ -0,0 +1,27 @@
# echo — CBOR-free `abi = c` C bindings
Generated by `nimble genbindings_c_abi_echo` (`-d:targetLang=c_abi`), this is the
**pure C ABI** rendering of the echo library, meant to be read side-by-side with
the CBOR rendering in [`../c_bindings/`](../c_bindings/) (issue #105).
| | `../c_bindings/` (`-d:targetLang=c`) | this dir (`-d:targetLang=c_abi`) |
|---|---|---|
| Wire format on the C side | CBOR | flat C structs (no serialization) |
| Third-party dependency | vendored TinyCBOR | none |
| Files | `echo.h` + `nim_ffi_cbor.h` + `nim_ffi_prelude.h` | one self-contained `echo.h` |
| String type | `NimFfiStr` (owned) | `const char*` (borrowed for the call) |
Both talk to the *same* unmodified Nim dylib API shape (`echo_ctx_create`,
`echo_ctx_shout`, …) and share the async, binding-owned callback contract. The
difference is only the ABI at the boundary: here the macro-generated `_CWire`
structs (`ShoutRequest { const char* text; }`, …) are passed as native C structs
and the Nim side converts them to/from Nim objects. CBOR is still used, but only
as an internal transport between the exported wrapper and the FFI worker thread —
it never appears in this header.
The library is built with `-d:ffiEchoAbiC`, which flips the shared
`examples/echo/echo.nim` source to `declareLibrary(..., defaultABIFormat = "c")`.
A library that always wants the C ABI would set that in its own `declareLibrary`
call and need no extra flag.
Run the end-to-end test with `nimble test_c_abi_e2e`.

View File

@ -0,0 +1,110 @@
#ifndef NIM_FFI_LIB_ECHO_C_ABI_H_INCLUDED
#define NIM_FFI_LIB_ECHO_C_ABI_H_INCLUDED
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
#define NIMFFI_RET_OK 0
#define NIMFFI_RET_ERR 1
#define NIMFFI_RET_MISSING_CALLBACK 2
/* Flat wire structs — the C ABI. Strings are borrowed, NUL-terminated
`const char*` valid only for the duration of the call they cross. */
typedef struct {
const char* prefix;
} EchoConfig;
typedef struct {
const char* text;
} ShoutRequest;
typedef struct {
const char* shouted;
const char* prefix;
} ShoutResponse;
typedef struct {
EchoConfig config;
} EchoCreateCtorReq;
typedef struct {
ShoutRequest req;
} EchoShoutReq;
typedef void (*EchoShoutReplyFn)(int err_code, const ShoutResponse* reply, const char* err_msg, void* user_data);
typedef void (*EchoCreateRawFn)(int err_code, const char* ctx_addr, const char* err_msg, void* user_data);
#ifdef __cplusplus
extern "C" {
#endif
void* echo_create(const EchoCreateCtorReq* req, EchoCreateRawFn on_created, void* user_data);
int echo_shout(void* ctx, EchoShoutReplyFn on_reply, void* user_data, const EchoShoutReq* req);
int echo_destroy(void* ctx);
#ifdef __cplusplus
} /* extern "C" */
#endif
/* High-level context wrapper */
typedef struct {
void* ptr;
} EchoCtx;
typedef void (*EchoCreateFn)(int err_code, EchoCtx* ctx, const char* err_msg, void* user_data);
typedef struct { EchoCreateFn fn; void* user_data; } EchoCreateBox;
static void echo_create_trampoline(int ret, const char* ctx_addr, const char* err_msg, void* ud) {
EchoCreateBox* box = (EchoCreateBox*)ud;
if (!box) return;
if (!box->fn) { free(box); return; }
if (ret != 0) {
box->fn(ret, NULL, err_msg ? err_msg : "FFI create failed", box->user_data);
free(box);
return;
}
char* endp = NULL;
unsigned long long a = ctx_addr ? strtoull(ctx_addr, &endp, 10) : 0;
bool ok = ctx_addr && *ctx_addr && endp && *endp == '\0';
if (!ok) {
box->fn(-1, NULL, "FFI create returned non-numeric address", box->user_data);
free(box);
return;
}
EchoCtx* ctx = (EchoCtx*)calloc(1, sizeof(EchoCtx));
if (!ctx) {
box->fn(-1, NULL, "out of memory", box->user_data);
free(box);
return;
}
ctx->ptr = (void*)(uintptr_t)a;
box->fn(NIMFFI_RET_OK, ctx, NULL, box->user_data);
free(box);
}
static inline int echo_ctx_create(const EchoConfig* config, EchoCreateFn on_created, void* user_data) {
EchoCreateCtorReq ffi_req;
memset(&ffi_req, 0, sizeof(ffi_req));
ffi_req.config = *config;
EchoCreateBox* box = (EchoCreateBox*)malloc(sizeof(EchoCreateBox));
if (!box) {
if (on_created) on_created(-1, NULL, "out of memory", user_data);
return -1;
}
box->fn = on_created;
box->user_data = user_data;
(void)echo_create(&ffi_req, echo_create_trampoline, box);
return 0;
}
static inline void echo_ctx_destroy(EchoCtx* ctx) {
if (!ctx) return;
if (ctx->ptr) { echo_destroy(ctx->ptr); ctx->ptr = NULL; }
free(ctx);
}
static inline int echo_ctx_shout(const EchoCtx* ctx, const ShoutRequest* req, EchoShoutReplyFn on_reply, void* user_data) {
EchoShoutReq ffi_req;
memset(&ffi_req, 0, sizeof(ffi_req));
ffi_req.req = *req;
return echo_shout(ctx->ptr, on_reply, user_data, &ffi_req);
}
#endif /* NIM_FFI_LIB_ECHO_C_ABI_H_INCLUDED */

View File

@ -6,7 +6,13 @@ import ffi, chronos, strutils
type Echo = object
prefix: string
declareLibrary("echo", Echo)
# `-d:ffiEchoAbiC` builds the CBOR-free `abi = c` variant (flat `_CWire` structs
# on the wire); the default is the CBOR ABI. The same source drives both the
# `c_bindings/` (CBOR) and `c_abi_bindings/` (flat) example outputs.
when defined(ffiEchoAbiC):
declareLibrary("echo", Echo, defaultABIFormat = "c")
else:
declareLibrary("echo", Echo)
type EchoConfig {.ffi.} = object
prefix: string

View File

@ -84,6 +84,19 @@ proc applyTsanSuppressions() =
elif "suppressions=" notin existing:
putEnv("TSAN_OPTIONS", existing & ":suppressions=" & suppPath)
proc removeStaleEchoLib() =
## The CBOR and `abi = c` echo e2e suites both compile examples/echo/echo.nim
## to the same repo-root `libecho.so`, differing only by `-d:ffiEchoAbiC`.
## CMake keys the dylib rebuild on echo.nim's mtime, not the ABI flag, so a
## `libecho.so` left by an earlier CBOR e2e step is silently reused by the
## abi=c build — the flat-struct C caller then reaches the CBOR entry points
## (wrong arity/ABI) and segfaults. Delete it first to force a fresh rebuild
## with the right ABI.
for name in ["libecho.so", "libecho.dylib", "echo.dll"]:
let path = thisDir() / name
if fileExists(path):
rmFile(path)
task buildffi, "Compile the library":
exec "nim c " & nimFlagsOrc & " --app:lib --noMain ffi.nim"
@ -138,6 +151,14 @@ task test_c_e2e, "Build and run the C end-to-end tests for the timer example":
runOrQuit "cmake --build tests/e2e/c/build --config Debug"
runOrQuit "ctest --test-dir tests/e2e/c/build --output-on-failure -C Debug"
task test_c_abi_e2e, "Build and run the CBOR-free abi=c C end-to-end test (echo)":
# Regenerate the abi=c bindings so the suite always runs against fresh codegen.
runOrQuit "nimble genbindings_c_abi_echo"
removeStaleEchoLib()
runOrQuit "cmake -S tests/e2e/c_abi -B tests/e2e/c_abi/build"
runOrQuit "cmake --build tests/e2e/c_abi/build --config Debug"
runOrQuit "ctest --test-dir tests/e2e/c_abi/build --output-on-failure -C Debug"
task test_sanitized,
"Run all unit tests under a sanitizer (NIM_FFI_SAN) and mm (NIM_FFI_MM)":
let san = getEnv("NIM_FFI_SAN", "none")
@ -169,6 +190,16 @@ task test_c_e2e_sanitized,
runOrQuit "cmake --build tests/e2e/c/build --config Debug -j"
runOrQuit "ctest --test-dir tests/e2e/c/build --output-on-failure -C Debug"
task test_c_abi_e2e_sanitized,
"Build and run the abi=c C e2e test with a sanitizer (NIM_FFI_SAN)":
let san = getEnv("NIM_FFI_SAN", "none")
runOrQuit "nimble genbindings_c_abi_echo"
removeStaleEchoLib()
runOrQuit "cmake -S tests/e2e/c_abi -B tests/e2e/c_abi/build" & " -DNIM_FFI_SANITIZER=" &
san
runOrQuit "cmake --build tests/e2e/c_abi/build --config Debug -j"
runOrQuit "ctest --test-dir tests/e2e/c_abi/build --output-on-failure -C Debug"
task genbindings_example, "Generate Rust bindings for the timer example":
exec "nim c " & nimFlagsOrc &
" --app:lib --noMain --nimMainPrefix:libmy_timer -d:ffiGenBindings -o:/dev/null examples/timer/timer.nim"
@ -227,6 +258,16 @@ task genbindings_c_echo, "Generate C bindings for the echo example":
" -d:ffiGenBindings -d:targetLang=c" & " -d:ffiOutputDir=examples/echo/c_bindings" &
" -d:ffiSrcPath=../echo.nim" & " -o:/dev/null examples/echo/echo.nim"
task genbindings_c_abi_echo, "Generate CBOR-free abi=c C bindings for the echo example":
exec "nim c " & nimFlagsOrc & " --app:lib --noMain --nimMainPrefix:libecho" &
" -d:ffiEchoAbiC -d:ffiGenBindings -d:targetLang=c_abi" &
" -d:ffiOutputDir=examples/echo/c_abi_bindings" & " -d:ffiSrcPath=../echo.nim" &
" -o:/dev/null examples/echo/echo.nim"
exec "nim c " & nimFlagsRefc & " --app:lib --noMain --nimMainPrefix:libecho" &
" -d:ffiEchoAbiC -d:ffiGenBindings -d:targetLang=c_abi" &
" -d:ffiOutputDir=examples/echo/c_abi_bindings" & " -d:ffiSrcPath=../echo.nim" &
" -o:/dev/null examples/echo/echo.nim"
task check_bindings_rust, "Verify checked-in Rust bindings match Nim source":
exec "nimble genbindings_rust"
exec "git diff --exit-code --" & " examples/timer/rust_bindings/Cargo.toml" &
@ -250,7 +291,13 @@ task check_bindings_c, "Verify checked-in C bindings match Nim source":
" examples/echo/c_bindings/nim_ffi_cbor.h" &
" examples/echo/c_bindings/CMakeLists.txt"
task check_bindings_c_abi, "Verify checked-in abi=c C bindings match Nim source":
exec "nimble genbindings_c_abi_echo"
exec "git diff --exit-code --" & " examples/echo/c_abi_bindings/echo.h" &
" examples/echo/c_abi_bindings/CMakeLists.txt"
task check_bindings, "Verify all checked-in example bindings match Nim source":
exec "nimble check_bindings_rust"
exec "nimble check_bindings_cpp"
exec "nimble check_bindings_c"
exec "nimble check_bindings_c_abi"

View File

@ -44,6 +44,18 @@ proc dealloc*(p: cstring) {.inline.} =
if not p.isNil():
c_free(cast[pointer](p))
proc allocBox*(size: int): pointer =
## `c_malloc` block for a cross-thread callback box (allocated on the foreign
## caller thread, freed on the FFI thread). Uses libc for the same
## thread-lifetime safety reason as the rest of this module. Free with
## `freeBox`.
c_malloc(csize_t(size))
proc freeBox*(p: pointer) =
## Releases a block from `allocBox`. Nil-safe.
if not p.isNil():
c_free(p)
proc allocSharedSeq*[T](s: seq[T]): SharedSeq[T] =
if s.len == 0:
return (cast[ptr UncheckedArray[T]](nil), 0)

443
ffi/codegen/c_abi.nim Normal file
View File

@ -0,0 +1,443 @@
## CBOR-free C99 binding generator for the nim-ffi framework (`-d:targetLang=c_abi`).
## Where the `c` backend speaks CBOR on the wire (vendoring TinyCBOR), this one
## emits a single self-contained header whose flat structs *are* the C ABI:
## they mirror the macro-generated `*_CWire` layout byte-for-byte, so the C
## consumer passes native structs and links no CBOR at all. The Nim dylib
## converts flat struct ⇄ Nim object at the boundary (see the `abi = c` dispatch
## in `ffi/internal/c_macro_helpers.nim`) and keeps CBOR purely as an internal
## transport detail.
##
## Layout contract (must stay in lock-step with `c_macro_helpers.wireValueType`
## / `wireFieldsFor`): `string`→`const char*`, `seq[T]`→`<wireT>* <f>_items` +
## `ptrdiff_t <f>_len`, `Option[T]`→`<wireT>*` (NULL = none), nested `{.ffi.}`
## type → its flat struct, `ptr`/`pointer`→`void*`, POD unchanged.
import std/[os, strutils, tables, sets]
import ./meta, ./string_helpers, ./c_cpp_common, ./types_ir
const CPtrType = "void*"
## Wire C type for any Nim `ptr T` / `pointer` (mirrors the `_CWire` `pointer`).
const CMakeListsTpl = staticRead("templates/c_abi/CMakeLists.txt.tpl")
func leafCTypeAbi(t: string): tuple[ok: bool, cType: string] =
## Maps a Nim leaf type to the flat C type used in a wire struct. `ok` is
## false for composites (seq/Option/user structs), handled separately.
case t
of "int", "int64":
(true, "int64_t")
of "int32":
(true, "int32_t")
of "int16":
(true, "int16_t")
of "int8":
(true, "int8_t")
of "uint", "uint64":
(true, "uint64_t")
of "uint32":
(true, "uint32_t")
of "uint16":
(true, "uint16_t")
of "uint8", "byte":
(true, "uint8_t")
of "bool":
(true, "bool")
of "float", "float64":
(true, "double")
of "float32":
(true, "float")
of "pointer":
(true, CPtrType)
of "string", "cstring":
(true, "const char*")
else:
(false, "")
type AbiReg = object
typeTable: Table[string, FFITypeMeta] ## user structs + synthetic Req structs
emitted: HashSet[string] ## struct names already emitted
decls: seq[string] ## struct typedefs, dependency order
proc ensureAbiStruct(reg: var AbiReg, typeName: string)
proc wireValueCType(reg: var AbiReg, nimType: string): string =
## Flat C type for a value-position field (everything except a top-level
## `seq`, which splits into two fields — see `fieldDecls`).
let t = nimType.strip()
if t.startsWith("ptr ") or t == "pointer":
return CPtrType
let leaf = leafCTypeAbi(t)
if leaf.ok:
return leaf.cType
var optInner = genericInnerType(t, "Option[")
if optInner.len == 0:
optInner = genericInnerType(t, "Maybe[")
if optInner.len > 0:
return wireValueCType(reg, optInner.strip()) & "*"
if genericInnerType(t, "seq[").len > 0:
raise newException(
ValueError,
"abi = c: `seq` has no single-field wire form, so it can't nest inside " &
"another container: " & t,
)
if genericInnerType(t, "array[").len > 0:
raise newException(
ValueError, "abi = c: array fields are not yet supported by the C backend: " & t
)
if t in reg.typeTable:
ensureAbiStruct(reg, t)
return t
raise newException(ValueError, "abi = c: unknown field type: " & t)
proc fieldDecls(reg: var AbiReg, name, nimType: string): seq[string] =
## C struct member line(s) for one Nim field. A `seq[T]` becomes the
## `<name>_items` pointer + `<name>_len` count pair; everything else is one
## member.
let seqInner = genericInnerType(nimType.strip(), "seq[")
if seqInner.len > 0:
let elemC = wireValueCType(reg, seqInner.strip())
return @[elemC & "* " & name & "_items;", "ptrdiff_t " & name & "_len;"]
@[wireValueCType(reg, nimType) & " " & name & ";"]
proc emitAbiStruct(reg: var AbiReg, t: FFITypeMeta) =
var members: seq[string] = @[]
for f in t.fields:
for line in fieldDecls(reg, f.name, f.typeName):
members.add(" " & line)
if members.len == 0:
members.add(" uint8_t _placeholder; /* C forbids empty structs */")
reg.decls.add("typedef struct {\n" & members.join("\n") & "\n} " & t.name & ";")
proc ensureAbiStruct(reg: var AbiReg, typeName: string) =
if typeName in reg.emitted:
return
reg.emitted.incl(typeName)
if typeName in reg.typeTable:
emitAbiStruct(reg, reg.typeTable[typeName])
else:
reg.decls.add("/* unknown type referenced: " & typeName & " */")
proc reqTypeMeta(p: FFIProcMeta): FFITypeMeta =
## The per-proc Req envelope as an FFITypeMeta, mirroring the Nim macro. A
## pointer/handle param rides as the opaque `pointer` wire type.
var fields: seq[FFIFieldMeta] = @[]
for ep in p.extraParams:
let typeName = if ep.ridesAsPtr(): "pointer" else: ep.typeName
fields.add(FFIFieldMeta(name: ep.name, typeName: typeName))
FFITypeMeta(name: reqStructName(p), fields: fields)
proc newAbiReg(types: seq[FFITypeMeta], procs: seq[FFIProcMeta]): AbiReg =
var reg = AbiReg()
for t in types:
reg.typeTable[t.name] = t
for p in procs:
if p.kind != FFIKind.DTOR:
let rt = reqTypeMeta(p)
reg.typeTable[rt.name] = rt
reg
func paramByValue(nimType: string, ridesAsPtr: bool): bool =
## Scalars / opaque pointers / string views pass by value; composite
## aggregates (seq, Option, user structs) pass by const pointer.
if ridesAsPtr:
return true
leafCTypeAbi(nimType.strip()).ok
proc reqParamsAndAssigns(
reg: var AbiReg, extraParams: seq[FFIParamMeta]
): tuple[params, assigns: seq[string]] =
## The C parameter list + `ffi_req` field assignments shared by the ctor and
## method wrappers: by-value params copy straight into the request struct,
## by-const-pointer aggregates are dereferenced in.
var params, assigns: seq[string] = @[]
for ep in extraParams:
let rides = ep.ridesAsPtr()
let cType =
if rides:
CPtrType
else:
wireValueCType(reg, ep.typeName)
if paramByValue(ep.typeName, rides):
params.add(cType & " " & ep.name)
assigns.add(" ffi_req." & ep.name & " = " & ep.name & ";")
else:
params.add("const " & cType & "* " & ep.name)
assigns.add(" ffi_req." & ep.name & " = *" & ep.name & ";")
(params, assigns)
proc methodReplyInfo(
reg: var AbiReg, libType: string, m: FFIProcMeta
): tuple[fnType, replyParam: string] =
## The reply-callback typedef name plus the C type of its `reply` argument.
## An object return hands back a `const <Struct>*`; a string return a
## `const char*`. Both are the raw callback the dylib invokes directly.
let pascal = snakeToPascalCase(stripLibPrefix(m.procName, m.libName))
let fnType = libType & pascal & "ReplyFn"
if m.returnRidesAsPtr():
raise newException(
ValueError,
"abi = c: handle/pointer returns are not yet supported by the C backend: " &
m.procName,
)
let rt = m.returnTypeName.strip()
let replyParam =
if rt == "string" or rt == "cstring":
"const char*"
elif leafCTypeAbi(rt).ok:
"const " & leafCTypeAbi(rt).cType & "*"
else:
ensureAbiStruct(reg, rt)
"const " & rt & "*"
(fnType, replyParam)
proc emitReplyTypedefs(
lines: var seq[string], reg: var AbiReg, libType: string, methods: seq[FFIProcMeta]
) =
for m in methods:
let info = methodReplyInfo(reg, libType, m)
lines.add(
"typedef void (*" & info.fnType & ")(int err_code, " & info.replyParam &
" reply, const char* err_msg, void* user_data);"
)
proc emitExternDecls(
lines: var seq[string],
reg: var AbiReg,
libName, libType: string,
procs: seq[FFIProcMeta],
) =
let createRawFn = libType & "CreateRawFn"
var haveCtor = false
for p in procs:
if p.kind == FFIKind.CTOR:
haveCtor = true
if haveCtor:
lines.add(
"typedef void (*" & createRawFn &
")(int err_code, const char* ctx_addr, const char* err_msg, void* user_data);"
)
lines.add("#ifdef __cplusplus")
lines.add("extern \"C\" {")
lines.add("#endif")
lines.add("")
for p in procs:
let reqStruct = reqStructName(p)
case p.kind
of FFIKind.FFI:
let info = methodReplyInfo(reg, libType, p)
lines.add(
"int " & p.procName & "(void* ctx, " & info.fnType &
" on_reply, void* user_data, const " & reqStruct & "* req);"
)
of FFIKind.CTOR:
lines.add(
"void* " & p.procName & "(const " & reqStruct & "* req, " & createRawFn &
" on_created, void* user_data);"
)
of FFIKind.DTOR:
lines.add("int " & p.procName & "(void* ctx);")
lines.add("")
lines.add("#ifdef __cplusplus")
lines.add("} /* extern \"C\" */")
lines.add("#endif")
lines.add("")
proc emitCtxAndCtor(
lines: var seq[string],
reg: var AbiReg,
libName, libType, ctxType: string,
ctors: seq[FFIProcMeta],
) =
lines.add("typedef struct {")
lines.add(" void* ptr;")
lines.add("} " & ctxType & ";")
lines.add("")
if ctors.len == 0:
return
let createFn = libType & "CreateFn"
let createBox = libType & "CreateBox"
let createRawFn = libType & "CreateRawFn"
let tramp = libName & "_create_trampoline"
lines.add(
"typedef void (*" & createFn & ")(int err_code, " & ctxType &
"* ctx, const char* err_msg, void* user_data);"
)
lines.add(
"typedef struct { " & createFn & " fn; void* user_data; } " & createBox & ";"
)
lines.add(
"static void " & tramp &
"(int ret, const char* ctx_addr, const char* err_msg, void* ud) {"
)
lines.add(" " & createBox & "* box = (" & createBox & "*)ud;")
lines.add(" if (!box) return;")
lines.add(" if (!box->fn) { free(box); return; }")
lines.add(" if (ret != 0) {")
lines.add(
" box->fn(ret, NULL, err_msg ? err_msg : \"FFI create failed\", box->user_data);"
)
lines.add(" free(box);")
lines.add(" return;")
lines.add(" }")
lines.add(" char* endp = NULL;")
lines.add(" unsigned long long a = ctx_addr ? strtoull(ctx_addr, &endp, 10) : 0;")
lines.add(" bool ok = ctx_addr && *ctx_addr && endp && *endp == '\\0';")
lines.add(" if (!ok) {")
lines.add(
" box->fn(-1, NULL, \"FFI create returned non-numeric address\", box->user_data);"
)
lines.add(" free(box);")
lines.add(" return;")
lines.add(" }")
lines.add(
" " & ctxType & "* ctx = (" & ctxType & "*)calloc(1, sizeof(" & ctxType & "));"
)
lines.add(" if (!ctx) {")
lines.add(" box->fn(-1, NULL, \"out of memory\", box->user_data);")
lines.add(" free(box);")
lines.add(" return;")
lines.add(" }")
lines.add(" ctx->ptr = (void*)(uintptr_t)a;")
lines.add(" box->fn(NIMFFI_RET_OK, ctx, NULL, box->user_data);")
lines.add(" free(box);")
lines.add("}")
lines.add("")
for ctor in ctors:
let reqStruct = reqStructName(ctor)
let (params, assigns) = reqParamsAndAssigns(reg, ctor.extraParams)
let head = "static inline int " & libName & "_ctx_create("
let sig =
if params.len > 0:
head & params.join(", ") & ", " & createFn & " on_created, void* user_data) {"
else:
head & createFn & " on_created, void* user_data) {"
lines.add(sig)
lines.add(" " & reqStruct & " ffi_req;")
lines.add(" memset(&ffi_req, 0, sizeof(ffi_req));")
for a in assigns:
lines.add(a)
lines.add(
" " & createBox & "* box = (" & createBox & "*)malloc(sizeof(" & createBox &
"));"
)
lines.add(" if (!box) {")
lines.add(
" if (on_created) on_created(-1, NULL, \"out of memory\", user_data);"
)
lines.add(" return -1;")
lines.add(" }")
lines.add(" box->fn = on_created;")
lines.add(" box->user_data = user_data;")
lines.add(" (void)" & ctor.procName & "(&ffi_req, " & tramp & ", box);")
lines.add(" return 0;")
lines.add("}")
lines.add("")
proc emitDestructor(lines: var seq[string], ctxType, libName, dtorProcName: string) =
lines.add("static inline void " & libName & "_ctx_destroy(" & ctxType & "* ctx) {")
lines.add(" if (!ctx) return;")
if dtorProcName.len > 0:
lines.add(" if (ctx->ptr) { " & dtorProcName & "(ctx->ptr); ctx->ptr = NULL; }")
lines.add(" free(ctx);")
lines.add("}")
lines.add("")
proc emitMethod(
lines: var seq[string],
reg: var AbiReg,
ctxType, libName, libType: string,
m: FFIProcMeta,
) =
let stripped = stripLibPrefix(m.procName, m.libName)
let reqStruct = reqStructName(m)
let info = methodReplyInfo(reg, libType, m)
let (params, assigns) = reqParamsAndAssigns(reg, m.extraParams)
let head =
"static inline int " & libName & "_ctx_" & stripped & "(const " & ctxType & "* ctx, "
let sig =
if params.len > 0:
head & params.join(", ") & ", " & info.fnType & " on_reply, void* user_data) {"
else:
head & info.fnType & " on_reply, void* user_data) {"
lines.add(sig)
lines.add(" " & reqStruct & " ffi_req;")
lines.add(" memset(&ffi_req, 0, sizeof(ffi_req));")
for a in assigns:
lines.add(a)
lines.add(" return " & m.procName & "(ctx->ptr, on_reply, user_data, &ffi_req);")
lines.add("}")
lines.add("")
proc generateCAbiLibHeader*(
procs: seq[FFIProcMeta],
types: seq[FFITypeMeta],
libName: string,
events: seq[FFIEventMeta] = @[],
): string =
if events.len > 0:
raise newException(
ValueError, "abi = c: the C backend does not yet support {.ffiEvent.} listeners"
)
let classified = classifyProcs(procs)
let libType = libTypeName(classified.ctors, libName)
let ctxType = libType & "Ctx"
var reg = newAbiReg(types, procs)
for t in types:
ensureAbiStruct(reg, t.name)
for p in procs:
if p.kind != FFIKind.DTOR:
ensureAbiStruct(reg, reqStructName(p))
let guard = "NIM_FFI_LIB_" & libName.toUpperAscii() & "_C_ABI_H_INCLUDED"
var lines: seq[string] = @[]
lines.add("#ifndef " & guard)
lines.add("#define " & guard)
lines.add("#include <stdint.h>")
lines.add("#include <stddef.h>")
lines.add("#include <stdbool.h>")
lines.add("#include <stdlib.h>")
lines.add("#include <string.h>")
lines.add("")
lines.add("#define NIMFFI_RET_OK 0")
lines.add("#define NIMFFI_RET_ERR 1")
lines.add("#define NIMFFI_RET_MISSING_CALLBACK 2")
lines.add("")
lines.add("/* Flat wire structs — the C ABI. Strings are borrowed, NUL-terminated")
lines.add(" `const char*` valid only for the duration of the call they cross. */")
for decl in reg.decls:
lines.add(decl)
lines.add("")
emitReplyTypedefs(lines, reg, libType, classified.methods)
lines.add("")
emitExternDecls(lines, reg, libName, libType, procs)
lines.add("/* High-level context wrapper */")
emitCtxAndCtor(lines, reg, libName, libType, ctxType, classified.ctors)
emitDestructor(lines, ctxType, libName, classified.dtorProcName)
for m in classified.methods:
emitMethod(lines, reg, ctxType, libName, libType, m)
lines.add("#endif /* " & guard & " */")
lines.join("\n") & "\n"
proc generateCAbiCMakeLists*(libName, nimSrcRelPath: string): string =
let src = nimSrcRelPath.replace("\\", "/")
CMakeListsTpl.multiReplace(("{{LIB}}", libName), ("{{SRC}}", src))
proc generateCAbiBindings*(
procs: seq[FFIProcMeta],
types: seq[FFITypeMeta],
libName: string,
outputDir: string,
nimSrcRelPath: string,
events: seq[FFIEventMeta] = @[],
) =
createDir(outputDir)
writeFile(
outputDir / (libName & ".h"), generateCAbiLibHeader(procs, types, libName, events)
)
writeFile(
outputDir / "CMakeLists.txt", generateCAbiCMakeLists(libName, nimSrcRelPath)
)

View File

@ -73,9 +73,10 @@ var genBindingsEmitted* {.compileTime.}: bool = false
var currentDefaultABIFormat* {.compileTime.}: ABIFormat = ABIFormat.Cbor
proc abiCodegenImplemented*(fmt: ABIFormat): bool =
## Whether `fmt` has a working proc-dispatch path. Only `Cbor` does today; the
## seam a future PR flips once the `c` dispatch path is wired.
fmt == ABIFormat.Cbor
## Whether `fmt` has a working proc-dispatch path. Both `Cbor` and `C` are
## wired: `Cbor` rides the generic overloads, `C` rides the flat `_CWire`
## companions (a CBOR-free foreign surface with CBOR transport internally).
fmt in {ABIFormat.Cbor, ABIFormat.C}
proc overrideKey*(override: string): string =
## Lowercased key of a `key = value` pragma override (the text before `=`),

View File

@ -0,0 +1,72 @@
cmake_minimum_required(VERSION 3.14)
project({{LIB}}_c_abi_bindings C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
# The CBOR-free `abi = c` binding links no TinyCBOR — the flat structs in the
# generated header are the ABI. Only the Nim dylib is built.
set(_search_dir "${CMAKE_CURRENT_SOURCE_DIR}")
set(REPO_ROOT "")
foreach(_i RANGE 10)
if(EXISTS "${_search_dir}/ffi.nimble")
set(REPO_ROOT "${_search_dir}")
break()
endif()
get_filename_component(_search_dir "${_search_dir}" DIRECTORY)
endforeach()
if("${REPO_ROOT}" STREQUAL "")
message(FATAL_ERROR "Cannot find repo root (no ffi.nimble in any ancestor)")
endif()
# Extra `nim c` arguments (e.g. a `-d:` that flips a shared example source to
# `abi = c`). A library that declares `defaultABIFormat = "c"` needs none.
set(NIM_FFI_EXTRA_ARGS "" CACHE STRING "Extra nim c args when building the dylib")
find_program(NIM_EXECUTABLE nim REQUIRED)
if(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
set(NIM_LIB_FILE "${REPO_ROOT}/lib{{LIB}}.dylib")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
set(NIM_LIB_FILE "${REPO_ROOT}/{{LIB}}.dll")
else()
set(NIM_LIB_FILE "${REPO_ROOT}/lib{{LIB}}.so")
endif()
get_filename_component(NIM_SRC "${CMAKE_CURRENT_SOURCE_DIR}/{{SRC}}" ABSOLUTE)
add_custom_command(
OUTPUT "${NIM_LIB_FILE}"
COMMAND "${NIM_EXECUTABLE}" c
--mm:orc
-d:chronicles_log_level=WARN
--app:lib
--noMain
"--nimMainPrefix:lib{{LIB}}"
${NIM_FFI_EXTRA_ARGS}
"-o:${NIM_LIB_FILE}"
"${NIM_SRC}"
WORKING_DIRECTORY "${REPO_ROOT}"
DEPENDS "${NIM_SRC}"
COMMENT "Compiling Nim library lib{{LIB}} (abi = c)"
VERBATIM
)
add_custom_target({{LIB}}_nim_lib ALL DEPENDS "${NIM_LIB_FILE}")
add_library({{LIB}} SHARED IMPORTED GLOBAL)
set_target_properties({{LIB}} PROPERTIES IMPORTED_LOCATION "${NIM_LIB_FILE}")
add_dependencies({{LIB}} {{LIB}}_nim_lib)
find_package(Threads REQUIRED)
add_library({{LIB}}_headers INTERFACE)
target_include_directories({{LIB}}_headers INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}")
target_link_libraries({{LIB}}_headers INTERFACE {{LIB}} Threads::Threads)
target_compile_definitions({{LIB}}_headers INTERFACE _POSIX_C_SOURCE=200809L)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/main.c")
add_executable({{LIB}}_example main.c)
target_link_libraries({{LIB}}_example PRIVATE {{LIB}}_headers)
add_dependencies({{LIB}}_example {{LIB}}_nim_lib)
endif()

View File

@ -50,7 +50,7 @@ type
structName*: proc(name: string): string {.noSideEffect, nimcall.}
## nil ⇒ the user type name passes through unchanged
func genericInnerType(typeName, prefix: string): string =
func genericInnerType*(typeName, prefix: string): string =
## Inner type of a single-parameter generic `Prefix[Inner]`, e.g.
## `genericInnerType("seq[int]", "seq[")` → `"int"`; "" if not that shape.
if typeName.startsWith(prefix) and typeName.endsWith("]"):

View File

@ -553,3 +553,377 @@ proc flushCWireCompanions*(): NimNode {.compileTime.} =
if typeMeta.abiFormat == ABIFormat.C:
ensureCWireFor(typeMeta.name, sink)
sink
## abi = c proc dispatch. The foreign surface is CBOR-free — the flat `_CWire`
## structs are the C ABI — but transport reuses the proven CBOR request path
## internally: the generated exported wrapper `cwireUnpack`s the request into a
## Nim object, `cborEncodeShared`s it onto the FFI thread, and a Nim reply
## trampoline `cborDecode`s the reply and `cwirePack`s it back into a `_CWire`
## struct delivered to the caller's typed callback. So the C consumer never
## links CBOR, yet the whole thread/dispatch machinery is unchanged.
##
## All of this is emitted at `genBindings()` time (after `flushCWireCompanions`)
## so the request-envelope companions and their `cwireUnpack` overloads are in
## scope: the exported wrappers reference them, and a proc must follow the
## procs it calls.
type
CAbiKind = enum
cakMethod
cakCtor
CAbiSpec = object
kind: CAbiKind
exportName: string ## snake_case C symbol, e.g. "echo_shout"
libType: NimNode ## library value type, e.g. `Echo`
envelope: NimNode ## per-proc Req type, e.g. `EchoShoutReq`
paramNames: seq[string] ## envelope field names (the extra params)
paramTypes: seq[NimNode] ## envelope field types
respType: NimNode ## method result T; empty for a ctor
var cAbiSpecs {.compileTime.}: seq[CAbiSpec]
proc copyTypes(types: seq[NimNode]): seq[NimNode] {.compileTime.} =
var res: seq[NimNode] = @[]
for t in types:
res.add(t.copyNimTree())
res
proc registerCAbiMethod*(
exportName: string,
libType, envelope: NimNode,
paramNames: seq[string],
paramTypes: seq[NimNode],
respType: NimNode,
) {.compileTime.} =
## Record an `abi = c` method so `flushCAbiDispatch` can emit its wrapper.
## Nodes are frozen with `copyNimTree` — the originals are shared with the Req
## `type` section, which the compiler later binds to `nnkSym`, and a bound
## type symbol reused in the generated body triggers a compiler ICE.
cAbiSpecs.add(
CAbiSpec(
kind: cakMethod,
exportName: exportName,
libType: libType.copyNimTree(),
envelope: envelope.copyNimTree(),
paramNames: paramNames,
paramTypes: copyTypes(paramTypes),
respType: respType.copyNimTree(),
)
)
proc registerCAbiCtor*(
exportName: string,
libType, envelope: NimNode,
paramNames: seq[string],
paramTypes: seq[NimNode],
) {.compileTime.} =
## Record an `abi = c` constructor so `flushCAbiDispatch` can emit its wrapper.
## See `registerCAbiMethod` for why the nodes are frozen with `copyNimTree`.
cAbiSpecs.add(
CAbiSpec(
kind: cakCtor,
exportName: exportName,
libType: libType.copyNimTree(),
envelope: envelope.copyNimTree(),
paramNames: paramNames,
paramTypes: copyTypes(paramTypes),
respType: newEmptyNode(),
)
)
proc cdeclReplyPragma(): NimNode =
nnkPragma.newTree(
ident("cdecl"),
ident("gcsafe"),
nnkExprColonExpr.newTree(ident("raises"), nnkBracket.newTree()),
)
proc cAbiCbType(replyType: NimNode): NimNode =
## `proc(err: cint, reply: <replyType>, errMsg: cstring, ud: pointer)
## {.cdecl, gcsafe, raises: [].}` — the caller's typed reply callback.
let fp = nnkFormalParams.newTree(
newEmptyNode(),
newIdentDefs(ident("err"), ident("cint")),
newIdentDefs(ident("reply"), replyType),
newIdentDefs(ident("errMsg"), ident("cstring")),
newIdentDefs(ident("ud"), ident("pointer")),
)
nnkProcTy.newTree(fp, cdeclReplyPragma())
proc boxTypeDef(boxName, cbType: NimNode): NimNode =
## `type <boxName> = object` holding the caller's callback + user data, heap
## boxed across the thread hand-off.
let recList = nnkRecList.newTree(
newIdentDefs(ident("fn"), cbType), newIdentDefs(ident("ud"), ident("pointer"))
)
let objTy = nnkObjectTy.newTree(newEmptyNode(), newEmptyNode(), recList)
nnkTypeSection.newTree(nnkTypeDef.newTree(boxName, newEmptyNode(), objTy))
proc replyTrampProc(trampName, body: NimNode): NimNode =
## A `FFICallBack`-shaped Nim proc: it runs on the FFI thread inside
## `handleRes`' `foreignThreadGc`, converts the reply, and frees the box.
newProc(
name = trampName,
params = @[
newEmptyNode(),
newIdentDefs(ident("ret"), ident("cint")),
newIdentDefs(ident("msg"), nnkPtrTy.newTree(ident("cchar"))),
newIdentDefs(ident("len"), ident("csize_t")),
newIdentDefs(ident("ud"), ident("pointer")),
],
body = body,
pragmas = cdeclReplyPragma(),
)
proc objectTrampBody(boxName, respType, respWire: NimNode): NimNode =
## Reply trampoline for an object return: recover the box, deliver a transport
## error as a copied NUL-terminated string, else CBOR-decode the reply,
## `cwirePack` it into the flat wire struct, hand a pointer to the caller, and
## release the wire. `err_msg` is always a non-nil string; the `reply` struct
## pointer is nil only on error, gated by a non-`RET_OK` `err_code`.
quote:
let box = cast[ptr `boxName`](ud)
if box.isNil():
return
defer:
freeBox(box)
if box.fn.isNil():
return
try:
if ret != RET_OK:
var em = newString(int(len))
if int(len) > 0:
copyMem(addr em[0], msg, int(len))
box.fn(ret, nil, em.cstring, box.ud)
return
let decoded =
cborDecodePtr(cast[ptr UncheckedArray[byte]](msg), int(len), `respType`)
if decoded.isErr():
box.fn(RET_ERR, nil, decoded.error.cstring, box.ud)
else:
var wire: `respWire`
cwirePack(wire, decoded.get())
box.fn(RET_OK, addr wire, "".cstring, box.ud)
cwireFree(wire)
except CatchableError as e:
box.fn(RET_ERR, nil, e.msg.cstring, box.ud)
proc stringTrampBody(boxName: NimNode): NimNode =
## Reply trampoline for a `string` return (and the ctor's address string):
## CBOR-decode the reply into a Nim string and hand its (NUL-terminated)
## `cstring` to the caller for the duration of the call. Reply and error
## strings are always non-nil empty strings on the paths they don't apply to,
## so a consumer can `strlen`/print either unconditionally without a nil deref.
quote:
let box = cast[ptr `boxName`](ud)
if box.isNil():
return
defer:
freeBox(box)
if box.fn.isNil():
return
try:
if ret != RET_OK:
var em = newString(int(len))
if int(len) > 0:
copyMem(addr em[0], msg, int(len))
box.fn(ret, "".cstring, em.cstring, box.ud)
return
let decoded = cborDecodePtr(cast[ptr UncheckedArray[byte]](msg), int(len), string)
if decoded.isErr():
box.fn(RET_ERR, "".cstring, decoded.error.cstring, box.ud)
else:
let replyStr = decoded.get()
box.fn(RET_OK, replyStr.cstring, "".cstring, box.ud)
except CatchableError as e:
box.fn(RET_ERR, "".cstring, e.msg.cstring, box.ud)
proc exportedMethodProc(
spec: CAbiSpec, boxName, envWire, trampName, poolIdent, cbType: NimNode
): NimNode =
# `cwireUnpack` and `cborEncodeShared` run on the *calling* thread and allocate
# GC memory, so the caller must be a GC-registered thread — which the dylib's
# load thread already is. Deliberately NOT wrapped in `foreignThreadGc`: its
# `tearDownForeignThreadGc` would destroy that thread's live ORC heap (a
# use-after-free / nil-read crash), and unlike the CBOR path — which only
# memcpy's bytes here — this path genuinely needs the heap intact.
let envName = spec.envelope
let libFFICtx =
nnkPtrTy.newTree(nnkBracketExpr.newTree(ident("FFIContext"), spec.libType))
# A string reply is an empty (non-nil) cstring on the error path, matching the
# trampoline; an object reply is a nil struct pointer gated by `err_code`.
let emptyReply =
if isStringType(spec.respType):
newDotExpr(newLit(""), ident("cstring"))
else:
newNilLit()
let body = quote:
if onReply.isNil():
return RET_MISSING_CALLBACK
if not `poolIdent`.isValidCtx(cast[pointer](ctx)):
onReply(RET_ERR, `emptyReply`, "ctx is not a valid FFI context".cstring, userData)
return RET_ERR
var reqObj: `envName` = cwireUnpack(req[])
let enc = cborEncodeShared(reqObj)
let reqBuf = enc.data
let reqBufLen = enc.len
let box = cast[ptr `boxName`](allocBox(sizeof(`boxName`)))
box.fn = onReply
box.ud = userData
let typeStr = $`envName`
let reqPtr = FFIThreadRequest.initFromOwnedShared(
`trampName`, box, typeStr.cstring, reqBuf, reqBufLen
)
let sendRes =
try:
ffi_context.sendRequestToFFIThread(ctx, reqPtr)
except Exception as e:
Result[void, string].err("sendRequestToFFIThread exception: " & e.msg)
if sendRes.isErr():
onReply(RET_ERR, `emptyReply`, sendRes.error.cstring, userData)
return RET_ERR
return RET_OK
newProc(
name = ident($envName & "CAbiExport"),
params = @[
ident("cint"),
newIdentDefs(ident("ctx"), libFFICtx),
newIdentDefs(ident("onReply"), cbType),
newIdentDefs(ident("userData"), ident("pointer")),
newIdentDefs(ident("req"), nnkPtrTy.newTree(envWire)),
],
body = body,
pragmas = nnkPragma.newTree(
ident("dynlib"),
nnkExprColonExpr.newTree(ident("exportc"), newStrLitNode(spec.exportName)),
ident("cdecl"),
nnkExprColonExpr.newTree(ident("raises"), nnkBracket.newTree()),
),
)
proc exportedCtorProc(
spec: CAbiSpec, boxName, envWire, trampName, poolIdent, cbType: NimNode
): NimNode =
let envName = spec.envelope
# See exportedMethodProc: the request conversion allocates GC on the calling
# thread, so no `foreignThreadGc` (its teardown would free that thread's heap).
# `when declared(initializeLibrary): initializeLibrary()` — built as raw AST;
# a `when` with an undeclared symbol inside `quote` trips a compiler ICE.
let initGuard = nnkWhenStmt.newTree(
nnkElifBranch.newTree(
newCall(ident("declared"), ident("initializeLibrary")),
newStmtList(newCall(ident("initializeLibrary"))),
)
)
let body = quote:
let ctxRes = `poolIdent`.createFFIContext()
if ctxRes.isErr():
if not onCreated.isNil():
onCreated(
RET_ERR,
"".cstring,
("ffiCtor: failed to create FFIContext: " & $ctxRes.error).cstring,
userData,
)
return nil
let ctx = ctxRes.get()
var reqObj: `envName` = cwireUnpack(req[])
let enc = cborEncodeShared(reqObj)
let reqBuf = enc.data
let reqBufLen = enc.len
let box = cast[ptr `boxName`](allocBox(sizeof(`boxName`)))
box.fn = onCreated
box.ud = userData
let typeStr = $`envName`
let reqPtr = FFIThreadRequest.initFromOwnedShared(
`trampName`, box, typeStr.cstring, reqBuf, reqBufLen
)
let sendRes =
try:
ctx.sendRequestToFFIThread(reqPtr)
except Exception as e:
Result[void, string].err("sendRequestToFFIThread exception: " & e.msg)
if sendRes.isErr():
if not onCreated.isNil():
onCreated(RET_ERR, "".cstring, sendRes.error.cstring, userData)
return nil
return cast[pointer](ctx)
body.insert(0, initGuard)
newProc(
name = ident($envName & "CAbiExport"),
params = @[
ident("pointer"),
newIdentDefs(ident("req"), nnkPtrTy.newTree(envWire)),
newIdentDefs(ident("onCreated"), cbType),
newIdentDefs(ident("userData"), ident("pointer")),
],
body = body,
pragmas = nnkPragma.newTree(
ident("dynlib"),
nnkExprColonExpr.newTree(ident("exportc"), newStrLitNode(spec.exportName)),
ident("cdecl"),
nnkExprColonExpr.newTree(ident("raises"), nnkBracket.newTree()),
),
)
proc ensureCWireForFields(
sink: NimNode, typeName: string, names: seq[string], types: seq[NimNode]
) {.compileTime.} =
## Emit the `_CWire` companion + conversion procs for a synthetic per-proc Req
## envelope (not a user `{.ffi.}` type, so it isn't in `ffiTypeRegistry`).
## Nested user-type deps are already emitted by `flushCWireCompanions`.
if isCWireEmitted(typeName):
return
var deps: seq[string] = @[]
collectNestedFFITypes(types, deps)
for dep in deps:
ensureCWireFor(dep, sink)
markCWireEmitted(typeName)
let section = newNimNode(nnkTypeSection)
section.add(buildCWireTypeDef(typeName, names, types))
sink.add(section)
for p in buildCWireProcs(typeName, names, types):
sink.add(p)
proc flushCAbiDispatch*(): NimNode {.compileTime.} =
## Emit the exported wrappers + reply trampolines for every registered
## `abi = c` proc. Runs after `flushCWireCompanions` so nested companions and
## their `cwireUnpack`/`cwirePack` overloads are already defined.
let sink = newStmtList()
for spec in cAbiSpecs:
let envName = spec.envelope
ensureCWireForFields(sink, $envName, spec.paramNames, spec.paramTypes)
let envWire = ident(cwireTypeName($envName))
let boxName = ident($envName & "CBox")
let trampName = ident($envName & "CReply")
let poolIdent = ident($spec.libType & "FFIPool")
case spec.kind
of cakCtor:
let cbType = cAbiCbType(ident("cstring"))
sink.add(boxTypeDef(boxName, cbType))
sink.add(replyTrampProc(trampName, stringTrampBody(boxName)))
sink.add(exportedCtorProc(spec, boxName, envWire, trampName, poolIdent, cbType))
of cakMethod:
let rt = spec.respType
if isStringType(rt):
let cbType = cAbiCbType(ident("cstring"))
sink.add(boxTypeDef(boxName, cbType))
sink.add(replyTrampProc(trampName, stringTrampBody(boxName)))
sink.add(
exportedMethodProc(spec, boxName, envWire, trampName, poolIdent, cbType)
)
elif rt.kind == nnkIdent:
let respWire = ident(cwireTypeName($rt))
let cbType = cAbiCbType(nnkPtrTy.newTree(respWire))
sink.add(boxTypeDef(boxName, cbType))
sink.add(replyTrampProc(trampName, objectTrampBody(boxName, rt, respWire)))
sink.add(
exportedMethodProc(spec, boxName, envWire, trampName, poolIdent, cbType)
)
else:
error(
"abi = c: unsupported response type for proc '" & spec.exportName & "': " &
rt.repr & " (only object and string returns are wired)"
)
sink

View File

@ -9,6 +9,7 @@ when defined(ffiGenBindings):
import ../codegen/rust
import ../codegen/cpp
import ../codegen/c
import ../codegen/c_abi
import ../codegen/cddl
proc requireLibraryDeclared(where: string) {.compileTime.} =
@ -954,9 +955,10 @@ macro ffi*(args: varargs[untyped]): untyped =
abiFormat: abiFormat,
)
# Qualifies for the CBOR-free fast path only if `abi = c`, every wire param +
# return is scalar (`isScalarOnly`), and the args fit the inline slots. A
# non-scalar `abi = c` proc stays gated — full C-wire dispatch is a follow-up.
# Does this proc qualify for the CBOR-free scalar fast path? Only `abi = c`
# opts in, and only when every wire param + the return is a plain scalar
# (see `isScalarOnly`) and the args fit the inline slots. A non-scalar
# `abi = c` proc rides the flat `_CWire` C-dispatch emitted by `asyncPath`.
let scalarEligible =
abiFormat == ABIFormat.C and isScalarOnly(procMeta) and
extraParamNames.len <= MaxScalarArgs
@ -1085,6 +1087,17 @@ macro ffi*(args: varargs[untyped]): untyped =
ffiProcRegistry.add(procMeta)
if abiFormat == ABIFormat.C:
# The flat-struct exported wrapper + reply trampoline are emitted at
# genBindings() time (see flushCAbiDispatch); the CBOR `ffiProc` is not.
registerCAbiMethod(
cExportName, libTypeName, reqTypeName, extraParamNames, extraParamTypes,
resultRetType,
)
return newStmtList(
helperProc, registerReq, registerRequestTimeout(reqTypeName, timeoutMs)
)
return newStmtList(
helperProc, registerReq, ffiProc, registerRequestTimeout(reqTypeName, timeoutMs)
)
@ -1109,9 +1122,6 @@ macro ffi*(args: varargs[untyped]): untyped =
procMeta = procMeta,
)
if abiFormat == ABIFormat.C and not scalarEligible:
gateABIFormat(abiFormat, "`.ffi.` proc")
let stmts =
if scalarEligible:
scalarPath()
@ -1534,16 +1544,31 @@ macro ffiCtor*(args: varargs[untyped]): untyped =
when not declared(`poolIdent`):
var `poolIdent`: FFIContextPool[`libTypeName`]
let stmts = newStmtList(
typeDef,
ffiNewReqProc,
helperProc,
processProc,
addToReg,
poolDecl,
ffiProc,
registerRequestTimeout(reqTypeName, timeoutMs),
)
let stmts =
if abiFormat == ABIFormat.C:
# The flat-struct exported wrapper is emitted at genBindings() time (see
# flushCAbiDispatch); the CBOR `ffiProc` is not.
registerCAbiCtor(cExportName, libTypeName, reqTypeName, paramNames, paramTypes)
newStmtList(
typeDef,
ffiNewReqProc,
helperProc,
processProc,
addToReg,
poolDecl,
registerRequestTimeout(reqTypeName, timeoutMs),
)
else:
newStmtList(
typeDef,
ffiNewReqProc,
helperProc,
processProc,
addToReg,
poolDecl,
ffiProc,
registerRequestTimeout(reqTypeName, timeoutMs),
)
when defined(ffiDumpMacros):
echo stmts.repr
@ -1720,6 +1745,11 @@ macro ffiEvent*(args: varargs[untyped]): untyped =
let (wireName, abiSpecStart) = resolveEventWireName(leading, userProcName)
let abiFormat = resolveABIFormat(leading[abiSpecStart ..^ 1])
gateABIFormat(abiFormat, "`.ffiEvent.` proc")
if abiFormat == ABIFormat.C:
error(
"`.ffiEvent.` proc: the `c` ABI does not yet support events; declare the " &
"event with `abi = cbor` (events still ride CBOR internally)"
)
let formalParams = prc[3]
@ -1839,15 +1869,21 @@ macro genBindings*(
generateCBindings(
genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, ffiEventRegistry
)
of "c_abi":
generateCAbiBindings(
genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, ffiEventRegistry
)
of "cddl":
generateCddlBindings(genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath)
else:
error(
"genBindings: unknown targetLang '" & lang &
"'. Use 'rust', 'cpp', 'c', or 'cddl'."
"'. Use 'rust', 'cpp', 'c', 'c_abi', or 'cddl'."
)
let cwireCompanions = flushCWireCompanions()
let emitted = flushCWireCompanions()
for node in flushCAbiDispatch():
emitted.add(node)
when defined(ffiDumpMacros):
echo cwireCompanions.repr
cwireCompanions
echo emitted.repr
emitted

View File

@ -0,0 +1,65 @@
cmake_minimum_required(VERSION 3.14)
project(echo_c_abi_e2e C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
# Sanitizer plumbing (mirrors tests/e2e/c). asan-ubsan / tsan flow to both the
# Nim dylib (via NIM_FFI_EXTRA_ARGS) and the C consumer (via compile/link opts).
set(NIM_SAN_ARGS "")
set(SAN_CFLAGS "")
if(NIM_FFI_SANITIZER STREQUAL "asan-ubsan")
set(NIM_SAN_ARGS -d:useMalloc "--passC:-fsanitize=address,undefined" "--passL:-fsanitize=address,undefined")
set(SAN_CFLAGS -fsanitize=address,undefined -fno-omit-frame-pointer)
elseif(NIM_FFI_SANITIZER STREQUAL "tsan")
set(NIM_SAN_ARGS "--passC:-fsanitize=thread" "--passL:-fsanitize=thread")
set(SAN_CFLAGS -fsanitize=thread -fno-omit-frame-pointer)
endif()
# Force the shared echo example source into its `abi = c` variant and thread the
# sanitizer args through to the dylib build. Must be `CACHE ... FORCE`: the
# bindings subdir declares `NIM_FFI_EXTRA_ARGS` as a `CACHE` variable, and under
# CMP0126 OLD (our `cmake_minimum_required(VERSION 3.14)`) that `set(... CACHE)`
# deletes any same-named normal variable from this scope so a plain `set` here
# would be wiped and the dylib would build with the default CBOR ABI, mismatching
# the flat `abi = c` header. Forcing the cache entry makes the child's non-FORCE
# cache set a no-op instead.
set(NIM_FFI_EXTRA_ARGS "-d:ffiEchoAbiC" ${NIM_SAN_ARGS}
CACHE STRING "Extra nim c args when building the dylib" FORCE)
set(ECHO_BINDINGS "${CMAKE_CURRENT_SOURCE_DIR}/../../../examples/echo/c_abi_bindings")
add_subdirectory("${ECHO_BINDINGS}" echo_c_abi_build)
enable_testing()
add_executable(test_echo_c_abi test_echo_c_abi.c)
target_link_libraries(test_echo_c_abi PRIVATE echo_headers)
add_dependencies(test_echo_c_abi echo_nim_lib)
if(SAN_CFLAGS)
target_compile_options(test_echo_c_abi PRIVATE ${SAN_CFLAGS})
target_link_options(test_echo_c_abi PRIVATE ${SAN_CFLAGS})
endif()
# Nim-built dylibs use `@rpath/lib*.so|dylib`; embed the imported lib's dir so
# the test runs without LD_LIBRARY_PATH.
get_target_property(_echo_loc echo IMPORTED_LOCATION)
get_filename_component(_echo_dir "${_echo_loc}" DIRECTORY)
set_target_properties(test_echo_c_abi PROPERTIES
BUILD_RPATH "${_echo_dir}"
INSTALL_RPATH "${_echo_dir}")
set(_san_test_env "")
if(NIM_FFI_SANITIZER STREQUAL "asan-ubsan")
list(APPEND _san_test_env
"ASAN_OPTIONS=halt_on_error=1:abort_on_error=1:detect_leaks=1"
"UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1"
"LSAN_OPTIONS=suppressions=${CMAKE_CURRENT_SOURCE_DIR}/lsan.supp:print_suppressions=0")
elseif(NIM_FFI_SANITIZER STREQUAL "tsan")
get_filename_component(_repo_tsan_supp
"${CMAKE_CURRENT_SOURCE_DIR}/../../../tsan.supp" ABSOLUTE)
list(APPEND _san_test_env
"TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1:history_size=7:suppressions=${_repo_tsan_supp}")
endif()
add_test(NAME echo_c_abi_e2e COMMAND test_echo_c_abi)
if(_san_test_env)
set_tests_properties(echo_c_abi_e2e PROPERTIES ENVIRONMENT "${_san_test_env}")
endif()

22
tests/e2e/c_abi/lsan.supp Normal file
View File

@ -0,0 +1,22 @@
# LeakSanitizer suppressions for the Nim runtime.
#
# These are process-lifetime allocations freed implicitly at exit —
# not real leaks. Add new entries here only with a comment justifying
# why the leak is unavoidable, and only for symbols inside the Nim
# standard library, chronos, or chronicles. Anything in our own code
# (ffi/*) or the generated bindings must be fixed, not suppressed.
# Nim runtime initialisation — allocates global state freed at exit.
leak:NimMain
leak:PreMain
leak:systemDatInit
# GC bootstrap — registers stack bottom / TLS slots once per thread.
leak:nimGC_setStackBottom
leak:initStackBottomWith
leak:setupForeignThreadGc
# Async / logging library globals (event loop singletons, logger
# registries) — owned for the process lifetime.
leak:chronos
leak:chronicles

View File

@ -0,0 +1,99 @@
/* End-to-end test for the CBOR-free `abi = c` echo bindings. Unlike the CBOR C
* backend, this header links no TinyCBOR: the flat structs in echo.h are the C
* ABI, strings are plain borrowed `const char*`. The test drives the same
* async, callback-per-call surface constructor, an object-returning method
* and teardown copying out what each callback delivers (owned by the binding,
* valid only for the call) and polling a `done` flag to sequence the async
* calls. A string-returning method (echoVersion) rides the CBOR-free scalar
* fast path instead of a flat `_CWire` wrapper, so it has no c_abi binding yet
* (foreign codegen for the scalar shape is a follow-up) and isn't exercised
* here. */
#include "echo.h"
#include <assert.h>
#include <stdatomic.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
/* The `done` flag is a C11 atomic: the callback fires on the FFI thread and
* stores it with release ordering after filling the waiter's fields, and the
* poller loads it with acquire ordering so the field writes are visible
* (and race-free under TSan) once `done` is seen set. */
static void wait_done(atomic_int* done) {
for (int i = 0; i < 500 && !atomic_load_explicit(done, memory_order_acquire); i++) {
struct timespec t = {0, 10 * 1000 * 1000}; /* 10ms */
nanosleep(&t, NULL);
}
assert(atomic_load_explicit(done, memory_order_acquire));
}
typedef struct {
atomic_int done;
int err_code;
EchoCtx* ctx;
char err[256];
} CreateWaiter;
static void on_created(int ec, EchoCtx* ctx, const char* em, void* ud) {
CreateWaiter* w = (CreateWaiter*)ud;
w->err_code = ec;
w->ctx = ctx;
if (em) {
snprintf(w->err, sizeof(w->err), "%s", em);
}
atomic_store_explicit(&w->done, 1, memory_order_release);
}
static EchoCtx* make_ctx(void) {
CreateWaiter w;
memset(&w, 0, sizeof(w));
EchoConfig config = {"c-abi"};
echo_ctx_create(&config, on_created, &w);
wait_done(&w.done);
if (w.err_code != 0) {
fprintf(stderr, "create failed: %s\n", w.err[0] ? w.err : "?");
}
assert(w.err_code == 0);
assert(w.ctx != NULL);
return w.ctx;
}
typedef struct {
atomic_int done;
int err_code;
char err[256];
char text_a[256];
char text_b[256];
} ReplyWaiter;
static void on_shout(int ec, const ShoutResponse* reply, const char* em, void* ud) {
ReplyWaiter* w = (ReplyWaiter*)ud;
w->err_code = ec;
if (reply) {
if (reply->shouted)
snprintf(w->text_a, sizeof(w->text_a), "%s", reply->shouted);
if (reply->prefix)
snprintf(w->text_b, sizeof(w->text_b), "%s", reply->prefix);
}
if (em) snprintf(w->err, sizeof(w->err), "%s", em);
atomic_store_explicit(&w->done, 1, memory_order_release);
}
static void test_shout(EchoCtx* ctx) {
ReplyWaiter w;
memset(&w, 0, sizeof(w));
ShoutRequest req = {"hello"};
echo_ctx_shout(ctx, &req, on_shout, &w);
wait_done(&w.done);
assert(w.err_code == 0);
assert(strcmp(w.text_a, "c-abi: HELLO") == 0);
assert(strcmp(w.text_b, "c-abi") == 0);
}
int main(void) {
EchoCtx* ctx = make_ctx();
test_shout(ctx);
echo_ctx_destroy(ctx);
printf("all abi=c echo e2e checks passed\n");
return 0;
}

View File

@ -122,10 +122,11 @@ suite "handler-timeout spec parsing (issue #93)":
check requestTimeoutsMs["AbitestSlowReq".cstring] == 30000
check not requestTimeoutsMs.hasKey("AbitestPingReq".cstring)
suite "ABI proc-dispatch readiness (why c is still gated on procs)":
test "cbor proc-dispatch is wired; c proc-dispatch is gated":
# This predicate is what the proc-form macros consult: `cbor` is wired
# end-to-end, while `c` is recognized but gated pending its codec. It is the
# single seam a future PR flips when the c codec and dispatch path land.
suite "ABI proc-dispatch readiness":
test "both cbor and c proc-dispatch are wired":
# This predicate is what the proc-form macros consult. Both ABIs now have a
# working dispatch path: `cbor` rides the generic overloads, `c` rides the
# flat `_CWire` companions (a CBOR-free foreign surface, CBOR transport
# internally). Events are the one `c` gap, gated separately in the macro.
check abiCodegenImplemented(ABIFormat.Cbor)
check not abiCodegenImplemented(ABIFormat.C)
check abiCodegenImplemented(ABIFormat.C)

View File

@ -0,0 +1,125 @@
## Unit tests for the CBOR-free `abi = c` C binding generator. Drives
## generateCAbiLibHeader directly against a synthetic registry (no macro
## pipeline, no files written) and asserts on the emitted text — same approach
## as test_c_codegen / test_cddl_codegen.
import std/strutils
import unittest2
import ffi/codegen/[meta, c_abi]
proc field(n, t: string): FFIFieldMeta =
FFIFieldMeta(name: n, typeName: t)
proc param(n, t: string, isPtr = false): FFIParamMeta =
FFIParamMeta(name: n, typeName: t, isPtr: isPtr)
suite "generateCAbiLibHeader":
setup:
let types = @[
FFITypeMeta(
name: "EchoRequest",
fields: @[field("message", "string"), field("delayMs", "int")],
),
FFITypeMeta(name: "EchoResponse", fields: @[field("echoed", "string")]),
FFITypeMeta(
name: "ComplexRequest",
fields:
@[field("messages", "seq[EchoRequest]"), field("note", "Option[string]")],
),
]
let procs = @[
FFIProcMeta(
procName: "timer_create",
libName: "timer",
kind: FFIKind.CTOR,
libTypeName: "Timer",
extraParams: @[param("config", "EchoRequest")],
returnTypeName: "Timer",
),
FFIProcMeta(
procName: "timer_echo",
libName: "timer",
kind: FFIKind.FFI,
libTypeName: "Timer",
extraParams: @[param("req", "ComplexRequest")],
returnTypeName: "EchoResponse",
),
FFIProcMeta(
procName: "timer_version",
libName: "timer",
kind: FFIKind.FFI,
libTypeName: "Timer",
extraParams: @[],
returnTypeName: "string",
),
FFIProcMeta(
procName: "timer_destroy",
libName: "timer",
kind: FFIKind.DTOR,
libTypeName: "Timer",
extraParams: @[],
returnTypeName: "",
),
]
let header = generateCAbiLibHeader(procs, types, "timer")
test "the header is self-contained and links no CBOR":
check "#include <stdint.h>" in header
check "nim_ffi_cbor.h" notin header
check "CborError" notin header
check "tinycbor" notin header.toLowerAscii()
test "flat wire structs mirror the _CWire layout":
# string -> const char*; POD unchanged.
check "const char* message;" in header
check "int64_t delayMs;" in header
# seq[T] -> <wireElem>* <f>_items + ptrdiff_t <f>_len (two fields).
check "EchoRequest* messages_items;" in header
check "ptrdiff_t messages_len;" in header
# Option[string] -> pointer to the element wire type (NULL = none).
check "const char** note;" in header
# nested {.ffi.} type rides as its flat struct.
check "EchoRequest config;" in header
test "per-proc Req envelopes are emitted as structs":
check "} TimerEchoReq;" in header
check "} TimerCreateCtorReq;" in header
test "exported symbols use the flat structs, not CBOR buffers":
check "req_cbor" notin header
check "const TimerEchoReq* req" in header
check "void* timer_create(const TimerCreateCtorReq* req," in header
check "int timer_destroy(void* ctx);" in header
test "object returns hand back a const struct pointer; string returns a cstring":
check "typedef void (*TimerEchoReplyFn)(int err_code, const EchoResponse* reply," in
header
check "typedef void (*TimerVersionReplyFn)(int err_code, const char* reply," in
header
test "constructor delivers its context address through a raw string callback":
check "TimerCreateRawFn)(int err_code, const char* ctx_addr," in header
check "strtoull(ctx_addr" in header
test "high-level context wrapper is emitted":
check "} TimerCtx;" in header
check "timer_ctx_create(" in header
check "timer_ctx_echo(" in header
check "timer_ctx_version(" in header
check "timer_ctx_destroy(" in header
test "events are rejected (CBOR-only for now)":
expect ValueError:
discard generateCAbiLibHeader(
procs,
types,
"timer",
@[
FFIEventMeta(
wireName: "on_tick",
nimProcName: "onTick",
libName: "timer",
payloadTypeName: "EchoResponse",
)
],
)