Хабрахабр

Операционные системы с нуля; уровень 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.

  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. Значит нам не интересны многие главы от слова совсем. В части 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. S и каталоге kernel/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 параметров передаются через регистры 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.

Когда вы вызываете
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

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

  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. Прежде чем продолжить, вы должны убедиться, что вы правильно определяете другие синхронные исключения. Вы также должны попытаться целенаправленно создать прерывание данных или команд, перейдя на адрес за пределами диапазона физической памяти.

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

Не переключайтесь. На сегодня всё.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»