Как стать автором
Обновить

Разработка модулей для Limbo на C (часть 1)

Время на прочтение 7 мин
Количество просмотров 3K
Модули для Limbo написанные на C так же иногда называют драйверами OS Inferno т.к. они встроены в ядро OS. Необходимость в таких модулях обычно вызвана либо желанием добавить к Limbo отсутствующую в Inferno функциональность (подключить существующие 3rd-party C/C++ библиотеки, дать доступ к специфичным для конкретной host OS syscall-ам) либо желанием выжать максимально возможную производительность (по моим наблюдениям разница в скорости между Limbo с включенным JIT и C примерно 1.3-1.5 раза, но иногда и это может оказаться критичным).

Содержание



Встраиваем модуль в ядро


К сожалению, пока в Inferno нет возможности динамически подгружать модули реализованные на C, поэтому приходится встраивать его прямо в ядро OS. (Это немного осложняет разработку, т.к. после каждого изменения приходится пересобирать Inferno. К счастью, необходимая частичная пересборка занимает примерно секунд 10.)

Для встраивания своего модуля необходимо модифицировать несколько файлов: libinterp/mkfile, module/runt.m, emu/Linux/emu и emu/Linux/emu-g. А поскольку каждый новый модуль пытается встраиваться в эти же файлы в одни и те же места, и таких модулей пользователь может захотеть добавить несколько, причём в неизвестном заранее порядке, то стандартная команда patch не сможет внести необходимые изменения. Один-два модуля она добавит, но со следующими у неё возникнет проблема т.к. редактируемое место в этих файлах начнёт слишком сильно отличаться от того, что она ожидала увидеть.

Для решения этой проблемы я набросал скриптик на Perl — в большинстве случаев достаточно изменить в нём название добавляемого модуля в строчке
my $MODNAME = 'CJSON';

и он внесёт необходимые изменения во все вышеупомянутые файлы встроив ваш модуль в ядро OS Inferno. В более сложных случаях, например когда необходимо подключить к Inferno дополнительные C/C++ библиотеки, этот скрипт придётся модифицировать под ваши нужды (пример такой модификации для подключения C++ библиотеки re2 можно увидеть в модуле Re2). Скрипт можно запускать с параметром -R для отката внесённых изменений.

Итак, скачиваем скрипт, кладём его в $INFERNO_ROOT, переименовываем в patch.example, изменяем в нём имя модуля на «Example», и запускаем. Теперь (несуществующий пока) модуль Example подключён к ядру, осталось его создать и пересобрать Inferno вместе с ним.

Для начала, создадим два файла:
  1. module/example.m
    Example: module
    {
            PATH: con "$Example";
    };
    
  2. libinterp/example.c
    #include <lib9.h>
    #include <isa.h>
    #include <interp.h>
    #include "runt.h"
    #include "examplemod.h"
    
    void
    examplemodinit(void)
    {
            builtinmod("$Example", Examplemodtab, Examplemodlen);
    }
    

И запустим пересборку OS Inferno:
$ (cd libinterp/; mk nuke)
$ rm Linux/386/bin/emu      # work around "text file busy" error
$ mk install

Теперь мы можем написать программу на Limbo, которая успешно подгрузит наш, пока ничего полезного не делающий, модуль:
  • testexample.b
    implement TestExample;
    include "sys.m";
    include "draw.m";
    include "example.m";
    
    TestExample: module
    {
            init: fn(nil: ref Draw->Context, nil: list of string);
    };
    
    init(nil: ref Draw->Context, nil: list of string)
    {
            sys := load Sys Sys->PATH;
            example := load Example Example->PATH;
            if(example == nil)
                    sys->print("fail to load Example: %r\n");
            else
                    sys->print("Example module loaded\n");
    }
    

И запустить:
$ emu
; limbo testexample.b
; testexample
Example module loaded
;

Как это работает

В процессе сборки файл module/example.m анализируется, и генерируются необходимые C-шные структуры описывающие этот модуль — в отдельном файле libinterp/examplemod.h — и весь его публичный интерфейс (константы, adt-шки, функции) — добавляются в файл libinterp/runt.h, содержащий информацию по всем C-модулям. Эти два .h-файла уже подключены к нашему libinterp/example.c.

Далее, в процессе загрузки OS Inferno будет однократно вызвана функция examplemodinit(), которая должна проинициализировать глобальные данные нашего модуля (если таковые есть) и подключить его (вызовом builtinmod(…)) к ядру Inferno. Вызов builtinmod() устанавливает связь между нашим модулем и псевдо-путём к нему $Example указанным в константе PATH, используемой из Limbo при загрузке этого модуля командой load.

Функции: приём параметров и возврат результата


Числа

Начнём с простых типов данных, чтобы не усложнять пример работой со ссылками.
  • module/example.m
    Example: module
    {
            ...
            increment: fn(i: int): int;
    };
    
  • libinterp/example.c
    ...
    void
    Example_increment(void *fp)
    {
            F_Example_increment *f;
            int i;
    
            f = fp;
            i = f->i;
    
            *f->ret = i + 1;
    }
    

Пересобираем Inferno.

  • testexample.b
    ...
    init(nil: ref Draw->Context, nil: list of string)
    {
            ...
            sys->print("increment(5) = %d\n", example->increment(5));
    }
    

Не забываем перезапустить emu перед запуском нашего примера, т.к.
текущий запущенный emu не содержит в себе модифицированный C-модуль.
$ emu
; limbo testexample.b
; testexample
Example module loaded
increment(5) = 6
;

Как это работает

При сборке, для функции increment() найденной в module/example.m, в файл libinterp/runt.h было автоматически добавлено описание этой функции, её параметров и возвращаемых значений:
void Example_increment(void*);
typedef struct F_Example_increment F_Example_increment;
struct F_Example_increment
{
        WORD    regs[NREG-1];
        WORD*   ret;
        uchar   temps[12];
        WORD    i;
};

Я пока не разбирался, что такое regs; temps добавлен явно для выравнивания; ret это указатель на возвращаемое значение; а i это наш параметр.

Строки

  • module/example.m
    Example: module
    {
            ...
            say: fn(s: string);
    };
    
  • libinterp/example.c
    ...
    void
    Example_say(void *fp)
    {
            F_Example_say *f;
            String *s;
            char *str;
    
            f = fp;
            s = f->s;
    
            str = string2c(s);
    
            print("%s\n", str);
    }
    
  • testexample.b
    ...
    init(nil: ref Draw->Context, nil: list of string)
    {
            ...
            example->say("Hello!");
    }
    
Собираем, перезапускаем, проверяем:
$ emu
; limbo testexample.b
; testexample
Example module loaded
increment(5) = 6
Hello!
;

Как это работает

Вот что получилось у нас в libinterp/runt.h:
void Example_say(void*);
typedef struct F_Example_say F_Example_say;
struct F_Example_say
{
        WORD    regs[NREG-1];
        WORD    noret;
        uchar   temps[12];
        String* s;
};

С noret вместо ret всё понятно, функция say() ничего не возвращает. Тип String* это C-реализация Limbo-вский строк. Найти struct String можно в include/interp.h, функции для работы со строками (вроде использованной в нашем примере string2c()) находятся в libinterp/string.c.

Аналогично реализуется работа с другими Limbo-вскими типами данных: через Array*, List*, etc. Не для всех структур есть готовые вспомогательные функции как для работы со строками, но можно найти достаточно примеров в реализации опкодов виртуальной машины libinterp/xec.c (например, как работать со срезами массивов).

Пользовательские adt объявленные в module/example.m преобразуются в обычные C-шные struct (а pick adt в union). Кортежи так же преобразуются в обычные struct.

Скорее всего после изменения module/example.m вам придётся запустить сборку (которая провалится по ошибке) чтобы обновился libinterp/runt.h и вы увидели какие именно структуры были созданы для ваших данных и поняли как реализовывать работу с ними в libinterp/example.c.

Исключения

Для генерации исключения достаточно вызвать функцию error(). Можно подключить raise.h для возврата стандартных ошибок описанных в libinterp/raise.c или объявить аналогичным образом свои собственные в libinterp/example.c.

Разумеется, если вы выделяли самостоятельно память через malloc(), то перед вызовом error() необходимо эту память освободить, иначе будет утечка. Объекты выделяемые стандартным образом через heap (вроде String* и Array*) освобождать не обязательно, их всё-равно чуть позже найдёт и удалит сборщик мусора. (Более детально о работе heap и сборщика мусора в части 2.)

Возвращаем ссылку

Один неявный момент при возвращении результата из функции связан с тем, что *f->ret физически указывает на ячейку памяти, где должен будет находиться результат выполнения функции после её успешного завершения. Из этого вытекают два следствия:
  1. Если вы сначала положите результат в *f->ret, а потом решите что произошла ошибка и сгенерируете исключение, то произойдёт кое-что невозможное с точки зрения Limbo: функция И вернёт значение И вызовет исключение.
  2. Если в переменной, куда возвращается результат вашей функции, уже лежит какое-то значение (которое тоже является ссылкой, разумеется, ведь тип у этой переменной такой же, как у возвращаемого вашей функцией значения), то вы должны его освободить из памяти до того, как перепишете эту ссылку своей.
Для демонстрации первой проблемы давайте модифицируем нашу функцию
increment() вот таким образом:
  • libinterp/example.c
    ...
    void
    Example_increment(void *fp)
    {
            ...
            *f->ret = i + 1;
            error("some error");
    }
    
  • testexample.b
    ...
    init(nil: ref Draw->Context, nil: list of string)
    {
            ...
            i := 0;
            {
                    i = example->increment(5);
            }
            exception e {
                    "*" => sys->print("catched: %s\n", e);
            }
            sys->print("i = %d\n", i);
    }
    

; testexample
...
catched: some error
i = 6
;

Для решения второй проблемы в C-функциях перед сохранением возвращаемого значения в *f->ret необходимо освободить текущее значение. Обычно это делается либо так:
destroy(*f->ret);
*f->ret = new_value;

либо так (H это C-шный аналог Limbo-вского nil):
void *tmp;
...
tmp = *f->ret;
*f->ret = H;
destroy(tmp);
...
*f->ret = new_value;

Насколько я понял, на данный момент разницы между этими вариантами нет, но если Dis будет переписан для работы одновременно на нескольких CPU/Core, то второй вариант будет корректно работать, а первый нет.

Блокирование Dis


В Inferno используется глобальная блокировка Dis (вероятно, аналогичная широко известному GIL в питоне). C-шные функции вызываются с установленной блокировкой, т.к. безопасно работать со структурами данных Dis (т.е. любыми значениями и переменными доступными из Limbo — включая параметры и возвращаемые значения C-шных функций) можно только с установленной блокировкой.

Но если ваша функция должна выполнить некоторую долгую операцию (например чтение/запись или вызов «тяжёлой» функции из внешней библиотеки или выполнить какие-то длительные вычисления), то необходимо перед этой операцией снять release() блокировку, чтобы Dis продолжил выполняться в другой нити параллельно с вашей функцией, а после снова её поставить acquire() (иначе нельзя будет вернуть результат и вернуться в вызвавший эту функцию код на Limbo). Пример можно найти в реализации sys->read() в файле emu/port/inferno.c:
void
Sys_read(void *fp)
{
        ...
        release();
        *f->ret = kread(fdchk(f->fd), f->buf->data, n);
        acquire();
}


Часть 2.
Теги:
Хабы:
+20
Комментарии 9
Комментарии Комментарии 9

Публикации

Истории

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн