This article is about what
actually crosses the R/C boundary in Rtinycc:
The statements below are based on the implemented wrapper generator and runtime helpers.
Scalar inputs are converted at the boundary. For example:
i8, i16, i32,
u8, u16 use integer coercion plus range
checksi64, u32, u64 use numeric
coercion plus integer-value checksbool rejects NAf32 and f64 are read from R numericsSo scalar arguments are not zero-copy views into R objects. They become C scalars inside the wrapper.
The array input types:
rawinteger_arraynumeric_arraylogical_arrayare passed as writable direct pointers into the underlying R vector storage. For ordinary already-materialized vectors, no extra buffer is allocated by the wrapper.
That means:
This is the main zero-copy part of the FFI boundary. One important
R-specific caveat is ALTREP: if an input vector is an ALTREP object,
asking R for a writable C data pointer can materialize that vector.
Checking ALTREP(x) in generated C could choose a different
policy, but using *_GET_REGION() into a temporary buffer
would be a different contract for these mutable types: it would need
copy-back behavior and would not automatically preserve pointer aliasing
when the same R vector is passed to multiple C arguments. Read-only
ALTREP-friendly array types would be a cleaner separate API.
tcc_call_symbol() Uses .C()-Style
Copy-In/Copy-OutWhen tcc_call_symbol() is called with extra arguments,
it follows the argument-type mapping of R’s .C() interface
rather than the zero-copy array contract used by tcc_ffi()
wrappers.
For atomic vectors and character vectors, Rtinycc copies inputs into
guarded mutable call storage and returns a list containing the
copied-back values. This means C mutations are visible in the returned
list, not by mutating the original R objects. Numeric vectors with
attr(x, "Csingle") are converted through a temporary
float * buffer, matching R’s legacy .C()
convention.
Lists and other non-atomic R objects follow the legacy
.C() read-only paths: lists are exposed as
SEXP *, while functions, environments, and other R objects
are exposed as SEXP. These values are borrowed only for the
duration of the call. C code must not mutate them through
tcc_call_symbol(), and if it stores a SEXP
beyond the call it must preserve and later release it with the R C API.
For ordinary lists, Rtinycc can pass the existing SEXP *
element storage read-only; for ALTREP or otherwise opaque list-like
vectors, it rebuilds a temporary call-lifetime SEXP * view
with VECTOR_ELT() so the path remains ALTREP-aware without
forcing writable data access.
Unlike R’s optional options(CBoundsCheck = TRUE),
tcc_call_symbol() checks guard bytes around its copied
atomic and character buffers by default. This can catch simple
underwrites and overwrites, but it is not a sandbox: far out-of-bounds
native writes are still bugs in the called C code. For character
arguments, C may edit the contents of each copied string buffer in
place, but it must not replace the char * elements in the
char ** array.
cstring_array Is Rebuilt Per Callcstring_array is different. The wrapper allocates a
temporary const char ** with R_alloc() and
fills it by translating each R string element.
So:
Array returns are always copied into a newly allocated R vector. The
wrapper uses the declared length_arg to size the R result,
then memcpy() copies the returned C buffer into that
vector.
If free = TRUE, the wrapper also frees the original
returned buffer after the copy.
So array returns are not borrowed views into C memory.
cstring Values Are CopiedFor cstring returns, the wrapper creates an R string
with mkString() when the returned pointer is non-NULL.
That means the resulting R value is a copy in R-managed memory, not a retained external pointer to the original C string.
ptr Values Stay as PointersFor ptr returns, the wrapper constructs an external
pointer around the raw address.
That means:
The same distinction matters for globals and struct fields.
sexp Passes Through Directlysexp is the most direct boundary mode:
sexp arguments are passed through as
SEXPsexp values are returned directlyThis is useful when you want the R C API contract rather than the stricter FFI conversion layer.
At the helper level:
tcc_malloc() and tcc_cstring() create
owned external pointerstcc_data_ptr() and tcc_read_ptr() return
borrowed external pointersstruct_outer_get_child() return borrowed nested views into
the owning struct storageUse tcc_ptr_is_owned() when you need to distinguish
these cases in R code.
Bitfield helpers behave like scalar getter/setter helpers at the R boundary, but that does not make them ordinary addressable fields.
In particular:
tcc_field_addr() and tcc_container_of()
reject bitfield membersSo bitfields are intentionally excluded from the borrowed-address helper model.
Compiled tcc_compiled objects store enough recipe
information to recompile after serialize() /
unserialize() or readRDS().
Raw pointers and raw tcc_state objects do not gain that
behavior. After serialization they are just dead addresses or invalid
states, not auto-reconstructed resources. The same applies to callback
tokens, struct/union external pointers, and helper allocations from
tcc_malloc() or tcc_cstring(): they do not
serialize as live native resources.
tcc_call_symbol()
Uses .C()-Style Copy-In/Copy-Outcstring_array Is
Rebuilt Per Callcstring Values Are Copiedptr
Values Stay as Pointerssexp Passes Through
Directly