Skip to content

Dynamic C libraries

A candela program calls into a shared library by declaring the functions it wants. The compiler opens the library, resolves each symbol, and builds the calling interface, so a call costs no more than the marshalling of its arguments.

Declaring a library

dylib "z" {
    string zlibVersion();
}

fn main() {
    print(z::zlibVersion());
}

A dylib block names the library, then lists one signature per line. Each signature is a return type, a name, and a parenthesised list of parameter types, ending in a semicolon. Parameter names are not part of the grammar; give the types only.

Omit the return type for a function that returns nothing:

dylib "mylib" {
    reset(int);
    int count();
}

The functions live in a namespace named after the library, so you call them as z::zlibVersion() or z.zlibVersion(); both spellings name the same function. A variable takes the name back: where z holds a value, z.zlibVersion() is that value's own method and the library is reachable only through z::. Wrapping each function in an ordinary candela function is the usual way to give callers a tidier surface.

Finding the library

The name you write is turned into a filename with the platform's convention:

Platform dylib "foo" resolves to
Linux libfoo.so
macOS libfoo.dylib
Windows foo.dll

Two forms bypass part of that. A name that already has an extension is used exactly as written, on every platform, so "libfoo.so.6" and an absolute path both pass through. A name containing a path separator gets the platform extension appended but no lib prefix, so "../native/mylib" becomes ../native/mylib.so on Linux.

A plain name is looked for beside the importing file first, then in the working directory, then through the operating system's own search path. A path form goes straight to the loader, resolved relative to the importing file. This makes a bare name behave the same way everywhere, since the loaders differ on whether they consider the application directory at all.

A Rust host that embeds candela can name directories to look in ahead of all that, which is what an application whose sources and libraries sit apart (src/ beside lib/) uses. Both forms honor them: a plain name is looked for there first, and a relative path is resolved against them first. See where a dylib import looks.

The root of every package the project depends on is searched too, after the directories the host named and before the importing file's own. A package that ships a shared library at its root therefore binds with nothing to configure, and a dylib import inside that package finds its own library rather than one from the program that pulled it in. A library the package keeps under dist/ is named by path from src/: dylib "../dist/mylib".

For a path form with no extension, an architecture-suffixed build is preferred when one sits beside it, so "../native/mylib" picks up mylib-x86_64.so or mylib-aarch64.so before falling back to mylib.so. That is how you ship one directory holding builds for several architectures.

Types that cross

candela C
int int64_t
float double
string char *, null-terminated
T[] pointer to the packed elements
struct the C struct of the same field types, by value
omitted return void

Anything else has no C representation and is rejected when the signature is compiled: bool, enums, maps, union types and any.

Strings you pass in are copied into a null-terminated buffer that lives for the duration of the call, so the C side must not keep the pointer. A string containing an interior null byte raises null_byte_in_string rather than being silently truncated.

A returned char * is copied into a candela string and never freed, so the C side keeps ownership; return a static or otherwise owned buffer. A null pointer comes back as null.

Arrays are passed as a pointer to the packed elements with no length alongside them, so pass the length as a separate int parameter. An array cannot be returned, because C does not convey how long it is; that signature raises c_array_return_type_not_supported.

Calls use the platform's default C calling convention. There is no way to select another.

When the library is needed

The library is opened and every symbol resolved while the program is compiled, so a missing library or a missing symbol is a compile error naming what it could not find, not a surprise at the first call.

Building a .cdlb artifact records the library name, the symbol and the signature, never the library's bytes. Loading the artifact re-opens the library and re-resolves the symbol, so the library has to be present wherever the artifact runs. A standard-library module's own library is the exception: it is recorded relative to the libs directory rather than to the module, so the runtime finds it through the toolchain. See artifacts.

Dynamic libraries are not available when candela is compiled to WebAssembly.

A worked example

The standard library's time module is a dynamic library binding. The C side is one file with its exports marked visible:

#ifdef _WIN32
#define EXPORT __declspec(dllexport)
#else
#define EXPORT __attribute__((visibility("default")))
#endif

#include <stdint.h>
#include <time.h>

EXPORT int64_t now(void) { return (int64_t)time(NULL); }

EXPORT const char *format(int64_t timestamp, const char *fmt) {
  static char buffer[128];
  time_t t = (time_t)timestamp;
  struct tm *info = localtime(&t);
  strftime(buffer, 128, fmt, info);
  return buffer;
}

candela's int is int64_t and its float is double. The returned string uses a static buffer, since candela copies it out and the C side keeps ownership.

Build it as a shared library. The shipped ones are built like this:

# Linux
clang -O2 -fvisibility=hidden -shared -fPIC -o time.so time.c
# macOS
clang -O2 -fvisibility=hidden -dynamiclib -o time.dylib time.c
# Windows
clang -O2 -fuse-ld=lld -fvisibility=hidden -shared -o time.dll time.c

The candela side declares the two symbols and wraps them:

dylib "../std_src/time/time" {
    int now();
    string format(int, string);
}

fn now() { return time::now(); }
fn format(date, pattern) { return time::format(date, pattern); }

The path form is what makes time.so resolve without a lib prefix, and the namespace comes from the last part of the path. Anyone importing the module calls time::now() and never sees the binding.

Host functions instead

A host block looks the same but binds to a Rust closure in an embedding program rather than a C symbol, and accepts types that C cannot carry. See embedding.