Foreign Interface (C Interop)
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:
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 spelling | Odin | Why you care |
|---|---|---|
char *, a C string | cstring | NUL-terminated, no length — must be converted before Odin sees it |
void * | rawptr | An untyped pointer; you must convert it to something typed to use it |
size_t | c.size_t | Counts bytes; not the same as Odin's int |
NULL | c.NULL | Defined in the package as rawptr(uintptr(0)) |
A struct you never inspect, like FILE | An opaque struct | Declared 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.
"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 pointer | What you should pass |
|---|---|
| Reads it during the call only | A clone on context.temp_allocator — freed for you |
| Stores it and reads it later | A clone on a long-lived allocator; free it when the library says it is finished |
| Owns and frees it itself | Read 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 importnames 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
cstringdeliberately, 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.