Pull to refresh

Почему я переписал прошивку для клавиатуры с Rust на Zig: слаженность, мастерство и развлечение

Reading time 16 min
Views 8.5K
Original author: Kevin Lynagh

Последний год я собирал различные клавиатуры, что включает в себя и написание прошивок под различные управляющие схемы.

Первоначально, я писал их на Rust, но несмотря на годы опыта разработки на нем, приходилось повоевать. Со временем, я заставил мои клавиатуры работать, но это заняло неприличное количество времени и не приносило мне удовольствия.

После неоднократных предложений от моего более подкованного в Rust-и-вычислительной технике друга Джейми Брэндона, я переписал прошивку на Zig, и вышло очень удачно.

Я нашел это поразительным, учитывая, что я никогда не видел Zig раньше, и этот язык, еще даже не версии 1.0, созданный хипстером из Университета Портленда, и описывается, по сути, всего одной страницей документации.

Опыт прошел настолько хорошо, что теперь я понимаю Zig (который использовал дюжину часов), так же как и Rust (которым я пользуюсь не менее тысячи часов).

Это, конечно, отражает не только меня и мои интересы, но и касается каждого из этих языков. Поэтому мне придется объяснить, чего я хочу от языка системного программирования в первую очередь.

Также, чтобы объяснить, почему я боролся с Rust, мне придётся показать много сложного кода, который мне определённо не нравится. Моя цель здесь не в том, чтобы упрекнуть Rust, а в том, чтобы показать мою (недостаточную) репутацию: это для того, чтобы вы могли сами судить, использую ли я возможности Rust рациональным образом, или же я полностью сбился с пути.

Наконец, несмотря на то, что блог рискует впасть в ужасно скучное "язык X лучше, чем язык Y", я чувствую, что для некоторых читателей было бы более полезным, если бы я явно сравнил Rust и Zig, вместо того, чтобы писать полностью положительную статью "Zig's великолепен!". (В конце концов, я неуклонно игнорировал шесть месяцев, когда Джейми рассказывал о Zig, потому что "это отлично, приятель, но я уже знаю Rust, и я просто хочу закончить свою клавиатуру, окей?").

Чего я хочу от системного языка программирования

Я получил образование в области физики и научился программированию, чтобы визуализировать данные. Моими первыми языками были PostScript и Ruby (динамические, интерпретируемые языки), а позже я перешел на JavaScript, чтобы рисовать в Интернете. Это привело меня к Clojure (использование ClojureScript для рисования в интернете), с которым я и провел большую часть своей карьеры.

В 2017 году я решил выучить системный язык. Частично это было интеллектуальное любопытство - я хотел поближе познакомиться с такими понятиями, как стек, куча, указатели и статические типы, которые оставались для меня, как для веб-разработчика, слишком сложными. Но в основном это было потому, что я хотел получить те возможности, которые обещали системные языки:

  1. Чтобы писать код, который был бы быстрым; чтобы использовать преимущества того, как компьютеры на самом деле работают и код работал так же быстро, как позволяет аппаратное обеспечение.

  2. Создавать приложения, которые могли бы работать в минимальном окружении, таких как микроконтроллеры или web assembly, где просто невозможно (по времени или размеру) таскать с собой сборщик мусора, большой рантайм и т.п.

Меня не интересовали (и до сих пор не интересуют) операционные системы, дизайн языка программирования или безопасность (в отношении памяти, формальной верификации, моделирования типов и т.д.).

Мне просто хотелось очень быстро мигать маленькими квадратиками на экране.

Основываясь на его растущей популярности в сообществе с открытым исходным кодом и тоннах документации по системному программированию для новичков, я подцепил Rust примерно версии 1.18.

С тех пор Rust, несомненно, помог мне достичь тех возможностей, которые мне были нужны: Я смог скомпилировать его в WASM модуль экранной разметки, создать и продать приложение для быстрого поиска на рабочем столе (Rust плюс Electron), а также скомпилировать Rust-программу для микроконтроллера stm32g4, чтобы управлять роботизированной трек-пилой (я даже нашел опечатку в определениях регистров; полный хардкор отладки встраиваемых систем!).

Несмотря на все это, я все еще не чувствую себя комфортно с Rust. Это ощущение фрактально сложное - кажется, каждый раз, когда я использую Rust на новом проекте, я сталкиваюсь с проблемой, которая заставляет меня столкнуться с новым острым углом языка/экосистемы. Разработка моей прошивки для клавиатуры не была исключением: Я столкнулся с двумя проблемами, и каждая из них требовала изучения совершенно новой функциональности языка.

Эти проблемы не являются специфическими для встраиваемых систем, но они представляют тот класс проблем, с которыми я столкнулся при использовании Rust за последние три года.

Если вам нужны кровавые подробности эмбеддед или понимание, почему я вообще пишу свою прошивку, используя новейшие языки, см. мои заметки о создании клавиатур.

Условная компиляция

Первая проблема, с которой я столкнулся с Rust, заключалась в том, чтобы заставить мою прошивку работать на аппаратном обеспечении, варьирующемся от 4-х кнопочных dev-kit'ов до левой/правой половин беспроводного сплита одного Atreus'a:

Изменение свойств прошивки во время компиляции называется "условной компиляцией". (Она должна выполняться во время компиляции, а не во время исполнения, так как микроконтроллеры имеют ограниченное программное пространство, в моём случае около 10-100 кБ). Rust решает эту проблему с помощью опций "features", которые определены в Cargo.toml:

[dependencies]
cortex-m = "0.6"
nrf52840-hal = { version = "0.11", optional = true, default-features = false }
nrf52833-hal = { version = "0.11", optional = true, default-features = false }
arraydeque = { version = "0.4", default-features = false }
heapless = "0.5"

[features]
keytron = ["nrf52833"]
keytron-dk = ["nrf52833"]
splitapple = ["nrf52840"]
splitapple-left = ["splitapple"]
splitapple-right = ["splitapple"]

# specify a default here so that rust-analyzer can build the project; when building use --no-default-features to turn this off
default = ["keytron"]

nrf52840 = ["nrf52840-hal"]
nrf52833 = ["nrf52833-hal"]

Например, опция keytron включена для конкретного аппаратного обеспечения клавиатуры. Это аппаратное обеспечение зависит от опции nrf52833 (представляющей собой разновидность микроконтроллера), которая зависит от крейта nrf52833-hal (фактический код, отображающий, как периферийная память микроконтроллера соотносится с типами Rust). Мой код Rust может затем использовать аннотации атрибутов для условного включения компонентов. Например, пространство имён может импортировать крейт, специфичный для микроконтроллера:

#[cfg(feature = "nrf52833")]
pub use nrf52833_hal::pac as hw;

#[cfg(feature = "nrf52840")]
pub use nrf52840_hal::pac as hw;

или вызывать соответствующую рутину сканирования клавиш:

fn read_keys() -> Packet {
    let device = unsafe { hw::Peripherals::steal() };

    #[cfg(any(feature = "keytron", feature = "keytron-dk"))]
    let u = {
        let p0 = device.P0.in_.read().bits();
        let p1 = device.P1.in_.read().bits();

        //invert because keys are active low
        gpio::P0::pack(!p0) | gpio::P1::pack(!p1)
    };

    #[cfg(feature = "splitapple")]
    let u = gpio::splitapple::read_keys();

    Packet(u)
}

Чтобы эта условная компиляция заработала, пришлось многому научиться:

  • условному мини-языку аннотации атрибутов (any в #[cfg(any(feature = "keytron", feature = "keytron-dk"))]).

  • что optional = true, должен быть добавлен в крейты устройств в Cargo.toml (даже если источник уже условно требует их!).

  • как включить опции при сборке статического бинарного файла (cargo build --release --no-default-features --features "keytron")

У меня до сих пор еще много нерешенных вопросов!

В какой-то момент я перестал пытаться передать периферийные устройства в качестве аргументов функции, потому что не мог разобраться, как добавлять условные атрибуты к типам - "очевидная" штука не работает:

fn read_keys(port: #[cfg(feature = "splitapple")]
                   nrf52840_hal::pac::P1
                   #[cfg(feature = "keytron")]
                   nrf52833_hal::pac::P0) -> Packet {}

Существует изящный встроенный фреймворк, RTIC, основной точкой входа которого является аннотация app, которая принимает крейт устройства в качестве, хм, аргумента:

#[app(device = nrf52833)]
const APP: () = {
  //your code here...
};

Как условно менять этот аргумент во время компиляции? Понятия не имею.

Типы и макросы

Rust также оказался сложным даже в рамках одной конфигурации аппаратного обеспечения.

Рассмотрим вопрос о сканировании клавиатурной матрицы: Если у нас не хватает контактов микроконтроллера для подключения каждого клавиатурного переключателя непосредственно к контакту, мы можем расположить переключатели с диодами (односторонние клапаны) в матрице:

Затем мы подаем высокий уровень сигнала на один столбец и считываем строки, чтобы найти состояние переключателей в этом столбце. В этом примере, если мы подадим сигнал на контакт 1.10 (col0) и затем прочитаем контакт 0.13 (строка 1) как высокий уровень, то мы знаем, что переключатель K8 нажат. Довольно просто в теории, но сложно в Rust потому что:

  1. Крейты устройств представляют аппаратную периферию в виде различных типов.

  2. Нельзя просто вычислять с разными типами в Rust.

Скажем, мне нужно инициализировать все столбцы как выходные контакты.

Сделать это для одного контакта, скажем, для периферийного порта P0's pin 10, достаточно просто:

P0.pin_cnf[10].write(|w| {
    w.input().disconnect();
    w.dir().output();
    w
});

Но мои столбцовые пины распределены по двум портам, так что я хочу написать:

for (port, pin) in &[(P0, 10), (P1, 7), ...] {
    port.pin_cnf[pin].write(|w| {
        w.input().disconnect();
        w.dir().output();
        w
    });
}

Это не взлетит, потому что теперь кортежи имеют разные типы - (P0, usize) и (P1, usize) - и поэтому они не могут висеть вместе в одной коллекции.

Вот решение, которое я придумал:

type PinIdx = u8;
type Port = u8;

const COL_PINS: [(Port, PinIdx); 7] =
    [(1, 10), (1, 13), (1, 15), (0, 2), (0, 29), (1, 0), (0, 17)];

pub fn init_gpio() {
    for (port, pin_idx) in &COL_PINS {
        match port {
            0 => {
                device.P0.pin_cnf[*pin_idx as usize].write(|w| {
                    w.input().disconnect();
                    w.dir().output();
                    w
                });
            }
            1 => {
                device.P1.pin_cnf[*pin_idx as usize].write(|w| {
                    w.input().disconnect();
                    w.dir().output();
                    w
                });
            }
            _ => {}
        }
    }
}

Ага, старая добрая копипаста как спасение.

Но подожди, я слышу, как вы спрашиваете, а как насчет макросов? О да, мой друг, я побрил макрояка в реальной рутине сканирования:

pub fn read_keys() -> u64 {
    let device = unsafe { crate::hw::Peripherals::steal() };

    let mut keys: u64 = 0;

    macro_rules! scan_col {
        ($col_idx: tt; $($row_idx: tt => $key:tt, )* ) => {
            let (port, pin_idx) = COL_PINS[$col_idx];

            ////////////////
            //set col high
            unsafe {
                match port {
                    0 => {
                        device.P0.outset.write(|w| w.bits(1 << pin_idx));
                    }
                    1 => {
                        device.P1.outset.write(|w| w.bits(1 << pin_idx));
                    }
                    _ => {}
                }
            }

            cortex_m::asm::delay(1000);

            //read rows and move into packed keys u64.
            //keys are 1-indexed.
            let val = device.P0.in_.read().bits();
            $(keys |= ((((val >> ROW_PINS[$row_idx]) & 1) as u64) << ($key - 1));)*

            ////////////////
            //set col low
            unsafe {
                match port {
                    0 => {
                        device.P0.outclr.write(|w| w.bits(1 << pin_idx));
                    }
                    1 => {
                        device.P1.outclr.write(|w| w.bits(1 << pin_idx));
                    }
                    _ => {}
                }
            }

        };
    };

    //col_idx; row_idx => key ID
    #[cfg(feature = "splitapple-left")]
    {
        scan_col!(0; 0 => 1 , 1 => 8  , 2 => 15 , 3 => 21 , 4 => 27 , 5 => 33 ,);
        scan_col!(1; 0 => 2 , 1 => 9  , 2 => 16 , 3 => 22 , 4 => 28 , 5 => 34 ,);
        scan_col!(2; 0 => 3 , 1 => 10 , 2 => 17 , 3 => 23 , 4 => 29 , 5 => 35 ,);
        scan_col!(3; 0 => 4 , 1 => 11 , 2 => 18 , 3 => 24 , 4 => 30 , 5 => 36 ,);
        scan_col!(4; 0 => 5 , 1 => 12 , 2 => 19 , 3 => 25 , 4 => 31 , 5 => 37 ,);
        scan_col!(5; 0 => 6 , 1 => 13 , 2 => 20 , 3 => 26 , 4 => 32 , 5 => 38 ,);
        scan_col!(6; 0 => 7 , 1 => 14 ,);
    }

    #[cfg(feature = "splitapple-right")]
    {
        scan_col!(0; 0 => 1 , 1 => 8  , 2 => 15 , 3 => 23 , 4 => 30 , 5 => 37 ,);
        scan_col!(1; 0 => 2 , 1 => 9  , 2 => 16 , 3 => 24 , 4 => 31 , 5 => 38 ,);
        scan_col!(2; 0 => 3 , 1 => 10 , 2 => 17 , 3 => 25 , 4 => 32 , 5 => 39 ,);
        scan_col!(3; 0 => 4 , 1 => 11 , 2 => 18 , 3 => 26 , 4 => 33 , 5 => 40 ,);
        scan_col!(4; 0 => 5 , 1 => 12 , 2 => 19 , 3 => 27 , 4 => 34 , 5 => 41 ,);
        scan_col!(5; 0 => 6 , 1 => 13 , 2 => 20 , 3 => 28 , 4 => 35 , 5 => 42 ,);
        scan_col!(6; 0 => 7 , 1 => 14 , 2 => 21 , 3 => 29 , 4 => 36 , 5 => 22 ,);
    }

    keys
}

Здесь многое происходит!

В принципе, каждый вызов макроса scan_col! расширяется в код, который устанавливает высокий уровень пину столбца, считывает строки и выдает их статус на соответствующие биты мутируемой keys: переменная u64 в начале функции.

Если вы хотите разобраться в деталях, возьмите свой любимый напиток и проведите некоторое время с макроразделом растбука или справочной документацией по макросам Rust.

Мне не нравится ни инициализация пинов, ни сам код матричного сканирования, который я здесь придумал, но они были самыми понятными, которые я смог написать. С первой страницы результатов Google по "прошивка для клавиатуры на Rust" выглядит так, как будто другие растаманы решали эту проблему с помощью:

Несмотря на то, что во всех этих решениях, безусловно, присутствует много языковой сложности, Rust заслуживает большой похвалы за то, что он более приятен, чем традиционные подходы. В отличие от печально известных текстовых препроцессорных макросов C (#define, #ifdef и т.д.), например, макросы Rust не приведут к необъяснимым синтаксическим ошибкам при расширении. (И весь развернутый код пройдет проверку типов!). Инструментарий Rust тоже намного лучше - анализатор Rust Analyzer достаточно компетентен, чтобы понять аннотации опций, когда прыгаешь по коду, чего я никогда не замечал на C.

Учитывая, насколько умны участники Rust - поищите все вдумчивые обсуждения и взвешивание компромиссов, которые они делают в публичном процессе RFC - у меня возник соблазн сделать вывод, что, ну, вся эта сложность должна быть присуща.

Может быть, просто сложно делать конфигурацию времени компиляции и эффективно проводить итерации над различными типами на безопасном, скомпилированном языке?

Возможно, но Zig приводит убедительные доводы в пользу того, что - по крайней мере, для моей пандемической-хобби-проектной клавиатурной прошивки - я могу обойтись гораздо меньшим количеством концепций.

Zig, язык попроще

Вот как я решил эти две проблемы с условной компиляцией и итерациями над различными типами с помощью Zig. (См. пост Джейми для более полного сравнения Rust и Zig).

Полное раскрытие: Это практически первый код, который я когда-либо писал на Zig, так что могут быть более идиоматические или аккуратные решения.

Для условной компиляции я переместил специфические для аппаратного обеспечения детали в отдельные файлы.

Например, dk.zig

usingnamespace @import("register-generation/target/nrf52833.zig");
usingnamespace @import("ztron.zig");

pub const led = .{ .port = p0, .pin = 13 };

и atreus.zig

usingnamespace @import("register-generation/target/nrf52840.zig");
usingnamespace @import("ztron.zig");

pub const led = .{ .port = p0, .pin = 11 };

Каждый из них импортирует свои специфические для микроконтроллера определения регистров и определяет назначения светодиодных контактов для печатной платы.

Общий файлztron.zig затем импортирует эти публичные константы через @import("root") ("root" - точка входа компилятора, так что это циклическая ссылка; это нормально!) и использует их напрямую:

usingnamespace @import("root");

export fn setup() void {

    led.port.pin_cnf[led.pin].modify(.{
        .dir = .output,
        .input = .disconnect,
    });

}

Нет специальной "feature" семантики для изучения, Cargo.toml для переупорядочивания, или флагов для передачи компилятору. Cargo.toml даже не существует!

Чтобы уточнить, какой код Вы хотите скомпилировать, просто скажите об этом компилятору: Чтобы скомпилировать оборудование devkit, запустите zig build-obj dk.zig; для Atreus - zig build-obj atreus.zig.

Это работает, потому что Zig вычисляет только тот код, который необходим. (И не только импортированные файлы - компилятор не возражает против написанных наполовину, или плохо написанных функций, если они не вызываются).

Что насчет настройки пин-кода клавиатурной матрицы? Ну, периферийные устройства все еще разные типы, но это... нормально:

const rows = .{
    .{ .port = p1, .pin = 0 },
    .{ .port = p1, .pin = 1 },
    .{ .port = p1, .pin = 2 },
    .{ .port = p1, .pin = 4 },
};

const cols = .{
    .{ .port = p0, .pin = 13 },
    .{ .port = p1, .pin = 15 },
    .{ .port = p0, .pin = 17 },
    .{ .port = p0, .pin = 20 },
    .{ .port = p0, .pin = 22 },
    .{ .port = p0, .pin = 24 },
    .{ .port = p0, .pin = 9 },
    .{ .port = p0, .pin = 10 },
    .{ .port = p0, .pin = 4 },
    .{ .port = p0, .pin = 26 },
    .{ .port = p0, .pin = 2 },
};

pub fn initKeyboardGPIO() void {
    inline for (rows) |x| {
        x.port.pin_cnf[x.pin].modify(.{
            .dir = .input,
            .input = .connect,
            .pull = .pulldown,
        });
    }

    inline for (cols) |x| {
        x.port.pin_cnf[x.pin].modify(.{
            .dir = .output,
            .input = .disconnect,
        });
    }
}

конструкция inline for генерирует разворачивающийся цикл во время компиляции.

Дело не в том, что меня волнуют здесь сгенерированные машинные инструкции - цикл на самом деле разворачивается или нет, а в том, что язык позволяет мне выразить желание "зацикливаться" на гетерогенно-типированной коллекции.

Тот же самый трюк делает и реальный код сканирования ключей гораздо более понятным:

const col2row2key = .{
    .{ .{ 0,  1 }, .{ 1, 11 }, .{ 2, 21 }, .{ 3, 32 } },
    .{ .{ 0,  2 }, .{ 1, 12 }, .{ 2, 22 }, .{ 3, 33 } },
    .{ .{ 0,  3 }, .{ 1, 13 }, .{ 2, 23 }, .{ 3, 34 } },
    .{ .{ 0,  4 }, .{ 1, 14 }, .{ 2, 24 }, .{ 3, 35 } },
    .{ .{ 0,  5 }, .{ 1, 15 }, .{ 2, 25 }, .{ 3, 36 } },
    .{                         .{ 2, 26 }, .{ 3, 37 } },
    .{ .{ 0,  6 }, .{ 1, 16 }, .{ 2, 27 }, .{ 3, 38 } },
    .{ .{ 0,  7 }, .{ 1, 17 }, .{ 2, 28 }, .{ 3, 39 } },
    .{ .{ 0,  8 }, .{ 1, 18 }, .{ 2, 29 }, .{ 3, 40 } },
    .{ .{ 0,  9 }, .{ 1, 19 }, .{ 2, 30 }, .{ 3, 41 } },
    .{ .{ 0, 10 }, .{ 1, 20 }, .{ 2, 31 }, .{ 3, 42 } },
};

pub fn readKeys() PackedKeys {
    var pk = PackedKeys.new();

    inline for (col2row2key) |row2key, col| {
        // set col high
        cols[col].port.outset.write_raw(1 << cols[col].pin);

        delay(1000);

        const val = rows[0].port.in.read_raw();
        inline for (row2key) |row_idx_and_key| {
            const row_pin = rows[row_idx_and_key[0]].pin;
            pk.keys[(row_idx_and_key[1] - 1)] = (1 == ((val >> row_pin) & 1));
        }

        // set col low
        cols[col].port.outclr.write_raw(1 << cols[col].pin);
    }

    return pk;
}

Концептуально, в Ziginline for решает ту же проблему, что и синтаксические макросы Rust (генерация кода, специфичного для конкретного типа, во время компиляции), но без побочного квеста обучения небольшому языку сопоставления паттернов/разворачивания макросов .

Фактически, поскольку компоновка строк/столбцов/переключателей существует в const-структуре, её можно вычислять. Например, вычислить (во время компиляции) количество переключателей на клавиатуре:

pub const switch_count = comptime {
    var n = 0;
    for (col2row2key) |x| n += x.len;
    return n;
};

Понятия не имею, как это можно сделать из синтаксических макровызовов Rust:

scan_col!(0; 0 => 1 , 1 => 8  , 2 => 15 , 3 => 21 , 4 => 27 , 5 => 33 ,);

(Хотя я уверен, что это возможно - эксперты обнаружили, что макросы Rust умеют считать примерно до 500 и, возможно, в один прекрасный день достигнут даже больших чисел).

Почему я борюсь с Rust'ом?

Использование Zig в течение всего нескольких часов высветило для меня аспекты Rust, которые я никогда раньше не рассматривал. В частности, та сложность, которую я бессознательно приписывал этой области - "вот что такое системное программирование" - была на самом деле следствием осознанных решений по проектированию Rust.

Например, теперь мне совершенно ясно, что Rust - это язык, который имеет для всего на свете специализированную фичу. Кроме своего знаменитого заимствования, Rust имеет модули, пакеты, дженерики, трейты, два вида макросов, аннотации к атрибутам и дюжину других вещей.

Чёрт, да даже определение неизменяемых переменных производится с помощью различных функций языка в зависимости от того, находится ли он в контексте функции или в контексте модуля:

fn main() {
    let message = "hello world"; // a regular immutable variable definition
}

let message = "hello world"; // doesn't work at toplevel

const message: &str = "hello world"; // you have to write `const` and declare the type yourself.

Я уверен, что есть веские причины, по которым были приняты все эти проектные решения. Я не историк языка, но могу предположить:

  • Может быть, макросы выполнения произвольного кода были бы слишком мощными, поэтому были выбраны более ограниченные синтаксические макросы для того, чтобы сделать компиляцию программ проще и быстрее.

  • Возможно, аннотации типов требуются на верхнем уровне, потому что последствия были бы слишком "жутким действием на расстоянии" для переменных, на которые широко ссылаются в большой кодовой базе.

  • Может быть, const, а не let, потому что есть гарантия, что let всегда находится на куче или стеке, а consts всегда находится в data-сегменте двоичного кода.

  • Если вы строите веб-браузер на 100 сотрудников, то да, абсолютно точно, весь код должен быть упакован в крейты с продуманными ограничениями по типу, которые доказывают специфические свойства безопасности и т.д.

Однако, когда я использую Rust в качестве физика-превращающегося в-веб-разработчика, ни одна из этих причин мне не ясна. (См. отличный разговор лингвиста Эвана Чаплицкого "В сказке" для более подробной информации).

Поэтому один из аспектов борьбы является мотивационным: мне приходится платить авансом за изучение сложности языка, но я могу только принять на веру, что эта сложность, в конечном счете, послужит мне. (Я получаю тот же самый флюид, занимаясь своими налогами: Существует своего рода фрактальная сложность документации и концепций, которые, предположительно, отражают тщательно продуманные компромиссы, которые делают умные люди, делая все, что могут, учитывая исторические происшествия, противоречивые требования и т.д.).

Даже если отбросить этот мотивационный ракурс, почему за последние три года я боролся за то, чтобы просто выучить Rust?

Полезной призмой является понятие "согласованности" в рамках когнитивных измерений:

особая форма угадывания: когда человек знает некоторую часть структуры языка, сколько остального можно успешно угадать?

У Rust много особенностей языка, и все они в значительной степени разобщены друг от друга, поэтому знание одних не помогает мне угадывать другие.

Ничего из того, что я знал о выражениях if не помогло мне предсказать или понять систему аннотаций атрибутов/опций, не смотря на то, что они оба удовлетворяют концептуально похожую потребность (условная логика). Ничего из того, что я знал о функциях, не помогло мне понять синтаксические макросы.

И наоборот, этот принцип "согласованности" также объясняет, почему я так легко вошел в Zig - он абсолютно превосходен в этом деле. Мало того, что функций языка стало меньше учить в первую очередь, так они еще и хорошо сочетаются друг с другом: Ключевые слова comptime и inline for, например, позволили мне использовать при компиляции все циклы, условия, арифметику и поток управления, которые я хотел, используя синтаксис и семантику, которые я уже усвоил - Zig!

Почему я в восторге от Zig?

Легкость в изучении - это хорошо, если вы можете ее получить, конечно, но я не подбираю системный язык, потому что я хочу что-то легкое в изучении. Я делаю это, потому что мне нужны возможности; я хочу нажимать на пиксели вокруг экрана как можно быстрее =D

Как таковой, я в восторге от Zig по двум важным причинам.

Первая - это то, что это совсем другой вид системного программирования, к которому я привык: Оно быстрое, маленькое и весёлое.

"Быстро" легко объяснить: Когда я открываю проект Rust, Emacs начинает пропускать нажатия клавиш, и мои бедные вентиляторы MacBook Air 2013 года сходят с ума:

С Rust 1.50 отладочная сборка моей клавиатурной прошивки с нуля занимает 70 секунд (релизная, 90 секунд), а target/директория занимает 450МБ диска.

Zig 0.7.1, с другой стороны, компилирует мою прошивку с нуля в режиме релиза примерно за 5 секунд, а его zig-cache/занимает 1.4МБ. Здорово!

"Маленький" - это так же просто; опять же, по сути, есть одна страница документации. Это ценностное предложение находится прямо в топе сайта Zig:

Сосредоточьтесь на отладке своего приложения, а не на отладке своего знания языка программирования.

Когда я впервые начал использовать Zig, я был встревожен тем, что в нем не хватает такого количества функций, которые мне нравились на других языках. Нет синтаксиса для диапазонов. Никаких замыканий. Нет синтаксиса итераторов.

Однако, в конце концов, я обнаружил, что эти отсутствия освобождают - вот тут-то и появляется "веселье".

После двух минут поиска я бы заключил: "Ну, думаю, мне просто придётся тупо написать цикл while", а затем я бы вернулся к работе над своей проблемой.

Чаще всего я оказывался в состоянии творческого процесса, разрабатывая планы, основанные на ограниченных возможностях Zig, а затем выполняя их. Этот процесс не был постоянно нарушен остановками для документации или побочными квестами для изучения некоторых возможностей/синтаксиса/библиотеки.

Это не столько наблюдение только за Zig, сколько о моих познаниях в Zig.

Язык настолько мал и последователен, что после нескольких часов изучения я смог загрузить достаточно информации в свою голову, чтобы просто делать свою работу.

Я написал прошивку для клавиатуры, и она заработала!

Через несколько дней я в паре с невидевшим-Zig-доэтого другом написал небольшой код обработки изображений для WASM, и это тоже сработало! (zig build-lib -target wasm32-freestanding -O ReleaseSmall foo.zig генерирует foo.wasm, вот и все!).

Несмотря на то, что я нахожусь в теме всего лишь дюжину часов, я чувствую, что уже могу быть продуктивным с Zig без подключения к Интернету. Такое ощущение, что Zig - это язык, в котором я мог бы стать мастером; чтобы полностью усвоить его, я могу использовать его, не задумываясь об этом. Это ощущение супер захватывающее и вдохновляющее.

Не подведу

Конечно, это все может быть случайностью. Может быть, мне просто не повезло, я очутился в неудобном уголке Rust, и в минуту слабости бросил его ради незрелого языка. Честно; Я сгенерировал из XML свою собственную библиотеку для периферии микроконтроллеров и столкнулся как минимум с одной ошибкой в Zig-компиляторе (не работает continue из цикла comptime).

Возможно, простота языка Zig приведет меня в заблуждение; в конце концов, мне придется столкнуться с гораздо худшими сложностями, связанными с трудновоспроизводимыми ошибками памяти, и я буду жалеть, что у меня не было проверки заимствования. Что я сделаю кашу невообразимо сложной логики времени компиляции и пожелаю синтаксических макросов и аннотаций к атрибутам. Что я не смогу рассуждать или расширять программы любой существенной сложности, и буду страдать при реализации собственной системы трейтов объектов или неуклюжего прувера безопасности.

Возможно, странно, но это вторая причина, почему я так в восторге от Zig: такое ощущение, что я не могу потерпеть неудачу.

Я либо успешно использую Zig для своих встраиваемых хобби-проектов, одноразовых WASM-помощников и необходимых биндингов к C API, либо, в борьбе за выполнение этих задач, я наконец-то начну больше понимать и ценить то, от каких проблем меня защищает Rust.

В любом случае, я весьма рад!

Благодарности

Спасибо Джулии Эванс, Пьеру Ив Бакку, Лоре Линдзи, Джейми Брендону и Лодкам за их вдумчивое обсуждение Rust/Zig и конструктивный отзыв на эту статью!

Tags:
Hubs:
+9
Comments 17
Comments Comments 17

Articles