Calling C
Pepper programs can call functions written in C: the C standard library, your
operating system’s API, or any other library with a C interface. You declare
the C function’s prototype with extern, make sure the library that defines it
is linked, and then call it like any Pepper function.
Declaring a C function
An extern declaration is a C prototype, written the way C writes it: the
return type, the name, and the parameter types, each followed by a parameter
name. It has no body.
extern ulong strlen(char* s);
extern int strcmp(char* a, char* b);
extern void* malloc(ulong size);
extern void free(void* p);
After that, strlen("hello") calls the C library’s strlen. The compiler
does not check your prototype against any C header, so it must match the real
C function exactly. A wrong parameter or return type compiles, and then passes
or reads the wrong bits when the program runs.
Match C types with Pepper types of the same size:
| C | Pepper |
|---|---|
char, signed char, unsigned char | char or i8, u8 or byte |
short, unsigned short | short or i16, ushort or u16 |
int, unsigned int | int or i32, uint or u32 |
long long, unsigned long long, int64_t, size_t (64-bit) | long or i64, ulong or u64 |
float, double | float, double |
T* | T* in the prototype |
Pepper’s long is always 64 bits. C’s long is 64 bits on Linux and macOS but
32 bits on Windows, so a C function that takes long needs a different
prototype per platform (see [Link(name = ...)]
below for an example).
32-bit targets
On a 32-bit target (wasm32-wasip1, see
WebAssembly), pointers, C’s long and
size_t are 32 bits. There, an extern’s long and ulong stand for C’s
pointer-sized long/ssize_t and unsigned long/size_t: the call passes
the low 32 bits and widens the result back to 64 (sign-extending a long,
zero-extending a ulong), so extern ulong strlen(char* s); works
unchanged. A Pepper function passed to C as a callback is converted the same
way in the other direction. i64 and u64 stay 64 bits on every target; use them for C’s
long long, int64_t and off_t. WebAssembly checks every call’s
signature, so a prototype with the wrong width traps instead of returning
garbage.
Some things to know about extern declarations:
- The prototype uses C syntax only. The
name(parameter: type): typeform that Pepper functions use is a parse error on an extern, and__ptr<T>as a parameter type is an error too: writechar*, not__ptr<char>. - An extern is private to its file (or module), like every declaration. Write
export extern ...to let files that import this one call it (see Visibility). - Several files may declare the same extern, since every declaration names the
same C function. The runtime already declares
malloc,freeand a few others privately, and your program can declare them again. - An extern is linked by name. If no linked library defines it, the program compiles and then fails to link, with the linker reporting an undefined symbol. See Linking for how to add libraries.
- An extern is a function value too, of the function
type its prototype gives (
absis an(int) => int), so it can be stored and passed like a Pepper function. Where a C function pointer type is expected, it is the C function’s own address (see Passing a Pepper function to C). A variadic extern (one ending in..., below) is not a value: wrap a call to it in a closure.
# An extern function is a function value, like a Pepper function.
extern int abs(int x);
extern ulong strlen(char* s);
apply(f: (int) => int, x: int): int {
return f(x);
}
main(): int {
# Its type comes from the prototype: `abs` is an `(int) => int`.
var absolute = abs;
assert(absolute(-3) == 3, "through a local");
assert(apply(abs, -4) == 4, "as an argument");
# A `char*` parameter is a `__ptr<char>` in the function type.
var length: (__ptr<char>) => ulong = strlen;
var word = "four";
assert(length(word.c_str()) == 4, "strlen");
# Where a C function pointer is expected, it is the C function itself.
var pointer: extern (int) => int = abs;
assert(pointer(-5) == 5, "C function pointer");
return 0;
}
Passing strings
Pepper’s String is a reference-counted object, not
a C string, so a String value cannot be passed where C expects char*.
- A string literal passed to an extern is passed as a NUL-terminated C
string, with no allocation:
strlen("hello")is 5. The same is true wherever a literal flows into a__ptr<char>,__ptr<u8>, or__ptr<void>in Pepper code (a variable, field, argument, or return value), soString("hello")copies the literal’s five bytes. - A
StringConstantpasses its constant NUL-terminated bytes with no copy, by itself where a__ptr<char>is expected or throughc_str(). The pointer is valid for the whole run of the program and is never__null. - A
Stringvalue is passed withc_str(), which returns the String’s buffer, NUL-terminated, as a__ptr<char>. A String is not guaranteed to be NUL-terminated otherwise, so always go throughc_str()when C needs the terminator. The pointer belongs to the String: it is valid until the String is destroyed or grows (for example withappendor+=). An emptyString()has no buffer, so itsc_str()is__null; check before you pass it to a C function that does not acceptNULL. - To turn a C string back into a
String, pass the pointer to the constructor:String(p)copies a NUL-terminated C string, andString(p, size)copiessizebytes. If you already know the number of code points, pass it as a third argument:String(p, size, String.length_of(p, size)). The constructor copies the bytes, so you still own (and may free) the C buffer. A C string is never passed or stored where aStringis expected without this copy: that is an error.
# Passing strings to C, and turning C strings back into Strings.
extern ulong strlen(char* s);
extern int strcmp(char* a, char* b);
extern char* strchr(char* s, int c);
main(): int {
# A string literal passed to an extern is a NUL-terminated C string.
assert(strlen("hello") == 5, "a literal is passed as a C string");
assert(strcmp("abc", "abc") == 0, "equal C strings compare as 0");
# So is one stored in a __ptr<char>, or passed to the String
# constructor, which copies it.
__ptr<char> greeting = "hello";
assert(strlen(greeting) == 5, "a literal in a __ptr<char> is a C string");
assert(String("hello").size == 5, "String(literal) copies its bytes");
# A String value goes through c_str().
var name = "Pepper";
name += " and C";
assert(strlen(name.c_str()) == name.size, "c_str() is the String's bytes");
# A char* result is a __ptr<char>. Copy it into a String with the
# constructor: the byte size and the number of code points.
var rest = strchr(name.c_str(), 32); # 32 is ' '
assert(rest != __null, "strchr found a space");
var size = strlen(rest);
var copy = String(rest, size, String.length_of(rest, size));
assert(copy == " and C", "the copy holds the tail of the C string");
return 0;
}
Passing a String value directly to an extern’s char* parameter is an error:
main.pep:4:19: error: cannot pass 'String' to parameter 1 of extern 'strlen' of type '__ptr<char>'; pass its 'c_str()'
4 | return strlen(name) as int;
| ^~~~
The reverse is an error too: a char* (or any raw pointer, void*
included) is never a String, so passing one where a String is expected,
assigning it to a String variable, or returning it as one is rejected. Copy
it into a String with String(p) first:
main.pep:3:19: error: cannot pass a value of type '__ptr<char>' as argument 2 of 'assert', which has type 'String'; use 'String(...)' to copy a C string
Variadic functions
A C function with a variable argument list, such as printf or snprintf,
ends its prototype with ... after at least one named parameter:
extern int snprintf(char* buffer, ulong size, char* format, ...);
The call uses the C calling convention for variadic functions, which differs
from a plain call on some targets (such as macOS on Apple silicon). Each
argument past the named ones gets C’s default argument promotions: integers
narrower than int widen to int, and float widens to double. Match your
format string to the promoted type: %d for a u8, %f for a float, and
%lld (not %ld) for a long, because C’s long is 32 bits on Windows.
... must come last and must follow a named parameter: extern int f(...);
is a parse error.
# Calling a C function that takes a variable argument list.
extern void* malloc(ulong size);
extern void free(void* p);
# On Windows, snprintf lives in legacy_stdio_definitions.
[Target(OS(Windows))] [Link("legacy_stdio_definitions")]
extern int snprintf(char* buffer, ulong size, char* format, ...);
[Target(!OS(Windows))]
extern int snprintf(char* buffer, ulong size, char* format, ...);
main(): int {
var buffer = malloc(64) as __ptr<char>;
u8 small = 200; # widened to int
float half = 0.5; # widened to double
long big = 5000000000;
var written = snprintf(buffer, 64, "%d %.1f %lld %s", small, half, big, "ok");
var text = String(buffer, written as ulong, String.length_of(buffer, written as ulong));
free(buffer);
assert(text == "200 0.5 5000000000 ok", "snprintf formatted every argument");
return 0;
}
On Windows, the C runtime defines snprintf and its relatives inline in its
headers, so the linkable symbols live in a separate library,
legacy_stdio_definitions. The example links it with [Link] on Windows only.
Pointers
C code deals in raw pointers, and Pepper has a raw pointer type for talking to
it: __ptr<T>, such as __ptr<void>, __ptr<char> or __ptr<__ptr<int>>.
A void* returned by an extern is a __ptr<void> in Pepper, and a
__ptr<T> can be passed to an extern that takes a C pointer.
A pointer supports these operations:
| Operation | Meaning |
|---|---|
p[i] | reads or writes the element i places from p; its type is T, so var v = p[i] declares a T |
p + n | the address n elements after p (n bytes for __ptr<void>) |
p == __null, p != __null | compares with the null pointer |
p == q, p != q | compares two addresses (a pointer also compares with an integer address) |
p as __ptr<void>, v as __ptr<T> | the same address, to and from __ptr<void> |
unsafe_cast<__ptr<U>>(p) | the same address, seen as pointing at U |
unsafe_cast<ulong>(p), unsafe_cast<__ptr<T>>(n) | the address as an integer, and back |
as converts a pointer only to and from __ptr<void>, like static_cast in
C++. Any other pointer conversion reinterprets memory, so it is spelled
unsafe_cast (see Reinterpreting with
unsafe_cast).
__null is the null pointer. sizeof<T>() gives the size of a type in bytes,
which you need to allocate memory for C. The
compiler built-ins reference lists these and the
other __ intrinsics.
T* spelling is for extern prototypes only, since they mirror C. Everywhere
else (parameters, return types, struct fields, locals, globals, type aliases,
generic arguments and casts) you write __ptr<T>, and T* is a compile error:
clear(p: void*, n: ulong) { }
main.pep:1:10: error: C pointer syntax `void*` is only allowed in extern declarations; use `__ptr<void>`
1 | clear(p: void*, n: ulong) { }
| ^~~~
# Raw pointers: __ptr<T> in Pepper code, T* in extern prototypes.
extern void* malloc(ulong size);
extern void free(void* p);
extern void* memset(void* p, int value, ulong size);
struct Buffer {
var data: __ptr<u8> = __null;
var size: ulong = 0;
}
make_buffer(size: ulong): Buffer {
var b = Buffer();
b.data = malloc(size) as __ptr<u8>;
b.size = size;
memset(b.data, 0, size);
return b;
}
sum(values: __ptr<int>, count: int): int {
var total = 0;
var i = 0;
while (i < count) {
total = total + values[i];
i = i + 1;
}
return total;
}
# `var` takes the element type from the pointer: `big` is a ulong, so a value
# above 2^32 reads back whole.
first(values: __ptr<ulong>): ulong {
var big = values[0];
return big;
}
main(): int {
# Indexing reads and writes elements of T.
var numbers = malloc(4 * sizeof<int>()) as __ptr<int>;
numbers[0] = 10;
numbers[1] = 20;
numbers[2] = 30;
numbers[3] = 40;
assert(sum(numbers, 4) == 100, "indexing reads what was written");
var wide = malloc(sizeof<ulong>()) as __ptr<ulong>;
wide[0] = 5000000000;
assert(first(wide) == 5000000000, "var v = p[i] has the pointee's type");
free(wide);
# p + n moves by n elements, not n bytes.
var third = numbers + 2;
assert(third[0] == 30, "numbers + 2 points at the third element");
assert(unsafe_cast<ulong>(third) - unsafe_cast<ulong>(numbers) == 8, "two ints are eight bytes");
# __ptr<void> strides by bytes; cast to see the memory another way.
var raw = numbers as __ptr<void>;
var bytes = raw as __ptr<u8>;
assert(bytes[4] == 20, "the second int starts at byte 4 (little-endian)");
free(raw);
# Pointers can live in struct fields, and compare with __null.
var b = make_buffer(16);
assert(b.data != __null, "malloc returned memory");
assert(b.data[15] == 0, "memset cleared it");
free(b.data);
b.data = __null;
# A pointer to pointers: indexing yields the inner pointer, and indexing
# again reads or writes through it.
var rows = malloc(2 * sizeof<__ptr<int>>()) as __ptr<__ptr<int>>;
var row = malloc(sizeof<int>()) as __ptr<int>;
rows[1] = row;
rows[1][0] = 7;
assert(rows[1][0] == 7, "rows[1][0] reads through the row pointer");
assert(row[0] == 7, "rows[1] is the row pointer");
free(row);
free(rows);
return 0;
}
Pointers are not reference-counted and nothing checks them: indexing past the
end of an allocation, using memory after free, or forgetting to free it
behaves exactly as it would in C. Keep raw pointers inside small functions or
structs that own the memory, and give the rest of your program ordinary Pepper
types.
Owning a C resource: Handle<T>
A C library hands out objects that it frees with a function of its own
(fclose, free, LLVMDisposeModule). Pepper’s reference counting does
not know about them, so the runtime’s Handle<T> ties one to Pepper’s
lifetimes, like C#’s SafeHandle: it holds the raw value and a release
function, and calls the function once, when the last reference to the
handle goes away.
# A C resource owned by a Handle: `free` runs when the last reference to
# the handle goes away.
extern void* malloc(ulong size);
extern void free(void* p);
extern void* memset(void* p, int value, ulong size);
# A buffer from C, freed with the struct that holds it.
struct Buffer {
var _memory: Handle<__ptr<void>>;
var size: ulong { get; }
construct(size: ulong) {
_memory = Handle<__ptr<void>>(malloc(size), (p) => free(p));
this.size = size;
}
fill(value: u8) {
memset(_memory.value, value as int, size);
}
at(index: ulong): u8 {
return (_memory.value as __ptr<u8>)[index];
}
}
main(): int {
var buffer = Buffer(16);
buffer.fill(7);
if (buffer.at(15) != 7) {
return 1;
}
# release() frees early; the handle then no longer owns the memory.
var scratch = Handle<__ptr<void>>(malloc(8), (p) => free(p));
scratch.release();
return scratch.is_owned() ? 2 : 0;
}
Handle<T>(value, release)takes ownership ofvalue.Tis usually__ptr<void>or an integer descriptor.valuereads the raw value, to pass to C.- A handle is a reference, so copies share the resource; it is released
when the last copy goes away. Keep it in a field of the struct that
wraps the resource, as
Bufferdoes. release()frees the resource early, anddetach()gives it back without freeing it. Either way the release function never runs again, andis_owned()becomes false.releaseis a function value, so a closure may capture what the release needs. A captured reference stays alive until the release runs, which is how a C object that must not outlive its parent (an LLVM module and its context) keeps the parent alive.
Passing a Pepper function to C
A Pepper function value is a reference-counted object,
not a C function pointer. For C, Pepper has a separate type,
extern (P...) => R: the address of a function that C calls with the C
calling convention. Declare a callback parameter of a C function with it,
and pass a named function, or a closure that captures nothing:
# `extern (P...) => R` is a C function pointer type: qsort calls `compare`
# with pointers to two elements.
extern void qsort(void* base, ulong count, ulong size, extern (void*, void*) => int compare);
ascending(a: __ptr<void>, b: __ptr<void>): int {
__ptr<int> x = a;
__ptr<int> y = b;
return x[0] - y[0];
}
main(): int {
__ptr<int> numbers = _pep_malloc(4 * sizeof<int>());
numbers[0] = 30;
numbers[1] = 10;
numbers[2] = 40;
numbers[3] = 20;
# A named function converts to the C function pointer type.
qsort(numbers, 4, sizeof<int>(), ascending);
assert(numbers[0] == 10 && numbers[3] == 40, "ascending");
# So does a closure that captures nothing; its parameter types come
# from the C function pointer type.
qsort(numbers, 4, sizeof<int>(), (a, b) => {
__ptr<int> x = a;
__ptr<int> y = b;
return y[0] - x[0];
});
assert(numbers[0] == 40 && numbers[3] == 10, "descending");
# A C function pointer is a value like a number: store it, call it.
var compare: extern (__ptr<void>, __ptr<void>) => int = ascending;
assert(compare(numbers, numbers + 1) > 0, "40 sorts after 30");
_pep_free(numbers);
return 0;
}
-
What converts. A named free function, an
externfunction, a static method (Order.descending), or a closure literal that captures nothing, whose parameter and return types match the C function pointer type exactly. A closure literal takes its parameter types from the type, as it does from any function type. -
Nothing captured. C calls the function with its arguments alone, so a closure that captures a value, or a bound method (
p.get), cannot convert:main.pep:4:43: error: closure passed as C function pointer type 'extern (__ptr<void>, __ptr<void>) => int' captures 'bias'; a C function pointer cannot hold captured values 4 | qsort(_pep_malloc(4), 0, 4, (a, b) => bias); | ^~~~ main.pep:3:9: note: pass what the callback needs through the C function's context argument instead 3 | var bias = 1; | ^~~~This is a design decision, not a missing feature: a C function pointer is a bare address, and giving each capturing closure an address of its own would mean writing executable code at run time, which some platforms forbid. A callback that needs state gets it another way (see Callbacks that need state).
A function value held in a variable does not convert either, since it may be a closure.
-
C types only. C knows nothing of reference counts, so the parameter and return types must be numbers,
bool, pointers, or other C function pointer types. A struct is an error:C function pointer type 'extern (String) => int' cannot use 'String'; C passes only numbers, 'bool' and pointers. Write pointers asvoid*in anexterndeclaration and as__ptr<void>elsewhere, as for any pointer. -
A value like a number. A C function pointer has no reference count. Store it in a variable, field or module-scope
const, return it, pass it to other threads, or call it:compare(a, b)calls through it with the C calling convention. Calling a null one (from C) panics. It converts tovoid*but not to a Pepper function type; wrap it in a closure that calls it,(x) => f(x), to use it as one.
__function_pointer(name) also gives a function’s address, as an untyped
__ptr<void>. It takes a free function, or a static method of the struct
whose method uses it. Inside a generic struct the static method is the one
for the current type arguments, which is how Thread.spawn gives the
operating system its thread entry point. Prefer a C function pointer type
where the signature is known, since the compiler then checks it.
Callbacks that need state
Since a C function pointer holds no captured values, a callback that needs state gets it another way:
- The C function’s context argument. Most C APIs that take a callback
also take a
void*context argument that they hand back to it; pass a pointer to the data the callback needs through that. - A thread-local or module-scope value. Set a
[ThreadLocal]variable, anasync.ThreadLocal<T>or anAtomic<T>before calling C, and read it in the callback, which is a named function:
# A C function pointer holds no captured values, so a callback that needs
# state reads it from somewhere C does not have to carry: here, a
# thread-local the caller sets before handing the callback to C.
extern void qsort(void* base, ulong count, ulong size, extern (void*, void*) => int compare);
# The value to sort around, for the calling thread.
[ThreadLocal]
var pivot: int = 0;
distance(x: int): int {
var d = x - pivot;
return d < 0 ? -d : d;
}
closest_first(a: __ptr<void>, b: __ptr<void>): int {
__ptr<int> x = a;
__ptr<int> y = b;
return distance(x[0]) - distance(y[0]);
}
main(): int {
__ptr<int> numbers = _pep_malloc(4 * sizeof<int>());
numbers[0] = 10;
numbers[1] = 40;
numbers[2] = 22;
numbers[3] = 31;
pivot = 30;
qsort(numbers, 4, sizeof<int>(), closest_first);
assert(numbers[0] == 31 && numbers[3] == 10, "closest to 30 first");
_pep_free(numbers);
return 0;
}
Binding to a different symbol name
[Link(name = "symbol")] binds an extern to a C symbol whose name differs
from the declared one. Pepper code calls the extern by its declared name, and
the object file refers to symbol. Use it to give a C function a clearer
name, to avoid clashing with a Pepper name, or, together with
[Target], to bind one Pepper name to a different
symbol on each platform. lib names the library that defines the symbol in
the same attribute, as [Link("library")]
does on its own:
# [Link(name = "symbol")] binds a Pepper name to a C symbol with a different name.
# llabs takes a 64-bit long long on every platform: `i64` in an extern is
# 64 bits everywhere, where `long` follows the size of a pointer.
[Link(name = "llabs")]
extern i64 absolute(i64 value);
# C's long is 64 bits on Linux and macOS but 32 bits on Windows and
# WebAssembly, so the 64-bit labs is only right on the first two.
[Target(OS(Windows) || OS(Wasi))]
[Link(name = "llabs")]
extern i64 abs64(i64 value);
[Target(OS(Linux) || OS(Mac))]
[Link(name = "labs")]
extern long abs64(long value);
# `lib` names the library that defines the symbol, as [Link("m")] does.
[Target(OS(Linux))]
[Link(lib = "m", name = "cbrt")]
extern double cube_root(double value);
main(): int {
assert(absolute(-7) == 7, "absolute calls llabs");
assert(abs64(-5000000000) == 5000000000, "abs64 calls a 64-bit abs");
[Target(OS(Linux))]
{
var root = cube_root(27.0);
assert(root > 2.999 && root < 3.001, "cube_root calls cbrt from libm");
}
return 0;
}
Real uses of the per-platform form include macOS on Intel, where the
64-bit-inode version of stat is named stat$INODE64:
[Target(OS(Mac) && Arch(x64))]
[Link(name = "stat$INODE64")]
extern int stat(char* path, void* buf);
[Target(OS(Mac) && Arch(aarch64))]
extern int stat(char* path, void* buf);
[Link] takes the library as its one positional argument or as lib, and
the symbol as name; each is a non-empty string, and an extern names at
most one symbol. Anything else is an error, such as:
main.pep:1:7: error: 'Link' has no argument named 'symbol'; use 'lib', 'path' or 'name'
1 | [Link(symbol = "labs")] extern long pep_abs(long v);
| ^~~~~~
The earlier [LinkName("symbol")] was removed; it is an error that suggests
the [Link(name = "symbol")] that replaces it.
LLVM intrinsics
An extern whose [Link(name = ...)] symbol starts with llvm. is not a C
function but an LLVM intrinsic:
a call to it compiles to the operation itself, usually one instruction, and
nothing is linked. The runtime’s bit and math methods
(Bit operations)
are written this way; declare one yourself for an intrinsic they do not
cover, such as a CPU-specific one:
# An extern bound to an LLVM intrinsic: a call is the instruction itself.
[Link(name = "llvm.umin")]
extern ulong smaller(ulong a, ulong b);
# `llvm.ctlz` takes a second argument that must be a constant.
[Link(name = "llvm.ctlz")]
extern ushort leading_zeros(ushort value, bool is_zero_poison);
main(): int {
assert(smaller(7, 3) == 3, "llvm.umin.i64");
assert(leading_zeros(1, false) == 15, "llvm.ctlz.i16");
return 0;
}
-
The extern’s prototype is the intrinsic’s signature, in the types Pepper maps to LLVM’s:
byteandchararei8,ushortandshorti16,uintandinti32,ulongandlongi64,booli1,floatanddoublethemselves, any pointerptr, and avoidreturn isvoid. Intrinsics that take or return vectors or structs cannot be declared yet. -
Name an overloaded intrinsic by its base name (
llvm.ctpop): the overload is picked from the prototype (llvm.ctpop.i32for auint). The mangled name works too. -
A prototype that matches no overload is an error at the extern, with LLVM’s reason:
main.pep:2:13: error: 'popcount' does not match the signature of LLVM intrinsic 'llvm.ctpop': intrinsic has incorrect argument type 2 | extern long popcount(int value); | ^~~~~~~~ -
Some arguments must be constants, such as
llvm.ctlz’sis_zero_poison; passing a variable is an error at the argument:main.pep:5:19: error: argument 2 to 'leading_zeros' must be a constant 5 | return leading_zeros(1, zero_poison); | ^~~~~~~~~~~
An unknown name (llvm.nonsense) is an error too. A target-specific
intrinsic (llvm.x86.*, llvm.aarch64.*) only exists on its architecture,
so pair it with [Target(Arch(...))].
Linking the library
A C function is only callable if the library that defines it is linked into
your program. The C library is always linked. For anything else, attach
[Link("name")] to the extern or pass -l name to the compiler. The
Linking chapter covers both.
[Target(OS(Windows))]
[Link("gdi32")]
extern ulong GetStockObject(int kind);
Calling Pepper from C
C code can call Pepper functions: mark each one [Export], and build the
program as a library with --lib static or --lib shared. The compiler
writes a C header beside the library that declares every exported function.
# A library C code calls: build it with `pepper build counter.pep --lib
# static` (or `--lib shared`), which also writes `counter.h`.
#
# `main` is not needed in a library; this one calls the exported functions
# from Pepper, so the example runs as a program too.
[Export]
add(a: int, b: int): int {
return a + b;
}
# A struct crosses to C as an opaque handle, `Counter*`.
struct Counter {
var count: int;
construct(this.count = 0) {}
}
# The C name can differ from the Pepper name.
[Export(name = "counter_new")]
make_counter(start: int): Counter {
return Counter(start);
}
[Export]
counter_increment(counter: Counter): int {
counter.count += 1;
return counter.count;
}
# A C function pointer, called back.
[Export]
apply(f: extern (int) => int, x: int): int {
return f(x);
}
# A String arrives as UTF-8 bytes and their size, and leaves as a
# NUL-terminated copy that C frees with `pepper_string_free`.
[Export]
greet(name: String): String {
return "hello, " + name;
}
main(): int {
var counter = make_counter(41);
assert(counter_increment(counter) == 42, "the counter counts");
assert(add(2, 3) == 5, "add adds");
assert(greet("C") == "hello, C", "greet greets");
return 0;
}
pepper build counter.pep --lib static # libcounter.a and counter.h
pepper build counter.pep --lib shared # libcounter.so and counter.h
The header declares the functions with C types:
typedef struct Counter Counter;
void counter_retain(Counter* handle);
void counter_release(Counter* handle);
void pepper_string_free(char* text);
int32_t add(int32_t a, int32_t b);
int32_t apply(int32_t (*f)(int32_t), int32_t x);
int32_t counter_increment(Counter* counter);
Counter* counter_new(int32_t start);
char* greet(const char* name, size_t name_size);
and a C program calls them like any C function:
#include <stdio.h>
#include "counter.h"
static int32_t twice(int32_t x) { return 2 * x; }
int main(void) {
Counter* counter = counter_new(41);
printf("%d\n", counter_increment(counter)); /* 42 */
counter_release(counter);
printf("%d\n", apply(twice, add(2, 3))); /* 10 */
char* greeting = greet("C", 1);
printf("%s\n", greeting); /* hello, C */
pepper_string_free(greeting);
return 0;
}
cc main.c -I. -L. -lcounter -lm -o main # Linux; macOS needs no -lm
Exporting a function
[Export]goes on a free function that is not generic. Its C name is its Pepper name;[Export(name = "c_name")]gives it another, which must be a C identifier. Two exports with one C name are an error.[Export]anywhere else (a struct, a method, an extern, a variable, a generic function) is a warning, and does nothing.- An exported function is still a Pepper function: Pepper code calls it by its Pepper name.
- Do not confuse
[Export]with theexportkeyword, which makes a declaration visible to other Pepper files (see Visibility). A function can be either, both or neither.
What crosses to C
Each parameter and the result must be a type C can hold:
| Pepper | C (in the header) |
|---|---|
bool | bool |
char, i8, u8, short, ushort, int, uint, long, ulong (and their i16… spellings) | char, int8_t, uint8_t, int16_t, uint16_t, int32_t, uint32_t, int64_t, uint64_t |
float, double | float, double |
__ptr<T> | T* for a number type, else void* |
extern (P...) => R | a C function pointer, R (*name)(P...) |
String | a parameter: const char* name, size_t name_size; a result: char* (see Strings) |
| a struct | an opaque handle, Name* |
Anything else is an error at the parameter or result that C cannot hold:
main.pep:2:15: error: cannot export 'total': C cannot hold 'Array<int>'
2 | total(values: Array<int>): int {
| ^~~~~~~~~~
note: only a non-generic struct passes to C, as a handle
A generic struct (Array<int>), a value struct, an enum, a trait, a tuple,
Optional and a Pepper function type cannot cross. Pass an array as a
pointer to its elements and a count, or wrap it in a struct of your own and
pass a handle.
Strings
A String crosses as UTF-8 bytes, copied each way, so neither side keeps a
pointer into the other’s memory:
- A
Stringparameter is two C parameters: a pointer to the bytes and their size,const char* name, size_t name_size. The bytes need no NUL terminator, and may contain NULs. The function gets its own copy for the call; C keeps its buffer. A size of 0 (with any pointer,NULLincluded) is the empty string. - A
Stringresult is a NUL-terminated copy of its bytes, aschar*, which the C code owns and frees withpepper_string_free, never withfree. An emptyStringis"", neverNULL. A string with NULs inside ends at its first one for C; return a pointer and a size instead when that matters. - The header declares
pepper_string_freewhen some export returns aString.
Handles
A struct reaches C as a pointer to an incomplete C type (Counter*), which
C code passes back but never looks inside. Handles follow Pepper’s
reference counting:
- A handle a function returns is one reference, which the C code owns.
It releases it with
<name>_release(counter_release) when it is done; the last release frees the object, running its destructor. - A handle C passes in stays C’s: the function borrows it for the call.
<name>_retainadds a reference, for C code that keeps a handle in two places; each retain needs a release.- The names are the struct’s name in snake case:
ByteBuffergetsbyte_buffer_retainandbyte_buffer_release. The header declares them for every struct an export passes.
A handle must not be null, and is freed at most once: C gets no checks on either.
Why a string is freed but a handle is released
The two look alike from C, a pointer Pepper hands over, but they are different things, and the names say which:
- A handle is a live Pepper object. Pepper may hold references to it
too: a field, a global, another thread. C holds one or more of those
references, so it uses the same counting Pepper does:
<name>_retainadds one,<name>_releasedrops one, and the object is destroyed only when the last reference anywhere goes away. Releasing is not freeing. - A returned string is not a Pepper object. It is a plain copy of the
bytes, made at the boundary like
strdupmakes one, with no count and no other owner. Pepper keeps no reference to it, so there is nothing to retain or share; the C code owns it outright and gives it back once withpepper_string_free. - Why not
free: the copy comes from Pepper’s allocator, which tracks live allocations, serves short strings from its own small block pools (see Where objects live) and, on Windows, may use a different C runtime’s heap than the caller’s.pepper_string_freereturns it to the allocator that made it.
A String parameter needs neither: Pepper copies the bytes for the call,
and C’s buffer stays C’s.
Building the library
--lib static writes an archive of the program’s objects: libname.a
(name.lib on Windows). --lib shared links a shared library:
libname.so, libname.dylib on macOS, or name.dll on Windows, with its
import library name.lib. -o chooses another name. Either way the header
is written beside the library, named for it without the lib prefix
(counter.h).
The compiler writes the archive itself, in the format of the platform it
builds for (with --target, another one’s: GNU’s for Linux and Android,
the BSD format Apple’s linkers read for macOS and iOS, the COFF archive for
Windows), with the index of the symbols its objects define, so no archiver
needs to be installed and a static library for another platform needs no
system root. A shared library links like an executable, with clang. With
--lto=full the library is optimized as one module first, as an
executable is (see --lto in pep-build).
- A library needs no
main. One that has amainkeeps it, and C code that links the library cannot have its own. - Only the exported functions and their handle functions are visible outside a shared library; everything else in it is hidden.
- A program that links the static library must also link the C libraries
Pepper’s runtime uses. The header lists them in a comment at the top, such
as
/* Link the static library with: -lm */on Linux. A shared library links them itself. - On Windows,
--lib staticand--lib sharedboth writename.lib(the archive, or the DLL’s import library), so build them into different folders. - A C program links at most one Pepper static library: each holds its own copy of the runtime, and two would define it twice. Shared libraries keep their runtimes to themselves, so a program may load several.
Initialization, panics and threads
- Module-scope variables are initialized when the library is loaded, before C calls any exported function (a static library’s initializer runs with the program’s other static constructors).
- A panic in Pepper code called from C prints its message and aborts the process, as it does in a Pepper program: it never unwinds into C.
- Exported functions may be called from any thread, including threads C
created. The thread-safety rules of threads still hold:
a handle to a struct that is not
[Shared]must stay on one thread at a time, and C does not check that.