Lowent 매뉴얼←↑→

29 C 와 만나는 자리

먼저 알아야 할 것

16장 권한 · cap c 는 시작점이 받을 수 없는 권한이다
14장 계약 · requires 는 진입에서 검사된다
21장 모듈 · export 한 op 만 C 에서 부를 수 있는 심볼이 된다

돌아보기

16장에서 cap c 는 왜 시작점이 요구할 수 없는 권한이라고 했는가?

답. 실행하는 쪽(운영체제)이 건넬 수 있는 권한이 아니기 때문이다. C 로 들어가는 문은 그것을 줄 자격이 있는 자리에서 만들어져 인자로 흘러야 한다. 이 장은 그 문 — extern — 을 다룬다. 들어가는 방향과 나가는 방향 모두다.

이 장의 필요성과 맥락

새 언어가 현실에서 쓰이려면 이미 있는 C 코드와 만나야 한다. 운영체제의 API, 수십 년 된 라이브러리, 하드웨어 공급사의 SDK 가 모두 C 다. 그런데 C 로 넘어가는 순간 이 언어가 지키던 모든 것 — 경계 검사, 소유, 효과 — 이 C 쪽에서 깨질 수 있다. Lowent 는 이 자리를 편하게 만드는 대신 좁고 보이게 만든다. 그리고 반대 방향, C 가 Lowent 를 부르는 자리에는 계약을 세워 문 안을 지킨다.

이 장이 끝나면

C 함수를 부르는 extern op 이 갖춰야 할 세 가지(unsafe 표시·cap c·효과 줄)와 link 절을 익힌다. 경계를 건널 수 있는 타입이 C ABI 가 표현할 수 있는 것으로 한정되고 슬라이스는 포인터와 길이 둘이 된다는 것을 알게 된다. export 한 op 을 --emit-h·--no-main 으로 C 프로그램에 넣고, C 가 계약을 어기면 문에서 멈추는 모습을 확인한다. 되부름과 소유를 넘기는 규칙도 보게 된다.

이 장에서 답할 질문

  1. unsafe 가 붙은 op 안에서는 아무 일이나 해도 되는가?

29.1 C 를 부른다 — 표시·권리·효과 줄#

examples/ch29/area.low

module area_ffi .

rem 몸이 C 에 있다 — 표시·권리·효과 줄 셋을 모두 갖춘다
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

실행 결과

$ lowentc --check area.low
== check: ok ==

C 함수를 부르는 op 은 셋을 모두 갖춘다.

갖출 것없으면누구에게 말하나
unsafe 표시E-FFI-NOUNSAFE사람에게 — 여기서부터는 언어가 아니라 사람이 책임진다
input k cap c .E-FFI-NOCAP처리기에게 — C 로 들어가는 것은 건네받은 권리다
효과 줄(적어도 effects unsafe)E-FFI-NOEFFECT부르는 쪽에게 — 무엇을 떠안는지 머리에서 배운다

표 29.1 — C 를 부르는 op 이 갖추어야 하는 것

extern op 은 몸이 C 에 있으므로 이쪽에는 몸이 없다. 대신 struct 가 칸을 담듯 절을 do … end 에 담고, 그 가운데 link "lw_c_area" 가 C 쪽 이름을 댄다. 블록에 문장을 적으면 몸이 둘이 되어 E-FFI-BODY 다. 이름은 처리기가 op 이름에서 지어내지 않는다. 다른 언어에 하는 약속이므로 약속한 사람이 적는다 — 빠뜨리면 E-FFI-LINK 다. area_twice 는 그 op 을 부르므로 스스로도 unsafe 이고 cap c 를 받는다. 표시와 권리가 호출 사슬을 따라 올라간다.

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

실행 결과

$ 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

실행 결과

$ 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

셋 중 하나만 있어도 “부를 수는 있다”. 그러나 그러면 이 언어는 자기가 무엇을 잃었는지 말하지 못한다.

문. unsafe 가 붙은 op 안에서는 아무 일이나 해도 되는가?

답. 아니다. unsafe 는 “아무렇게나 해도 된다” 가 아니라 “이 자리의 일부를 처리기가 검사할 수 없다” 는 표시다. unsafe op 의 본문에서도 경계 검사·소유·차용 규칙은 그대로 적용된다. 검사할 수 없는 것은 C 함수의 안이다. 그 표시가 소스에 남아 있어서 나중에 감사할 자리를 찾을 수 있다.

29.2 경계를 건너는 타입#

경계를 건널 수 있는 것은 C ABI 가 표현할 수 있는 것뿐이다. option·result·벡터·그릇은 C 에 없고, 처리기는 있는 척하지 않는다.

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

실행 결과

$ 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)

저절로 사상되는 것은 슬라이스뿐이다. slice τ 는 “τ 를 가리키는 포인터” 와 “개수” 두 인자가 된다. 폭도 안다 — slice u32 는 uint32_t * 이지 바이트 포인터가 아니다. 권한은 값이 아니므로 C 로 건너가지 않는다. ABI 이름은 c 하나다. “이 기계에서 C 가 무엇인가” 는 짓는 기계가 이미 정한다.

29.3 C 가 Lowent 를 부른다#

반대 방향이다. export 한 op 은 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

실행 결과

$ lowentc --run clamp_add exported.low 100 50
clamp_add(100, 50) = 150

--emit-h 가 헤더를 낸다. 헤더를 손으로 적으면 서명이 두 곳에 살고, 언젠가 갈린다.

long long clamp_add(long long, long long);
long long sum_bytes(const unsigned char *, size_t);

sum_bytes 의 slice u8 이 포인터와 길이 둘로 나뉜 것을 볼 수 있다.

 Lowent
   input xs slice u8 .

 C (--emit-h 가 낸 머리)
   sum_bytes(const unsigned char *, size_t)
             └──────┬────────────┘ └─┬──┘
                    │                개수 (len xs)
                    포인터 (xs 의 첫 칸)

--no-main 은 main 과 명령 줄 디스패처 없이 내보낸 op 의 진입점만 담은 C 를 낸다. 남의 빌드에 그대로 넣으면 된다. 다음 C 프로그램이 그 둘을 쓴다.

// 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)

앞의 두 호출은 답을 받는다. 셋째 호출은 requires le a 1000 . 을 어긴다. C 는 계약을 모르지만, 불려 들어오는 자리는 이쪽 문이므로 op 의 계약이 인자에 강제되고 진입에서 멈춘다. 문 안은 Lowent 가, 문 밖은 C 가 책임진다.

두 방향을 한 장에 그리면 이렇다. 나가는 쪽은 머리에 적고, 들어오는 쪽은 문에서 잰다.

 나가는 쪽 --- Lowent 가 C 를 부른다
   area_twice ──▶ c_area ══ link "lw_c_area" ══▶  lw_c_area (C)
   unsafe · cap c · effects unsafe                안은 검사 밖, 사람이 책임진다

 들어오는 쪽 --- C 가 Lowent 를 부른다
   host.c ── clamp_add(5000, 1) ══▶ ┃ requires le a 1000 .
                                    ┃ 5000 은 약속을 어긴다
                                    ┗━▶ 진입에서 멈춘다 (종료 코드 70)

흔한 오해. FFI 경계에서는 언어의 보장이 모두 사라진다

C 로 나가는 쪽에서는 C 함수 안이 검사 밖이다. 그러나 그 사실은 unsafe·cap c·효과 줄로 머리에 적힌다. C 가 들어오는 쪽에서는 보장이 그대로 선다. 들어오는 인자가 계약을 어기면 진입에서 멈춘다. 경계는 구멍이 아니라 문이고, 문에는 계약이 서 있다.

29.4 되부름과 소유#

C 에게 Lowent 함수를 넘겨 되부르게 하려면 export extern op 의 주소를 unsafe_fn <op> 으로 얻어 값으로 넘긴다. 새 규약은 없다. 규칙이 둘 붙는다.

owned τ 를 extern 에 넘기면 없앨 책임이 C 에게 옮겨간다. 이쪽의 의무는 그 지점에서 끝나고, 그 뒤의 반납이 지켜졌는지는 검증되지 않는다. 고정된 인자 뒤에 개수가 정해지지 않은 인자를 받는 C 함수는 variadic . 절로 부를 수 있지만, 그 인자에는 계약이 닿지 않으며 반대 방향(C 가 우리 가변인자를 부르기)은 없다.

examples/ch29/variadic.low

module variadic .

rem C 의 char* — unsafe_ptr 는 타입 앞에 붙는 한정자다
newtype cstr unsafe_ptr u8 .

unsafe extern proc c_printf do
  input k cap c .
  input fmt cstr .
  output i32 .
  effects unsafe .
  rem 고정 인자 뒤에 개수가 정해지지 않은 인자를 받는다
  variadic .
  link printf .
end

rem 고정 인자(서식) 뒤에 42 를 하나 더 건넨다 — 그 값에는 타입 검사도 계약도 닿지 않는다
unsafe proc report input k cap c . output i32 . effects unsafe .
do
  return c_printf k (cstr_of "sum=%d\n\0") 42 .
end

실행 결과

$ lowentc --check variadic.low
== check: ok ==

실제 사례. 두 수를 더하는 프로그램이 지는 짐

기본 방출은 프로그램이다. main 과 명령 줄 디스패처가 함께 나오고, 디스패처는 태그 경로와 그 풀들을 붙잡는다. 개발 저장소의 측정에서 두 수를 더하는 op 하나를 기본으로 내면 읽기 전용 데이터가 약 197 KB, 초기화되지 않은 데이터가 약 1.7 MB 였고, --no-main 으로 내면 둘 다 50 바이트 이하가 되었다. 라이브러리로 넣을 때 --no-main 을 쓰는 이유다. 운영체제 없는 대상(--target cortex_m)은 처음부터 디스패처를 내지 않는다.

29.5 흔한 실수#

반례. extern op 에 효과 줄을 적지 않는다

examples/ch29/mistake_noeffect.low

module mistake_noeffect .
rem expect: E-FFI-NOEFFECT

rem ✘ 효과 줄이 없다 --- 부르는 쪽이 무엇을 떠안는지 머리에서 알 수 없다
unsafe extern proc c_area do
  input k cap c .
  input w i64 .
  input h i64 .
  output i64 .
  link "lw_c_area" .
end

실행 결과

$ 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`)

진단이 둘 나온다. E-FFI-NOEFFECT 는 “C 를 부르는데 효과 줄이 없다” 이고, E-UNSAFE-UNUSED 는 “unsafe 표시를 했는데 unsafe 효과가 없으니 거짓 경보다” 이다. 둘은 같은 뿌리에서 나온다. 표시(unsafe)는 누가 책임지는가, 효과 줄(effects unsafe)은 무엇을 하는가 를 말하고, 한쪽만 있으면 짝이 맞지 않는다. effects unsafe . 한 줄로 둘 다 사라진다.

반례. extern op 에 link 절을 적지 않는다

examples/ch29/mistake_nolink.low

module mistake_nolink .
rem expect: E-FFI-LINK

rem ✘ `link` 절이 없다 --- 어느 C 심볼을 부르는지 아무 데도 안 적혀 있다
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

실행 결과

$ 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)

link 가 없으면 어느 C 심볼을 부르는지 소스 어디에도 없다. 처리기가 op 이름에서 지어내면 op 의 이름을 바꾸는 순간 다른 C 함수를 부르게 되고 그 사실이 적힌 데가 없다. 그래서 E-FFI-LINK 로 거절한다 — 약속은 적은 사람의 것이다.

반례. C 를 부르는 op 을 부르면서 unsafe 표시를 빠뜨린다

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 ✘ C 를 부르는 op 을 부르면서 `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

실행 결과

$ 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 는 effects unsafe 를 적었지만 머리에 unsafe 가 없다. 효과는 호출을 따라 올라가므로 부르는 op 도 unsafe 효과를 내고, 그 효과를 낸다면 누군가 서명해야 한다. E-UNSAFE-UNDECLARED 의 말대로 “아무도 서명하지 않은 unsafe op 이 바로 이 언어가 막으려는 구멍” 이다. unsafe proc area_twice … 로 적는다.

반례. 보통 op 의 주소를 되부름으로 넘긴다

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 ✘ 보통 op 의 주소를 C 에게 넘기려 한다 --- C 가 부를 수 있는 문이 없다
  let f u64 be unsafe_fn by_value .
  return f .
end

실행 결과

$ 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 가 부를 수 있는 것은 export extern op 이 만든 심볼뿐이다. 그 입구에서 계약이 인자에 강제된다. 보통 op 에는 그런 문이 없으므로 E-FN-NOTEXPORT 다. 머리를 export extern fn by_value … 로 바꾼다. 첫 줄의 W-EFFECT-OVER 는 주소를 얻는 것만으로는 unsafe 일을 한 것이 아니라는 알림이다. 그 주소로 C 를 부르는 op 에서 unsafe 가 선다.

반례. 되부름으로 쓸 op 이 권한을 받는다

examples/ch29/mistake_cbcap.low

module mistake_cbcap .
rem expect: E-FN-CAP

rem 되부름으로 쓸 op 이 권한을 입력으로 받는다
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 ✘ C 가 되부를 때 건넬 권한이 없다
  let f u64 be unsafe_fn cmp_logged .
  return f .
end

실행 결과

$ 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)

되부름에 들어서는 것은 C 이고 C 에게는 건넬 권한이 없다. 그러니 권한을 받는 op 은 되부름이 될 수 없다(E-FN-CAP). 출력이나 파일처럼 권한이 필요한 일은 되부름 밖에서, C 가 돌아온 뒤에 한다. 되부름 안은 셈만 한다.

29.6 이 장의 문법 한눈에#

모양뜻왜 이렇게
unsafe extern proc c_area do input k cap c . … effects unsafe . link "lw_c_area" . endC 에 몸이 있는 op — 절을 do … end 에 담는다표시·권리·효과 줄 셋이 모두 있어야 한다
unsafe proc area_twice input k cap c . … effects unsafe .C 를 부르는 op 을 부르는 op표시와 권리가 호출 사슬을 따라 올라간다
input xs slice u8 .(경계)C 에서는 포인터와 길이 두 인자저절로 사상되는 것은 슬라이스뿐
option·result·벡터를 경계에거절(E-FFI-TYPE)C ABI 에 없는 것을 있는 척하지 않는다
export fn clamp_add …C 에서 부를 수 있는 심볼들어오는 인자에 계약이 강제된다
lowentc --emit-h · --no-main헤더를 낸다 · main 없이 라이브러리로 낸다서명이 한 곳에만 산다
unsafe_fn cmpexport extern op 의 주소(되부름)보통 op 은 E-FN-NOTEXPORT · 권한을 받으면 E-FN-CAP
input h owned τ .(extern 에)없앨 책임이 C 로 넘어간다그 뒤의 반납은 검증되지 않는다
variadic .C 의 가변 인자 함수를 부른다가변 인자에는 계약이 닿지 않는다
newtype cstr unsafe_ptr u8 . · cstr_of "…\0"C 포인터 한정자 · 영 바이트로 끝나는 C 문자열로 보기포인터는 이름 붙인 타입으로만 다룬다

표 29.2 — C 경계의 문법 — 모양 · 뜻 · 왜 이렇게 생겼나

복습 정리

C 를 부르는 extern op 은 unsafe·cap c·효과 줄을 모두 갖추고 link 로 C 이름을 댄다. 경계를 건너는 타입은 C ABI 가 표현할 수 있는 것뿐이고 슬라이스는 포인터와 길이가 된다. export 한 op 은 --emit-h·--no-main 으로 C 에 넣으며, C 가 계약을 어기면 문에서 멈춘다. 되부름은 권한 없는 export extern op 의 주소로 하고, owned 를 넘기면 책임이 C 로 간다.