Вызов C из Mojo
На C написано почти всё, что лежит под современными программами: системные вызовы, сжатие, шифрование, работа с изображениями и звуком. Переписывать это незачем — Mojo умеет звать C напрямую.
В отличие от Python, здесь нет интерпретатора посередине, и вызов стоит почти ничего. Зато и страховки нет почти никакой — об этом большая часть главы.
Функция из libc: external_call
Заголовок раздела «Функция из libc: external_call»Стандартная библиотека C уже подключена к любой программе на Mojo, и звать её функции можно без подготовки:
from std.ffi import external_call, c_int, c_double
def main(): print(external_call["abs", c_int](c_int(-7))) print(external_call["sqrt", c_double](c_double(2.0))) print(external_call["getpid", c_int]() > 0)7 1.4142135623730951 True
В квадратных скобках — имя функции и тип результата, в круглых — аргументы. Типы аргументов компилятор выводит сам из того, что вы передали.
Строки: где кончается строка C
Заголовок раздела «Строки: где кончается строка C»В C у строки нет длины. Строка — это адрес первого байта, а конец
отмечен нулевым байтом. Функция вроде strlen просто идёт по памяти,
пока не наткнётся на ноль.
У String в Mojo для этого есть специальный метод — он гарантирует,
что ноль на месте:
from std.ffi import external_call, c_size_t
def main(): var word = String("привет") var n = external_call["strlen", c_size_t](word.as_c_string_span().ptr()) print(n, word.byte_length())12 12
Двенадцать, а не шесть: C считает байты, а каждая кириллическая буква
в UTF-8 занимает два. Ровно то же самое говорит byte_length() — подробно
об этом в главе Строки.
Ловушка: срез без нуля
Заголовок раздела «Ловушка: срез без нуля»Вот место, где легко получить неправильный ответ без единой ошибки. Срез строки в Mojo не копирует данные — он указывает внутрь исходной строки. И нуля после него нет:
from std.ffi import external_call, c_size_t
def main(): var text = String("abcdef") var view = text[byte=0:3] print(view, view.byte_length()) print(external_call["strlen", c_size_t](view.unsafe_ptr()))abc 3 6
Срез — три байта. А C насчитал шесть: после abc нуля нет, и strlen
пошёл дальше, в def, пока не нашёл ноль в конце исходной строки.
Здесь повезло: ноль нашёлся в пределах своей памяти. В другой раз C прочитает чужие данные — или программа упадёт.
Своя библиотека: OwnedDLHandle
Заголовок раздела «Своя библиотека: OwnedDLHandle»Функции libc доступны сразу. Для остальных библиотек её нужно
загрузить. Пусть есть файл vec.c:
#include <stddef.h>
double dot(const double *a, const double *b, size_t n) { double s = 0.0; for (size_t i = 0; i < n; i++) s += a[i] * b[i]; return s;}
int add(int a, int b) { return a + b; }Собираем его в разделяемую библиотеку:
gcc -O2 -shared -fPIC vec.c -o libvec.soИ зовём из Mojo:
from std.ffi import OwnedDLHandle, c_int, c_double, c_size_t
def use_library() raises: var lib = OwnedDLHandle("./libvec.so")
var add = lib.get_function[c_int]("add") print(add(c_int(2), c_int(3)))
var a: List[Float64] = [1.0, 2.0, 3.0] var b: List[Float64] = [4.0, 5.0, 6.0] var dot = lib.get_function[c_double]("dot") print(dot(a.unsafe_ptr(), b.unsafe_ptr(), c_size_t(3)))532.0Массивы передаются так же, как в C: указателем на первый элемент
и отдельно длиной. List хранит числа подряд, без обёрток, поэтому
его unsafe_ptr() — ровно то, чего ждёт функция на C.
Самое опасное: компилятор вам верит
Заголовок раздела «Самое опасное: компилятор вам верит»Вот главное, что нужно знать о вызове C. Mojo не видит исходник функции на C и не может проверить, правильно ли вы описали её тип. Что вы написали — тому он и верит.
Функция dot возвращает double. Ошибёмся и объявим int:
var dot = lib.get_function[c_int]("dot") # на самом деле doubleprint(dot(a.unsafe_ptr(), b.unsafe_ptr(), c_size_t(3)))3Не 32, не ошибка, не предупреждение — просто три. Программа
собралась, запустилась и спокойно выдала чушь.
Откуда тройка
Заголовок раздела «Откуда тройка»На x86-64 целый результат функция кладёт в регистр rax, дробный —
в xmm0. Функция dot честно положила ответ в xmm0, а мы попросили
прочитать rax. Что же там лежит? Дизассемблер показывает цикл
внутри dot:
xor %eax,%eax ; i = 0 — счётчик цикла живёт в rax...add $0x1,%rax ; i += 1cmp %rax,%rdx ; i == n ?jne ...movapd %xmm1,%xmm0 ; ответ — в xmm0После цикла счётчик i равен n, то есть трём. Его мы и прочитали.
И это число — не свойство программы, а случайность сборки. Та же библиотека, собранная с другими флагами:
Флаги gcc | n = 3 | n = 5 | n = 7 |
|---|---|---|---|
-O2 | 3 | 5 | 7 |
-O3 -march=native | 2 | 4 | 6 |
Мусор не просто неправильный — он правдоподобный. Число, похожее на размер данных, легко принять за осмысленный ответ.
Аргументы тоже не проверяются
Заголовок раздела «Аргументы тоже не проверяются»То же самое с типами аргументов. Функция add ждёт два int. Передадим
два дробных:
var add = lib.get_function[c_int]("add")print(add(Float64(2.0), Float64(3.0)))1069267464Снова ни ошибки, ни предупреждения.
Как защититься
Заголовок раздела «Как защититься»Mojo сигнатуру не проверит — но проверить её можно, причём тем, кто её знает точно: компилятором C, по настоящему заголовочному файлу.
Пусть у библиотеки есть заголовок vec.h:
#include <stddef.h>double dot(const double *a, const double *b, size_t n);int add(int a, int b);Рядом кладём маленький файл, который повторяет ту сигнатуру, что мы записали в Mojo:
#include "vec.h"
_Static_assert(__builtin_types_compatible_p(__typeof__(dot), double(const double *, const double *, size_t)), "dot: signature mismatch");gcc -fsyntax-only check_signatures.cСовпало — тишина. Ошиблись, написав int вместо double, — сборка
падает:
error: static assertion failed: "dot: signature mismatch"Такой файл стоит держать рядом с обёрткой и прогонять в CI: если библиотека обновится и сигнатура изменится, узнаете сразу.
Сколько стоит вызов
Заголовок раздела «Сколько стоит вызов»В главе Вызов Python из Mojo переход
границы стоил около 300 наносекунд. Для C тот же замер — тысяча
вызовов функции add из своей библиотеки — даёт:
| Куда | Наносекунд на вызов |
|---|---|
| функция из библиотеки на C | ≈ 2 |
| функция на Python | ≈ 300 |
Разница — примерно в полтораста раз. Вызов C — это обычный машинный переход по адресу: ни упаковки в объекты, ни интерпретатора.
Поэтому правило «не зовите в горячем цикле» для C почти не действует: пару наносекунд на вызов можно себе позволить там, где триста — уже нельзя.
Всё вместе
Заголовок раздела «Всё вместе»Пример использует только libc — она есть везде, собирать ничего не нужно. Каждый результат сверяется с Mojo или с заранее известным ответом.
# Вызовы стандартной библиотеки C из Mojo.## Всё здесь — функции libc: она есть в любой системе, ставить ничего# не нужно. Каждый результат сверяется с тем же расчётом на Mojo.
from std.ffi import external_call, c_int, c_double, c_size_tfrom std.math import sqrt
def main(): print("1. числа: C и Mojo должны совпасть") var x = c_double(2.0) var from_c = external_call["sqrt", c_double](x) print(" sqrt из C: ", from_c) print(" sqrt из Mojo:", sqrt(Float64(2.0))) print(" pow(2, 10): ", external_call["pow", c_double](c_double(2.0), c_double(10.0))) print(" abs(-42): ", external_call["abs", c_int](c_int(-42)))
print("2. строка в C: считаются байты, а не символы") var word = String("привет") var c_length = external_call["strlen", c_size_t](word.as_c_string_span().ptr()) print(" strlen: ", c_length) print(" byte_length: ", word.byte_length())
print("3. ловушка: срез без завершающего нуля") var text = String("abcdef") var view = text[byte=0:3] print(" срез:", view, "| его длина:", view.byte_length()) # unsafe_ptr() среза указывает внутрь "abcdef": после "abc" нуля нет, # и C читает дальше, пока не наткнётся на ноль исходной строки. print(" strlen по unsafe_ptr среза:", external_call["strlen", c_size_t](view.unsafe_ptr())) # Правильно: сделать из среза настоящую строку — у неё ноль на месте. var copy = String(view) print(" strlen по копии:", external_call["strlen", c_size_t](copy.as_c_string_span().ptr()))1. числа: C и Mojo должны совпасть sqrt из C: 1.4142135623730951 sqrt из Mojo: 1.4142135623730951 pow(2, 10): 1024.0 abs(-42): 42 2. строка в C: считаются байты, а не символы strlen: 12 byte_length: 12 3. ловушка: срез без завершающего нуля срез: abc | его длина: 3 strlen по unsafe_ptr среза: 6 strlen по копии: 3
🎯 Проверь себя
Что указывается в квадратных скобках `external_call[...]`, а что — в круглых?
В квадратных — имя функции и тип результата: external_call["sqrt", c_double]. В круглых — аргументы; их типы компилятор выводит сам.
Тип результата вывести нельзя: Mojo не видит исходник функции на C.
Почему `strlen` для строки «привет» возвращает 12, а не 6?
Потому что C считает байты, а не символы. В UTF-8 каждая кириллическая
буква занимает два байта. Mojo говорит то же самое через
byte_length().
Срез `text[byte=0:3]` от строки «abcdef» передали в `strlen` через `unsafe_ptr()` и получили 6. Почему?
Срез не копирует данные, а указывает внутрь исходной строки,
и после abc нуля нет. strlen идёт по памяти до первого нуля —
до конца abcdef. Правильно: превратить срез в строку String(view)
и передать через as_c_string_span().ptr().
Функцию, возвращающую `double`, по ошибке объявили с `c_int`. Что скажет компилятор?
Ничего. Mojo не видит исходник на C и верит объявлению. Программа
соберётся и вернёт мусор — в нашем замере 3 вместо 32.0: мы
прочитали регистр rax, где лежал счётчик цикла функции. С другими
флагами сборки число будет другим. Ловится это двумя способами:
проверкой сигнатуры компилятором C по заголовку и прогоном на данных
с известным ответом.
Как проверить, что сигнатура в Mojo совпадает с настоящей?
Компилятором C: в отдельном файле подключить настоящий заголовок
и написать _Static_assert(__builtin_types_compatible_p(__typeof__(f), тип_из_Mojo), "..."). Команда gcc -fsyntax-only упадёт, если
сигнатуры разошлись. Такую проверку стоит держать в CI.
Можно ли звать функцию на C внутри горячего цикла?
Как правило, да. Вызов стоит около двух наносекунд — это обычный машинный переход, без интерпретатора. Для сравнения, вызов Python стоит около трёхсот. Правило «не пересекать границу в цикле» относится к Python, а не к C.
Что дальше
Заголовок раздела «Что дальше»Упаковка и распространение: как собрать программу на Mojo так, чтобы она запустилась на чужой машине — и что для этого нужно положить рядом.
Тексты курса — CC BY-NC-SA 4.0, код примеров — Apache 2.0