29 Meeting C
What to know first
cap c is a capability the entry point cannot receiverequires is checked on entryexported ops become symbols C can callLooking back
Why did chapter 16 say cap c is a capability the entry point cannot ask for?
A. Because it is not a capability the runner (the operating system) can hand over. The door into C must be made somewhere entitled to give it and flow in as an argument. This chapter covers that door — extern — in both directions, going out and coming in.
The need for this chapter, and its context
By the end of this chapter
extern op calling a C function must have (the unsafe mark, cap c and an effects line) and the link clause. You will see that types crossing the boundary are limited to what the C ABI can express, and that a slice becomes a pointer and a length. You will put exported ops into a C program with --emit-h and --no-main and watch a C caller that breaks a contract stop at the door. You will also see the rules for callbacks and handing over ownership.The questions this chapter answers
- Inside an op marked
unsafe, may you do anything?
29.1 Calling C — mark, right, effects line#
examples/ch29/area.low
module area_ffi .
rem the body lives in C — it carries all three: marker, rights and effects
unsafe extern proc c_area do
input k cap c .
input w i64 .
input h i64 .
output i64 .
effects unsafe .
link "lw_c_area" .
end
unsafe proc area_twice input k cap c . input w i64 . input h i64 . output i64 .
effects unsafe .
do
return add (c_area k w h) (c_area k w h) .
end
Output
$ lowentc --check area.low
== check: ok ==
An op that calls a C function has all three.
| Must have | Without it | Whom it tells |
|---|---|---|
the unsafe mark | E-FFI-NOUNSAFE | The person — from here on a person, not the language, is responsible |
input k cap c . | E-FFI-NOCAP | The processor — entering C is a right handed over |
an effects line (at least effects unsafe) | E-FFI-NOEFFECT | The caller — it learns from the head what it takes on |
Table 29.1 — What an op calling C must have
An extern op’s body is in C, so there is no body here. Instead, the way a struct holds its fields, it holds its clauses in do … end, and among them link "lw_c_area" names the C side. A statement in that block would be a second body: E-FFI-BODY. The processor does not make the name up from the op’s name. It is a promise made to another language, so whoever promises writes it — leaving it out is E-FFI-LINK. area_twice calls that op, so it is itself unsafe and receives cap c. The mark and the right travel up the call chain.
examples/ch29/nounsafe.low
module nounsafe .
rem expect: E-FFI-NOUNSAFE
extern proc c_area do
input k cap c .
input w i64 .
input h i64 .
output i64 .
effects unsafe .
link "lw_c_area" .
end
Output
$ lowentc --check nounsafe.low
nounsafe.low:4:0 E-UNSAFE-UNDECLARED: this op declares the `unsafe` EFFECT but is not marked `unsafe`. The effect says WHAT it does; the modifier says WHO takes responsibility. Write `unsafe proc …` — an unsafe op that nobody signed for is exactly the hole the discipline exists to close
nounsafe.low:4:0 E-FFI-NOUNSAFE: calling C without `unsafe`. Inside that C function every invariant this language enforces can be broken, and the tool cannot see it. What cannot be checked must at least be MARKED (RFC-0063 D1)
examples/ch29/nocap.low
module nocap_ffi .
rem expect: E-FFI-NOCAP
unsafe extern proc c_area do
input w i64 .
input h i64 .
output i64 .
effects unsafe .
link "lw_c_area" .
end
Output
$ lowentc --check nocap.low
nocap.low:4:0 E-FFI-NOCAP: this op calls C but receives NO right to do so. Calling into C is a capability you are HANDED — `input k cap c .` — exactly like the heap (RFC-0043), the device bus (RFC-0042) and raw machine instructions (RFC-0041). An ambient door into C is one every caller silently inherits
With only one of the three, the call “could work”. But then this language could not say what it has given up.
Q. Inside an op marked unsafe, may you do anything?
A. No. unsafe does not mean “anything goes”; it marks that “part of this place cannot be checked by the processor”. In the body of an unsafe op, bounds checks, ownership and borrowing rules still apply. What cannot be checked is inside the C function. The mark stays in the source, so the places to audit can be found later.
29.2 Types that cross the boundary#
Only what the C ABI can express crosses the boundary. option, result, vectors and containers do not exist in C, and the processor does not pretend they do.
examples/ch29/fftype.low
module fftype .
rem expect: E-FFI-TYPE
unsafe extern proc c_find do
input k cap c .
input key u64 .
output option u64 .
effects unsafe .
link "lw_c_find" .
end
Output
$ lowentc --check fftype.low
fftype.low:4:0 E-FFI-TYPE: this signature carries a type the C ABI cannot express (option / result / vector / container). C has no such thing, and the tool will not PRETEND it does: pass integers, f64, or a byte slice (which becomes a POINTER and a LENGTH — two C arguments, because that is what a slice honestly IS in C)
Only slices are mapped automatically. slice τ becomes two arguments, “a pointer to τ” and “a count”. The width is known too — slice u32 is uint32_t *, not a byte pointer. Capabilities are not values, so they do not cross into C. There is one ABI name, c. “What C is on this machine” is already decided by the machine building it.
29.3 C calls Lowent#
The other direction. An exported op becomes a symbol callable from C.
examples/ch29/exported.low
module exported .
rem run: clamp_add 100 50
export fn clamp_add input a u32 . input b u32 . output u32 .
requires le a 1000 .
requires le b 1000 .
ensures le ret 2000 .
do
return add a b .
end
export fn sum_bytes input xs slice u8 . output u64 .
do
var s u64 be 0 .
for x xs do
set s (add s (widen u64 x)) .
end
return s .
end
Output
$ lowentc --run clamp_add exported.low 100 50
clamp_add(100, 50) = 150
--emit-h emits the header. A header written by hand keeps signatures in two places, and one day they diverge.
long long clamp_add(long long, long long);
long long sum_bytes(const unsigned char *, size_t);You can see sum_bytes’s slice u8 split into a pointer and a length.
Lowent
input xs slice u8 .
C (the header --emit-h writes)
sum_bytes(const unsigned char *, size_t)
└──────┬────────────┘ └─┬──┘
│ count (len xs)
pointer (xs's first item)--no-main emits C holding only the entry points of exported ops, without main and the command-line dispatcher, ready to drop into someone else’s build. The following C program uses the two.
// host-for: exported.low
#include <stdio.h>
#include "lowent.h"
int main(void) {
const unsigned char bytes[] = {1, 2, 3, 4};
printf("clamp_add(100, 50) = %lld\n", clamp_add(100, 50));
printf("sum_bytes = %lld\n", sum_bytes(bytes, 4));
fflush(stdout);
printf("clamp_add(5000, 1) = %lld\n", clamp_add(5000, 1));
return 0;
}
$ cc host.c <exported.low 을 --no-main 으로 낸 C> && ./host
clamp_add(100, 50) = 150
sum_bytes = 10
panic: requires violated at entry
(종료 코드 70)
The first two calls get answers. The third breaks requires le a 1000 .. C does not know contracts, but the place being called into is this side’s door, so the op’s contract is enforced on the arguments and it stops on entry. Lowent answers for inside the door, C for outside.
Both directions in one picture. Going out, it is written in the head; coming in, it is measured at the door.
going out --- Lowent calls C
area_twice ──▶ c_area ══ link "lw_c_area" ══▶ lw_c_area (C)
unsafe · cap c · effects unsafe inside is unchecked; a person answers
coming in --- C calls Lowent
host.c ── clamp_add(5000, 1) ══▶ ┃ requires le a 1000 .
┃ 5000 breaks the promise
┗━▶ stops at entry (exit code 70)A common misconception. At the FFI boundary every guarantee of the language disappears
unsafe, cap c and an effects line. Where C comes in, the guarantees stand as they are. Incoming arguments that break a contract stop on entry. The boundary is not a hole but a door, and a contract stands at the door.29.4 Callbacks and ownership#
To let C call a Lowent function back, take the address of an export extern op with unsafe_fn <op> and pass it as a value. There is no new convention. Two rules apply.
unsafe_fnmay point only atexport externops (E-FN-NOTEXPORT). The address of a door C cannot call is not an address.- An op used as a callback cannot require capabilities (
E-FN-CAP). It is C that enters the callback, and C has no capabilities to hand over. Work needing capabilities is done outside the callback; inside, it only computes.
Passing owned τ to an extern moves the responsibility to dispose of it to C. This side’s obligation ends there, and whether it is later released is not verified. C functions taking an unfixed number of arguments after the fixed ones can be called with a variadic . clause, but contracts do not reach those arguments, and the opposite direction (C calling our variadics) does not exist.
examples/ch29/variadic.low
module variadic .
rem C's char*; unsafe_ptr is a qualifier placed before a type
newtype cstr unsafe_ptr u8 .
unsafe extern proc c_printf do
input k cap c .
input fmt cstr .
output i32 .
effects unsafe .
rem takes an unspecified number of arguments after the fixed ones
variadic .
link printf .
end
rem after the fixed argument (the format) one more value, 42, is passed; neither type checks nor contracts reach it
unsafe proc report input k cap c . output i32 . effects unsafe .
do
return c_printf k (cstr_of "sum=%d\n\0") 42 .
end
Output
$ lowentc --check variadic.low
== check: ok ==
newtype cstr unsafe_ptr u8 .turns C’schar*into a named type.unsafe_ptris not a type used on its own but a qualifier placed before a type.- The
variadic .clause says “takes more after the fixed arguments”, andlink printf .writes the C name. The42at the call site follows the fixed arguments, so neither type checks nor contracts reach it. The classic C defect of a format not matching its arguments is not stopped here, and that is whyunsafeis attached. cstr_of "…\0"views a string literal ending in a zero byte as a C string. The VM cannot call C, so this example is only checked; built natively, it printssum=42.
In practice. The load carried by a program that adds two numbers
main and the command-line dispatcher come along, and the dispatcher holds the tagged path and its pools. In the development repository’s measurement, emitting one op that adds two numbers by default gave about 197 KB of read-only data and about 1.7 MB of uninitialised data, while --no-main brought both under 50 bytes. That is why --no-main is used when embedding as a library. Targets without an operating system (--target cortex_m) do not emit the dispatcher in the first place.29.5 Common mistakes#
Counter-example. Leaving the effects line off an extern op
examples/ch29/mistake_noeffect.low
module mistake_noeffect .
rem expect: E-FFI-NOEFFECT
rem ✘ no effects line --- callers cannot learn from the head what they take on
unsafe extern proc c_area do
input k cap c .
input w i64 .
input h i64 .
output i64 .
link "lw_c_area" .
end
Output
$ lowentc --check mistake_noeffect.low
mistake_noeffect.low:5:0 E-UNSAFE-UNUSED: this op is marked `unsafe` but declares no `unsafe` effect. An `unsafe` that buys nothing is a FALSE ALARM — and false alarms are how real ones stop being read. Drop the modifier
mistake_noeffect.low:5:0 E-FFI-NOEFFECT: this op calls C and declares no effect. The effect row is how a CALLER learns what it is taking on; C that hides in a clean signature is the hidden cost this language exists to remove (declare at least `effects unsafe`)
Two diagnostics appear. E-FFI-NOEFFECT says “this calls C and declares no effect”, and E-UNSAFE-UNUSED says “this is marked unsafe but has no unsafe effect, so it is a false alarm”. Both have the same root. The marker (unsafe) says who takes responsibility, the effects line (effects unsafe) says what it does, and one without the other does not pair up. A single effects unsafe . line removes both.
Counter-example. Leaving the link clause off an extern op
examples/ch29/mistake_nolink.low
module mistake_nolink .
rem expect: E-FFI-LINK
rem ✘ no `link` clause --- nothing says which C symbol is called
unsafe extern proc c_area do
input k cap c .
input w i64 .
input h i64 .
output i64 .
effects unsafe .
end
unsafe proc area_twice input k cap c . input w i64 . input h i64 . output i64 .
effects unsafe .
do
return add (c_area k w h) (c_area k w h) .
end
Output
$ lowentc --check mistake_nolink.low
mistake_nolink.low:5:0 E-FFI-LINK: an `extern` op does not say which C symbol it calls. Write `link "strlen" .` (and `link "sin" from "m" .` when it lives in a library). The C name is a promise made to another language, so the author writes it: derived from the op name it would change the moment the op is renamed, with nothing in the source saying so (RFC-0063)
Without link, nothing in the source says which C symbol is called. If the tool derived it from the op name, renaming the op would call a different C function with nothing recording that. So it is rejected with E-FFI-LINK — the promise belongs to whoever writes it.
Counter-example. Calling an op that calls C without the unsafe marker
examples/ch29/mistake_callerunsafe.low
module mistake_callerunsafe .
rem expect: E-UNSAFE-UNDECLARED
unsafe extern proc c_area do
input k cap c .
input w i64 .
input h i64 .
output i64 .
effects unsafe .
link "lw_c_area" .
end
rem ✘ calls an op that calls C, but leaves out the `unsafe` marker
proc area_twice input k cap c . input w i64 . input h i64 . output i64 .
effects unsafe .
do
return add (c_area k w h) (c_area k w h) .
end
Output
$ lowentc --check mistake_callerunsafe.low
mistake_callerunsafe.low:14:0 E-UNSAFE-UNDECLARED: this op declares the `unsafe` EFFECT but is not marked `unsafe`. The effect says WHAT it does; the modifier says WHO takes responsibility. Write `unsafe proc …` — an unsafe op that nobody signed for is exactly the hole the discipline exists to close
area_twice declares effects unsafe but has no unsafe in its head. Effects travel up the calls, so the calling op performs the unsafe effect too, and whatever performs it must be signed for. As E-UNSAFE-UNDECLARED puts it, “an unsafe op that nobody signed for is exactly the hole” this language exists to close. Write unsafe proc area_twice ….
Counter-example. Passing the address of an ordinary op as a callback
examples/ch29/mistake_cbplain.low
module mistake_cbplain .
rem expect: E-FN-NOTEXPORT
fn by_value input a i64 . input b i64 . output i64 .
requires ge a -1000000 .
requires le a 1000000 .
requires ge b -1000000 .
requires le b 1000000 .
do
return sub a b .
end
unsafe proc callback_addr input k cap c . output u64 . effects unsafe .
do
rem ✘ tries to hand C the address of an ordinary op --- it has no door C can call
let f u64 be unsafe_fn by_value .
return f .
end
Output
$ lowentc --check mistake_cbplain.low
mistake_cbplain.low:14:1 W-EFFECT-OVER: this op DECLARES `unsafe` but never PERFORMS it. A declared effect is a cost the caller must budget for (a pure caller cannot call an `io` op). Drop it, or — if a future version will perform it — say so (RFC-0007)
mistake_cbplain.low:16:0 E-FN-NOTEXPORT: `unsafe_fn <op>` needs an `export extern` op — only those have a C-callable symbol whose entry checks the contract (RFC-0066 §2). A plain op has no address C can call
C can call only the symbols made by export extern ops, and at that entry the contract is enforced on the arguments. An ordinary op has no such door, hence E-FN-NOTEXPORT. Change the head to export extern fn by_value …. The W-EFFECT-OVER on the first line says that merely taking an address does no unsafe work; unsafe arises in the op that calls C with that address.
Counter-example. Giving a callback op a capability input
examples/ch29/mistake_cbcap.low
module mistake_cbcap .
rem expect: E-FN-CAP
rem the op meant as a callback takes a capability as input
export extern fn cmp_logged input out cap io . input a i64 . input b i64 . output i64 .
requires ge a -1000000 .
requires le a 1000000 .
requires ge b -1000000 .
requires le b 1000000 .
do
return sub a b .
end
unsafe proc callback_addr input k cap c . output u64 . effects unsafe .
do
rem ✘ when C calls back, it has no capability to hand over
let f u64 be unsafe_fn cmp_logged .
return f .
end
Output
$ lowentc --check mistake_cbcap.low
mistake_cbcap.low:15:1 W-EFFECT-OVER: this op DECLARES `unsafe` but never PERFORMS it. A declared effect is a cost the caller must budget for (a pure caller cannot call an `io` op). Drop it, or — if a future version will perform it — say so (RFC-0007)
mistake_cbcap.low:17:0 E-FN-CAP: `unsafe_fn <op>` names an op that requires a CAPABILITY (a `cap` parameter) — but a callback is entered BY C, and C has no capability to hand it. Do the capability-needing work OUTSIDE the callback and keep the callback pure or limited-effect (RFC-0066)
It is C that enters a callback, and C has no capability to hand over. So an op that takes a capability cannot be a callback (E-FN-CAP). Do capability-needing work, such as output or files, outside the callback, after C returns. Inside the callback, only compute.
29.6 This chapter’s syntax at a glance#
| Shape | Meaning | Why |
|---|---|---|
unsafe extern proc c_area do input k cap c . … effects unsafe . link "lw_c_area" . end | an op whose body is in C — its clauses go in do … end | marker, right and effects line must all be present |
unsafe proc area_twice input k cap c . … effects unsafe . | an op that calls an op that calls C | marker and right travel up the call chain |
input xs slice u8 . (at the boundary) | two arguments in C: pointer and length | only slices are mapped automatically |
option·result·vectors at the boundary | rejected (E-FFI-TYPE) | nothing absent from the C ABI is faked |
export fn clamp_add … | a symbol C can call | the contract is enforced on incoming arguments |
lowentc --emit-h · --no-main | emit a header · emit as a library without main | the signature lives in one place |
unsafe_fn cmp | address of an export extern op (callback) | ordinary op: E-FN-NOTEXPORT · with a capability: E-FN-CAP |
input h owned τ . (to an extern) | responsibility for destroying passes to C | what C does with it afterwards is not verified |
variadic . | call a C variadic function | contracts do not reach variadic arguments |
newtype cstr unsafe_ptr u8 . · cstr_of "…\0" | C pointer qualifier · view as a zero-terminated C string | pointers are handled only through named types |
Table 29.2 — C boundary syntax — shape · meaning · why it looks this way
Recap
extern op calling C has all of unsafe, cap c and an effects line, and names the C symbol with link. Only what the C ABI can express crosses the boundary, and a slice becomes a pointer and a length. exported ops go into C with --emit-h and --no-main, and a C caller breaking a contract stops at the door. Callbacks use the address of an export extern op without capabilities, and passing owned hands responsibility to C.