Lowent Manual←↑→

29 Meeting C

What to know first

chapter 16, Capabilities · cap c is a capability the entry point cannot receive
chapter 14, Contracts · requires is checked on entry
chapter 21, Modules · only exported ops become symbols C can call

Looking 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

For a new language to be used in practice, it must meet existing C code. Operating system APIs, decades-old libraries and hardware vendors’ SDKs are all C. But the moment control passes into C, everything this language kept — bounds checks, ownership, effects — can break on the C side. Lowent does not make this place comfortable; it makes it narrow and visible. And in the other direction, where C calls Lowent, it puts contracts at the door to guard the inside.

By the end of this chapter

You will learn the three things an 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

  1. 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 haveWithout itWhom it tells
the unsafe markE-FFI-NOUNSAFEThe person — from here on a person, not the language, is responsible
input k cap c .E-FFI-NOCAPThe processor — entering C is a right handed over
an effects line (at least effects unsafe)E-FFI-NOEFFECTThe 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

Going out to C, the inside of the C function is outside the checks. But that fact is written in the head with 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.

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 ==

In practice. The load carried by a program that adds two numbers

The default emission is a program. 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#

ShapeMeaningWhy
unsafe extern proc c_area do input k cap c . … effects unsafe . link "lw_c_area" . endan op whose body is in C — its clauses go in do … endmarker, 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 Cmarker and right travel up the call chain
input xs slice u8 . (at the boundary)two arguments in C: pointer and lengthonly slices are mapped automatically
option·result·vectors at the boundaryrejected (E-FFI-TYPE)nothing absent from the C ABI is faked
export fn clamp_add …a symbol C can callthe contract is enforced on incoming arguments
lowentc --emit-h · --no-mainemit a header · emit as a library without mainthe signature lives in one place
unsafe_fn cmpaddress 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 Cwhat C does with it afterwards is not verified
variadic .call a C variadic functioncontracts do not reach variadic arguments
newtype cstr unsafe_ptr u8 . · cstr_of "…\0"C pointer qualifier · view as a zero-terminated C stringpointers are handled only through named types

Table 29.2 — C boundary syntax — shape · meaning · why it looks this way

Recap

An 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.