Встраивание kola в C-программу: заголовок, символы и шов
Почти во всём учебнике программа на kola самостоятельна: она и есть вся программа. В реальной системе kola обычно становится частью другой программы: объектным файлом, который компонуется в существующий образ на C и вызывается из C-кода, появившегося раньше. Гипервизор, прошивку маршрутизатора или модуль ядра никто переписывать не станет, и main они не отдадут. Им нужна одна функция: написанная на kola, со всеми доказательствами, которые даёт kola, и при этом доступная из C обычным вызовом. В этой главе описано, как такая граница выглядит на диске: что пишет kola build, что объявляет сгенерированный заголовок, какие символы выходят из объектного файла, что должен предоставить хост на голом железе и чего граница делать не станет.
Библиотека для встраивания
Всё, что показано ниже, получено из одного этого файла. Он разбирает восьмибайтовый заголовок кадра из байтов, пришедших по сети. Файл маленький, но в нём есть все формы, важные для границы: структура, которая её пересекает, параметр-срез, возврат Option, контракт, байтовый строковый литерал и закрытая вспомогательная функция.
// frame.kl: библиотека. `fn main` здесь нет, поэтому объект экспортирует
// собственные `pub`-элементы (не из std) и больше ничего.
use std::encoding::bytes;
use std::num::bits;
/// Разобранный заголовок: простые данные, которые код на C может назвать и прочитать.
pub struct Header {
magic: u16,
len: u16,
crc: u32,
}
layout_assert[Header](8, 4);
const MAGIC: u16 = 0x4B4Cu16;
/// Не `pub`: все вызовы находятся внутри этого объекта, поэтому функция выпускается как `static`.
fn crc_of(buf: &[u8]) -> u32 !{} {
let mut acc = 2166136261u32;
for b in buf {
acc = bits::rotl32(acc ^ widen[u32](b), 7u32);
}
acc
}
/// Экспортируемая точка входа. Вызывающего на C kola не видит, поэтому
/// контракт проверяется во время выполнения внутри экспортируемого символа.
pub fn parse(buf: &[u8]) -> Option[Header] !{}
req buf.len >= 8
{
let m = bytes::rd16(buf, 0);
if m != MAGIC {
return None;
}
Some(Header {
magic: m,
len: bytes::rd16(buf, 2),
crc: bytes::rd32(buf, 4),
})
}
pub fn body_ok(buf: &[u8], h: &Header) -> bool !{} {
if buf.len < 8 {
return false;
}
crc_of(buf) == h.crc
}
/// Байтовый строковый литерал: у библиотеки появляется собственный блок данных.
pub fn name() -> &heap [u8] !{} {
&b"kola/frame"
}
test parses_a_good_header {
let wire = b"\x4b\x4c\x00\x03\xde\xad\xbe\xef";
let h = parse(&wire);
assert h.is_some();
}У полей Header нет pub. В kola поле можно читать везде, где виден тип его структуры, а pub на поле — ошибка. Всё решает видимость самой структуры, и именно поэтому pub struct может означать «эта раскладка — часть моего ABI».
Что kola build кладёт на диск
$ kola build -o out frame.kl
build ok: 7 native fn(s), 4 exported item(s) (3 fn, 0 const, 1 type) → out/frame.o
crc_of
parse
body_ok
name
std::num::bits::rotl32
std::encoding::bytes::rd16
std::encoding::bytes::rd32
note: 1 exported fn(s) return a by-value aggregate in the SysV-vs-MS x64 return-mechanism divergence window (sizes 3,5,6,7 and 9-16). `sizeof` AGREES across those ABIs, so a `_Static_assert` cannot see it: SysV returns the value in RAX:RDX, MS x64 through a hidden pointer.
KOpt_struct_k_frame__Header (12 bytes) — parse
= note: harmless if every consumer of this `.kh` uses ONE psABI. If a C consumer tests on host SysV and ships to MS x64, run that leg on the real target ABI — the size pins in the header will not catch it.
$ ls out
frame.c frame.kh frame.o kola.h kola_asm.SABI (application binary interface, двоичный интерфейс приложений) — это набор правил, на которых сходится скомпилированный код одной платформы: как структуры раскладываются в памяти, в каких регистрах передаются аргументы и возвращаемые значения, как называются символы. Примечание note в выводе — компилятор предупреждает о вопросе ABI, который разобран ниже: parse возвращает по значению 12-байтовую структуру Option, а это как раз тот диапазон размеров, где два ABI для x64 согласны насчёт размера структуры, но возвращают её разными способами.
У каждого файла своя задача:
frame.c— сгенерированный C. Это продукт сборки, а не исходник для правки, но его намеренно делают читаемым. Если нужно узнать, во что обходится та или иная конструкция, смотрите сюда.frame.kh— сгенерированный заголовок. Это и есть шов: он объявляет типы и экспортируемые функции, и именно его подключает ваш код на C.frame.o— объектный файл.kola.h— среда выполнения (вспомогательный код, нужный любой программе на kola: выделение памяти, обработка трапов и прочее). Он копируется рядом с результатами сборки, и его подключает каждый сгенерированный заголовок. Это один самодостаточный заголовок, к которому не нужно компоновать никакой библиотеки; в нём куча, путь обработки трапа, функции для срезов и таблицы для разных архитектур. Вkola_asm.Sлежат ассемблерные реализации, которыми пользуются некоторые функции std.
На результат влияют флаги:
-o <dir>записывает файлы в указанный каталог, а не вbuild/рядом с исходником. Две сборки с разными каталогами-oдруг другу не мешают, даже если исходный файл один и тот же. Этим флагом стоит пользоваться в первую очередь.--freestandingсобирает для голого железа: без libc, со статической кучей и хуками, которые предоставляете вы. Об этом ниже.--target <triple>передаёт--target=<triple>тому же компилятору C (он должен этот флаг понимать; clang понимает). Сборка только компилирует, поэтому для чужой платформы она доходит до того же места, что и для родной: до объектного файла.--link <path>вдобавок собирает исполняемый файл; для этого нужнаfn main. На x86-64 и AArch64 сначала пробуются собственный бэкенд и компоновщик kola, без всякого компилятора C. Программу, от которой собственный бэкенд отказался, собирает cc с флагами, которые подставляет сама kola, так что нет вручную скопированной строкиcc, которая могла бы с ними разойтись. Лежащий рядом.oостаётся без точки входа. Вместе с--freestandingи с--crossэтот флаг отклоняется, и в каждом случае компилятор называет причину.
Без --link команда kola build никогда не компонует: она компилирует с -c и останавливается. Во встраиваемой сборке скрипт компоновщика, точка входа и раскладка секций принадлежат хосту, и компилятор, который попытался бы ими распоряжаться, только мешал бы.
Заголовок
В out/frame.kh около 830 строк, и почти все они — общие перечисления ошибок из std (OsErr, NetErr, FsErr, DevErr и другие). Каждый заголовок выпускает их заново, пользуется ими библиотека или нет. Они защищены от повторного включения, так что стоят только времени на чтение. Вот начало файла и та часть, которая относится к этой библиотеке:
/* kola-provenance 1: what this file was generated FROM.
*
* Generated by kola; do not edit. Deterministic: the same inputs produce
* this block byte for byte on any machine, from any directory. UNPINNED
* marks a source that has no version by construction (a working tree);
* the sha256 beside it is of the source that was actually compiled.
*
* compiler kola 0.1.0 card v0.234 std sha256:10be4338…
* closure sha256:2f4b969b… (3 module(s), 0 embedded file(s))
*
* root std embedded in kola - covered by the `compiler` line above
*
* module frame.kl <entry> sha256:eb18b335…
* module std::encoding::bytes std sha256:b71b8427…
* module std::num::bits std sha256:ad31cce2…
*/
#ifndef KOLA_KH_6D8947763A893BF7
#define KOLA_KH_6D8947763A893BF7
#include "kola.h"
struct OsErr;
struct NetErr;
/* ... предварительные объявления, затем каждое перечисление std: тело под защитой,
его проверки раскладки и защита для макроса каждого варианта ... */
#ifndef KOLA_T_struct_k_frame__Header
#define KOLA_T_struct_k_frame__Header
struct k_frame__Header {
uint16_t magic;
uint16_t len;
uint32_t crc;
};
#endif /* KOLA_T_struct_k_frame__Header */
_Static_assert(sizeof(struct k_frame__Header) == 8, "kola ABI: sizeof(struct k_frame__Header) -- the target C compiler disagrees with kola layout");
_Static_assert(_Alignof(struct k_frame__Header) == 4, "kola ABI: _Alignof(struct k_frame__Header)");
_Static_assert(offsetof(struct k_frame__Header, magic) == 0, "kola ABI: k_frame__Header.magic moved");
_Static_assert(offsetof(struct k_frame__Header, len) == 2, "kola ABI: k_frame__Header.len moved");
_Static_assert(offsetof(struct k_frame__Header, crc) == 4, "kola ABI: k_frame__Header.crc moved");
#ifndef KOLA_T_KOpt_struct_k_frame__Header
#define KOLA_T_KOpt_struct_k_frame__Header
typedef struct { bool some; struct k_frame__Header val; } KOpt_struct_k_frame__Header;
#endif /* KOLA_T_KOpt_struct_k_frame__Header */
#ifndef KOLA_T_KSlice_uint8_t
#define KOLA_T_KSlice_uint8_t
typedef struct { uint8_t* ptr; size_t len; } KSlice_uint8_t;
#endif /* KOLA_T_KSlice_uint8_t */
#ifndef KOLA_BSTR_10_25B55AA00F3C6B36
#define KOLA_BSTR_10_25B55AA00F3C6B36
static KOLA_UNUSED const KArr_uint8_t_10 k_bstr_10_25B55AA00F3C6B36 = /* 10 байт строки "kola/frame" */;
#endif /* KOLA_BSTR_10_25B55AA00F3C6B36 */
_Static_assert(sizeof(struct k_frame__Header) == 8, "kola layout_assert[Header]: claimed sizeof == 8");
_Static_assert(_Alignof(struct k_frame__Header) == 4, "kola layout_assert[Header]: claimed _Alignof == 4");
KOpt_struct_k_frame__Header k_frame__parse(KSlice_uint8_t buf);
bool k_frame__body_ok(KSlice_uint8_t buf, struct k_frame__Header* h);
KSlice_uint8_t k_frame__name(void);
#endif /* KOLA_KH_6D8947763A893BF7 */Строки #ifndef/#define/#endif — защита от повторного включения (include guard), стандартный приём C: содержимое заголовка учитывается один раз, даже если его подключили несколько раз. Почему kola выпускает их так много, объясняет раздел о двух библиотеках. Три последних объявления и есть весь открытый интерфейс. crc_of в файле нет, потому что она не pub; по той же причине нет и MAGIC. Константа модуля без pub определяется только в .c: в заголовке она стала бы static const, на которой две библиотеки могли бы столкнуться в одной единице трансляции. Если константа нужна потребителю на C, пометьте её pub.
Комментарий в начале — запись о происхождении. С такой записи начинается каждый выпущенный .c и .kh: версия компилятора, версия карты, которую он реализует, и хеш встроенной std; по строке на каждый модуль с sha256 его исходника; и одна строка closure sha256: — хеш всей записи. Если вы храните сгенерированный C в репозитории, файл сам ответит, из чего он сделан, а две сборки сравниваются по строке closure. В записи нет ни отметки времени, ни имени машины, ни абсолютных путей, поэтому одни и те же входные данные на любой машине дают побайтно одинаковый результат. У корня с пометкой UNPINNED (например, рабочего дерева, указанного в kola.work) версии нет, поэтому вместо неё записан хеш содержимого; grep UNPINNED по закоммиченному .c покажет, какие входные данные ничем не закреплены. Флага, отключающего эту запись, нет.
Какие типы kola видны вызывающему на C
Составные типы kola превращаются в структуры C с механически построенными именами, так что сигнатуру можно предсказать, ещё не заглянув в заголовок:
kola | C | примечания |
|---|---|---|
|
| как есть |
|
| разрядность целевой платформы |
|
| указатель и длина, всегда вместе |
|
| обёрнут, чтобы передаваться по значению |
|
| поля |
|
| сначала читайте |
|
| сначала читайте |
|
| поле в поле, с проверками раскладки |
|
| значения вариантов — макросы |
|
| представление, без завершающего NUL |
| непрозрачные | через границу не передаются |
Владеющие значения kola плохо переносят границу. Срез её пересекает, потому что это окно в память, которой вызывающий и так владеет. Vec живёт в регионе или в heap, и у вызывающего на C нет способа рассуждать о его времени жизни. Если нужно отдать данные в C, работает такая схема: kola заполняет буфер, который предоставил вызывающий.
Не исчезает на шве и параметр-регион. (Регион — это блок памяти, который kola освобождает целиком в заранее известной точке; см. Память: регионы и ссылки.) Функция, выделяющая память в регионе вызывающего, обязана назвать этот регион в типе параметра, и в C регион превращается в дополнительный аргумент:
// Регион вызывающего пересекает шов как дополнительный параметр.
pub fn fill[r](out: &r mut Vec[u64], n: usize) !{alloc(r)}
req n <= 1000usize
{
for i in 0..n { out.push(widen[u64](i)); }
}
// Регион остаётся внутри: обычная сигнатура C.
pub fn build(n: usize) -> usize !{}
req n <= 1000usize
{
let mut out = 0usize;
region r {
let v: &r mut Vec[u64] = alloc in r mut Vec::new();
for i in 0..n { v.push(widen[u64](i)); }
out = v.len;
}
out
}/* pool.kh */
void k_pool__fill(KOLA_UNUSED KRegion* k_rgn_r, KVec* out, size_t n);
size_t k_pool__build(size_t n);fill перекладывает на сторону C ответственность за регион kola и за Vec kola, а на шве такое нужно редко. build держит регион внутри экспортируемой функции, и её сигнатуру программист на C легко удержит в голове.
Синтезированные типы возврата и ABI
KOpt_struct_k_frame__Header никто не объявлял. Этот тип синтезировала kola, компилирует его C, а то, как небольшая структура возвращается по значению, решает ABI целевой платформы, а не kola и не заголовок. Пока обе стороны собраны одним компилятором для одной платформы, этого не видно. Если нет, может больно ударить. Когда-то перечисление без полей превращалось в структуру с пустым объединением (union), которое в SysV занимает 0 байт, а в ABI Microsoft — 1 байт. В итоге у любой структуры с полем-перечислением раскладка зависела от платформы, а тестовый стенд, компилировавший только под SysV, этого не замечал. Примечание, которое kola build напечатал выше, нужно ради того же класса проблем. Если вы встраиваете kola, знайте, какие типы пересекают вашу границу, и составьте их список.
Закрепить раскладку, от которой зависит вызывающий на C, позволяет layout_assert. Это элемент уровня модуля, заявляющий размер и выравнивание типа, а C-бэкенд превращает его в _Static_assert — выше это две строки layout_assert[Header]. Интерпретатор считает его пустой операцией, потому что раскладки C у него нет. Подробно layout_assert разобран в главе Голое железо: узлы устройств, freestanding-сборки и layout-утверждения.
Символы
Имя функции kola в C складывается из k_, имени единицы компиляции и имени самой функции, причём каждый знак, кроме букв и цифр, заменяется на _. Единица — это то, на что вы указали kola: объявленный в пакете [pack] entry даёт <pack>, любой другой файл пакета — <pack>::<module path>, а .kl без манифеста над ним — имя файла без расширения. Так parse из frame.kl становится k_frame__parse, а std::encoding::bytes::rd16 — k_std__encoding__bytes__rd16. Единица зависит от исходника, а не от того, где он лежит в файловой системе: пакет, подключённый через символическую ссылку, даёт те же символы, что и пакет в настоящем каталоге.
На деле из объектного файла выходит гораздо меньше (это объект ELF для Linux; Mach-O добавляет в начало имени подчёркивание):
$ nm -g out/frame.o
U exit
U __fprintf_chk
U free
U _GLOBAL_OFFSET_TABLE_
00000000000001f0 T k_frame__body_ok
0000000000000240 T k_frame__name
0000000000000170 T k_frame__parse
U __longjmp_chk
U stderrT отмечает символ, который этот объект определяет и экспортирует; U — символ, которым объект пользуется и который компоновщик должен найти в другом месте (здесь это несколько функций libc, которые вызывает среда выполнения в hosted-режиме). Экспортируемых функций три, ровно столько, сколько pub-функций. crc_of и вспомогательные функции std вкомпилированы с внутренним связыванием (static в C: видны только внутри этого объекта) и в списке не появляются. Всё это следует из одного правила: связывание следует за видимостью. Из него вытекает следующее:
Функция без
pubвыпускается какstatic.Импортированные функции std выпускаются как
staticв каждой единице трансляции, которая ими пользуется, поэтому два отдельно собранных объекта kola, использующиеstd::encoding::bytes, компонуются вместе без ошибокmultiple definition.Выпускается только достижимое. Объект библиотеки содержит всё, что достижимо из её собственных
pub-элементов (не из std). Программа, то есть файл сfn main, содержит всё, что достижимо изmainи изpub-функций входного файла.
Это отсечение можно увидеть своими глазами:
use std::log;
pub fn reachable(x: u32) -> u32 !{} { x + 1u32 }
pub fn also_pub_never_called(x: u32) -> u32 !{} { x + 2u32 }
fn private_and_dead(x: u32) -> u32 !{} { x + 3u32 }
fn main(sys: Sys) -> u8 !{log} {
let v = reachable(41u32);
log::info(f"v={v}");
0
}$ kola build -o outprog prog.kl
build ok: 4 native fn(s), 2 exported item(s) (2 fn, 0 const, 0 type) → outprog/prog.o
reachable
also_pub_never_called
main
std::log::info
$ nm -g outprog/prog.o | grep ' T '
0000000000000190 T k_prog__also_pub_never_called
0000000000000170 T k_prog__reachableprivate_and_dead до генерации кода так и не дошла: она и недостижима из main, и не pub. also_pub_never_called уцелела, хотя её никто не вызывает, потому что pub-функции входного файла — это опубликованный вами ABI.
Каждое экспортируемое имя несёт свою единицу, поэтому два отдельно собранных пакета, в каждом из которых объявлена pub fn header_sane, выпускают k_a__header_sane и k_b__header_sane и спокойно компонуются вместе. Ключа в манифесте, которым можно было бы выбрать префикс, нет. Исключения — main и общие перечисления std (NetErr, FsErr и остальные): у них одно имя как раз для того, чтобы два заголовка в одной единице трансляции могли ими делиться. Две единицы компиляции с одинаковым именем, например два файла cfg.kl в разных каталогах, всё равно получают одинаковый префикс, так что давайте единицам разные имена.
Кому принадлежит main
k_main в том списке тоже нет. Точка входа kola выпускается как static, а настоящая main для C спрятана за макросом:
static KOLA_UNUSED uint8_t k_main(KOLA_UNUSED KSys sys) { /* ... */ }
#ifdef KOLA_MAIN
int main(int argc, char** argv){ (void)argc; (void)argv;
/* ... */
KSys s=k_sys_new(0, k_run_seed()); return (int)k_main(s); }
#endifПоэтому один и тот же объект годится для обоих случаев, а выбор делается при компоновке:
# хост оставляет свою main и вызывает kola как библиотеку
$ cc -I. -o drive drive.c prog.c && ./drive
42
# или берём точку входа kola
$ kola build --link outprog/kmain prog.kl
build ok: 1 native fn(s) → outprog/kmain (own Linux-x64 executable, 6064 bytes)
backend: own LIR/x86-64; linker: own ELF; no external compiler
$ ./outprog/kmain
[info] v=42Контракт и есть граница
Именно это даёт kola право на место в чужом образе. req — предусловие: то, что обязан гарантировать вызывающий, например минимальную длину буфера. pub fn с req проверяет его в самом начале экспортируемого символа. Внутри kola места вызова, где предусловие доказано, проверку пропускают: для них компилятор выпускает тело __nr без проверки. Обе половины видны на вспомогательной функции std rd16, которую parse вызывает, доказав границы:
KOpt_struct_k_frame__Header k_frame__parse(KSlice_uint8_t buf) {
if (!(/* buf.len >= 8 */)) k_trap_k(K_TRAP_REQ, "error[card §8]: req violated calling `parse`\n --> frame.kl:29");
uint16_t m = k_std__encoding__bytes__rd16__nr(buf, (size_t)0ull);
/* ... */
}
static uint16_t k_std__encoding__bytes__rd16__nr(KSlice_uint8_t buf, size_t off) { /* без проверки */ }
static uint16_t k_std__encoding__bytes__rd16(KSlice_uint8_t buf, size_t off) {
if (!(/* off + 2 <= buf.len */)) k_trap_k(K_TRAP_REQ, "error[card §8]: req violated calling `std::encoding::bytes::rd16`\n --> std::encoding::bytes:79");
return k_std__encoding__bytes__rd16__nr(buf, off);
}Вызывающий на C ничего не доказывает, поэтому всегда входит через проверку. Вот программа на C, которая ошибается с длиной:
#include <stdio.h>
#include "frame.kh"
int main(void) {
uint8_t wire[4] = { 0x4b, 0x4c, 0x00, 0x03 };
KSlice_uint8_t s = { .ptr = wire, .len = sizeof wire }; /* 4, а не 8 */
KOpt_struct_k_frame__Header h = k_frame__parse(s);
printf("returned some=%d (we never get here)\n", (int)h.some);
return 0;
}$ cc -I. -o short short.c frame.o && ./short
trap: error[card §8]: req violated calling `parse`
--> frame.kl:29
$ echo $?
101Без проверки вызов прочитал бы четыре байта за концом массива на стеке и вернул бы правдоподобный на вид Header. С проверкой программа детерминированно останавливается и называет функцию и правило. Это тот же req, что и в главе Контракты, трапы и тесты, и на границе языков он никуда не исчезает.
Флага, убирающего проверку, нет. Такой флаг означал бы «доверяем внешнему C соблюдать контракт, который никто не проверяет», а req существует ровно для того, чтобы этого не было. Написали pub — получили проверку. Обратная сторона: экспортировать функцию, не сделав её pub, нельзя. Если тестовому стенду на C нужно вызывать то, чего не должен вызывать ваш код на kola, вынесите это в отдельный модуль, объявите там как pub и нигде этот модуль не импортируйте.
Две библиотеки в одной единице трансляции
Единица трансляции — один файл .c вместе со всем, что он подключает; компилятор C обрабатывает каждую из них отдельно. Программа на C, в которую встроили одну библиотеку kola, обычно со временем получает и вторую, а потом какой-нибудь заголовок подключает обе или одну из них дважды — через цепочку включений, которую никто не рисовал. Работает и то и другое:
#include <stdio.h>
#include "frame.kh"
#include "tag.kh"
#include "frame.kh" /* ещё раз, как сделала бы транзитивная цепочка */
int main(void) {
uint8_t wire[8] = { 0x4b, 0x4c, 0x00, 0x03, 0xde, 0xad, 0xbe, 0xef };
KSlice_uint8_t s = { .ptr = wire, .len = sizeof wire };
KOpt_struct_k_frame__Header h = k_frame__parse(s);
struct k_tag__Tag t = k_tag__read_tag(s);
printf("header magic=%04x tag kind=%u slot=%u\n", h.val.magic, t.kind, t.slot);
return 0;
}$ cc -I. -o both both.c frame.o tag.o && ./both
header magic=4b4c tag kind=75 slot=76Работают здесь два механизма, потому что решают они разные задачи:
Защита на уровне типа,
#ifndef KOLA_T_<cname>, вокруг тела каждого типа. Защита целого блока подавила бы и собственные типы второй библиотеки, а сталкиваются именно отдельные типы. Предварительные объявления остаются без защиты, ведьstruct X;можно повторять.Защита всего файла,
#ifndef KOLA_KH_<hash>, с ключом по содержимому, — на случай, когда один и тот же заголовок подключён дважды. Ключ берётся из содержимого, потому что генератор не знает, как вы назовёте файл, а два заголовка с одинаковым именем из разных пакетов не должны подавлять друг друга. Используются#ifndef/#define, поскольку#pragma onceне входит в ISO C.
Почему проверки раскладки стоят вне защиты
У защиты с ключом по имени типа есть отказ похуже той проблемы, которую она решает. Если две единицы определяют каждая свой Cfg с разными полями (скажем, два файла cfg.kl в разных каталогах), защита убирает второе определение, и вызовы второй библиотеки работают с раскладкой первой. Получается тихое расхождение ABI.
Поэтому каждый _Static_assert в заголовке стоит вне защиты своего типа. Защита подавляет повторное определение, а проверки всё равно выполняются — против того определения, которое победило:
# a/cfg.kl: pub struct Cfg { a: u64, b: u64 } -> 16 байт
# b/cfg.kl: pub struct Cfg { a: u32 } -> 4 байта
$ cc -I. -c clash.c
In file included from clash.c:2:
cfg_b.kh:796:1: error: static assertion failed: "kola ABI: sizeof(struct k_cfg__Cfg) -- the target C compiler disagrees with kola layout"Ошибка возникает в месте подключения и называет тип. (Clang добавляет expression evaluates to '16 == 4'.)
Байтовые строковые литералы
Литерал &b"..." становится в заголовке массивом static const, имя которого построено по содержимому: k_bstr_<len>_<fnv1a64-hex>. Длина входит в имя, чтобы двум разным байтовым строкам достался один идентификатор лишь в случае, когда совпадут и 64-битный хеш, и длины. У каждого определения своя защита #ifndef KOLA_BSTR_<key>, поэтому две библиотеки, в каждой из которых есть b"kola/frame", выпускают одно и то же объявление, и в единице трансляции остаётся одна копия. В отличие от типа, байтовая строка не может разойтись сама с собой, так что проверки рядом с этой защитой не нужны. Отдельно скомпилированные объекты по-прежнему несут собственные копии, но это стоит лишь нескольких байт.
Голое железо: что предоставляет хост
--freestanding собирает для образа без libc: без stdio, без malloc, без среды выполнения C. Средой выполнения по-прежнему служит kola.h, но её freestanding-ветка даёт статическую кучу и вызывает хуки, которые предоставляете вы. Ссылка на каждый хук появляется, только если он нужен программе:
void k_panic(const char *msg)— если после прувера остаётся путь к трапу. Это обработчик трапа, из которого нет возврата (записать в журнал и остановиться или сообщить об ошибке гостю); сюда попадает любое нарушение контракта, границ или переполнение.uint64_t k_now_ns(void)иint64_t k_now_wall_us(void)— если программа читаетClock.void k_io_write(const char *s)— если модуль скомпилирован с-DKOLA_LOG_HOOKи программа пишет в журнал. Без этого макроса строки журнала отбрасываются.-DKOLA_HEAP_BYTES=<n>задаёт размер статического пула (по умолчанию 16 МиБ), а-DKOLA_REGION_DEFAULT=<n>— размер первого блока каждого региона (по умолчанию 1 МиБ). Оба значения должны совпадать во всех единицах трансляции образа.
Приёмочный тест — nm -u
Каждый объектный файл перечисляет символы, которыми пользуется, но которых не определяет, и каждый из них компоновщик должен где-то найти, собирая итоговый образ. nm -u печатает этот список. У гипервизора нет процесса, который можно было бы просто убить, поэтому полезный вопрос звучит так: ведёт ли из объекта хоть один путь в панику? Это вопрос о том, каким объект видит компоновщик, и nm -u на него отвечает. У библиотеки frame такой путь один, потому что parse объявлена как pub и у неё есть req:
$ kola build --freestanding -o outfs frame.kl
$ nm -u outfs/frame.o
U k_panicЭто единственный неопределённый символ, и из libc не нужно ничего. Модуль, у которого не осталось обязательств на время выполнения, не ссылается ни на что:
pub fn mix(a: u32, b: u32) -> u32 !{} {
(a ^ b) | 1u32
}$ kola build --freestanding -o outp pure.kl
$ nm -u outp/pure.o
(nothing)Пустым получается и модуль, чей req снят во всех местах вызова: тогда его проверяющий вход становится мёртвым static-кодом, который оптимизатор удаляет вместе со ссылкой на k_panic, а импортированные функции std локальны для единицы. Сделайте список неопределённых символов проверкой в своей сборке: запускайте nm -u для каждого объекта kola, попадающего в образ, и проваливайте сборку на любом символе, которого нет в вашем списке разрешённых. Компилятор C может сам синтезировать из цикла вызов memcpy или memset (для freestanding-сборок kola.h определяет обе функции), поэтому проверяйте каждый объект, а не полагайтесь на предположения.
Измеряем кучу
Freestanding-программа выделяет память из одного статического пула на образ: фиксированного блока памяти, отведённого при компоновке, из которого память выдаётся сдвигом указателя вперёд. Каждый region нарезает из него блоки, а закрывающийся регион откатывает пул, если всё выше его отметки принадлежит ему. Хост может прочитать учёт:
size_t used = k_heap_used_bytes(); /* взятые блоки регионов и куски роста */
size_t total = k_heap_total_bytes(); /* размер пула после компоновки */Вот хост, который читает пул, из которого выделяла память библиотека kola, — с функцией build из примера выше:
/* board.c: единица трансляции, отдельная от сгенерированного pool.c */
#include <stdio.h>
#include <stdlib.h>
#include "pool.kh"
void k_panic(const char *msg) { fprintf(stderr, "PANIC: %s\n", msg); exit(1); }
int main(void) {
printf("pool total = %zu bytes, used before = %zu\n",
k_heap_total_bytes(), k_heap_used_bytes());
size_t n = k_pool__build(200);
printf("built %zu elements; used after = %zu bytes\n", n, k_heap_used_bytes());
return 0;
}$ kola build --freestanding -o ofs pool.kl
$ cd ofs
$ cc -O2 -DKOLA_FREESTANDING -DKOLA_HEAP_BYTES=65536 -I. -o board board.c pool.c && ./board
pool total = 65536 bytes, used before = 0
PANIC: bare heap exhaustedДвести значений u64 — это 1600 байт данных, но каждый регион просит у пула целый блок, а блок по умолчанию занимает 1 МиБ. Уже один регион не помещается в пул на 64 КиБ. Подберите размер блока под целевую платформу, и всё поместится:
$ cc -O2 -DKOLA_FREESTANDING -DKOLA_HEAP_BYTES=65536 -DKOLA_REGION_DEFAULT=8192 -I. -o board board.c pool.c && ./board
pool total = 65536 bytes, used before = 0
built 200 elements; used after = 0 bytesused after равен нулю, потому что регион внутри build закрылся и пул откатился следом. Компилируйте с оптимизацией, как здесь: при -O0 неиспользуемые функции часов в kola.h остаются живыми, и компоновщик требует k_now_ns, хотя программа часов не читает.
Шов при кросс-компиляции
--target <triple> передаёт триплет компилятору C. В шве ничего не меняется, просто объектный файл предназначен для другой машины:
$ KOLA_CC=clang kola build --freestanding --target aarch64-unknown-linux-gnu -o outx frame.kl
build ok (freestanding, cross --target=aarch64-unknown-linux-gnu, compile-only): 7 native fn(s), 4 exported item(s) (3 fn, 0 const, 1 type) → outx/frame.o
$ file outx/frame.o
outx/frame.o: ELF 64-bit LSB relocatable, ARM aarch64, version 1 (SYSV), not stripped
$ nm -u outx/frame.o
U k_panicВ ELF (формате объектных файлов Linux и большинства инструментариев для голого железа) пул по умолчанию на 16 МиБ лежит в .bss — секции для данных, инициализированных нулями, которую загрузчик выделяет при запуске, а не хранит в файле. Поэтому пул занимает память во время выполнения, но не добавляет ни байта к объектному файлу (frame.o выше весит около 3 КБ). Пул — слабое определение (компоновщик может слить его с другими определениями того же имени), а Mach-O, формат macOS, не умеет помещать слабое определение в bss, и там массив превращается в настоящие байты объектного файла. Собирая freestanding на Mac, передавайте небольшой -DKOLA_HEAP_BYTES.
kola build --cross --target <arch> — другой путь, и он упомянут здесь, чтобы за него не схватились по ошибке. Он пишет объектный файл собственным бэкендом kola, без компилятора C, что полезно там, где cc нет. .kh он не пишет, потому что C в этой цепочке нет, и охватывает меньшую часть языка:
$ kola build --cross --target x86_64 --format elf -o outc/frame.o frame.kl
build ok: 25 native fn(s), 9 skipped → outc/frame.o (x86_64/elf, 13200 bytes)
skipped `parse`: return `Option[struct#0]` is outside native-m2
skipped `body_ok`: slot `h`: `&struct#0` is outside native-m2
skipped `name`: return `&[u8]` is outside native-m2
...Пропущены все pub-функции этой библиотеки. Для встраивания в C путь один — C-бэкенд.
Направление, которого нет
Вызвать C из kola нельзя. Для этого нет даже синтаксиса:
extern "C" fn host_tick() -> u64;error[card §1]: expected an item, found identifier `extern`
--> ffi.kl:1:1
|
1 | extern "C" fn host_tick() -> u64;
| ^^^^^^
= help: items: fn, struct, resource struct, enum, impl, const, test, use, mod, layout_assertextern — не зарезервированное слово, отложенное на будущее, и типа «сырой указатель» тоже нет. Каждая функция kola находится внутри проверяемого мира, и поэтому рецензент может доверять сигнатуре: граница FFI была бы местом, где эффекты, регионы и контракты перестают что-либо значить.
Нужны ли kola FFI к C или ассемблерные вставки, чтобы быть быстрой, проверяли измерениями — на медиакодеках и на TLS. В обоих случаях отставание удалось устранить внутри kola: недостающие SIMD-интринсики оказались обычной работой над компилятором, а большое отставание в рукопожатии TLS свелось к одной процедуре умножения в конечном поле, которую исправило переписывание цикла.
Остаются три узаконенных способа:
C вызывает kola, как в этой главе.
kola запускает другую программу как процесс (
sys.proc, см. главу Командная строка). Готовый кодировщик переиспользуют через порождённый процесс, а не через скомпонованный вызов.Для горячей функции, которой нужна определённая последовательность инструкций, предложение
hwназывает символ на C или ассемблере для конкретной архитектуры — только для этой одной функции. Определением остаётся тело на kola: его выполняет интерпретатор и его доказывает чекер,kola test --nativeсравнивает обе реализации, а--no-hwотключает внешнюю. Символ должен быть листовой функцией, и через границу проходят только скаляры, массивы и срезы скаляров. Правила описаны в SPEC-CARD §8.
Если кажется, что выйти из kola нужно ради тестирования, например чтобы построить дифференциальный тест против реализации на C, подумайте, не сможет ли оракулом послужить корпус входных данных с ожидаемыми результатами. Обычно может, и тогда на пути нет никакого чужого кода.
Контрольный список для встраивания kola
Собирайте с
-o <dir>и держите сгенерированные файлы вне дерева исходников.Подключайте
.kh, но никогда не.c. Сам.cкомпилируйте (или компонуйте.o) как ещё одну единицу своей сборки.Составьте список типов в вашем открытом интерфейсе и закрепите каждый через
layout_assert. Ничего не предполагайте о синтезированном возвращаемомKOpt_илиKRes_, пока не проверите его на настоящем ABI целевой платформы.Сверяйте в сборке вывод
nm -uс явным списком разрешённых символов.Держите регионы внутри kola.
KRegion*в экспортируемой сигнатуре делает сторону C ответственной за время жизни, которым управляет kola.Размер кучи определяйте по пику: снимайте
k_heap_used_bytes()под реальной нагрузкой, по нему задавайтеKOLA_HEAP_BYTES, аKOLA_REGION_DEFAULTвыбирайте осознанно.Давайте единицам компиляции разные имена. У двух файлов с одинаковым именем или двух пакетов с одинаковым названием префикс всех экспортируемых символов совпадает.