Runtime C calls from a pure-C register VM

Archived MVP specification. For ASPL 0.5.0, see the current reference and native HTTP guide.

Finding

ASPL can load any compatible ELF shared object and resolve a named symbol on x86-64 Linux. It cannot safely invoke every runtime-declared C signature under the current constraints.

The safe pure-C subset is exact and finite:

A foreign function is callable only when the ASPL executable already contains a C call site whose function type is compatible with the function's real C type.

This is a C language limit, not a missing parser feature. POSIX supplies runtime symbol lookup. ISO C supplies calls whose types the C compiler knows while compiling ASPL. Neither supplies an operation that takes a runtime list of types and arranges an arbitrary call.

The MVP should separate two claims:

Calling "arbitrary C libraries" is defensible with that distinction. Calling arbitrary C functions is not.

What dlopen and dlsym provide

POSIX defines dlopen as loading a shared object and returning an opaque handle. dlsym looks up a function or data symbol by name. POSIX also requires the void * returned for a function symbol to work when converted to the correct function-pointer type, although ISO C alone does not define that object-pointer to function-pointer conversion. This makes dynamic lookup usable on the chosen Linux target, but it does not erase the need to know the correct function type. See the POSIX dlsym specification and the Linux man-pages project's POSIX dlsym page.

ASPL should retain each dlopen handle for at least as long as any resolved address can be called. After dlclose, POSIX tells applications to treat symbols obtained through that handle as unavailable. Keeping handles until VM shutdown is the simple MVP rule. See POSIX dlclose and its POSIX manual-page rendering.

This is runtime loading, not traditional ELF link editing. An emitted ASPL executable can record a SONAME or path, call dlopen during startup, then resolve its declared symbols. The shared library remains a deployment dependency. Build-time validation cannot guarantee that the same library or ABI will exist on another machine.

Why a generic C function pointer is unsafe

C11 says that calling through a pointer to an incompatible function type has undefined behavior. Function compatibility includes the return type, parameter count, parameter types, and whether the declaration uses an ellipsis. Signed and unsigned integers of the same width remain different types. Pointers and 64-bit integers remain different types. An ABI-compatible bit pattern is not enough. See C11 draft N1570 sections 6.3.2.3 paragraph 8, 6.5.2.2 paragraph 9, and 6.7.6.3 paragraph 15 in the WG14 committee draft.

An old-style function pointer such as R (*fp)() is not a general escape hatch. It can be compatible with a non-variadic prototype only when each parameter type is unchanged by the default argument promotions. This excludes float, _Bool, char, and short parameters. More importantly, every C call expression still has a fixed number of arguments, and every argument expression still has one static C type. A VM would need a compiled branch for every accepted ordered combination of argument types and arity.

Using one uint64_t carrier for all values may appear to work for some integer and pointer calls on current compilers. C11 still treats calls through the resulting incompatible function type as undefined. ASPL should not make compiler optimization flags part of its FFI correctness story.

What the x86-64 ABI changes

The System V x86-64 ABI classifies integer scalars and pointers as INTEGER, while float and double use the SSE class. Calls allocate up to six general-purpose argument registers and eight vector argument registers before placing overflow arguments on the stack. Aggregate classification can split one value across classes or force the whole value into memory. Returns have their own rules, including hidden caller-provided storage for some aggregate results. Variadic calls also communicate the number of used vector registers in %al. The canonical rules are in the x86 psABI project's parameter-passing source under "Parameter Passing."

A C compiler handles those rules when it sees the true static prototype. The ABI does not give standard C a syntax for filling an arbitrary register image and making a call. It also does not override C's compatible-type requirement. Grouping values into only INTEGER and SSE classes is therefore insufficient for a safe C implementation.

The often-quoted six-integer and eight-vector register counts are not natural FFI arity limits. A statically typed C call can pass more arguments on the stack. Any lower MVP limit exists to keep the precompiled adapter catalog small.

Pure-C implementation choices

Exact adapters

The smallest honest design is an explicit catalog of full function types. Each catalog entry has a normal C function-pointer type and a normal call expression. Any symbol from any loaded library may use an entry when its real C declaration is compatible with that entry.

The two current acceptance calls need only these entries:

int (*)(const char *)
double (*)(double)

They cover puts and sqrt. The ASPL checker should reject a foreign declaration that has no catalog entry before producing bytecode. The runtime must never guess a near match.

This catalog can grow, but the growth is combinatorial. With three parameter types and arities zero through four, there are 1 + 3 + 9 + 27 + 81 = 121 ordered parameter layouts for each return type. Four return choices require 484 statically typed call branches. Exact C pointer types, signedness, and widths increase the count further.

For that reason, the MVP should start with named, reviewed entries that serve its conformance programs. It should not generate a broad matrix merely to make the feature list sound larger.

Precompiled shims

A C library or binding package can expose a wrapper with one ASPL-supported signature and translate inside the wrapper. This preserves a pure-C VM. It does not meet the no-other-tools promise when an end user must compile the shim, so only already-built shims qualify for the normal installation story.

Runtime call machinery

Broad runtime-shaped calls require ABI-specific machinery. libffi exists for exactly this interpreter use case. Its ffi_prep_cif and ffi_call APIs accept a runtime call description and marshal arguments for the platform ABI. The upstream libffi manual describes this interpreter-to-compiled-code role and the call interface.

Vendoring libffi and linking it into the ASPL executable would avoid an end-user package dependency. It would not preserve the current "C only" rule. The upstream x86-64 backend combines the C classifier in ffi64.c with the assembly call path in unix64.S. Reimplementing the same mechanism inside ASPL would also introduce architecture-specific assembly or emitted machine code.

This makes statically vendored libffi a sensible later option when the project admits assembly. It is not a hidden pure-C shortcut for this MVP.

Required exclusions for the MVP

The pure-C signature catalog should exclude these cases unless an exact, separately reviewed adapter is added:

The calling mechanism also does not solve value conversion or ownership. The libffi manual explicitly leaves language-level conversions to the embedding language. For example, an ASPL string passed to const char * needs a NUL-terminated byte representation and a lifetime that covers the call. The MVP should prohibit a foreign function from retaining such a borrowed pointer unless a later unsafe memory model defines that behavior.

Recommendation for the ASPL specification

Keep the C-only MVP and narrow the FFI promise:

  1. The runtime uses POSIX dlopen, dlsym, dlerror, and dlclose on x86-64 glibc Linux. It keeps successful library handles open until process shutdown.
  2. Foreign declarations must match a closed catalog of exact, non-variadic C function types already compiled into the VM. The initial required catalog contains the exact puts and sqrt signatures. Any additions need conformance tests against a real C library.
  3. Unsupported declarations fail during aspl check and aspl build. Diagnostics name the unsupported full signature. There is no best-effort ABI coercion.
  4. The documentation says ASPL can load arbitrary compatible shared libraries, subject to its documented callable-signature subset. It does not say ASPL can call arbitrary C functions.
  5. General scalar signatures, aggregates, variadics, and callbacks wait until ASPL either vendors libffi or accepts its own assembly call bridge. Statically vendored libffi best preserves the end-user promise of one executable and no separately installed compiler, linker, or FFI runtime.

That boundary is small, but it is real. It proves dynamic C interoperability with puts and sqrt without smuggling undefined behavior or an undeclared assembler into the MVP.

Primary sources