В этой лабе мы будем реализовывать возможность запуска пользовательских программ. Т.е. процессы и всю зависимую инфраструктуру. В начале разберёмся как переключаться из привилегированного кода, как переключать контексты процессов. Затем реализуем простенький 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
L-- 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. Вот эти три:
- ARMv8 Reference Manual
Это оффициальное справочное руководство по архитектуре ARMv8. Цельное руководство, которое охватывает всю архитектуру целиком и полностью. Для конкретной реализации этой архитектуры в проце малинки нам потребуется мануал №2. Мы будем ссылаться на разделы этого большого мануала по ARMv8 по средством примечаний вида (ref: C5.2). В данном случае это означает, что надо посмотреть в ARMv8 Reference Manual в разделе C5.2. - ARM Cortex-A53 Manual
Это уже мануал по вполне конкретной реализации ARMv8 (v8.0-A), которая и используется в малинке. На этот мануал мы будем ссылаться примечаниями вида (A53: 4.3.30). - 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 параметров передаются через регистры
r0
…r7
в прямом порядке слева направо. - Как вернуть значения из функции. На AArch64 первые 8 возвращаемых значений передаются через регистры
r0
…r7
. - Какое состояние (регистры, стек и т.д.) функция должна сохранять.
Регистры обычно разделяют на caller-saved или callee-saved.
caller-saved — не гарантируются к сохранению после вызова функции. Таким образом, если caller требует сохранения значения в регистре, он должен сохранить значение регистра перед вызовом функции.
И наоборот. callee-saved — гарантируется сохранение во время вызова. Т.е. вызванная функция должна заботиться об этих регистрах и возвращать их в том же виде, в каком они были ей переданы.
Значения регистров обычно сохраняются и восстанавливаются с использованием стека.
На AArch64 регистрыr19
...r29
иSP
— callee-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"); }
Дальше действуем следующим образом:
- Заполняем таблицу
_vectors
с использованием макросаHANDLE
. Убедитесь, что ваши записи будут правильно создавать структуруInfo
. - Вызываем
handle_exception
изcontext_save
.
Убедитесь, что сохранили/восстановили все caller-saved регистры по мере необходимости и передали соответствующие параметры. Вы должны использовать от 5 до 9 инструкций. На данный момент можно передавать0
вместо параметраtf
. Этот параметр мы будем использовать позже.
Обратите внимание. AArch64 требует, чтобSP
был выровнен по 16 байт всякий раз, когда его используют для загрузки/восстановления. Убедитесь, что выполняете это требование. - Настройте регистр
VBAR
, пользуясь пометкой в коде:
// FIXME: load `_vectors` addr into appropriate register (guide: 10.4)
- На этом этапе
handle_exception
должна вызываться всякий раз, когда возникает исключение.
Вhandle_exception
напечатайте значение параметровinfo
иesr
и убедитесь, что они являются тем, что вы ожидаете. Затем поставьте бесконечный цикл. Для того, чтоб убедиться, что цикл не удалён оптимизацией, туда можно поставитьaarch64::nop()
. Нам нужно будет написать больше кода для правильного возврата из обработчика исключений, поэтому мы просто будем блокировать всё и вся на данный момент. Мы исправим это в следующей подфазе. - Реализуйте методы
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-битные варианты некоторых исключений и объединили исключения, когда они идентичны, но встречаются с разными классами исключений. - Запустите оболочку при возникновении исключения
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
. Вы также должны попытаться целенаправленно создать прерывание данных или команд, перейдя на адрес за пределами диапазона физической памяти.
Как только все будет работать так, как вы ожидали, вы готовы перейти к следующему этапу.
На сегодня всё. Не переключайтесь.
Alex_ME
Спасибо, с большим удовольствием читаю Ваши переводы этого курса.