Foreign Interface (C Interop)

Phase 6 turns outward. Odin has a large standard library, but the world is full of C libraries — decades of code for graphics, audio, compression, networking, and everything else. Odin talks to them directly, and this lesson is how.

Two paths lead to the same place. The easy path takes bindings the Odin team already maintains; the direct path declares the C function you want yourself. Most projects use the first and fall back to the second, so we will take them in that order.

Why Interop Matters

A language with no way out of itself makes you rewrite the world. Odin's answer is not a special "foreign" syntax layer but an ordinary part of the language: a procedure can be declared without a body, and the compiler leaves the call for the linker to resolve.

The Easy Path: vendor:

You met the vendor: collection in the packages lesson: bindings maintained by the Odin team, shipped with the compiler. If the library you need is there, interop is one import line:

import "vendor:raylib"          // or, with a shorter name:
import rl "vendor:raylib"

rl.InitWindow(800, 200, "text field")
defer rl.CloseWindow()

Nothing in that code looks foreign at all — no pointer juggling, no manual string conversion, no ceremony. That is the point of the vendor: collection: somebody has already dealt with the boundary so you do not have to.

So the first question to ask of any C library is not "how do I bind this?" but "has this already been bound?" The answer is often yes, and the Odin website's package documentation lists what is available.

The Direct Path: foreign

When no binding exists, you write the declarations yourself. Here is a real example, quoted from Odin's own runtime — it declares two Windows functions:

// Quoted from Odin's runtime: link against a system library...
foreign import kernel32 "system:Kernel32.lib"

// ...and declare the procedures that live in it.
@(default_calling_convention="system")
foreign kernel32 {
    GetConsoleOutputCP :: proc() -> u32 ---
    SetConsoleOutputCP :: proc(codepage: u32) -> b32 ---
}

Three things to notice, and they are the whole of the mechanism. foreign import names a library to link against. The foreign { } block declares procedures inside it. And each procedure ends with --- instead of a body, which is how Odin says "this one is not written here — the linker will find it".

The boundary is a place where two worlds meet, and each side has its own idea of what a string or an integer is. The diagram shows what has to be translated:

Diagram of the Odin-to-C boundary: Odin values on the left, C values on the right, with foreign import and a foreign block in the middle, and the conversions needed for strings, integers and structs
The Odin side keeps its strings, slices and allocators; the C side receives pointers and fixed-width integers. The foreign block is the only place where the two languages meet, and the conversions happen on the Odin side of it.

The C Types You Will Meet

A C header says int, long, size_t. Odin cannot simply call those int and long, because Odin's own int is a platform-sized integer and it would be a lie to reuse the name for a C type whose width depends on the compiler and the platform. So Odin keeps C's types in their own package.

The core:c Package

Quoted from the package source, whose header comment states its job exactly: "Defines the basic types used by C programs for foreign function and data structure interop."

// Quoted from core/c/c.odin — a selection of the definitions:
package c

char           :: builtin.u8  // assuming -funsigned-char

schar          :: builtin.i8
short          :: builtin.i16
int            :: builtin.i32
long           :: builtin.i32 when (ODIN_OS == .Windows || size_of(builtin.rawptr) == 4) else builtin.i64
longlong       :: builtin.i64

uchar          :: builtin.u8
ushort         :: builtin.u16
uint           :: builtin.u32
ulong          :: builtin.u32 when (ODIN_OS == .Windows || size_of(builtin.rawptr) == 4) else builtin.u64
ulonglong      :: builtin.u64

size_t         :: builtin.uint
ssize_t        :: builtin.int
wchar_t        :: builtin.u16 when (ODIN_OS == .Windows) else builtin.u32

Notice that the whole nation of compile-time technique you learned in Phase 5 shows up in a five-line type definition. C's long is 32 bits on Windows and 64 bits on 64-bit Unix, and Odin says so precisely: i32 when (ODIN_OS == .Windows || size_of(rawptr) == 4) else i64. The when expression chooses the definition per platform, so a binding written for c.long is correct everywhere without a single #ifdef.

Reading the Table

Four groups cover almost everything a header will hand you. First the plain integer and float types — c.char, c.short, c.int, c.long, c.float, c.double — whose widths follow the C compiler's rules, not Odin's.

Second, the fixed-width types that were added to C later and whose widths are guaranteed: c.int32_t, c.uint64_t and the rest. When a header offers you the choice, these are the ones to bind to, because their size cannot surprise you on another platform.

Third, the size-related types you will see in arguments and return values constantly: size_t, ssize_t, intptr_t, uintptr_t and ptrdiff_t.

C spellingOdinWhy you care
char *, a C stringcstringNUL-terminated, no length — must be converted before Odin sees it
void *rawptrAn untyped pointer; you must convert it to something typed to use it
size_tc.size_tCounts bytes; not the same as Odin's int
NULLc.NULLDefined in the package as rawptr(uintptr(0))
A struct you never inspect, like FILEAn opaque structDeclared with no fields, because you only ever pass it around

Declaring Foreign Procedures

Now the mechanism in full. A foreign import and a foreign block are the two halves of every binding, and both are ordinary declarations with a keyword attached.

foreign import and a foreign Block

The import names the library; the block declares what lives inside it. Between them you have a complete binding. In the runtime example earlier the library was given as "system:Kernel32.lib" — the system: prefix means "a library the operating system already provides", so nothing needs to be bundled with your program. A plain name instead points at a library file to link against.

Inside the block, each procedure looks like an ordinary signature followed by ---. Quoted from the language grammar, this is exactly what --- is for — a procedure may have a body, or a dash:

ProcBody = "---" | Block or Expression

So a foreign procedure is not a special kind of declaration. It is an ordinary declaration whose body has been postponed to link time. That also means everything you know about parameters still applies: pass the C types from core:c, and the compiler will type-check every call site for you.

Calling Conventions

A calling convention is the agreement between caller and callee about where arguments go and who cleans up — registers in one order, stack in another, x86 versus ARM. Getting it wrong produces not a compile error but a crash or silent nonsense.

The runtime example sets a default for its whole block: @(default_calling_convention="system"), so every procedure in the block uses the platform's system convention. This is the pattern to copy when binding a system library. Note the word default: it can be overridden, and for that Odin has a calling convention that is part of the procedure's type, written between proc and its parameters. From the language's own bindings for time functions:

// A procedure whose convention is part of its type:
time :: proc "c" (loc: ^LTime) -> time_t ---

// And a contextless procedure: no implicit context is passed in.
gmtime :: proc "contextless" (time: ^time_t) -> ^Tm ---

The "c" convention is the one C libraries expect, and "contextless" is Odin's own convention for procedures that must not rely on the implicit context — you will see why that matters in the threading lesson.

A rule of thumb for bindings: copy the convention from the header you are binding. If a function is a plain C function, it is "c". If the block is a system library, set the default once for the whole block rather than repeating it on every declaration.

Strings Across the Boundary

Almost every interesting thing you pass to a C library is text, and text is where the two worlds disagree most.

cstring and string(cstr)

An Odin string is a pointer with a length: it may contain zero bytes, and it knows where it ends without scanning. A C string is a pointer without a length: it runs until the first zero byte. Odin has a type for the C version, and it is called cstring.

The conversion from C to Odin is a plain conversion, which makes the important work visible: something has to count the bytes.

// Quoted in spirit from Odin's text editor example: the editor's own
// string type is converted to a C string at the call site.
if text.bytes != nil {
    // A nil check first: a C string is a pointer, and this one may be nil.
    cstr := string(text.bytes)
    // `cstr` is now an ordinary Odin string, with a length.
    fmt.println(cstr)
}

Converting the other way requires an allocation, because a C string must live in memory that C can read and that is NUL-terminated. Odin's strings package provides the helper, and — in the spirit of everything you learned in Phase 4 — it takes an allocator explicitly:

// Quoted from Odin's text editor example: the temporary allocator is a
// natural fit for a string that only needs to survive one call.
cstr := strings.clone_to_cstring(text, context.temp_allocator)

Read that line carefully, because it contains the entire discipline of interop in miniature. The allocator is named, so nobody has to guess where the memory came from. The temporary allocator is used, so the memory is freed automatically when the temporary region is cleaned up — no leak even if the C function throws the pointer away.

Giving C a String That Outlives the Call

The temporary allocator is the right answer when the C function only reads the string during the call — passing a filename, setting a window title, logging a message. It is the wrong answer when C keeps the pointer, because after the temporary region is cleaned the pointer dangles, and a dangling pointer on the C side is a bug that appears somewhere else entirely.

For those cases you allocate from a longer-lived allocator, keep the resulting pointer, and free it yourself when the C library tells you it is done with it. This is the same lifetime reasoning as Phase 4, only now the length of the lifetime is dictated by someone else's API documentation:

What C does with the pointerWhat you should pass
Reads it during the call onlyA clone on context.temp_allocator — freed for you
Stores it and reads it laterA clone on a long-lived allocator; free it when the library says it is finished
Owns and frees it itselfRead the library's documentation — ownership has to be spelled out on one side only

Data Across the Boundary

Functions are the easy part. Structs are where interop gets interesting, because a struct is nothing but a memory layout, and both sides must agree on it to the byte.

Structs That Must Match C

When you mirror a C struct in Odin, you declare the fields in the same order with the same types from core:c, and the compiler lays them out with the same alignment rules a C compiler would. That works for ordinary structs. The trouble starts with a header that pads or packs its struct deliberately — a C compiler directive that changes the layout.

For those, Odin lets the struct carry its alignment explicitly, which is the same idea as the alignment control you met in Phase 3. The rule is to copy what the header says rather than guessing: if the C declaration rests on compiler-specific packing, the binding has to say so too, and a mismatch here shows up as a wrong value read from the middle of a struct rather than as a panic.

Opaque Types: FILE

Plenty of C types are only ever handled through pointers; you never look inside them. The standard library gives the pattern a name and the short definition it deserves. Quoted from core/c/c.odin:

// An opaque C struct: the fields are a secret, and that is fine,
// because you only ever pass a pointer to it around.
FILE :: struct {}

// A varargs parameter list for C functions, defined in terms of
// an intrinsic rather than a type Odin could write itself.
va_list :: intrinsics.c_va_list

Two lessons in two lines. First, an empty struct is a legitimate declaration when you mean "there is a type here, but its contents are not mine to know". Second, the package reaches for intrinsics when the C feature has no equivalent Odin could express — that is what the intrinsics package is for, and it is a signal to read the source rather than the summary.

Where This Goes Next

The next lesson takes the program beyond a single thread of execution, and with it the context — the quiet companion of every procedure you have written so far — becomes something you must reason about explicitly. Interop and threading meet in exactly that place, which is why they are taught next to each other.

The Page in One Breath

  • Check the vendor: collection before writing a line of binding code.
  • foreign import names the library; foreign { } declares its procedures; --- postpones the body to link time.
  • Use the types from core:c, and prefer the fixed-width ones when the header allows a choice.
  • The calling convention belongs to the procedure's type; system libraries set one default per block.
  • Convert cstring deliberately, and choose the allocator by asking how long C will keep the pointer.
  • Mirror structs exactly; an empty struct is how you declare a type whose inside is not your business.

In the next lesson the program acquires more than one thread of execution — and the context you have been taking for granted since the very first lesson turns out to be a per-thread object with real consequences for your allocators.