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

  • Tutorial

В этой лабе мы будем реализовывать возможность запуска пользовательских программ. Т.е. процессы и всю зависимую инфраструктуру. В начале разберёмся как переключаться из привилегированного кода, как переключать контексты процессов. Затем реализуем простенький round-robin планировщик, системные вызовы и управление виртуальной памятью. В конце концов выведем наш шелл из пространства ядра в пространство пользователя.


оригинал


Нулевая лаба


Первая лаба: младшая половина и старшая половина


Вторая лаба: младшая половина и старшая половина


Полезности


  • Книга по Rust v2. Вся необходимая инфа по Rust, необходимая в рамках этого курса.
  • Документация стандартной библиотеки Rust
  • Документация BCM2837. Наша модифицированная версия документации BCM2835 с исправлениями для BCM2837.
  • ARMv8 Reference Manual. Справочное руководство по архитектуре ARMv8. Это цельное руководство, охватывающее всю архитектуру в целом. Для конкретной реализации архитектуры см. Руководство ARM Cortex A53.
  • ARM Cortex-A53 Manual. Руководство для конкретной реализации архитектуры ARMv8 (v8.0-A). Именно это используется в малинке.
  • Руководство программиста ARMv8-A. Руководство высокого уровня по программированию процесса ARMv8-A.
  • AArch64 Procedural Call Standard
    Стандартная стандартная процедура для архитектуры AArch64.
  • ARMv8 ISA Cheat Sheet. Краткое описание инструкций сборки ARMv8, представленных в этой лабе. За авторством Griffin Dietz.

Фаза 0: Начало работы


Как и в прошлых частях, для гарантированной работы требуется:


  • Машина с современным Юниксом: Linux, BSD или macOS.
  • 64-битная ОС.
  • Наличие USB порта.
  • Установлено ПО из прошлых выпусков.

Получение кода


В репе 3-spawn нет ничего кроме вопросиков, но стащить никто не мешает:


git clone https://web.stanford.edu/class/cs140e/assignments/3-spawn/skeleton.git 3-spawn

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


cs140e
├── 0-blinky
├── 1-shell
├── 2-fs
├── 3-spawn
└── os

А вот внутри os-репы переключиться на ветку 3-spawn будет таки необходимо:


cd os
git fetch
git checkout 3-spawn
git merge 2-fs

Скорее всего вы опять увидите конфликты слияния. Что-то вроде такого:


Auto-merging kernel/src/kmain.rs
CONFLICT (content): Merge conflict in kernel/src/kmain.rs
Automatic merge failed; fix conflicts and then commit the result.

Конфликты слияния надобно будет разрешить вручную, меняя файл kmain.rs. При этом надо убедиться, что вы сохранили все свои изменения из лабы 2. После устранения конфликтов добавьте файлы git add и закоммитите это всё. Для того, чтоб получить больше инфы по этой теме — смотрите туториал на githowto.com.


Документация ARM


В этом задании мы будем постоянно ссылаться на три оффициальных документа по ARM. Вот эти три:


  1. ARMv8 Reference Manual
    Это оффициальное справочное руководство по архитектуре ARMv8. Цельное руководство, которое охватывает всю архитектуру целиком и полностью. Для конкретной реализации этой архитектуры в проце малинки нам потребуется мануал №2. Мы будем ссылаться на разделы этого большого мануала по ARMv8 по средством примечаний вида (ref: C5.2). В данном случае это означает, что надо посмотреть в ARMv8 Reference Manual в разделе C5.2.
  2. ARM Cortex-A53 Manual
    Это уже мануал по вполне конкретной реализации ARMv8 (v8.0-A), которая и используется в малинке. На этот мануал мы будем ссылаться примечаниями вида (A53: 4.3.30).
  3. ARMv8-A Programmer Guide
    Теперь перед нами достаточно высокоуровневый мануал по программированию ARMv8-А. На него мы будем ссылаться примечаниями вида (guide: 10.1)

Крайне рекомендую скачать эти мануалы к себе на диск. Так будет проще открывать их каждый раз. Особенно первый ибо оно весьма и весьма большой. Кстати об этом.


Как это вообще читать? Нам не требуется читать его целиком и полностью. По этому для начала крайне важно знать, чего мы хотим найти в этом мануале. Оный мануал имеет хорошую годную структуру. Он разбит на несколько частей. Нас интересует AArch64 и не интересует слишком глубокое погружение (мы же не производители процессоров). Значит нам не интересны многие главы от слова совсем. По факту нам достаточно частей A, B и некоторой информации из C и D. В первых двух частях описываются общие понятия применительно к архитектуре и к AArch64 в частности. В части C описывается набор инструкций. Эту часть мы будем использовать как справочную по самым основным инструкциям и регистрам (например SIMD нас не интересует сейчас). В части D описываются некоторые детали AArch64. В частности про прерывания и всё такое.


Фаза 1: ARM and a Leg (Рука и нога)


В этой фазе мы будем изучать архитектуру ARMv8, переключаться на менее привилегированный уровень, настраивать векторы исключений процессора, обрабатывать прерывание таймера и прерывание точек останова. Изучим уровни исключений в архитектуре ARM. Главным образом нам интересно как эти самые исключения и прерывания ловить.


Субфаза A: Обзор ARMv8


В этой подфазе мы будем изучать архитектуру ARMv8. Тут мы не будем писать какой либо код, но тут есть вопросы для самопроверки.


ARM (Acron RISC Machine) — это архитектура микропроцессоров с более чем 30 летней историей. На текущий момент есть восемь версий этой архитектуры. Последняя ARMv8 была представлена в 2011 году. Чип BCM2837 от Broadcom содержит в себе ядра ARM Cortex-A53, которые являются ядрами, основанными на ARMv8.0. Cortex-A53 (и подобные) — это реализация архитектуры. И это та реализация, которую мы будем изучать во всей этой части.


Микропроцессоры ARM доминируют на мобильном рынке.

ARM это примерно 95% всего мирового рынка смартфонов и 100% флагманских смартфонов. Включая Apple iPhone или Google Pixel.

До сих пор мы старались избегать делатей, относящихся к архитектуре процессора. За нас всё делал Rust. Для того, чтоб у нас работали процессы в юзерспейсе, нам потребуется провести некоторое количество работ на низком уровне. Программирование на проце напрямую потребует ознакомления с ассемблером этой архитектуры и со всеми связанными концепциями вокруг этого. Мы начнём с обзора архитектуры и разберёмся с самымми основными ассемблерными инструкциями.


Регистры


В архитектуре ARMv8 есть следующие регистры (ref: D1.2.1):


  • r0...r30 — 64-битные регистры общего назначения. Доступ к регистрам осуществляется по псевдонимам (алиасам). Регистры x0...x30 являются алиасами для 64-битной версии (т.е. полной). Ещё есть алиасы w0...w30. По последним осуществляется доступ к нижним 32 битам регистра.
  • lr — 64-битный ссылочный регистр. Алиас для x30. Используется для хранения адреса перехода. Инструкция bl <addr> сохраняет текущий счётчик команд (PC) в lr и переходит на адрес addr. Обратную работу будет делать инструкция ret. Она возьмёт адрес из lr и присвоит его PC.
  • sp — указатель стека. Нижние 32 бита доступны по алиасу wsp. Указатель стека всегда должен быть выровнен по 16 байт.
  • pc — програмный счётчик. В этот регистр нельзя писать напрямую, но можно прочитать. Он обновляется на инструкциях перехода, при вызове прерываний, при возврате.
  • v0...v31 — 128-битные SIMD и FP регистры. Эти используются для векторных SIMD операций и для операций с плавающей запятой. Эти регистры доступны по алиасам. q0...q31 — алиасы для всех 128 бит регистра. Регистры d0...d31 это нижние 64 бита. По мимо этого есть алиасы для нижних 32, 16 и 8 бит по префиксам s, h и b соотвесвенно.
  • xzr — нулевой регистр. Это псевдорегистр, который может быть или не быть хардварным регистром. Всегда содержит 0. Этот регистр можно только читать.

Есть ещё много регистров специального назначения. О них мы поговорим чуть позже.


PSTATE


В любой момент времени проц ARMv8 даёт возможность получить доступ к состоянию программы через псевдорегистр по имени PSTATE (ref: D1.7). Это не обычный регистр. Его нельзя прочитать или записать в него напрямую. Вместо есть несколько регистров специального назначения, которые можно использовать для того, чтоб оперировать с частями псевдорегистра PSTATE. На ARMv8.0 это:


  • NZCV — флаги состояния
  • DAIF — битовая маска исключений, которая используется для того, чтоб включать и выключать эти самые исключения
  • CurrentEL — текущий уровень исключений (будет описан позже)
  • SPSel — селектор указателей стека (их на самом деле несколько)

Подобные регистры принадлежат к классу системных или специальных регистров (ref: C5.2). Обычные регистры можно прочитать из оперативной памяти при помощи ldr или записать в память при помощи str. Системные регистры так использовать не получится. Вместо этого требуется использовать специальные команды mrs и msr (ref: C6.2.162 — C6.2.164). Например для того, чтоб прочитать NZCV в x1 нам следует использовать следующую запись:


mrs x1, NZCV

Состояние выполнения


В любой момент времени проц ARMv8 выполняется с определённым состоянием выполнения (execution state). Всего есть ровно два таких состояния. AArch32 — режим совместимости с 32-битным ARMv7. И AArch64 — 64-битный режим ARMv8 (guide: 3.1). Мы будем работать только с AArch64.


Безопасный режим


В любой момент времени наш проц исполняется с определённым состоянием безопасности (security state) (guide: 3). Эту фигню можно искать ещё и по security mode или по security world. Всего два состояния: secure и non-secure. Т.е. безопасное и обычное. Мы будем работать целиком в обычном режиме.


Уровни исключений


По мимо этого есть ещё и уровни исключений (exception level) (guide: 3). Каждый уровень исключений соответствует определённому уровню привилегий. Чем выше уровень исключений, тем больше привилегий получает программа, запущенная на этом уровне. Всего есть 4 уровня:


  • EL0 (user) — Обычно используется для запуска пользовательских прог.
  • EL1 (kernel) — Привилегированный режим. Обычно тут запускается ядро операционной системы.
  • EL2 (hypervisor) — Обычно используется для запуска гипервизоров виртуальных машин.
  • EL3 (monitor) — Обычно используется для низкоуровневой прошивки.

Процессор Raspberry Pi загружается в EL3. На этом этапе запускается прошивка, предоставляемая фондом Raspberry Pi. Прошивка переключает процессор на EL2 и запускает наш файлик kernel8.img. Таким образом наше ядро запускается с уровня EL2. Чуть позже мы займёмся переключением из EL2 на EL1, чтоб наше ядро работало на соответствующем уровне исключений.


Регистры ELx


Некоторое количество системных регистров, таких как ELR, SPSR и SP, дублируются для каждого уровня исключений. При этом к их именам ставится суффикс _ELn, где n — уровень исключений, к которому этот регистр относится. Например ELR_EL1 является регистром ссылок исключений для уровня EL1, а ELR_EL2 — тоже самое, но для уровня EL2.


Мы будем использовать суффикс x (например в ELR_ELx), когда надо сослаться на регистр из целевого уровня исключения x. Целевой уровень исключений — это уровень исключений, на который CPU переключится (если необходимо) при запуске вектора исключения.


Мы будем использовать суффикс s (например в SP_ELs, когда надо сослаться на регистр в исходном уровне исключения s. Исходный уровень исключения — это уровень исключения, на котором CPU выполнялся до возникновения исключения.


Переключение между уровнями исключений


Существует ровно один механизм увеличения уровня исключения и ровно один механизм для уменьшения уровня исключения.


Для переключения с более высокого уровня на более низкий уровень (уменьшение привилегий), работающая программа должно выполнить возврат (return) из этого уровня исключения при помощи команды eret (ref: D1.11). При выполнении команды eret для уровня ELx процессор:


  • Установит PC на значение из спец-регистра ELR_ELx.
  • Установит PSTATE на значение из спец-регистра SPSR_ELx.

Регистр SPSR_ELx (ref: C5.2.18) по мимо прочего содержит в себе тот уровень исключений, на который надо перейти. Кроме того стоит обратить внимание на следующие дополнительные последствия смены уровней исключений:


  • При возврате к ELs, sp устанавливается в SP_ELs если SPSR_ELx[0] == 1 или в SP_EL0 если SPSR_ELx[0] == 0.

Переход от более низкого уровня к более высокому происходит только в результате исключения (guide: 10). Если не настроено иначе, то проц будет перехватывать исключения для следующего уровня. Например в случае, если прерывание получено во время работы в EL0, то проц переключится на EL1 для обработки исключения. При переходе на ELx проц будет делать следующее:


  • Выключит (замаскирует) все исключения и прерывания: PSTATE.DAIF = 0b1111.
  • Сохранит PSTATE и всякое в SPSR_ELx.
  • Сохранит адрес возврата в ELR_ELx (ref: D1.10.1).
  • Установит sp на SP_ELx если SPSel равен 1.
  • Установит синдром исключения (опишем это позже) в ESR_ELx (ref: D1.10.4).
  • Установит pc на адрес, соответствующий вектору исключения (опишем чуть позже).

Обратите внимание, что регистр синдрома исключения действителен только для синхронных исключений. Все регистры общего назначения и регистры SIMD/FP будут содержать значения, которые они имели при возникновении исключения.


Векторы исключений


При возникновении исключений CPU передаёт управление в то место, где располагается вектор исключений (ref: D1.10.2). Существует 4 типа исключений, каждый из которых содержит 4 возможных источника исключений. Т.е. всего 16 векторов исключений. Вот четыре типа исключений:


  • Synchronous — исключения, вызванные инструкциями типа svc или brk. Ну и вообще для всяких событий, в которых повинен программер.
  • IRQ — асинхронные прерывания из внешних источников.
  • FIQ — асинхронные прерывания из внешних источников. Версия для быстрой обработки.
  • SError — прерывания типа "system error".

А вот четыре источника прерываний:


  • Текущий уровень исключений при SP = SP_EL0
  • Текущий уровень исключений при SP = SP_ELx
  • Более низкий уровень исключений, на котором выполняется AArch64
  • Более низкий уровень исключений, на котором выполняется AArch32

Из описания руководства (guide: 10.4):


Когда возникает исключение, процессор должен выполнить код обработчика, который соответствует исключению. Место в памяти, где хранится обработчик [исключения], называется вектором исключения. В архитектуре ARM векторы исключений хранятся в таблице, называемой таблицей векторов исключений. Каждый уровень исключений имеет свою собственную таблицу векторов, то есть для каждого из EL3, EL2 и EL1. Таблица содержит инструкции для выполнения, а не набор адресов [как в x86]. Каждая запись в векторной таблице имеет размер в 16 инструкций. Векторы для отдельных исключений расположены с фиксированными смещениями с начала таблицы. Виртуальный адрес каждой таблицы основывается на [специальных] векторных адресных регистрах VBAR_EL3, VBAR_EL2 и VBAR_EL1.

Эти векторы физически расположены в памяти следующим образом:


Текущий уровень исключений при SP = SP_EL0


Смещение от VBAR_ELx Исключение
0x000 Synchronous exception
0x080 IRQ
0x100 FIQ
0x180 SError

Текущий уровень исключений при SP = SP_ELx


Смещение от VBAR_ELx Исключение
0x200 Synchronous exception
0x280 IRQ
0x300 FIQ
0x380 SError

Более низкий уровень исключений, на котором выполняется AArch64


Смещение от VBAR_ELx Исключение
0x400 Synchronous exception
0x480 IRQ
0x500 FIQ
0x580 SError

Более низкий уровень исключений, на котором выполняется AArch32


Смещение от VBAR_ELx Исключение
0x600 Synchronous exception
0x680 IRQ
0x700 FIQ
0x780 SError

Резюме


На текущий момент это всё, что нам следует знать об архитектуре ARMv8. Прежде чем продолжить, постарайтесь ответить на эти вопросы. Для самопроверки.


Какие есть псевдонимы у регистра x30? [arm-x30]

Если мы запишем 0xFFFF в регистр x30, то какие два других имени этого регистра мы можем использовать для извлечения этого значения?

Как можно поменять значение PC на определённый адрес? [arm-pc]

Как можно установить PC на адрес A при помощи инструкции ret? Как установить PC на адрес A при помощи инструкции eret? Укажите, какие регистры вы будете изменять для того, чтоб этого достичь.

Каким образом можно определить текущий уровень исключений? [arm-el]

Какие именно инструкции вы бы выполнили для определения текущего уровня исключения?

Как бы вы изменили указатель стека на возврат исключения? [arm-sp-el]

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

Какой вектор используется для системных вызовов из более низкого EL? [arm-svc]

Пользовательский процесс выполняется на EL0. Этот процесс вызывает svc. По какому адресу будет передано управление?

Какой вектор используется для прерываний из более низкого EL? [arm-int]

Пользовательский процесс выполняется на EL0. В этот момент возникает прерывание таймера. По какому адресу будет передано управление?

Каким образом можно включить обработку IRQ исключений? [arm-mask]

В какой регистр какие значения надо записать для того, чтоб разблокировать прерывания IRQ?

Каким образом вы бы использовали eret для включения режима AArch32? [arm-aarch32]

Источником исключения является AArch64. Обработчик этого исключения тоже на AArch64. Какие значения в каких регистрах вы бы изменили для того, чтоб при возврате из исключения через eret проц переключился в режим выполнения AArch32?
Подсказка: смотреть (guide: 10.1)

Субфаза B: Инструкции ассемблера



В этой подфазе мы изучим самые базовые команды из набора команд ARMv8. Писать код прямо сейчас не будем, но тут есть парочка вопросов для самопроверки.


Доступ к памяти


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


Существует множество инструкций для загрузки/выгрузки (load/store) в различных вариациях (по большей части они однотипны). Начнём с самой простой формы:


  • ldr <ra>, [<rb>]: загружает значение из адреса <rb> в <ra>.
  • str <ra>, [<rb>]: сохраняет значение <ra> по адресу из <rb>.

Регистр <rb> называется базовым регистром. Например если r3 = 0x1234, то:


ldr r0, [r3]      // r0 = *r3 (то есть, r0 = *(0x1234))
str r0, [r3]      // *r3 = r0 (то есть, *(0x1234) = r0)

Кроме этого можно добавить смещение из промежутка [-256, 255]:


ldr r0, [r3, #64]      // r0 = *(r3 + 64)
str r0, [r3, #-12]     // *(r3 - 12) = r0

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


ldr r0, [r3], #30      // r0 = *r3; r3 += 30
str r0, [r3], #-12     // *r3 = r0; r3 -= 12

Или пре-индекс, который изменит значение в базовом регистре перед применения загрузки или сохранения:


ldr r0, [r3, #30]!     // r3 += 30; r0 = *r3
str r0, [r3, #-12]!    // r3 -= 12; *r3 = r0

Смещение, пост-индекс и пре-индекс, они известны как режимы адресации.


Помимо этого есть ещё команда, которая может загружать/выгружать сразу два регистра. Инструкции ldp и stp (load pair, store pair). Эти инструкции можно использовать с теми же режимами адресации, что и ldr и str.


// кладём `x0` и `x1` на стек. после этой операции стек будет:
//
//   |------| <x (оригинальный SP)
//   |  x1  |
//   |------|
//   |  x0  |
//   |------| <- SP
//
stp x0, x1, [SP, #-16]!

// вынимаем `x0` и `x1` со стека. после этой операции стек будет:
//
//   |------| <- SP
//   |  x1  |
//   |------|
//   |  x0  |
//   |------| <x (original SP)
//
ldp x0, x1, [SP], #16

// эти четыре операции выполняют то же самое, что и предыдущие две
sub SP, SP, #16
stp x0, x1, [SP]
ldp x0, x1, [SP]
add SP, SP, #16

// Всё тоже самое, но уже для четырёх регистров x0, x1, x2, и x3.
sub SP, SP, #32
stp x0, x1, [SP]
stp x2, x3, [SP, #16]

ldp x0, x1, [SP]
ldp x2, x3, [SP, #16]
add SP, SP, #32

Непосредственная загрузка значений


Непосредственное (immediate) значение — это другое имя для целого числа, значение которого известно безо всяких вычислений. Для того, чтоб загрузить (например) 16 бит immediate в регистр, опционально сдвинув его на некоторое количество бит влево, нам нужна команда mov (move). Для того, чтоб загрузить те же самые 16 бит со сдвигом, но без замены остальных бит, нам потребуется movk (move/keep). Вот пример использования всего этого:


mov   x0, #0xABCD, LSL #32  // x0 = 0xABCD00000000
mov   x0, #0x1234, LSL #16  // x0 = 0x12340000

mov   x1, #0xBEEF           // x1 = 0xBEEF
movk  x1, #0xDEAD, LSL #16  // x1 = 0xDEADBEEF
movk  x1, #0xF00D, LSL #32  // x1 = 0xF00DDEADBEEF
movk  x1, #0xFEED, LSL #48  // x1 = 0xFEEDF00DDEADBEEF

Обратите внимание, что сами загружаемые значения имеют префикс #. LSL при этом всём обозначает сдвиг влево.


В регистр может быть загружено только 16 бит с опциональным сдвигом. Кстати ассемблер может во многих случаях сам определить необходимый сдвиг. Например автоматически заменить mov x12, #(1 << 21) на mov x12, 0x20, LSL #16.


Загрузка адресов из меток


Секции в ассемблере можно пометить метками в форме <label>::


add_30:
    add x1, x1, #10
    add x1, x1, #20

Для того, чтоб загрузить адрес первой инструкции после метки, можно использовать инструкции adr или ldr:


adr x0, add_30    // x0 = адрес первой инструкции после add_30
ldr x0, =add_30   // x0 = адрес первой инструкции после add_30

Вы должны использовать ldr если метка не находится в той же секции компоновщика. В противном случае следует использовать adr.


Перемещение данных между регистрами


Для того, чтоб перемещать данные между регистрами, следует использовать уже знакомую нам инструкцию mov:


mov  x13, #23    //          x13 = 23
mov  sp, x13     // sp = 23, x13 = 23

Работа со специальными регистрами


Специальные и системные регистры вроде ELR_EL1 могут быть записаны/прочитаны только через регистры общего назначения и только используя специальные инструкции mrs и msr.


Для того, чтоб записать в спец-регистр надо использовать msr:


msr ELR_EL1, x1  // ELR_EL1 = x1

Для чтения из спец-регистра использовать mrs:


mrs x0, CurrentEL // x0 = CurrentEL

Арифметика


Для простейших арифметических действий нам на текущий момент будет достаточно инструкций add и sub:


add <dest> <a> <b> // dest = a + b
sub <dest> <a> <b> // dest = a - b

Для примера:


mov x2, #24
mov x3, #36
add x1, x2, x3  // x1 = 24 + 36 = 60
sub x4, x3, x2  // x4 = 36 - 24 = 12

При этом вместо параметра <b> можно использовать непосредственное значение:


sub sp, sp, #120 // sp -= 120
add x3, x1, #120 // x3 = x1 + 120
add x3, x3, #88  // x3 += 88

Логические инструкции


Инструкции and и orr используются для битовых операций AND и OR. Эквивалентно add и sub:


mov x1, 0b11001
mov x2, 0b10101

and x3, x1, x2  // x3 = x1 & x2 = 0b10001
orr x3, x1, x2  // x3 = x1 | x2 = 0b11101
orr x1, x1, x2  // x1 |= x2
and x2, x2, x1  // x2 &= x1

and x1, x1, #0b110  // x1 &= 0b110
orr x1, x1, #0b101  // x1 |= 0b101

Ветвление


Ветвление (Branching) — еще один термин для перехода на адрес. Оно изменяет PC на переданный адрес или на адрес метки. Для того, чтоб перейти без условий к какой либо метке используется инструкция b:


b label // jump to label

Чтобы перейти к метке при сохранении следующего адреса в реестре ссылок (lr), используйте bl. Команда ret перескакивает на адрес из lr:


my_function:
    add x0, x0, x1
    ret

mov  x0, #4
mov  x1, #30
bl   my_function  // lr = адрес инструкции `mov x3, x0`
mov  x3, x0       // x3 = x0 = 4 + 30 = 34

Команды br и blr аналогичны b и bl соответственно, но переходят к адресу, содержащемуся в регистре:


ldr  x0, =label
blr  x0          // идентично bl label
br   x0          // идентично b  label

Условное ветвление


Инструкция cmp может использоваться для сравнения двух регистров или регистра и значения. Она устанавливает все необходимые флаги для последующего применения таких инструкций, как bne (branch not equal), beq (branch if equal), blt (branch if less than) и т.д. (ref: C1.2.4)


// добавлять 1 к x0 до тех пор, пока он не станет равным x1,
// затем вызвать `function_when_eq`, и выйти
not_equal:
    add  x0, x0, #1
    cmp  x0, x1
    bne  not_equal
    bl   function_when_eq

exit:
    ...

// вызывается когда x0 == x1
function_when_eq:
    ret

Используя значение:


cmp  x1, #0
beq  x1_is_eq_to_zero

Обратите внимание: если ветвление не сработало, то выполнение просто продолжается со следующей инструкции.


Обобщение


В наборе команд ARMv8 имеется еще много инструкций. Вы уже знаете самые основные и этого будет достаточно для того, чтоб легко разобраться с большинством остальных инструкций. Инструкции описаны в (ref: C1.2.4). Для краткой справки приведенных выше инструкций см. Эту ISA-шпаргалку от Griffin Dietz. Прежде чем продолжить, ответьте на парочку вопросов во имя самопроверки:


Как вы могли бы написать memcpy на ассемблере ARMv8? [arm-memcpy]

Предположим, что исходный адрес лежит в x0, адрес того, куда класть в x1, а количество байт в x2 (гарантировано больше нуля и делится на 8 нацело). Каким образом вы реализовали бы memcpy? Убедитесь, что выполните в конце ret
Подсказка: Эту функцию можно реализовать за 6-7 строк ассемблерного кода.

Как вы будете записывать значение 0xABCDE в ELR_EL1? [arm-movk]

Предположим, что прога запущена в EL1, как бы вы написали сразу 0xABCDE в регистр ELR_EL1 с помощью сборки ARMv8?
Подсказка: Понадобится три инструкции.

Что делает инструкция cbz? [arm-cbz]

Прочитайте документацию по инструкции cbz (ref: C6.2.36). Что эта инструкция делает? Для чего её можно использовать?

Что делает init.S? [asm-init]

Файлик os/kernel/ext/init.S — это часть ядра, которая запускается перед всеми остальными. В частности символ _start будет находится по адресу 0x80000 после всей инициализации прошивки малинки. Чуть позже мы пофиксим этот файлик для того, чтоб он переключался на EL1 и настраивал векторы исключений.

Прочитайте файлик os/kernel/ext/init.S примерно до context_save. Затем для каждого комментария в файле, указывающего на то, как что-то работает, объясните, что делает этот код. Например для объяснения двух комментариев (“read cpu affinity”, “core affinity != 0”) мы можем сказать что-то такое:

Первые два бита регистра MPIDR_EL1 (ref: D7.2.74) считываются (Aff0), что даёт нам номер ядра, которое в данный момент выполняет наш код. Если это число равно нулю — переходим к setup. Иначе ядро мы усыпляем ядро с помощью wfe для сохранения энергии.
Подсказка: Обратитесь к мануалу за любой инструкцией / регистром, с которыми вы еще не знакомы.

Субфаза C: Переключение в EL1


В этой подфазе мы будем писать ассемблерный код для переключения из EL2 в EL1. Основная работа идёт в файлах os/kernel/ext/init.S и os/kernel/src/kmain.rs. Рекомендуется переходить к этой подфазе только после того, как вы ответили на вопросы предыдущих подфаз.


Текущий уровень исключений


Нами уже дописаны некоторые функции в модуле aarch64 (os/kernel/src/aarch64.rs), которые используют внутри себя встраивание ассемблера для доступа к низкоуровневым сведениям о системе. Например функция sp() позволяет в любой момент времени извлекать текущий указатель стека. Или функция current_el(), которая возвращает текущий уровень исключений. Мы уже упоминали, что проц будет работать в EL2 при старте ядра. Подтвердите так ли это, отпечатав в kmain() текущий уровень исключений. Обратите внимание, что для вызова current_el() требуется unsafe. Мы уберём этот вызов, когда убедимся, что успешно перешли на уровень EL1.


Переключение


Допишите немного ассемблерного кода, чтоб переключиться на EL1. Найдите вот эту строчку в os/kernel/ext/init.S:


// FIXME: Return to EL1 at `set_stack`.

Сразу после неё есть парочка инструкций ассемблера:


mov     x2, #0x3c5
msr     SPSR_EL2, x2

Из предыдущей подфазы вы должны знать, что они делают. В частности, вы должны знать, какие биты надо установить у SPSR_EL2 и каковы будут последствия этого после вызова eret.


Допишите код переключения, заменив FIXME на правильные инструкции. Убедитесь, что проц корректно переходит на EL1 CPU и прыгает к set_stack, после чего продолжается настройка ядра. Для завершения кода вам понадобится ровно три инструкции. Напомним, что единственный способ уменьшить уровень исключения — через eret. После завершения убедитесь, что current_el() теперь возвращает 1.


Подсказка: Какой регистр используется для установки PC при возврате из исключения?

Субфаза D: Векторы исключений


В этой подфазе мы установим и настроим векторы исключений и обработчики этих самых исключений. Это будет первым шагом к тому, чтоб наше ядрышко могло обрабатывать произвольные исключения и прерывания. Вы проверите свой код обработки этого всего, написав минималистичный отладчик, который запускается в ответ на brk #n. Основная работа в файлике kernel/ext/init.S и каталоге kernel/src/traps.


Обзор


Напомним, что таблица векторов исключений состоит из 16 векторов, где каждый вектор представляет собой серию не более 16 команд. Мы выделили пространство в init.S для этих векторов и поместили метку _vectors в базу таблицы. Ваша задача состоит в том, чтобы заполнить таблицу 16 векторами таким образом, чтобы в конечном итоге функция handle_exception Rust в kernel/src/traps/mod.rs вызывалась с соответствующими аргументами при возникновении исключения. Все исключения будут перенаправлены на функцию handle_exception. Функция определит, почему произошло исключение, и отправит исключение для обработчиков более высокого уровня по мере необходимости.


Соглашения о вызовах


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


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


  • Как передать параметры функции. На AArch64 первые 8 параметров передаются через регистры r0r7 в прямом порядке слева направо.
  • Как вернуть значения из функции. На AArch64 первые 8 возвращаемых значений передаются через регистры r0r7.
  • Какое состояние (регистры, стек и т.д.) функция должна сохранять.
    Регистры обычно разделяют на caller-saved или callee-saved.
    caller-saved — не гарантируются к сохранению после вызова функции. Таким образом, если caller требует сохранения значения в регистре, он должен сохранить значение регистра перед вызовом функции.
    И наоборот. callee-saved — гарантируется сохранение во время вызова. Т.е. вызванная функция должна заботиться об этих регистрах и возвращать их в том же виде, в каком они были ей переданы.
    Значения регистров обычно сохраняются и восстанавливаются с использованием стека.
    На AArch64 регистры r19...r29 и SPcallee-saved. Остальные — caller-saved. Обратите внимание, что lr (x30) тоже входит сюда. SIMD/FP регистры имеют нетривиальные правила по части сохранения. Для наших целей достаточно будет сказать, что они тоже caller-saved.
  • Как передавать управление обратно. На AArch64 есть регистр lr, который содержит ссылку на обратный адрес. Инструкция ret переходит по адресу из lr.

В AArch64 все эти соглашения в развёрнутом виде можно почитать в (guide: 9) и в procedure call standard. Когда вы вызываете
Rust-функцию handle_exception из ассемблера, вам нужно убедиться, что вы следуете всем этим соглашениям.


Как Rust узнаёт, какое соглашение использовать?

Если строго придерживаться соглашениям о вызовах, то это исключает все виды оптимизаций с вызовами функций. В результате по умолчанию функции Rust не гарантируют соответствия каким бы то ни было соглашениям о вызовах. Для того, чтоб заставить Rust использовать при компиляции некой функции соглашения платформы, требуется добавить этой функции квалификатор extern. Мы уже объявили handle_exception как extern поэтому мы можем быть уверены, что Rust скомпилирует функцию ожидаемым образом.

Таблица векторов


Для того, чтоб помочь вам заполнить таблицу векторов, мы предоставили макросс HANDLER(source, kind), который содержит в себе последовательность из шести инструкций и необходимые пометки о выравнивании. Когда HANDLER(a, b) используется как "инструкция", он раскрывается до тех строчек, которые следуют за #define. Т.е. вот такая запись:


_vectors:
    HANDLER(32, 39)

Станет вот такой:


_vectors:
    .align 7
    stp     lr, x0, [SP, #-16]!
    mov     x0, #32
    movk    x0, #39, LSL #16
    bl      context_save
    ldp     lr, x0, [SP], #16
    eret

Этот код сохраняет lr и x0 на стеке и создаёт в x0 32-битное значение из 16 бит на source и 16 бит на kind. Затем вызывается context_save, объявленная перед _vectors. После того, как функция отдаёт управление, lr и x0 восстанавливаются из стека и в конце происходит выход из исключения.


Функция context_save в данный момент ничего не делает. Просто проваливается до ret из context_restore. Чуть позже мы изменим context_save для того, чтоб она правильно вызывала функцию из Rust.


Syndrome


Когда возникает синхронное исключение (исключение, вызванное выполнением или попыткой выполнения инструкции), проц устанавливает значение в регистре синдрома (ESR_ELx) который описывает причину этого исключения (ref: D1.10.4). Структуры для обработки этого уже можно найти в kernel/src/traps/syndrome.rs. Там же есть некоторые заготовки для анализа значения синдрома для создания Syndrome-перечисления. Чуть позже вам предстоит написать код, который передаёт значение ESR_ELx в Rust как параметр esr. Затем использовать Sydnrome::from(esr) для разбора того, чтоб определить, что дальше то делать.


Info


Функция handle_exception принимает в качестве первого параметра структуру Info. Эта структура имеет два поля по 16 бит: source и kind. Как вы могли догадаться, это 32-битное значение, которое макрос HANDLE устанавливает в x0. Вам нужно будет убедиться, что вы используете правильные HANDLE-вызовы для правильных записей, чтобы структура Info была правильно создана.


Реализация


Теперь вы готовы написать минимальный код обработки исключений. Первое исключение, которое мы будем обрабатывать — brk, т.е. точка останова. Когда возникает такое исключение, нам надо запустить интерактивную оболочку, которая теоретически позволит нам исследовать состояние машины на этот момент.


Для начала давайте вставим вызов brk в kmain. Используя ассемблерную вставку вроде такой:


unsafe { asm!("brk 2" :::: "volatile"); }

Дальше действуем следующим образом:


  1. Заполняем таблицу _vectors с использованием макроса HANDLE. Убедитесь, что ваши записи будут правильно создавать структуру Info.
  2. Вызываем handle_exception из context_save.
    Убедитесь, что сохранили/восстановили все caller-saved регистры по мере необходимости и передали соответствующие параметры. Вы должны использовать от 5 до 9 инструкций. На данный момент можно передавать 0 вместо параметра tf. Этот параметр мы будем использовать позже.
    Обратите внимание. AArch64 требует, чтоб SP был выровнен по 16 байт всякий раз, когда его используют для загрузки/восстановления. Убедитесь, что выполняете это требование.
  3. Настройте регистр VBAR, пользуясь пометкой в коде:
    // FIXME: load `_vectors` addr into appropriate register (guide: 10.4)
  4. На этом этапе handle_exception должна вызываться всякий раз, когда возникает исключение.
    В handle_exception напечатайте значение параметров info и esr и убедитесь, что они являются тем, что вы ожидаете. Затем поставьте бесконечный цикл. Для того, чтоб убедиться, что цикл не удалён оптимизацией, туда можно поставить aarch64::nop(). Нам нужно будет написать больше кода для правильного возврата из обработчика исключений, поэтому мы просто будем блокировать всё и вся на данный момент. Мы исправим это в следующей подфазе.
  5. Реализуйте методы Syndrome::from() и Fault::from().
    При этом первый метод должен вызывать второй. Вам нужно будет обратиться к (ref: D1.10.4, ref: Table D1-8) для того, чтоб реализовать всё корректно. Нажмите на “ISS encoding description” в таблице для того, чтоб посмотреть подробную информацию, как декодировать синдром для определённого класса исключений. Например вы должны убедиться, что синдром для brk 12 декодируется как Syndrome::Brk(12), а для svc 77 декодируется как Syndrome::Svc(77). Обратите внимание, что мы исключили 32-битные варианты некоторых исключений и объединили исключения, когда они идентичны, но встречаются с разными классами исключений.
  6. Запустите оболочку при возникновении исключения brk.
    Используйте метод Syndrome::from() в handle_exception для того, чтоб обнаружить исключение brk. Когда возникает такое исключение, запустите оболочку. Вы можете использовать другой префикс оболочки для разграничения между оболочками. Обратите внимание, что для синхронных исключений вы должны вызывать Syndrome::from(). В противном случае регистр ESR_ELx не будет содержать действительное значение.
    На этом этапе вам также потребуется изменить оболочку и реализовать команду exit. Когда в оболочке вызывается exit, оная должна завершить цикл и возвратить управление. Это позволит нам позже выходить из исключения brk. Вместе с подобным изменением вам возможно потребуется обернуть вызов shell() из kmain в loop { } для того, чтоб предотвратить сбои ядра.

Как только вы закончите, инструкция brk 2 в kmain должна вызывать исключение с синдромом Brk(2), source, равным CurrentSpElx и kind равным Synchronous. На этом этапе должна вызываться отладочная оболочка. Когда вызывается команда оболочки exit, оболочка должна прекратить работу и обработчик исключений должен проваливаться в бесконечный цикл.


Прежде чем продолжить, вы должны убедиться, что вы правильно определяете другие синхронные исключения. Вы должны попробовать вызвать другие инструкции, вызывающие исключение, такие как svc 3. Вы также должны попытаться целенаправленно создать прерывание данных или команд, перейдя на адрес за пределами диапазона физической памяти.


Как только все будет работать так, как вы ожидали, вы готовы перейти к следующему этапу.



UPD: следующая часть

  • +28
  • 11,3k
  • 3
Поделиться публикацией
Комментарии 3
    +2
    Спасибо, с большим удовольствием читаю Ваши переводы этого курса.
      0
      В AArch64 длинна инструкции 32бита?
        0

        Да. 32 бита. Из (ref: C1.1):


        All A64 instructions have a width of 32 bits.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое