Операционные системы с нуля; Уровень 1 (старшая половина)

в 16:27, , рубрики: OSDev, Rust, Мисака, Мисака-Мисака, операционные системы, ОС, Программирование, программирование микроконтроллеров, системное программирование, систра МИСАКА

Операционные системы с нуля; Уровень 1 (старшая половина) - 1 Настало время следующей части. Это вторая половина перевода лабы №1. В этом выпуске мы будем писать драйверы периферии (таймер, GPIO, UART), реализуем протокол XMODEM и одну утилитку. Используя всё это мы напишем командную оболочку для нашего ядра и загрузчик, который позволит нам не тыкать microSD-карточку туда-сюда.

Младшая половина.
Начинать чтение стоит с нулевой лабы.

Фаза 3: Не раковина (Not a Seashell)

Операционные системы с нуля; Уровень 1 (старшая половина) - 2

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

Что такое драйвер?

Термин драйвер или драйвер устройства — программное обеспечение, которое напрямую взаимодействует с неким аппаратным устройством, управляет им и т.д. Драйверы предоставляют интерфейс более высокого уровня для тех устройств, которыми они управляют. Операционные системы взаимодействую с драйверами устройств, чтоб поверх оных выстроить ещё более высокий уровень абстракции над ними (ради удобства конечно же!). Например ядро Linux предоставляет ALSA (Advanced Linux Sound Architecture) — API для аудио, который взаимодействует с драйверами, которые в свою очередь напрямую общаются со звуковыми картами.

Субфаза A: Начало работы

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

Рекомендую следующую структуру каталогов для лаб и всего прочего, применительно к этому курсу:

cs140e
├── 0-blinky
│   ├── Makefile
│   ├── phase3
│   └── phase4
├── 1-shell
│   ├── ferris-wheel
│   ├── getting-started
│   ├── stack-vec
│   ├── ttywrite
│   ├── volatile
│   └── xmodem
└── os
    ├── Makefile
    ├── bootloader
    ├── kernel
    ├── pi
    ├── std
    └── 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 — вторая версия одноимённой библиотеки из фазы 2
  • std — минимальный огрызок стандартной библиотеки Rust
  • bootloader — загрузчик, который мы будем дописывать в фазе 4
  • kernel — основное ядро ОС

Весь код драйверов находится в библиотеке pi. pi использует библиотеку volatile и (опционально) std. kernel и bootloader используют pi для общения с устройствами. И помимо этого зависят от std. volatile не зависит ни от чего. Графически эти взаимоотношения будут выглядеть примерно так:

Операционные системы с нуля; Уровень 1 (старшая половина) - 3

Прошивка

Нам требуется обновить прошивку малинки перед тем, как продолжить. Загрузить это всё можно командой make fetch из каталога os. Оно загрузит необходимые материалы в папочку files/. Скопируйте firmware/bootcode.bin, firmware/config.txt и firmware/start.elf в корень microSD-карточки. Можно скопировать act-led-blink.bin из прошлой части, переименовать в kernel8.img. Так можно проверить, что всё работает. Там должен мигать зелёный светодиодик на самой малинке.

Обновлённый volatile

Эта библиотека из папки os немного отличается от того, кода, который изучался в фазе 2. Изменения чуточку облегчают использование этой либы в контексте написания драйверов устройств. Основные отличия такие:

  1. UniqueVolatile заменён на Unique<Volatile>
  2. Добавлен тип 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: Системный таймер

Операционные системы с нуля; Уровень 1 (старшая половина) - 4

В этой подфазе мы будем писать драйвер для встроенного таймера. Основные работы ведутся в файлах 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.

Конечные автоматы

Операционные системы с нуля; Уровень 1 (старшая половина) - 5

Все аппаратные устройства по сути можно считать конечными автоматами (eng). Они инициализируются с некоторым состоянием и переходят в другие состояния явно или не очень. При этом устройства предоставляют различные функциональные возможности в зависимости от текущего состояния. Другими словами в неких конкретных состояниях работоспособен только определённый набор переходов в другие состояния.

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

*Это выглядит как какое-то исследование...

Вы поймали меня. По сути это моя область исследования в данное время. — Sergio

Ниже можно увидеть диаграмму состояний для подмножества свойство конечного автомата GPIO (для одного пина):

Операционные системы с нуля; Уровень 1 (старшая половина) - 6

Наша цель — реализовать это всё в Rust. Начнём с того, что собсна говорит нам эта диаграмма:

  • GPIO начинает свою работу из состояния START
  • Из состояния START мы можем перейти в следующие состояния:
    1. ALT, у которого нет переходов в другие состояния
    2. OUTPUT — с двумя доступными переходами в себя же: SET и CLEAR
    3. 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]

По какой схеме вы решили включать/выключать светодиоды? Выбрать можно множество вариантов на свой вкус. Но если с выбором туго — можно включать и выключать их по кругу. image

Как только ваш драйвер 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

Операционные системы с нуля; Уровень 1 (старшая половина) - 8

Для того, чтоб написать шелл, нам понадобится что-то похожее на глобальный ввод/вывод по умолчанию. 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} в строковом литерале для печати любого символа с байтовым значением ASCII b

Вы должны вывести и 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.

Автор: lain8dono

Источник

Поделиться

* - обязательные к заполнению поля