Последний год я собирал различные клавиатуры, что включает в себя и написание прошивок под различные управляющие схемы.
Первоначально, я писал их на 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 году я решил выучить системный язык. Частично это было интеллектуальное любопытство - я хотел поближе познакомиться с такими понятиями, как стек, куча, указатели и статические типы, которые оставались для меня, как для веб-разработчика, слишком сложными. Но в основном это было потому, что я хотел получить те возможности, которые обещали системные языки:
Чтобы писать код, который был бы быстрым; чтобы использовать преимущества того, как компьютеры на самом деле работают и код работал так же быстро, как позволяет аппаратное обеспечение.
Создавать приложения, которые могли бы работать в минимальном окружении, таких как микроконтроллеры или 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 потому что:
Крейты устройств представляют аппаратную периферию в виде различных типов.
Нельзя просто вычислять с разными типами в 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" выглядит так, как будто другие растаманы решали эту проблему с помощью:
итерации по usize и сопоставление для деструктуризации кортежей; мне нравится этот подход без макросов (я взял его для своей роскошной сенсорной панели), хотя определение ключей по координатам строки/столбца подразумевает, что каждая строка/столбец имеет одно и то же количество ключей, что бывает не всегда.
полагаясь (с их слов) на макрос для реализации итератора на объектах трейта из структуры кортежа; я не совсем понимаю, что здесь происходит.
поистине астрального уровня понимания; я вообще не догоняю, что здесь происходит.
Несмотря на то, что во всех этих решениях, безусловно, присутствует много языковой сложности, 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 и конструктивный отзыв на эту статью!
agalakhov
Хм, но ведь на Rust в embedded обычно не используют тот способ, которым пользовался автор. Это низкоуровневый вариант. Есть высокоуровневый (embedded-hal), он намного проще. Оверхед нулевой.