Odin · foreign & bindings

foreign, across the boundary line

A foreign block is a declaration with no body. You write the Odin shape of a procedure — its name, arguments, return, calling convention — and end it with --- instead of { ... }. There is no code to generate; the body already exists, compiled, inside a precompiled library across the C ABI. At link time the symbol you named gets wired to that real code. Pick a procedure and watch the declaration map across the line.

The declarations below link and run on this toolchain — the program at the bottom of Level 4 is their real captured output (claims/lessons/15-foreign-and-bindings).
your Odin side — the shape
linker wires it
the library side — the body

The three moving parts

The import line

names the library file the linker searches:
foreign import my_libc
  "system:libucrt.lib"

system: = "ask the system linker to find it." A library you ship instead takes a relative path, e.g. "vendor/SDL3/SDL3.lib".

Level 1 wired the call. Level 2 is what travels across it: strings change shape at the boundary. An Odin string and a cstring are two different memory layouts — and only one of them is what a C-ABI procedure can read.

An Odin string is two machine words: a pointer to bytes plus a stored length. It carries its own size and needs no terminator. A cstring is one pointer; its length is not stored anywhere — it is found by scanning forward until a zero byte. The strip shows the same six letters under each layout:

the bytes "abcdef" the terminating zero pointer word (where) length word (how many)

Widths (size_of)

string  = 16 (pointer + length)
cstring = 8 (one pointer)
rawptr  = 8

What you're seeing

Because the layouts differ, the type system keeps them apart: a string handed to a cstring parameter is rejected at compile time — one of the few foreign mistakes Odin can catch before the program runs:

libc.puts(s)   // s : string

Error: Cannot assign value 's' of type 'string' to 'cstring' in a procedure argument

The fix: convert at the boundary. A bare "hello" literal aimed at a cstring parameter is special — the compiler emits a null-terminated byte buffer for you, so cstring("hello") Just Works. A string built at runtime (from fmt.tprintf, slicing, a file read) is not terminated, so you allocate a terminated copy with strings.clone_to_cstring(s, context.temp_allocator) and pass that:

runtime string, passed raw

greeting := fmt.tprintf("hello, %s", name)
it's a string — no trailing zero
libc.puts(greeting) ✕
rejected: string is not cstring

convert, then pass

greeting := fmt.tprintf("hello, %s", name)
clone_to_cstring → terminated copy
libc.puts(c_greeting) ✓
prints: hello, Ada

The terminator is the length. Calling a hand-bound strlen shows it directly: strlen walks the bytes until it hits the zero, and returns the count not including it. So a six-glyph literal returns 6, never 7 — the terminator is the signal to stop, not a counted byte:

my_strlen(cstring("abcdef")) // → 6
my_strlen(cstring("hi"))     // → 2

Level 2 marshalled the data. Level 3 is the bill: a foreign declaration is a promise the compiler cannot check. It emits no code — only a reference for the linker to resolve — so it has nothing on the other side to verify your shape against.

A foreign block generates not one instruction of a body. It produces a reference: "at this call site, jump to wherever the linker decides this symbol lives." That hands you two failure modes the ordinary compiler errors don't cover — and they surface at different times. Pick one:


      

Why the renamed symbol needs @(link_name). When you give the Odin proc a different name than the C symbol — my_puts for the library's puts — the link name changes with it. Without an explicit @(link_name="puts"), the linker hunts for a symbol literally called my_puts, which nothing exports, and the build dies with an unresolved external. The attribute is the wire: Odin-side name on the left, real C symbol on the right.

the wiring
@(link_name="puts") my_puts :: proc(s: cstring) -> i32 ---
link_name pins it
resolves to
the C symbol puts
(not "my_puts")
the part with no guardrail at all: the signature The two errors above are the kind ones — they stop the build. The dangerous case is a declaration that compiles, links, and runs while being wrong about the shape. Declare puts :: proc(s: cstring) -> [4]f64 --- and Odin accepts it without complaint; at runtime the real puts returns one integer, your code reads four floats out of registers holding garbage, and nothing crashes and nothing warns. An ABI mismatch is silently wrong, not loudly wrong. The compiler has no view of the library's true signature, so verifying your declaration against the real interface is your job, not the toolchain's.

The last level is what the mechanism unlocks at scale: every precompiled library in the world becomes callable through the same four pieces. A vendor: package is not a different feature — it is exactly this foreign block, already written for you.

Once you can name a library, name a calling convention, declare a shape, and marshal a cstring across, the door opens onto every existing C-ABI library — operating-system calls, audio, physics, the graphics stack. Most of them already have the block written: core:c/libc wraps the C standard library, and Odin's vendor: tree ships ready-made bindings of this kind for the big libraries. Calling libc.puts and calling your own hand-bound my_puts produce indistinguishable output — because they are the same four pieces, one just typed by someone else.

Why the alternatives lose

refusing the boundary
reimplement the library in Odin
shell out to a separate process
hand-marshal bytes through a generic buffer
each one is more code, slower, or a fresh source of bugs — to avoid four declarations.

Reimplementing the library means rewriting decades of someone else's audited, optimized code — for OpenGL or SQLite that is not a weekend, it is a career, and your copy starts out buggier than the original.

Shelling out to a separate process and parsing its text output pays a process-launch and serialization cost on every call, loses every typed return value to string-scraping, and turns a direct register-level call into inter-process plumbing.

Hand-marshalling bytes through a generic buffer rebuilds, by hand and without checks, exactly what the foreign declaration + cstring conversion already do — more code, the same idea, more bugs. The foreign block is the marshalling, written once and type-checked on the Odin side.

Here is the real program — the four steps end to end, its output captured from a compiled run:

--- step 1: pre-made libc binding ---
--- step 2: convert Odin string -> cstring ---
--- step 3: hand-written foreign import ---
--- step 4: my_strlen round-trip ---
strlen of abcdef = 6
from the bundled binding
hello, Ada
from my own foreign import

Why the order looks scrambled. All four fmt.println headers and the strlen result print first, then the three puts lines. That is not a bug — Odin's fmt writes to its own stdout buffer and the library's puts writes to the C runtime's separate buffer, and the two flush on their own schedules. The instant two runtimes write to one terminal, ordering between them is no longer guaranteed; you'd flush explicitly or route every line through one side if you needed it strict.

That's the arc: L1 a bodyless declaration the linker wires to a real library symbol → L2 data changes shape at the boundary, so cstring and conversion live there → L3 the bill is that the shape is an unchecked promise — a missing symbol stops the build, a wrong signature does not → L4 get those four pieces right and every precompiled library, including all of vendor:, is callable.

probes reproduce with odin run / odin build · sizes, strlen counts, the program output & both error strings are real compiler output (claims/lessons/15-foreign-and-bindings)