Internals

This article is about how Rtinycc works internally. It is not the stable user-facing API contract. The pieces described here are current implementation choices and may change as the package evolves.

At a high level, the package is built as a pipeline:

  1. an R-side recipe is accumulated in a tcc_ffi object
  2. that recipe is turned into a generated C translation unit
  3. TinyCC compiles and relocates the generated code in memory
  4. wrapper pointers are recovered and exposed back to R as closures

The tcc_ffi Object Is a Recipe

tcc_ffi() does not compile anything by itself. It creates a plain R object that accumulates:

That state lives in the tcc_ffi list object built by tcc_ffi_object(). The important point is that tcc_compile() works from this declarative recipe, not from an already-live TCC process.

ffi <- tcc_ffi() |>
  tcc_source("int add(int a, int b) { return a + b; }") |>
  tcc_bind(add = list(args = list("i32", "i32"), returns = "i32"))

names(ffi)
#>  [1] "state"           "symbols"         "headers"         "c_code"         
#>  [5] "options"         "libraries"       "lib_paths"       "include_paths"  
#>  [9] "output"          "compiled"        "wrapper_symbols" "globals"

Code Generation Is Central

tcc_compile() calls the internal generate_ffi_code() helper to assemble one large C source string. That generated source is the real boundary layer between R and the target C functions.

Internally, the generated translation unit is assembled in this order:

For a small binding:

code <- Rtinycc:::generate_ffi_code(
  symbols = ffi$symbols,
  headers = ffi$headers,
  c_code = ffi$c_code,
  is_external = FALSE,
  structs = ffi$structs,
  unions = ffi$unions,
  enums = ffi$enums,
  globals = ffi$globals,
  container_of = ffi$container_of,
  field_addr = ffi$field_addr,
  struct_raw_access = ffi$struct_raw_access,
  introspect = ffi$introspect
)

grepl("SEXP R_wrap_add", code, fixed = TRUE)
#> [1] TRUE

The wrapper is where input coercion, range checks, callback trampoline setup, actual C invocation, and return boxing happen.

How Values Move Between R, The Wrapper, And C

The important internal boundary is not “R calls user C directly”. The flow is:

  1. an R closure created by make_callable() calls .Call with the compiled wrapper’s native symbol external pointer
  2. that wrapper receives SEXP arguments
  3. wrapper code uses the R C API to decode or borrow data from those SEXPs
  4. the wrapper calls the target C symbol using ordinary C arguments
  5. the wrapper converts the C result back into a SEXP
  6. .Call returns that SEXP to the R interpreter

So the generated wrapper is the translator between:

This is why Rtinycc includes R.h and Rinternals.h in every generated translation unit and why the wrapper code uses constructors and accessors such as:

At the R level, make_callable() builds a small closure around the compiled wrapper pointer. That closure does argument-count validation, checks that the pointer is still valid, and then hands control to .Call.

The wrapper itself is where the actual C API interaction happens.

Copying Versus Borrowing Happens In The Wrapper

The copy model is mostly determined by the generated conversion code.

Scalar inputs are copied or coerced into local C values:

These are not zero-copy paths.

Vector inputs are split into two groups:

String and pointer inputs need more care:

Returns have their own copy model:

So the internal design is intentionally mixed:

That is the main semantic reason the generated wrapper layer exists.

Why lambda.r Is Used

The large rule file R/aaa_ffi_codegen_rules.R uses lambda.r as a small dispatch DSL. The package imports %as% and UseFunction, and defines rules like:

Those rules are not user-facing metaprogramming. They are an internal way to register many small code-generation cases without turning R/ffi_codegen.R into one enormous nest of if and switch statements.

In practice, generate_c_input() and generate_c_return() delegate into that rule table:

Rtinycc:::generate_c_input("x", "arg1_", "i32")
#> [1] "  int _x = asInteger(arg1_);\n  if (_x == NA_INTEGER) Rf_error(\"integer value is NA\");\n  if (_x < INT32_MIN || _x > INT32_MAX) Rf_error(\"i32 out of range\");\n  int32_t x = (int32_t)_x;"
Rtinycc:::generate_c_return("res", "f64")
#> [1] "return ScalarReal(res);"

The main tradeoff is simple:

So lambda.r here is being used for internal rule dispatch and code-template selection, not because the public API depends on functional programming style.

Wrapper Builders Work at the SEXP Boundary

Rtinycc is not using a libffi ABI layer. The generated wrappers are normal C functions with SEXP signatures so that R can call them through .Call.

The key internal steps are:

For non-variadic bindings, the generated wrapper is named R_wrap_<symbol>. Variadic bindings generate several wrapper variants and dispatch is chosen later from R based on tail arity or inferred tail types.

This design keeps platform-specific calling conventions inside compiled C rather than trying to reproduce them from R.

Protection And Lifetime Rules Matter

Because wrappers use the R C API directly, protection and object lifetime are part of the internal design.

When wrapper code allocates a fresh R object, it protects that object until the result is fully built and returned. Typical cases include:

Borrowed pointers have a different constraint: they are only sound as long as the underlying owner stays alive and the wrapper does not invalidate the assumption by introducing unexpected allocation patterns.

This is especially important for:

The package also uses external pointer metadata and protected slots to encode lifetime relationships. For example, borrowed field pointers can keep their owner object alive by storing that owner in the external pointer’s protected field.

Ownership And Lifetime Semantics In The Main Cases

The main internal cases are easier to reason about if you separate them by who owns the underlying storage and how long the view is valid.

Call-scoped borrows from R objects

These values are borrowed from existing R objects and are only intended to be used during the wrapper call:

The wrapper does not transfer ownership of these objects to C. If target C code stores the pointer and uses it after the call returns, that is outside the safe contract.

Owned native allocations

These are heap allocations owned through explicit external-pointer semantics:

These objects have a stable native lifetime until:

Borrowed native views

These are external pointers that point into someone else’s storage:

Borrowed pointers do not imply ownership and must not be freed as if they were rtinycc_owned. Their validity depends entirely on the lifetime of the underlying storage.

Returned R objects

When the wrapper returns a scalar, string, or copied array to R, the result is an ordinary R-managed object:

Once returned, these objects follow the normal R GC lifetime and are no longer tied to the lifetime of the original C storage.

Callback registry lifetime

Callbacks have a separate ownership model:

This means the callback object is not just a function pointer. It is a managed pairing of:

Compiled object lifetime

A tcc_compiled object owns a live TCC state and the wrapper pointers recovered from that state.

When that state dies, the wrapper pointers are dead as machine-code references even though the R closures still exist. That is why the package stores a recipe and recompiles instead of pretending those pointers survive serialization.

Host Symbol Injection Happens Before Relocation

After the generated code is compiled, tcc_ffi_compile_state() calls the C entry point RC_libtcc_add_host_symbols() before tcc_relocate().

That host-injection step registers package-side C helpers with the live TCC state. This matters most on macOS, where the package cannot rely on the dynamic linker to expose every host symbol the same way TinyCC expects.

The injected symbols include:

The important semantic point is that some generated C code depends on package runtime helpers, not just on user code and the R API.

Callback Round-Trips Cross The Boundary Twice

Callbacks are the clearest example of value exchange between plain C and the R interpreter.

For synchronous callbacks:

  1. generated C trampoline code receives plain C arguments
  2. the trampoline boxes them into a VECSXP argument list
  3. it calls RC_invoke_callback_id()
  4. the runtime builds and evaluates the R call with R_tryEvalSilent()
  5. the result is converted back into the declared C return type
  6. the trampoline returns that C value to the original compiled code

So a callback call is:

Async callbacks add one more layer: arguments are first marshaled into a cross-thread task representation, then rebuilt as fresh R objects on the main thread before the callback is evaluated.

State Creation Is Separate from Compilation

The TCC state is created first, then populated and compiled.

Internally:

This split is useful because both tcc_compile() and tcc_link() follow the same broad pattern even though one starts from user C source and the other starts from external-library declarations.

The Compiled Object Is an Environment of Closures

After relocation, tcc_compiled_object() recovers wrapper symbols with tcc_get_symbol() and turns them into R callables with make_callable().

That compiled object is an environment, not an S4 class or external pointer wrapper. The environment stores:

For non-variadic functions, make_callable() creates a closure that:

For variadic bindings, the closure selects the matching precompiled wrapper first, then calls that wrapper pointer.

Serialization Works by Recompiling the Recipe

Compiled wrapper pointers do not survive serialization as usable machine code. Rtinycc handles this by storing the original recipe:

So serialization support is not pointer persistence. It is recipe persistence plus transparent recompilation.