Операционные системы с нуля; уровень 3 (младшая половина)
Т.е. В этой лабе мы будем реализовывать возможность запуска пользовательских программ. В начале разберёмся как переключаться из привилегированного кода, как переключать контексты процессов. процессы и всю зависимую инфраструктуру. В конце концов выведем наш шелл из пространства ядра в пространство пользователя. Затем реализуем простенький 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.
При этом надо убедиться, что вы сохранили все свои изменения из лабы 2. Конфликты слияния надобно будет разрешить вручную, меняя файл kmain.rs
. Для того, чтоб получить больше инфы по этой теме — смотрите туториал на githowto.com. После устранения конфликтов добавьте файлы git add
и закоммитите это всё.
Документация 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. Значит нам не интересны многие главы от слова совсем. В части C описывается набор инструкций. В первых двух частях описываются общие понятия применительно к архитектуре и к AArch64 в частности. В части D описываются некоторые детали AArch64. Эту часть мы будем использовать как справочную по самым основным инструкциям и регистрам (например SIMD нас не интересует сейчас). В частности про прерывания и всё такое.
Фаза 1: ARM and a Leg (Рука и нога)
Изучим уровни исключений в архитектуре ARM. В этой фазе мы будем изучать архитектуру ARMv8, переключаться на менее привилегированный уровень, настраивать векторы исключений процессора, обрабатывать прерывание таймера и прерывание точек останова. Главным образом нам интересно как эти самые исключения и прерывания ловить.
Субфаза A: Обзор ARMv8
Тут мы не будем писать какой либо код, но тут есть вопросы для самопроверки. В этой подфазе мы будем изучать архитектуру ARMv8.
На текущий момент есть восемь версий этой архитектуры. ARM (Acron RISC Machine) — это архитектура микропроцессоров с более чем 30 летней историей. Чип BCM2837 от Broadcom содержит в себе ядра ARM Cortex-A53, которые являются ядрами, основанными на ARMv8. Последняя ARMv8 была представлена в 2011 году. Cortex-A53 (и подобные) — это реализация архитектуры. 0. И это та реализация, которую мы будем изучать во всей этой части.
Микропроцессоры ARM доминируют на мобильном рынке.
Включая Apple iPhone или Google Pixel. ARM это примерно 95% всего мирового рынка смартфонов и 100% флагманских смартфонов.
За нас всё делал Rust. До сих пор мы старались избегать делатей, относящихся к архитектуре процессора. Программирование на проце напрямую потребует ознакомления с ассемблером этой архитектуры и со всеми связанными концепциями вокруг этого. Для того, чтоб у нас работали процессы в юзерспейсе, нам потребуется провести некоторое количество работ на низком уровне. Мы начнём с обзора архитектуры и разберёмся с самымми основными ассемблерными инструкциями.
Регистры
2. В архитектуре ARMv8 есть следующие регистры (ref: D1. 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
7). В любой момент времени проц ARMv8 даёт возможность получить доступ к состоянию программы через псевдорегистр по имени PSTATE (ref: D1. Его нельзя прочитать или записать в него напрямую. Это не обычный регистр. На ARMv8. Вместо есть несколько регистров специального назначения, которые можно использовать для того, чтоб оперировать с частями псевдорегистра PSTATE. 0 это:
NZCV
— флаги состоянияDAIF
— битовая маска исключений, которая используется для того, чтоб включать и выключать эти самые исключенияCurrentEL
— текущий уровень исключений (будет описан позже)SPSel
— селектор указателей стека (их на самом деле несколько)
2). Подобные регистры принадлежат к классу системных или специальных регистров (ref: C5. Системные регистры так использовать не получится. Обычные регистры можно прочитать из оперативной памяти при помощи ldr
или записать в память при помощи str
. 2. Вместо этого требуется использовать специальные команды mrs
и msr
(ref: C6. 2. 162 — C6. Например для того, чтоб прочитать NZCV
в x1
нам следует использовать следующую запись: 164).
mrs x1, NZCV
Состояние выполнения
Всего есть ровно два таких состояния. В любой момент времени проц ARMv8 выполняется с определённым состоянием выполнения (execution state). И AArch64 — 64-битный режим ARMv8 (guide: 3. AArch32 — режим совместимости с 32-битным ARMv7. Мы будем работать только с AArch64. 1).
Безопасный режим
Эту фигню можно искать ещё и по security mode или по security world. В любой момент времени наш проц исполняется с определённым состоянием безопасности (security state) (guide: 3). Т.е. Всего два состояния: secure и non-secure. Мы будем работать целиком в обычном режиме. безопасное и обычное.
Уровни исключений
Каждый уровень исключений соответствует определённому уровню привилегий. По мимо этого есть ещё и уровни исключений (exception level) (guide: 3). Всего есть 4 уровня: Чем выше уровень исключений, тем больше привилегий получает программа, запущенная на этом уровне.
- EL0 (user) — Обычно используется для запуска пользовательских прог.
- EL1 (kernel) — Привилегированный режим. Обычно тут запускается ядро операционной системы.
- EL2 (hypervisor) — Обычно используется для запуска гипервизоров виртуальных машин.
- EL3 (monitor) — Обычно используется для низкоуровневой прошивки.
На этом этапе запускается прошивка, предоставляемая фондом Raspberry Pi. Процессор Raspberry Pi загружается в EL3. Таким образом наше ядро запускается с уровня EL2. Прошивка переключает процессор на EL2 и запускает наш файлик kernel8.img
. Чуть позже мы займёмся переключением из EL2 на EL1, чтоб наше ядро работало на соответствующем уровне исключений.
Регистры ELx
При этом к их именам ставится суффикс _ELn
, где n
— уровень исключений, к которому этот регистр относится. Некоторое количество системных регистров, таких как ELR
, SPSR
и SP
, дублируются для каждого уровня исключений. Например ELR_EL1
является регистром ссылок исключений для уровня EL1, а ELR_EL2
— тоже самое, но для уровня EL2.
Целевой уровень исключений — это уровень исключений, на который CPU переключится (если необходимо) при запуске вектора исключения. Мы будем использовать суффикс x
(например в ELR_ELx
), когда надо сослаться на регистр из целевого уровня исключения x
.
Исходный уровень исключения — это уровень исключения, на котором CPU выполнялся до возникновения исключения. Мы будем использовать суффикс s
(например в SP_ELs
, когда надо сослаться на регистр в исходном уровне исключения s
.
Переключение между уровнями исключений
Существует ровно один механизм увеличения уровня исключения и ровно один механизм для уменьшения уровня исключения.
11). Для переключения с более высокого уровня на более низкий уровень (уменьшение привилегий), работающая программа должно выполнить возврат (return) из этого уровня исключения при помощи команды eret
(ref: D1. При выполнении команды eret
для уровня ELx
процессор:
- Установит PC на значение из спец-регистра
ELR_ELx
. - Установит PSTATE на значение из спец-регистра
SPSR_ELx
.
2. Регистр SPSR_ELx
(ref: C5. Кроме того стоит обратить внимание на следующие дополнительные последствия смены уровней исключений: 18) по мимо прочего содержит в себе тот уровень исключений, на который надо перейти.
- При возврате к
ELs
,sp
устанавливается вSP_ELs
еслиSPSR_ELx[0] == 1
или вSP_EL0
еслиSPSR_ELx[0] == 0
.
Если не настроено иначе, то проц будет перехватывать исключения для следующего уровня. Переход от более низкого уровня к более высокому происходит только в результате исключения (guide: 10). При переходе на ELx
проц будет делать следующее: Например в случае, если прерывание получено во время работы в EL0, то проц переключится на EL1 для обработки исключения.
- Выключит (замаскирует) все исключения и прерывания:
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 будут содержать значения, которые они имели при возникновении исключения. Обратите внимание, что регистр синдрома исключения действителен только для синхронных исключений.
Векторы исключений
10. При возникновении исключений CPU передаёт управление в то место, где располагается вектор исключений (ref: D1. Существует 4 типа исключений, каждый из которых содержит 4 возможных источника исключений. 2). всего 16 векторов исключений. Т.е. Вот четыре типа исключений:
- Synchronous — исключения, вызванные инструкциями типа
svc
илиbrk
. Ну и вообще для всяких событий, в которых повинен программер. - IRQ — асинхронные прерывания из внешних источников.
- FIQ — асинхронные прерывания из внешних источников. Версия для быстрой обработки.
- SError — прерывания типа "system error".
А вот четыре источника прерываний:
- Текущий уровень исключений при
SP = SP_EL0
- Текущий уровень исключений при
SP = SP_ELx
- Более низкий уровень исключений, на котором выполняется AArch64
- Более низкий уровень исключений, на котором выполняется AArch32
4): Из описания руководства (guide: 10.
Место в памяти, где хранится обработчик [исключения], называется вектором исключения. Когда возникает исключение, процессор должен выполнить код обработчика, который соответствует исключению. Каждый уровень исключений имеет свою собственную таблицу векторов, то есть для каждого из EL3, EL2 и EL1. В архитектуре ARM векторы исключений хранятся в таблице, называемой таблицей векторов исключений. Каждая запись в векторной таблице имеет размер в 16 инструкций. Таблица содержит инструкции для выполнения, а не набор адресов [как в x86]. Виртуальный адрес каждой таблицы основывается на [специальных] векторных адресных регистрах
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
при помощи инструкцииeret
? Как можно установить PC на адресA
при помощи инструкцииret
? Укажите, какие регистры вы будете изменять для того, чтоб этого достичь.Каким образом можно определить текущий уровень исключений? [arm-el]
Какие именно инструкции вы бы выполнили для определения текущего уровня исключения?
Как бы вы изменили указатель стека на возврат исключения? [arm-sp-el]
После обработки исключения вы хотите вернуться обратно туда, где выполнялась программа, но хотите изменить указатель стека на
B
. Указатель стека запущенной программы равенA
в момент возникновения исключения. Как вы это сделаете?Какой вектор используется для системных вызовов из более низкого EL? [arm-svc]
Этот процесс вызывает
svc
. Пользовательский процесс выполняется на EL0. По какому адресу будет передано управление?Какой вектор используется для прерываний из более низкого EL? [arm-int]
В этот момент возникает прерывание таймера. Пользовательский процесс выполняется на EL0. По какому адресу будет передано управление?
Каким образом можно включить обработку IRQ исключений? [arm-mask]
В какой регистр какие значения надо записать для того, чтоб разблокировать прерывания IRQ?
Каким образом вы бы использовали
eret
для включения режима AArch32? [arm-aarch32]Обработчик этого исключения тоже на AArch64. Источником исключения является AArch64. 1) Какие значения в каких регистрах вы бы изменили для того, чтоб при возврате из исключения через
eret
проц переключился в режим выполнения AArch32?
Подсказка: смотреть (guide: 10.
Субфаза B: Инструкции ассемблера
Писать код прямо сейчас не будем, но тут есть парочка вопросов для самопроверки. В этой подфазе мы изучим самые базовые команды из набора команд ARMv8.
Доступ к памяти
Определяющей особенностью такого набора команд можно назвать тот маленький факт, что доступ к памяти может осуществляться только через чётко определённые инструкции. ARMv8 — это набор инструкций загрузки/хранения RISC (компьютер с сокращённым набором команд). В частности память можно читать только путём считывания в регистр инструкцией загрузки, а записывать только инструкцией сохранения.
Начнём с самой простой формы: Существует множество инструкций для загрузки/выгрузки (load/store) в различных вариациях (по большей части они однотипны).
ldr <ra>, [<rb>]
: загружает значение из адреса<rb>
в<ra>
.str <ra>, [<rb>]
: сохраняет значение<ra>
по адресу из<rb>
.
Например если r3 = 0x1234
, то: Регистр <rb>
называется базовым регистром.
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
Непосредственная загрузка значений
Для того, чтоб загрузить (например) 16 бит immediate в регистр, опционально сдвинув его на некоторое количество бит влево, нам нужна команда mov
(move). Непосредственное (immediate) значение — это другое имя для целого числа, значение которого известно безо всяких вычислений. Вот пример использования всего этого: Для того, чтоб загрузить те же самые 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
В противном случае следует использовать adr
. Вы должны использовать ldr
если метка не находится в той же секции компоновщика.
Перемещение данных между регистрами
Для того, чтоб перемещать данные между регистрами, следует использовать уже знакомую нам инструкцию 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
Логические инструкции
Эквивалентно add
и sub
: Инструкции and
и orr
используются для битовых операций AND
и OR
.
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
Ветвление
Оно изменяет PC на переданный адрес или на адрес метки. Ветвление (Branching) — еще один термин для перехода на адрес. Для того, чтоб перейти без условий к какой либо метке используется инструкция b
:
b label // jump to label
Команда ret
перескакивает на адрес из lr
: Чтобы перейти к метке при сохранении следующего адреса в реестре ссылок (lr
), используйте bl
.
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
Условное ветвление
Она устанавливает все необходимые флаги для последующего применения таких инструкций, как bne
(branch not equal), beq
(branch if equal), blt
(branch if less than) и т.д. Инструкция cmp
может использоваться для сравнения двух регистров или регистра и значения. 2. (ref: C1. 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 имеется еще много инструкций. 2. Инструкции описаны в (ref: C1. Для краткой справки приведенных выше инструкций см. 4). Прежде чем продолжить, ответьте на парочку вопросов во имя самопроверки: Эту ISA-шпаргалку от Griffin Dietz.
Как вы могли бы написать
memcpy
на ассемблере ARMv8? [arm-memcpy]Каким образом вы реализовали бы
memcpy
? Предположим, что исходный адрес лежит вx0
, адрес того, куда класть вx1
, а количество байт вx2
(гарантировано больше нуля и делится на 8 нацело). Убедитесь, что выполните в концеret
Подсказка: Эту функцию можно реализовать за 6-7 строк ассемблерного кода.Как вы будете записывать значение
0xABCDE
вELR_EL1
? [arm-movk]Предположим, что прога запущена в
EL1
, как бы вы написали сразу0xABCDE
в регистрELR_EL1
с помощью сборки ARMv8?
Подсказка: Понадобится три инструкции.Что делает инструкция
cbz
? [arm-cbz]2. Прочитайте документацию по инструкции
cbz
(ref: C6. Что эта инструкция делает? 36). Для чего её можно использовать?S? [asm-init] Что делает
init.
S — это часть ядра, которая запускается перед всеми остальными. Файлик
os/kernel/ext/init. Чуть позже мы пофиксим этот файлик для того, чтоб он переключался на EL1 и настраивал векторы исключений. В частности символ
_start
будет находится по адресу0x80000
после всей инициализации прошивки малинки.S примерно до
context_save
. Прочитайте файликos/kernel/ext/init. Например для объяснения двух комментариев (“read cpu affinity”, “core affinity != 0”) мы можем сказать что-то такое: Затем для каждого комментария в файле, указывающего на то, как что-то работает, объясните, что делает этот код.
2. Первые два бита регистра
MPIDR_EL1
(ref: D7. Если это число равно нулю — переходим кsetup
. 74) считываются (Aff0
), что даёт нам номер ядра, которое в данный момент выполняет наш код. Иначе ядро мы усыпляем ядро с помощьюwfe
для сохранения энергии.
Подсказка: Обратитесь к мануалу за любой инструкцией / регистром, с которыми вы еще не знакомы.
Субфаза C: Переключение в EL1
Основная работа идёт в файлах os/kernel/ext/init. В этой подфазе мы будем писать ассемблерный код для переключения из EL2 в EL1. Рекомендуется переходить к этой подфазе только после того, как вы ответили на вопросы предыдущих подфаз. S
и os/kernel/src/kmain.rs
.
Текущий уровень исключений
Например функция sp()
позволяет в любой момент времени извлекать текущий указатель стека. Нами уже дописаны некоторые функции в модуле aarch64
(os/kernel/src/aarch64.rs
), которые используют внутри себя встраивание ассемблера для доступа к низкоуровневым сведениям о системе. Мы уже упоминали, что проц будет работать в EL2 при старте ядра. Или функция current_el()
, которая возвращает текущий уровень исключений. Обратите внимание, что для вызова current_el()
требуется unsafe
. Подтвердите так ли это, отпечатав в kmain()
текущий уровень исключений. Мы уберём этот вызов, когда убедимся, что успешно перешли на уровень EL1.
Переключение
Найдите вот эту строчку в os/kernel/ext/init. Допишите немного ассемблерного кода, чтоб переключиться на EL1. S
:
// FIXME: Return to EL1 at `set_stack`.
Сразу после неё есть парочка инструкций ассемблера:
mov x2, #0x3c5
msr SPSR_EL2, x2
В частности, вы должны знать, какие биты надо установить у SPSR_EL2
и каковы будут последствия этого после вызова eret
. Из предыдущей подфазы вы должны знать, что они делают.
Убедитесь, что проц корректно переходит на EL1 CPU и прыгает к set_stack
, после чего продолжается настройка ядра. Допишите код переключения, заменив FIXME
на правильные инструкции. Напомним, что единственный способ уменьшить уровень исключения — через eret
. Для завершения кода вам понадобится ровно три инструкции. После завершения убедитесь, что current_el()
теперь возвращает 1
.
Подсказка: Какой регистр используется для установки PC при возврате из исключения?
Субфаза D: Векторы исключений
Это будет первым шагом к тому, чтоб наше ядрышко могло обрабатывать произвольные исключения и прерывания. В этой подфазе мы установим и настроим векторы исключений и обработчики этих самых исключений. Основная работа в файлике kernel/ext/init. Вы проверите свой код обработки этого всего, написав минималистичный отладчик, который запускается в ответ на
и каталоге brk #n
. Skernel/src/traps
.
Обзор
Мы выделили пространство в init. Напомним, что таблица векторов исключений состоит из 16 векторов, где каждый вектор представляет собой серию не более 16 команд. Ваша задача состоит в том, чтобы заполнить таблицу 16 векторами таким образом, чтобы в конечном итоге функция
для этих векторов и поместили метку handle_exception
Rust в kernel/src/traps/mod.rs
вызывалась с соответствующими аргументами при возникновении исключения. S_vectors
в базу таблицы. Функция определит, почему произошло исключение, и отправит исключение для обработчиков более высокого уровня по мере необходимости. Все исключения будут перенаправлены на функцию handle_exception
.
Соглашения о вызовах
В частности, мы должны знать, где функция должна ожидать найти значения для своих параметров info
, esr
и tf
, что она обещает о состоянии машины после вызова функции и как она возвратит управление. Чтобы правильно вызвать функцию handle_exception
, объявленную в Rust, мы должны знать, как функция будет вызвана.
Вместо того, чтоб изучать, как этим всем занимается каждый отдельный ЯП, используются стандарты и соглашения о вызовах. Эта проблема знания вызова внешних функций возникает всякий раз, когда один язык вызывает другой (как в лабе 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
.
Когда вы вызываете
Rust-функцию handle_exception
из ассемблера, вам нужно убедиться, что вы следуете всем этим соглашениям. В AArch64 все эти соглашения в развёрнутом виде можно почитать в (guide: 9) и в procedure call standard.
Как Rust узнаёт, какое соглашение использовать?
В результате по умолчанию функции Rust не гарантируют соответствия каким бы то ни было соглашениям о вызовах. Если строго придерживаться соглашениям о вызовах, то это исключает все виды оптимизаций с вызовами функций. Мы уже объявили
handle_exception
какextern
поэтому мы можем быть уверены, что Rust скомпилирует функцию ожидаемым образом. Для того, чтоб заставить Rust использовать при компиляции некой функции соглашения платформы, требуется добавить этой функции квалификаторextern
.
Таблица векторов
Когда HANDLER(a, b)
используется как "инструкция", он раскрывается до тех строчек, которые следуют за #define
. Для того, чтоб помочь вам заполнить таблицу векторов, мы предоставили макросс HANDLER(source, kind)
, который содержит в себе последовательность из шести инструкций и необходимые пометки о выравнивании. вот такая запись: Т.е.
_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
Затем вызывается context_save
, объявленная перед _vectors
. Этот код сохраняет lr
и x0
на стеке и создаёт в x0
32-битное значение из 16 бит на source
и 16 бит на kind
. После того, как функция отдаёт управление, lr
и x0
восстанавливаются из стека и в конце происходит выход из исключения.
Просто проваливается до ret
из context_restore
. Функция context_save
в данный момент ничего не делает. Чуть позже мы изменим context_save
для того, чтоб она правильно вызывала функцию из Rust.
Syndrome
10. Когда возникает синхронное исключение (исключение, вызванное выполнением или попыткой выполнения инструкции), проц устанавливает значение в регистре синдрома (ESR_ELx
) который описывает причину этого исключения (ref: D1. Структуры для обработки этого уже можно найти в kernel/src/traps/syndrome.rs
. 4). Чуть позже вам предстоит написать код, который передаёт значение ESR_ELx
в Rust как параметр esr
. Там же есть некоторые заготовки для анализа значения синдрома для создания Syndrome
-перечисления. Затем использовать Sydnrome::from(esr)
для разбора того, чтоб определить, что дальше то делать.
Info
Эта структура имеет два поля по 16 бит: source
и kind
. Функция handle_exception
принимает в качестве первого параметра структуру Info
. Вам нужно будет убедиться, что вы используете правильные HANDLE
-вызовы для правильных записей, чтобы структура Info
была правильно создана. Как вы могли догадаться, это 32-битное значение, которое макрос HANDLE
устанавливает в x0
.
Реализация
Первое исключение, которое мы будем обрабатывать — brk
, т.е. Теперь вы готовы написать минимальный код обработки исключений. Когда возникает такое исключение, нам надо запустить интерактивную оболочку, которая теоретически позволит нам исследовать состояние машины на этот момент. точка останова.
Используя ассемблерную вставку вроде такой: Для начала давайте вставим вызов brk
в kmain
.
unsafe
Дальше действуем следующим образом:
- Заполняем таблицу
_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
. Прежде чем продолжить, вы должны убедиться, что вы правильно определяете другие синхронные исключения. Вы также должны попытаться целенаправленно создать прерывание данных или команд, перейдя на адрес за пределами диапазона физической памяти.
Как только все будет работать так, как вы ожидали, вы готовы перейти к следующему этапу.
Не переключайтесь. На сегодня всё.