Настало время следующей части. Это вторая половина перевода лабы №1. В этом выпуске мы будем писать драйверы периферии (таймер, GPIO, UART), реализуем протокол XMODEM и одну утилитку. Используя всё это мы напишем командную оболочку для нашего ядра и загрузчик, который позволит нам не тыкать microSD-карточку туда-сюда.
Младшая половина.
Начинать чтение стоит с нулевой лабы.
Фаза 3: Не раковина (Not a Seashell)
На этот раз мы напишем парочку драйверов для встроенной периферии. Нас интересует встроенный таймер, GPIO и UART. Их нам будет достаточно для написания встроенной командной строки, а чуть позже пригодится для создания загрузчика (который несколько упростит дальнейшую работу).
Что такое драйвер?
Термин драйвер или драйвер устройства — программное обеспечение, которое напрямую взаимодействует с неким аппаратным устройством, управляет им и т.д. Драйверы предоставляют интерфейс более высокого уровня для тех устройств, которыми они управляют. Операционные системы взаимодействую с драйверами устройств, чтоб поверх оных выстроить ещё более высокий уровень абстракции над ними (ради удобства конечно же!). Например ядро Linux предоставляет ALSA (Advanced Linux Sound Architecture) — API для аудио, который взаимодействует с драйверами, которые в свою очередь напрямую общаются со звуковыми картами.
Субфаза A: Начало работы
В оставшейся части задания мы будем работать внутри репы os
, которая будет использоваться не только в этой части, но и на протяжении оставшегося курса. Именно этот репозиторий в конце концов станет операционной системой.
Рекомендую следующую структуру каталогов для лаб и всего прочего, применительно к этому курсу:
cs140e
+-- 0-blinky
¦ +-- Makefile
¦ +-- phase3
¦ L-- phase4
+-- 1-shell
¦ +-- ferris-wheel
¦ +-- getting-started
¦ +-- stack-vec
¦ +-- ttywrite
¦ +-- volatile
¦ L-- xmodem
L-- os
+-- Makefile
+-- bootloader
+-- kernel
+-- pi
+-- std
L-- volatile
Удобно и аккуратно. 0-blinky
и 1-shell
относятся к предыдущей и текущей лабе, а os
получить можно вот таким образом:
git clone https://web.stanford.edu/class/cs140e/os.git os
git checkout master
Убедитесь, что всё правильно расположено и запустите make
внутри os/kernel
. Если всё нормально, то команда успешно выполнится.
Структура проекта
Каталог os
содержит в себе следующий наборчик подкаталогов:
pi
— библиотека, в которой содержатся драйверы и некоторое количество низкоуровневого кода для нашей ОСvolatile
— вторая версия одноимённой библиотеки из фазы 2std
— минимальный огрызок стандартной библиотеки Rustbootloader
— загрузчик, который мы будем дописывать в фазе 4kernel
— основное ядро ОС
Весь код драйверов находится в библиотеке pi
. pi
использует библиотеку volatile
и (опционально) std
. kernel
и bootloader
используют pi
для общения с устройствами. И помимо этого зависят от std
. volatile
не зависит ни от чего. Графически эти взаимоотношения будут выглядеть примерно так:
Прошивка
Нам требуется обновить прошивку малинки перед тем, как продолжить. Загрузить это всё можно командой make fetch
из каталога os
. Оно загрузит необходимые материалы в папочку files/
. Скопируйте firmware/bootcode.bin
, firmware/config.txt
и firmware/start.elf
в корень microSD-карточки. Можно скопировать act-led-blink.bin
из прошлой части, переименовать в kernel8.img
. Так можно проверить, что всё работает. Там должен мигать зелёный светодиодик на самой малинке.
Обновлённый volatile
Эта библиотека из папки os
немного отличается от того, кода, который изучался в фазе 2. Изменения чуточку облегчают использование этой либы в контексте написания драйверов устройств. Основные отличия такие:
UniqueVolatile
заменён наUnique<Volatile>
- Добавлен тип
Reserved
, который не умеет решительно ничего и используется в качестве заглушки
Есть ещё одно, более существенное отличие. Все типы из библиотеки обёртывают T
, а не *mut T
. Это позволяет нам использовать всякие сырые адреса не обёртывая, а кастуя их примерно так: 0x1000 as *mut Volatile<T>
. Помимо этого мы можем указать структуру, содержащую поля, обёрнутые в Volatile
. Что-то вроде такого:
#[repr(C)]
struct Registers {
REG_A: Volatile<u32>,
REG_B: Volatile<u8>
}
// Тут сказано примерно следующее. Структуру `Registers` по адресу `0x4000` найти можно.
// В данном случае после `u32` можно найти ещё и `u8` чуть дальше после этого адреса
// (см. структуру).
let x: *mut Registers = 0x4000 as *mut Registers;
// Для работы с сырыми указателями нужен `unsafe`.
// Ибо в Rust разыменование таких указателей закономерно не является безопастным
unsafe {
// Кроме того Rust не разыменовывает такие указатели автоматически
(*x).REG_A.write(434);
let val: u8 = (*x).REG_B.read();
}
Что такое#[repr(C)]
?
Приписка#[repr(C)]
заставляет Rust формировать структуры в памяти так же, как в Сишечке. Без этого Rust имеет право оптимизировать порядок полей и отступы в памяти между оными. Когда мы работаем с сырыми указателями, то в большинстве случаев имеем ввиду вполне себе конкретную структуру в памяти. Соответственно#[repr(C)]
позволяет нам утверждать, что Rust будет размещать структуру в памяти именно так, как мы предполагаем.
Ядро
Каталог os/kernel
содержит заготовки для кода ядра нашей операционки. Вызов make
внутри этого каталога соберёт наше ядрышко. Результат сборки при этом будет лежать в подкаталоге build/
. Для того, чтоб запустить это дело потребуется скопировать build/kernel.bin
в корень microSD-карточки под именем kernel8.img
. На текущий момент ядро не делает ничего. К концу этой фазы ядро будет содержать текстовую интерактивную оболочку, с которой можно пообщаться.
Крейт kernel
зависит от крейта pi
. Можно увидеть extern crate pi;
в kernel/src/kmain.rs
и запись об этом в Cargo.toml
. Т.е. мы можем невозбранно использовать все типы и конструкции, объявленные в либе pi
.
Документация
При написании драйверов нам сильно пригодится мануал по периферийным устройствам BCM2837.
Субфаза B: Системный таймер
В этой подфазе мы будем писать драйвер для встроенного таймера. Основные работы ведутся в файлах os/pi/src/timer.rs
и os/kernel/src/kmain.rs
. Таймер документирован на странице 172 (раздел 12) мануала по периферийным устройствам BCM2837.
Для начала посмотрите на тот код, который уже есть в os/pi/src/timer.rs
. По крайней мере вот эти части:
const TIMER_REG_BASE: usize = IO_BASE + 0x3000;
#[repr(C)]
struct Registers {
CS: Volatile<u32>,
CLO: ReadVolatile<u32>,
CHI: ReadVolatile<u32>,
COMPARE: [Volatile<u32>; 4]
}
pub struct Timer {
registers: &'static mut Registers
}
impl Timer {
pub fn new() -> Timer {
Timer {
registers: unsafe { &mut *(TIMER_REG_BASE as *mut Registers) },
}
}
}
Тут есть одна строчка кода с unsafe
, на которую следует обратить внимание в первую очередь. В этой строчке кастуется адрес TIMER_REG_BASE
в *mut Registers
, а затем незамедлительно превращается в &'static mut Registers
. По сути мы сообщаем расту, что у нас должна быть статическая ссылка на структуру по адресу TIMER_REG_BASE
.
Что там конкретно лежит по адресу TIMER_REG_BASE
? На 172 странице можно мануала можно обнаружить, что 0x3000
является смещением от начала периферии для таймера. Т.е. TIMER_REG_BASE
— это адрес, с которого начинаются регистры этого самого таймера. После одной строчки с unsafe
мы можем использовать поле registers
для вполне себе безопасного доступа ко всему этому. Например мы можем читать регистр CLO
при помощи self.registers.CLO.read()
или записывать в
CS
при помощи self.registers.CS.write()
.
Почему мы не можем писать в регистрыCLO
иCHI
? [restricted-reads]
В документации по BCM2837 сказано, что регистрыCLO
иCHI
доступны только на чтение. Наш код обеспечивает это свойство. Каким образом? Что мешает нам писать вCLO
иCHI
?
Что именно не безопасно?
Если вкратце, тоunsafe
это маркер для компилятора Rust, говорящий, что вы берёте на себя управление безопасностью памяти. Компилятор не будет вас защищать от проблем с памятью в этих кусках кода. Вunsafe
частях кода Rust позволяет делать всё то, что можно делать в Няшном Си. Можнограбить кораваныдостаточно свободно кастовать одни типы в другие, играться с сырыми указателями, создавать сроки жизни.
Однако обратите внимание, что код вunsafe
блоке может быть очень опасен. Вы должны убедиться, что то, что вы делаете в небезопасной секции, на самом деле безопасно. Это сложнее, чем кажется на первый взгляд. Особенно по той причине, что концепции безопасности в Rust более строгие, нежели в других языках. Надо стараться не использоватьunsafe
вообще. По мере возможности конечно. Для таких штук, как операционные системы, нам потребуется использоватьunsafe
, если мы хотим напрямую общаться с оборудованием. Но мы будем ограничивать использование этого на столько, на сколько это вообще возможно.
Если хочется почитать моар проunsafe
— стоит взглянуть на главу 1 из Nomicon. Там и далее по этой книжице можно узнать много чего из полезного для разнообразных сильных колдунств в Rust.
Реализация драйвера
Реализуйте Timer::read()
из файла os/pi/src/timer.rs
. Затем методы current_time()
, spin_sleep_us()
и spin_sleep_ms()
, которые можно найти поблизости. Комментарии и имена этих функций вполне себе указывают на их ожидаемую функциональность. Для реализации Timer::read()
потребуется читнуть документацию по BCM2837 в соответствующем разделе. По крайней мере вы должны понять, какие регистры надо будет прочитать для получения всего 64-битного значения таймера. Можно собирать крейт pi
командой cargo build
. Хотя быстрее будет просто проверить правильность написанного при помощи cargo check
.
Тестирование драйвера
Не будет лишним убедиться, что функция spin_sleep_ms()
реализована верно. Для этого следует написать соответствующий код в kernel/src/kmain.rs
.
Скопируйте код мигания светодиодиком из фазы 4 нулевой лабы. Вместо функции сна, которая просто крутится в цикле, стоит использовать нашу функцию spin_sleep_ms()
для создания пауз между миганиями. Перекомпилируйте ядро и загрузите его на карту памяти под именем kernel8.img
. Запустите всё и убедитесь, что светодиод мигает с той частотой, с которой вы планировали. Попробуйте поставить другой размер задержки и убедитесь, что всё работает. Да, постоянно тыкать туда-сюда microsd-карточку достаточно утомительно. К концу этой части у нас будет загрузчик, который решит эту проблему.
Если ваша реализация драйвера для таймера работает, то можно перейти к следующей подфазе.
Субфаза C: GPIO
В этой подфазе будем писать обобщённый, независимый от конкретного номера пина, драйвер GPIO. Основная работа ведётся в файлах os/pi/src/gpio.rs
и os/kernel/src/kmain.rs
. Документацию по GPIO можно найти на странице 89 (раздел 6) мануала по периферии BCM2837.
Конечные автоматы
Все аппаратные устройства по сути можно считать конечными автоматами (eng). Они инициализируются с некоторым состоянием и переходят в другие состояния явно или не очень. При этом устройства предоставляют различные функциональные возможности в зависимости от текущего состояния. Другими словами в неких конкретных состояниях работоспособен только определённый набор переходов в другие состояния.
Большинство языков программирования делают невозможным точное следование семантике конечных автоматов. Но к Rust это разумеется не относится. Rust позволяет нам следовать этой семантике достаточно явно. Этим мы и воспользуемся для реализации более безопасного драйвера GPIO. Наш драйвер будет гарантировать, что каждый GPIO-пин будет всегда использоваться правильно. На этапе компиляции.
*Это выглядит как какое-то исследование...
Вы поймали меня. По сути это моя область исследования в данное время. — Sergio
Ниже можно увидеть диаграмму состояний для подмножества свойство конечного автомата GPIO (для одного пина):
Наша цель — реализовать это всё в Rust. Начнём с того, что собсна говорит нам эта диаграмма:
- GPIO начинает свою работу из состояния
START
- Из состояния
START
мы можем перейти в следующие состояния:
ALT
, у которого нет переходов в другие состоянияOUTPUT
— с двумя доступными переходами в себя же:SET
иCLEAR
INPUT
— с одним переходом по имениLEVEL
Какие переходы вы использовали в лабе 0? [blinky-states]
Когда вы писали код для фазы 4 из лабы 0, вы по сути неявно реализовывали подмножество нашего конечного автомата. Какие переходы между состояниями при этом реализовывались?
Мы будем использовать систему типов Rust дла того, чтоб предоставить гарантии того, что пин умеет только SET
и CLEAR
, если он в состоянии OUTPUT
и только LEVEL
, если в состоянии INPUT
. Взгляните на объявление структуры GPIO
из файлика pi/src/gpio.rs
:
pub struct Gpio<State> {
pin: u8,
registers: &'static mut Registers,
_state: PhantomData<State>
}
Структура имеет один обобщённый аргумент по имени State
. Его использует только PhantomData
и более никто. Собственно ради такого PhantomData и существует: для того, чтоб убедить Rust, что структура каким-то образом использует обобщённый аргумент. Мы собираемся использовать State
как маркер того, в каком состоянии находится Gpio
. При этом нам ещё нужно гарантировать, что конкретное значение для этого параметра нельзя будет создать.
Макрос state!
генерирует такие типы, которые вроде бы и есть, но создать их нельзя. В данном случае он генерирует список состояний, в которых может находиться Gpio
:
states! {
Uninitialized, Input, Output, Alt
}
// И каждый из них по сути будет чем-то вроде такого:
enum Input { }
Это выглядит странным. Зачем нам создавать перечисления без каких либо возможных значений? У них есть одно приятное свойсво. Их нельзя создать. Но их можно использовать как маркеры. Никто никогда не сможет передать нам значение типа Input
ибо его нельзя создать. Они живут и сущесвуют только на уровне типов и нигде более.
Затем можно реализовать методы для каждого состояния с соответсвующим набором переходов:
impl Gpio<Output> {
/// Включаем пин
pub fn set(&mut self) { ... }
/// Выключаем пин
pub fn clear(&mut self) { ... }
}
impl Gpio<Input> {
/// Читаем текущее значение у пина
pub fn level(&mut self) -> bool { ... }
}
Это уже похоже на гарантию того, что Gpio
можно теребить только строго определённым образом в зависимости от состояния. Неплохо, да? Но как нам этих состояний достичь? Для этого у нас есть метод Gpio::transition()
:
impl<T> Gpio<T> {
fn transition<S>(self) -> Gpio<S> {
Gpio {
pin: self.pin,
registers: self.registers,
_state: PhantomData
}
}
}
Этот метод позволяет легко и свободно переводить Gpio
из одного состояния в другое. Получает Gpio
в состоянии T
и отдаёт Gpio
в состоянии S
. Обратите внимание, что оно работает для любых S
и T
. Мы должны очень осторожно использовать этот метод. Если мы ошибёмся во всём этом, то наш драйвер можно будет считать написанным неправильно.
Для того, чтоб использовать transition()
нам нужно указать тип S
для Gpio<S>
. Мы предоставляем Rust'у достаточно информации, чтоб он мог вывести это всё самостоятельно. Например реализация метода into_output
:
pub fn into_output(self) -> Gpio<Output> {
self.into_alt(Function::Output).transition()
}
Этот метод требует, чтоб его возвращаемый тип был Gpio<Output>
. Когда система типов Rust смотрит на вызов transition()
, то ищет метод Gpio::transition()
, который возвратит Gpio<Output>
. Находит он метод, который возвращает Gpio<S>
, который существует для любого S
. Соответственно вместо S
можно спокойно подставить Output
. В итоге он преобразует Gpio<Alt>
(от функции into_alt
) в Gpio<Output>
.
Что будет не так, если клиент сможет передавать произвольные состояния? [fake-states ]
Подумайте, что произойдёт, если мы позволим пользовательскому коду свободно выбирать начальное состояние для структурыGpio
. Что может пойти не так?
Почему это всё возможно только в Rust?
Обратите внимание на тот маленький факт, чтоinto_
-переходы используют семантику перемещения. Это означает, что как толькоGpio
переходит в другое состояние, то уже не может быть доступно в предыдущем состоянии. До тех пор, пока тип не реализуетClone
,Copy
и некоторые другие способы дублирования, обратный переход недоступен. Никакой другой язык так не умеет. ДажеC++
. Подобное колдунсво во время компеляции со всеми гарантиями есть только тут. (Гуру в плюсах или чём либо ещё могут попробовать оспорить это утверждение)
Реализация драйвера
Напишите весь необходимый код вместо unimplemented!()
в файле pi/src/gpio.rs
. Из комментариев и сигнатур всех этих методов можно понять при помощи дедукции их ожидаемую функциональность. Не лишним будет обратиться к документации (страница 89, раздел 6 из мануала по BCM2837). Не забудьте о полезности cargo check
.
Подсказка: помните, что можно создавать произвольные лексические области видимости при помощи фигурных скобочек { ... }
.
Тестирование драйвера
Очевидно, что для тестирования драйвера нам нужно написать немного кода в файлике kernel/src/kmain.rs
.
На этот раз вместо чтения/записи непосредственно в сами регистры мы будем использовать наш драйвер для того, чтоб мигать светодиодиком. Посредством включения/выключения GPIO-пина номер 16. При этом всём код будет выглядеть намного чище и элегантнее. Скомпиляйте ядро, загрузите его на карточку под именем kernel8.img
и запустите малинку с этим всем. Светодиод должен мигать абсолютно так же, как и ранее.
Теперь можно подключить побольше светодиодов. Используем контакты GPIO под номерами 5, 6, 13, 19 и 26. Обратитесь к диаграмме с нумерацией пинов из нулевой лабы, чтоб определить их физическое расположение. Пусть ядро мигает множеством светодиодиков так, как вы пожелаете!
Какой паттерн мигания вы выберали? [led-pattern]
По какой схеме вы решили включать/выключать светодиоды? Выбрать можно множество вариантов на свой вкус. Но если с выбором туго — можно включать и выключать их по кругу.
Как только ваш драйвер GPIO станет вполне себе рабочим — можно переходить к следующей подфазе.
Субфаза D: UART
В этой подфазе мы напишем драйвер устройства mini UART, который встроен в проц нашей малинки. Большинство работы выполняется в файлах os/pi/src/uart.rs
и os/kernel/src/kmain.rs
. Mini UART документирован на страницах 8 и 10 (разделы 2.1 и 2.2) манулала по BCM2837.
UART: Universal Asynchronous RX/TX
UART (ru) или Универсальный Синхронный ПриёмоПередатчик — это устройство и последовательный протокол для общения железок при помощи всего двух проводов. Это те самые два проводка (rx/tx), которые использовались в фазе 1 из нулевой лабы для того, чтоб подключить устройство UART на USB-модуле CP2102 к устройству UART на малинке. По UART можно отправлять любые данные: текст, двоичные файлы, картинки с котиками и на что там ещё фантазии хватит. В качестве примера прямо в следующей подфазе мы напишем интерактивную оболочку, которая будет читать с UART на малинке и писать в UART на CP2102. В фазе 4 мы будем передавать двоичную информацию примерно тем же самым способом.
У протокола UART есть несколько конфигурационных параметров. Как приёмник, так и передатчик должны быть настроены идентично для того, чтоб всё заработало. Вот такие эти параметры:
- Data Size — длинна одного кадра с данными (8 или 9 бит)
- Parity Bit — следует ли отправлять бит чётности (контрольный бит) после данных
- Stop Bits — сколько бит будем использовать для определения того, что данные переданы (1 или 2 бита)
- Baud Rate — скорость передачи в битах в секунду
Mini UART не поддерживает биты чётности и поддерживает только один стоповый бит. Таким образом нам требуется сконфигурировать только скорость передачи и длину кадра. Чуть подробнее про сам UART можно читнуть в документе под названием Basics of UART Communication (нуждается в переводе?).
Реализация драйвера
На этом этапе у нас есть все необходимые инструменты для написания драйвера устройства без того, чтоб расписывать каждый шаг. Мои поздравления!
Задача состоит в том, чтоб реализовать всё необходимое в файлике pi/src/uart.rs
. Потребуется дописать содержимое структуры Registers
. При этом используйте вариант типа Volatile
с минимально необходимым набором возможности для каждого регистра. Те регистры, которые доступны только для чтения должны использовать ReadVolatile
. Если разрешена только записть, то WriteVolatile
. Для зарезервированного пространсва есть Reserved
. После этого всего допишите функцию new()
и установите скорость 115200 (делитель 270) и длинну кадра данных в 8 бит. После всего замените все unimplemented!()
на то, что там должно быть по смыслу. Ну и реализуйте трейты fmt::Write
, io::Read
и io::Write
для типа MiniUart
.
Подсказка: вам потребуется писать в регистрыLCR
,BAUD
иCNTL
для функцииnew
/
Подсказка: используйте драйвер GPIO из прошлой подфазы.
Тестирование драйвера
Проверьте драйвер, написав код (в kernel/src/kmain.rs
), который возвращает переданное обратно в неизменном виде. В псевдокоде это выглядит похожим на такое:
loop {
write_byte(read_byte())
}
Используйте screen /dev/<имя_устройства> 115200
для связи по UART. screen
отправит все нажатые кнопочки клавиатуры через TTY на малинку. Если всё правильно, то ещё и прочитает оттуда их же в неизменном виде. Т.е. вы увидите все набранные символы. Можно попробовать отправлять дополнительные парочку символов каждый раз при получении:
loop {
write_byte(read_byte())
write_str("<-")
}
Как только наш драйвер будет работать так, как от него ожидают — можно переходить к следующей подфазе.
Субфаза E: The Shell
В этой подфазе мы будем использовать наш свеженький драйвер UART для реализации простенькой оболочки к нашей операционной системе. Основная работа ведётся в файлах os/kernel/src/console.rs
, os/kernel/src/shell.rs
и os/kernel/src/kmain.rs
.
Console
Для того, чтоб написать шелл, нам понадобится что-то похожее на глобальный ввод/вывод по умолчанию. Unix и его друзья с родсвенниками предоставляют программам stdin
и stdout
для подобных целей. Мы будем использовать Console
для наших скромных нужд. Console
позволит нам реализовать макросы kprint!
и kprintln!
. Оные будут делать примерно то же самое, что и print!
и println!
. Только на уровне ядра. Наши макросы будут использовать Console
для того, чтоб делать своё чорное дело.
Взгляните на os/kernel/src/console.rs
. Файлик содержит в себе недописанную реализацию структуры Console
. По сути это синглтон-обвязка вокруг MiniUart
. Только один экземпляр консольки сущесвует в нашем ядре. При этом он доступен везде и всегда. Это всё позволит нам взаимодействовать с MiniUart
без явной передачи экземпляра MiniUart
или экземпляра Console
.
Глобальная изменяемость
Понятие глобальной изменяемой структуры — довольно страшная, грязная и противная мысль. Особенно в контексте Rust. В конце концов Rust не допускает более одной изменяемой ссылки на какое либо значение. Как нам убедить его в использовании такого количесва, в котором мы имеем потребность? Тут есть один трюк, который полагается на unsafe
. Идея такая: мы говорим Rust'y, что будем только читать значения, т.е. использовать неизменяемую ссылку, но на самом деле мы будем небезопастно преобразовывать эту "неизменяемую" ссылку в изменяемую. Мы можем создавать столько неизменяемых ссылок, сколько захотим. Rust не будет бить по нашим ручкам и всё такое. Выглядеть подобное может примерно так:
// Эта функция никогда не должна существовать!
fn make_mut<T>(value: &T) -> &mut T {
unsafe { /* чорная магия и сильное колдунство */ }
}
Это очень и очень плохая идея. Такое предложение Очень Опасно. Нам всё равно нужно будет обеспечивать, чтоб всё, что делается в unsafe
секции следовало правилам Rust. То, что мы предолжили только что явно не следует правилам. По меньшей мере правило "не более одной мутабельной ссылки за раз". Т.е. в любой точке программмы значение должно иметь не более одной изменяемой ссылки. И никак иначе.
Для того, чтоб соблюсти это правило нам нужно следующее. Вместо того, чтоб компилятор проверял за нас эту всю фигню с заимствованиями нам надо обеспечить проверку этого динамически во время выполнения проги. В результате мы можем делиться ссылками на такую структуру столько раз, сколько хотим (через &
). Ну и безопастно извлекать изменяемую ссылку, когда она нам на самом деле требуется (&T -> &mut
со всякими необходимыми проверками).
Есть много конкретных реализаций этой идеи. Одна такая гарантирует, что только одна изменяемая ссылка существует, при помощи блокировки:
fn lock<T>(value: &T) -> Locked<&mut T> {
unsafe { lock(value); cast value to Locked<&mut T> }
}
impl Drop for Locked<&mut T> {
fn drop(&mut self) { unlock(self.value) }
}
Эта штука называется Mutex и её можно найти в стандартной библиотеке. Другой способ — убивать программу, если будет создано более одной ссылки:
fn get_mut<T>(value: &T) -> Mut<&mut T> {
unsafe {
if ref_count(value) != 0 { panic!() }
ref_count(value) += 1;
cast value to Mut<&mut T>
}
}
impl Drop for Mut<&mut T> {
fn drop(&mut self) { ref_count(value) -= 1; }
}
Суть та же, что у RefCell::borrow_mut(). Ну и ещё один вариант — вернуть мутабельную ссылочку только если известно, что она такая одна уникальная:
fn get_mut<T>(value: &T) -> Option<Mut<&mut T>> {
unsafe {
if ref_count(value) != 0 { None }
else {
ref_count(value) += 1;
Some(cast value to Mut<&mut T>)
}
}
}
impl Drop for Mut<&mut T> {
fn drop(&mut self) { ref_count(value) -= 1; }
}
Это соотвественно RefCell::try_borrow_mut(). Это всё были примеры реализации различных форм "внутренней мутабельности": они позволяют изменять неизменяемое. Для Console
мы будем использовать Mutex
. Поскольку для std::Mutex
нам требуется поддержка со стороны операционной системы — у нас есть собсвенная реализация. С гейшами и го. Найти можно в файлике kernel/src/mutex.rs
. На данный момент реализация вполне себе верная, но нам придётся подправить её, когда будем использовать кеширование или параллелизм, чтоб продолжать следовать правилам Rust. На данный момент нам не важны тонкости внутренностей Mutex
, но важно понимать, как оно должно использоваться.
Итак. Глобальный синглтон объявлен как CONSOLE
в kernel/src/console.rs
. Эта глобальная переменная используется в макросах kprint!
и kprintln!
, определённых чуть ниже в файле. После того, как вы реализуете всё недостающее в Console
— вы сможете эти самые макросы использовать для вывода информации через этот самый Console
. Помимо этого можно использовать CONSOLE
для глобального доступа к Console
.
Rust помимо прочего также требует от статических глобальных переменных бытьSync
.
Чтобы схоронить значение некого типаT
вstatic
переменной, этот типT
обязан реализовыватьSync
. Это связано с тем, что Rust требует гарантий безопастности применительно к гонкам данных. Поскольку глобальные значения можно получать из любого потока, Rust должен гарантировать, что эти обращения потокобезопасны. СпецтрейтыSend
иSync
, также система владения Rust гарантируют отсутсвие гонок данных.
**Почему мы никогда не должны напрямую возвращать&mut T
? [drop-container]
Как можно заметить, каждый предоставленный пример оборачивает изменяемую ссылку в контейнер, который реализуетDrop
. Что может пойти не так, если мы можем отдавать&mut T
напрямую?
Куда идёт вызовwrite_fmt
? [write-fmt]
Вспомогательная функция_print
вызываетwrite_fmt
у экземпляраMutexGuard
(который пришел изMutex<Console>::lock()
. Какой тип имеет методwrite_fmt
и откуда берётся реализация этого метода?
Реализация и тестирование Console
Реализуйте всё, что должно быть на месте unimplemented!()
в файлике kernel/src/console.rs
. После этого попробуйте макросы kprint!
и kprintln!
в действии, написав немного кода в kernel/src/kmain.rs
, который будет выводить что либо после того, как вы приняли какой либо символ. Эти макросы используются тем же самым образом, что и print!
и println!
. Используйте screen /dev/<имя-файла> 115200
для связи с малинкой.
Если бы мы писали на Няшном Си...
Мы получаем реализациюprintln!
практически бесплатно и с нулевыми усилиями — ещё одно преимущество Rust. В Сишечке нам потребовалось бы самостоятельно реализовыватьprintf
. В Rust нам предоставили общую, абстрактную, безопасную и независимую от ОС реализацию этого всего. Бонусом она ещё и быстрее работает. Неплохо, да? Я тоже так считаю.
Подсказка: реализация методов для Console
будут очень короткими: примерно по строке на каждую.
Реализация оболочки
Теперь у нас есть абсолютно всё необходимое для реализации встроенной оболочки. Работаем в файле kernel/src/shell.rs
. Там уже есть структура Command
. Метод Command::parse()
предоставляет простой синтаксический анализатор аргументов командной строки и возвращает структуру Command
. По сути parse
разбивает переданную строку на пробелы и сохраняет все непустые аргументы в поле args
как StackVec
, используя buf
в качесве хранилища. При этом вы должны реализовать Command::path()
самостоятельно.
Используйте все уже написанные библиотеки (Command
, StackVec
, Console
через CONSOLE
, kprint!
, kprintln!
и всё, что сочтёте нужным помимо этого) для реализации функции shell
. Наша оболочка должна печатать содержимое prefix
каждый раз, когда будет ожидаться ввод. В приведённой выше гифке в качестве префикса используется "> "
. Затем оболочка должна прочитать строку ввода от пользователя, проанализировать её как команду и выполнить. Это всё должно делаться ad-infinitum. Мы только начали пилить нашу операционку и пока не можем запускать интересные команды. Однако мы можем создать простенкие команды вроде echo
.
Для того, чтоб быть завершенной наша оболочка должна:
- реализовывать встроенную команду
echo $a $b $c
, которая должна напечатать$a $b $c
- принимать символы
\r
и\n
как нажатияenter
, отмечая конец строки - принимать backspace и delete (ASCII 8 и 127) для удаления одного символа
- звонить в колокольчик (ASCII 7), если переданы не обрабатываемые невидимые символы
- выводить
unknown command: $command
для всех несуществующих$command
- не позволять стирать префикс
- не позволять вводить больше символом, чем разрешено
- принимать команды длинной до 512 байт
- принимать до 64 аргументов на команду
- начинать новую строку без всяких ошибок и уже с
prefix
, если пользователь ввёл пустую команду - выводить
error: too many arguments
если передано слишком много аргументов
Протестируйте shell
. Вызовите нашу функцию из kernel/src/kmain.rs
. Если не считать баннера с SOS, то взаимодействие с оболочкой должно быть похоже на то, что на гифке. Кроме того вам надо проверить все требования, которые мы предоставили. Как только оболочка заработает — наслаждайтесь достижениями. Затем можно перейти к следующему этапу.
Подсказки:
Байтовый литералb'a'
имеет типu8
и его код соответсвует символу'a'
Используйте\u{b}
в строковом литерале для печати любого символа с байтовым значением ASCIIb
Вы должны вывести и\r
и\n
символы для перевода строки
Для того, чтоб стереть символ можно вывести backspace, затем пробел, затем снова backspace
ИспользуйтеStackVec
для пользовательского ввода
Функция std::str::from_utf8 будет весьма и весьма полезна
Мы не используем реальныйstd
!
Напомню, что мы используем кастомную пользовательскую реализациюstd
в нашем ядре. Реализация не полная. Для того, чтоб увидеть, что там есть можно выполнитьxargo doc --open
в директорииos/std
.
Как наша оболочка соединяет множество разных частей вместе? [shell-lookback]
Оболочка использует большую часть кода из написанного нами. Вкратце поясните, какие части она использует и каким образом.
Фаза 4: Загрузчик
В этой фазе мы доиспользуем всё, что написали за эту часть для того, чтоб реализовать загрузчик для Raspberry Pi. Основной код пишем в os/bootloader/src/kmain.rs
Вы наверняка заметили, что монотонные движения при втыкании и вытыкании MicroSD-карточки напрягают. Загрузчик, который мы сейчас напишем, позволит нам сделать процесс более быстрым и приятным. Мы сможем оставить в покое карточку на том месте, куда всунули.
Сам загрузчик — это "ядро", которое принимает файлы, используя протокол XMODEM поверх UART. Он записывает полученные данные по известному адресу в памяти, а затем передаёт туда управление. Мы будем использовать нашу утилиту ttywrite
для отправки двоичных файлов загрузчику. В результате процесс загрузки двоичных файлов для выполнения на малинке будет выглядеть таким образом:
- Сбросить питание у малинки для запуска загрузчика
- Выполнить
ttywrite -i имя-бинарника.bin /dev/<имя-устройсва>
Загрузка двоичных файлов
По умолчанию Raspberyy Pi 3 загружает файл с именем kernel8.img
по адресу 0x80000
. Другими словами малинка последовательно, байт за байтом, копирует содержимое kernel8.img
по 0x80000
и после некоторой инициализации устанавливает ARM'овский счётчик текущей команды (program counter) на 0x80000
. В результате мы должны убедиться, что наш бинарный файл будет загружен по этому адресу. Это означает, что все адреса в бинарнике должны начинаться с 0x80000
.
Этими вопросами занимается линкёр (linker, он же компоновщик). Ему мы должны сообщить наши пожелания. Для этих всех штук мы используем скрипт компоновщика: файлик, который наш линкёр читает. В нём мы описываем, как нам присваивать адреса различным символам в двоичном файле. Этот скриптик можно найти в виде файла с именем os/kernel/ext/layout.ld
(у бутлоадера свой есть). Если открыть файлик, то можно заметить адрес 0x80000
. Строка эта как раз и указывает линкёру начинать выделение адресов с 0x80000
.
Для того, чтоб поддерживать совместимость с этим значением по умолчанию, наш загрузчик тоже должен загружать файлики по адресу 0x80000
. Но тут встаёт небольшая проблемка. Наш загрузчик тоже расположен начиная с адреса 0x80000
. Т.е. загрузка другого двоичного файла с тем же адресом приведёт к перезаписи нашего загрузчика прямо во время выполнения загрузчика! Нам надо избежать этого конфликта интересов. Для этого мы должны использовать разные начальные адреса для загрузчика и двоичных файлов. При этом мы не хотим менять стартовый адрес нашего ядра. Следовательно менять будем адрес загрузки загрузчика. Как?
Создание пространсва
Во первых нам надо выбрать новый адрес. Если посмотреть на содержимое os/bootloader/ext/layout.ld
, то можно заметить, что мы выбрали 0x4000000
в качестве начального адреса загрузчика. Это исправляет адреса в бинарнике, но сама малинка всё ещё будет загружать его по адресу 0x80000
. Впрочем мы можем попросить малинку загрузить бинирник и по другому адресу при помощи параметра kernel_address
в файлике config.txt
. Готовую версию этого файлика можно обнаружить по имени bootloader/ext/config.txt
. Убедитесь, что вы используете этот файлик для загрузки загрузчика. Т.е. скопируйте его в корень MicroSD-карточки.
По итогам этого вся память между 0x80000
и 0x4000000
будет свободна и мы можем загрузить в это "окно" наше ядро.
Достаточно ли 63.5 мегабайт? [small-kernels]
Можно подумать, что места, которое мы освободили, будет недостаточно. Один из способов ответить на эту дилему — посмотреть на размер файлов с ядрами Больших И Успешных Операционных Систем. Вроде той, которую вы используете. Подойдут ли по размеру их ядра?
Определите, насколько большой размер у файликов с ядром текущей вашей операционной системы. На новых версиях macOS их можно поискать где-то внутри/System/Library/Kernels/kernel
. На старых в/mach_kernel
. В Linux обычно где-то внутри/boot/
и под названиями вродеvmlinuz
,vmlinux
илиbzImage
. На сколько большой файл вашего ядра? Войдёт ли оный в ограничение на 63.5 мегабайт?
Реализация загрузчика
Реализовывать реализацию будем в файле bootloader/src/kmain.rs
. Там уже объявлен начальный адрес загрузчика, адрес того, куда надо грузить и максимальный размер двоичного файла. Всё это в const
верхней части файла. Помимо этого предоставлена функция jump_to
, которая оборачивает безусловный переход по адресу addr
. Тем самым мы можем установить счётчик команд в нужное нам значение. Загрузчик должен использовать всё это вместе с библиотекой pi
и библиотекой xmodem
для получения данных по UART, которые в свою очередь надо будет записать начиная с адреса памяти, по которому ожидается загрузка бинарника. Когда передача завершена, загрузчик должен выполнить наш бинарник.
Имейте ввиду, что загрузчик должен постоянно пытаться инициировать приём через XMODEM, установив при этом некоторый таймаут (например 750 милисекунд). А по истечению таймаута повторять попытку снова и снова. Если приём по каким либо причинам невозможен — следует распечатать сообщение об ошибке и снова повторить всё сначала. После того, как вы реализуете загрузчик — протестируйте его. Отправьте бинарник с ядром os/kernel/build/kernel.bin
на малинку используя утилитку ttywrite
. Если всё в порядке — вы увидите свою командную оболочку, когда подключаетесь к малинке при помощи screen
.
Зачем нужен таймаут? [bootloader-timeout]
Без таймаута и повторных попыток передачи загрузчик может зависнуть на неопределённое время при некоторых условиях. Каковы эти условия и почему зависание будет бесконечным?
Не забудьте использовать правильную версиюconfig.txt
, которая совместима с двоичными файлами загрузчика!
Подсказки:
Размер кода уkmain()
будет около 15 строк кода.
std::slice::from_raw_parts_mut окажется весьма и весьма полезным.
Тип&mut [u8]
реализуетio::Write
.
Комментарии (19)
crea7or
25.03.2018 13:45Почему каждый фанат раста пытается шпилек с торону с/с++ насовать? Без этого никак что ли?
lain8dono Автор
25.03.2018 14:59Почему каждый фанат раста пытается шпилек с торону с/с++ насовать?
Может быть по той причине, что основная цель раста — замена C/C++?
Без этого никак что ли?
Никак. Священные Войны вокруг языков программирования и технологий вообще — неотъемлемая часть IT-культуры. Вы либо получаете удовольствие от участия в этом, либо стоите в сторонке и тихо посмеиваетесь. Либо и то и другое по настроению.
crea7or
25.03.2018 16:21Может быть по той причине, что основная цель раста — замена C/C++?
Go тоже хотел заменить C++ и так и остался нишевым, а его сам гугл двигает до сих пор.creker
25.03.2018 16:36Хотел и заменил там, где планировали. Гугл его кстати не двигает, это самостоятельная организация по большей части. Rust уготовано тоже самое. Это уже проблема C++, что он проник в такое количество сфер применения. И каждый лепит из него своего уродца, лишь бы всунуть куда-нить еще. Из некоторых сфер его вполне можно убрать, намного улучшив жизнь программиста, чем и заняты новые языки все эти.
crea7or
25.03.2018 16:49А если не уберёте то что? Откуда столько агрессии к плюсам?
lain8dono Автор
25.03.2018 17:32Откуда столько агрессии к плюсам?
От размера спецификаций к ним. Например. От того, что порог вхождения низкий. От шаблонов. От легаси.
А если не уберёте то что?
Уберём. А ещё и новые создадим.
crea7or
25.03.2018 18:30+1От размера спецификаций к ним. Например. От того, что порог вхождения низкий. От шаблонов. От легаси.
Так вы просто не умеете. Зачем тогда туда лезть — не понимаю.
kovserg
Чем больше смотрю на это безобразие том больше кажется что обычный C здесь более уместен чем Rust.
red75prim
А конкретнее, что именно не устраивает? В каких строках кода безобразие?
red75prim
Мне, например, нравится возможность сказать компилятору: "Если я сделаю эту глупость — программу не компилируй".
lain8dono Автор
На няшном код выглядел бы более лаконичным. Но код такого же качества на оном было бы не так уж и просто писать на самом деле. Например практически весь код курса уже сейчас потокобезопасен. Там только mutex пофиксить. Без настроек mmu правильный вариант работать не будет (я успел проверить, ага).
Плюсы Rust: много гарантий из коробки, мало UB, большая выразительность, чем в няшном.
Минус Rust: у него достаточно большой порог входа. Няшный быстрее выучить на минимальном уровне.
Ещё минус Rust: он новый. Для некоторых штук код ещё не написан. На няшном код есть для всего.
Но а что лично вам не нравится? Лично мне в некоторой степени нравятся Golang, Няшный и Rust. Не сказал бы, что какой-то из них прям так уж хуже, чем другой. Просто подходы разные.
Katasonov
Няшный это си? Походу я немного отстал в плане лексикона русс.яз. программистов :)
lain8dono Автор
Его так называю только я.
Mingun
Да ладно, живой язык :) Как будто лекцию ушами прослушал. У автора определённо есть свой стиль подачи материала и он не так уж и плох. Хотите больше формальности — по тексту обильно ссылки растыканы на спецификации и даташиты, там на десятерых формализма хватит ж)