[Перевод] Операционные системы с нуля; уровень 3 (старшая половина)
В этой части мы допишем обработку прерываний и возьмёмся за планировщик. Наконец-то у нас появятся элементы многозадачной операционной системы! Разумеется это только начало темы. Одно прерывание таймера, один системный вызов, базовая часть простого планировщика потоков. Ничего сложного. Однако этим мы подготовим плацдарм для создания полноценной системы, которая будет заниматься самыми настоящими процессами безо всяких «но». Прямо как в этих ваших линупсах и прочих. До конца этого курса осталось уже чуть менее половины.
Нулевая лаба
Первая лаба: младшая половина и старшая половина
Вторая лаба: младшая половина и старшая половина
Третья лаба: младшая половина
Субфаза E: Возврат из исключений
В этой субфазе мы будем писать код для возврата из обработчика исключений любых видов, форм и расцветок. Основная работа будет проводиться в файлике kernel/ext/init.S
и папке kernel/src/traps
.
Обзор
Если вы попытаетесь удалить бесконечный цикл из handle_exception
, то скорее всего Raspberry Pi войдёт в цикл исключений. Т.е. неправильно обработанные исключения будут возникать снова и снова, а в некоторых случаях будет крешиться наша debug-оболочка. Это всё связано с тем, что когда обработчик исключений пытается вернуться в точку, где код выполнялся, состояние процессора (особенно данные в регистрах) изменилось без учёта того, что в этом самом коде происходило.
Для примера рассмотрим такой вот код:
1: mov x3, #127
2: mov x4, #127
3: brk 10
4: cmp x3, x4
5: beq safety
6: b oh_no
Когда возникает исключение brk
, вызовется наш вектор исключения, который в конечном счёте вызовет handle_exception
. Эта самая функция handle_exception
, которая скомпилирована Rust-ом, будет помимо прочего использовать регистры x3
и x4
для своих грязных делишек. Когда наш обработчик исключений будет возвращаться в место вызова brk
, состояние x3
и x4
будет совсем не то, каким мы его ожидаем. Соответственно и для инструкции beq
в строке 5 не гарантируется правильное состояние. Может быть код прыгнет до safety
, а может и нет.
В результате для того, чтоб наш обработчик исключений мог использовать всё состояние проца по своему усмотрению, нам потребуется убедиться, что мы сохранили весь контекст обработки (регистры и т.д.) прежде чем этот самый обработчик начнёт свою работу. После того, как обработчик выполнит свою священную миссию, нам потребуется восстановить ранее сохранённый контекст. Всё во имя того, чтоб внешний код работал безупречно. Сам процесс сохранения/восстановления контекста называется переключением контекста (context switch).
Почему именно переключение контекста?
Кажется, что слово переключение здесь не слишком уместно. Мы же вроде как просто возвращаемся к тому же самому контексту, верно же?
В некоторых случаях это так. Однако на самом деле мы редко хотим возвращаться к тому же самому контексту выполнения. Чаще мы хотим изменить этот самый контекст для того, чтоб процессор делал всякие разные полезные штуки. Например когда нам понадобится реализовать переключение между разными процессами, мы будем подменять один контекст, на другой. Таким образом мы достигнем многозадачности. Когда мы будем реализовывать системные вызовы нам потребуется изменять значение регистров для того, чтоб реализовать возвращаемые значения. Даже в случае точек останова нам потребуется изменить регистр
ELR
для того, чтоб выполнялась следующая команда (иначе будет вызываться обработчикbrk
снова и снова).
В этой подфазе мы и будем заниматься сохранением/восстановлением контекста. Структура, которая будет содержать наш сохранённый контекст, будет называться фреймовой ловушкой (trap frame). Недописанную структуру TrapFrame
можно найти в файлике kernel/src/traps/trap_frame.rs
. Эту структуру мы будем использовать для того, чтоб получать доступ к сохранённым регистрам из Rust. С другой стороны заполнять эту структуру мы будем в ассемблерном коде. Останется только передать указатель на эту структуру через параметр tf
в функцию handle_exception
.
Trap Frame
Trap Frame — это имя, которое мы даём структуре, которая содержит весь контекст процессора. Имя «trap frame» происходит от термина «trap» (ловушка), который является общим термином для описания механизма, с помощью которого процессор вызывает более высокий уровень привилегий при возникновении некоторого события. Не знаю на счёт хорошего годного термина для обозначения этого всего на русском. Думаю в данном случае удобнее будет пользоваться только англоязычным термином.
Существуют различные способы создания trap frame, но суть их одна. Нам требуется сохранить всё состояние, которое необходимо для выполнения, в оперативную память. Большинство реализаций кладут всё состояние на стек. После того, как мы заполним стек содержимым регистров, указатель на верхушку стека станет нашим указателем на ловушку. Именно такую вариацию мы и будем в дальнейшем использовать.
На данный момент нам надо сохранить следующие части состояния ядра Cortex-A53:
x0
…x30
— т.е. все 64-битные регистры, коих целых 31 штука.q0
…q31
— все 128-битные регистры SIMD/FP.pc
— программный счётчик.
За это отвечает регистрELR_ELx
. Он может быть, а может и не быть PC. Так или иначе, но это тот адрес, куда нам следует вернуться после выполнения обработчика исключения. Обычно вELR_ELx
содержится либо PC непосредственно, либоPC + 4
, т.е. адрес следующей команды.PSTATE
— флаги состояния процессора.
Напомним, что состояние проца передаётся нам через регистрSPSR_ELx
при предыдущем уровнеELx
.sp
— указатель на границу стека.
К его содержимому можно получить доступ черезSP_ELs
для уровня исключенийs
.TPIDR
— 64-битное значение текущего «ID процесса».
Значение можно получить изTPIDR_ELs
для уровня исключенийs
.
Вот это всё нам и нужно сохранить в нашем trap frame. Сохранять будем на стеке до того, как вызовем обработчик исключений. После того, как обработчик отдаст управление обратно ассемблерному коду, нам потребуется это состояние вернуть, каким оно там было. После того, как мы положим всё необходимое на стек, его содержимое должно выглядеть примерно таким вот образом:
Обратите внимание на SP
и TPIDR
в этой структуре. Они должны быть именно указателями стека и ID потока источника, а не частью состояния прерывания. Поскольку единственным возможным источником у нас будет EL0
, их можно будет получить через чтение SP_EL0
и TPIDR_EL0
. При этом текущий SP
(который используется вектором исключения) будет указывать на начало trap frame. Сразу после того, как мы на этот самый стек положим необходимые значения разумеется.
После того, как мы заполним стек необходимыми значениями, мы передадим указатель на верх стека в качестве третьего аргумента handle_exception
. Тип этого аргумента: &mut TrapFrame
. Как уже говорилось, этот самый TrapFrame
можно найти в файлике kernel/src/traps/trap_frame.rs
. Вам необходимо дописать эту структуру.
Что за идентификатор треда?
Регистр
TPIDR
(которыйTPIDR_ELx
) позволяет операционке хранить некоторую информацию о том, что в настоящее время выполняется. Позже мы реализуем процессы и будем хранить в этом регистре идентификатор процесса. Прямо сейчас мы будем просто сохранять и восстанавливать этот регистр.
Предпочтительный адрес возврата из исключения
Когда случается исключительная ситуация на уровне ELx
, требующая обработки, CPU сохраняет предпочтительный адрес возврата в ELR_ELx
. Подробности можно найти в документации (ref: D1.10.1). Вот кой чего оттуда:
- Для асинхронных исключений это адрес первой команды, которая ещё не была выполнена, либо выполнена не полностью в тот момент, когда наше исключение возникло.
- Для синхронных исключений (кроме системных вызовов) это адрес той инструкции, которая генерирует это исключение.
- Для инструкций, которые генерируют исключения, это адрес инструкции, которая следует за инструкцией, которая это исключение генерирует.
Инструкция brk
принадлежит ко второй категории. Таким образом ежели мы хотим продолжить выполнение после команды brk
, нам потребуется убедиться, что в ELR_ELx
содержится адрес следующей инструкции. Поскольку все инструкции в AArch64 имеют размер в 32 бита, то нам будет достаточно перезаписать это значение на ELR_ELx + 4
.
Реализация
Начните с реализации context_save
и context_restore
из файлика os/kernel/ext/init.S
. Подпрограмма context_save
должна класть на стек все необходимые регистры, а затем вызывать handle_exception
, передав этой функции все необходимые аргументы, включая и trap frame в качестве третьего аргумента. После того, как разберётесь с этим, займитесь подпрограммой context_restore
. Эта подпрограмма должна восстанавливать контекст обратно.
Обратите внимание на инструкции, которые созданы макросом HANDLER
. Там уже выполняется сохранение и восстановление x0
и x30
. Вы не должны трогать эти регистры при сохранении/восстановлении в процедурах context_{save, restore}
. Однако эти регистры должны лежать в trap frame.
Для того, чтоб свести к минимуму потери производительности при переключении контекста, вам следует класть на стек и вынимать со стека значения вот таким вот образом:
// кладём на стек значения регистров `x1`, `x5`, `x12` и `x13`
sub SP, SP, #32
stp x1, x5, [SP]
stp x12, x13, [SP, #16]
// вынимаем из стека значения регистров `x1`, `x5`, `x12` и `x13`
ldp x1, x5, [SP]
ldp x12, x13, [SP, #16]
add SP, SP, #32
Убедитесь, что SP
всегда выровнен по 16 байт. Вы обнаружите, что при таком подходе будет создаваться reserved
в нашем trap frame. Этот самый reserved
следует заполнять нулями.
Как только вы закончите с этими двумя подпрограммами, займитесь структурой TrapFrame
из kernel/src/traps/trap_frame.rs
. Убедитесь, что порядок и размер полей в точности соответствует с тем, что вы сохраняете в context_save
и передаёте в качестве параметра tf
.
В конце концов добавьте в handle_exception
увеличение ELR
на 4
перед тем, как возвращаться из обработчика исключения brk
. Как только вы успешно реализуете переключение контекста, ваше ядро должно работать нормально после выхода из debug-оболочки. Когда всё будет готово — переходите к следующему этапу.
Содержимое вашего trap frame не обязано в точности соответствовать диаграмме, однако обязательно должно содержать все те же самые данные.
И не забудьте, что регистры
qn
имеют размер в 128 бит!Подсказки:
Для того, чтоб вызвать
handle_exception
вам надо будет заняться сохранением/восстановлением регистров, которые не являются частью trap frame.У Rust есть типы
u128
иi128
для значений размером 128 бит.Используйте инструкции
mrs
иmsr
для чтения/записи специальных регистров.Наша версия
context_save
занимает около 45 инструкций.Наша версия
context_restore
занимает около 41 инструкции.А наша
TrapFrame
состоит из 68 полей с общим размером в 800 байт.
Каким образом можно лениво обрабатывать регистры для чисел с плавающей запятой? [lazy-float]
Сохранение и восстановление всех 128-битных SIMD/FP регистров достаточно дорогое удовольствие. Они занимают целых 512 байт из 800 в структуре
TrapFrame
! Было бы идеально обрабатывать эти регистры только в том случае, если они реально использовались источником исключения или целью переключения контекста.Архитектура AArch64 позволяет нам выборочно включать/выключать использование этих регистров. Как мы могли бы использовать эту возможность для того, чтоб лениво подгружать эти регистры только в тех случаях, когда они реально используются? Но при этом иметь возможность эти регистры использовать свободно в своём коде. Какой код вы напишите для обработчика исключений? Нужно ли как либо модифицировать структуру
TrapFrame
для того, чтоб добавить какое либо дополнительное состояние и как это доп. состояние следует поддерживать?
Фаза 2: Это процесс
В этой части мы перейдём к самому вкусному. Мы будем реализовывать пользовательские процессы. Начнём с реализации структуры Process
, которая будет работать с состоянием нашего процесса. Затем мы запустим первый процесс. После этого мы реализуем планировщик процессов типа round-robin. Для этого нам надо будет реализовать драйвер контроллера прерываний и включить прерывание таймера. Следом мы будем запускать наш планировщик при возникновении прерывания таймера и займёмся переключением контекста дабы осуществить переход к следующему процессу. И наконец мы реализуем первый системный вызов: sleep
.
По завершении этой фазы у нас уже будет минимальная, но уже вполне себе полноценная многозадачная операционная система. На данный момент процессы будут разделять физическую память с ядром и другими процессами. Однако уже в следующей фазе мы разберёмся с этим недоразумением и реализуем виртуальную память. Всё ради того, чтоб изолировать процессы друг от друга и защитить память ядра от шаловливых писателей программ пользовательского пространства.
Субфаза A: Процесс
В этой подфазе мы будем реализовывать всё необходимое для функционирования типа Process
из файла kernel/src/process/process.rs
. Весь этот код нам пригодится уже в следующей подфазе.
Чем является Процесс?
Процесс представляет собой контейнер для кода и данных, которые выполняются, управляются и защищаются ядром. По сути это единственная часть кода, которая относится ко всему, что находится за пределами ядра. Либо код выполняется как часть какого либо процесса, либо код выполняется как часть ядра. Существует достаточно много различных архитектур операционных систем (особенно если речь идёт о чисто исследовательских штуках), но практически все из них имеют концепцию, которую можно считать пользовательскими процессами.
В большинстве случаев процессы выполняются с ограниченным набором привилегий (в нашем случае это EL0
). Всё во имя того, чтоб ядро могло обеспечить необходимый уровень стабильности и безопасности всей системы в целом. Если один из процессов ломается, то мы не хотим чтоб такая же участь постигла остальные процессы. Тем более мы не хотим, чтоб результатом этого был полный крах всей системы. Помимо этого мы не хотим, чтоб процессы мешали друг другу. Если один процесс завис, то мы хотим, чтоб остальные процессы всё ещё выполнялись. Таким образом процессы подразумевают изоляцию. Они работают до некоторой степени независимо друг от друга. Вероятно вы видите все эти свойства каждый день: когда у вас завис браузер, то остальная часть продолжает работу или тоже зависает?
В любом случае реализация процессов заключается в создании структур и алгоритмов для защиты, изоляции, выполнения и управления ненадёжным кодом и данными.
Что внутри Процесса?
Для реализации процессов нам потребуется отслеживать код и данные процесса и всяческую вспомогательную информацию. Всё ради того, чтоб мы могли легко и свободно управлять состоянием процессов и изолировать процессы друг от друга. Это всё означает, что нам надо отслеживать:
- Стек
Для каждого процесса требуется свой собственный уникальный стек. При реализации процессов нам необходимо выделить раздел памяти, который подойдёт для использования в качестве стека процесса. И разумеется нам нужно будет изменять указатель стека процесса таким образом, чтоб он указывал на эту область памяти. - Куча (heap)
Для того, чтоб работать с динамической памятью, каждому процессу потребуется выделить свою кучу. В самом начале куча будет совершенно пустой, но её можно будет расширить при помощи специальных системных вызовов. Мы пока оставим эту тему и вернёмся к ней в будущем. - Код
Процесс практически бесполезен, если он не выполняет какой либо код. Следовательно нашему ядру нужно будет каким-то образом загружать код процесса в память и передавать этому коду управление тогда, когда это необходимо. - Виртуальное адресное пространство
Поскольку мы не хотим давать процессам возможность доступа к памяти ядра и памяти других процессов, каждый процесс будет ограничен своим собственным адресным пространством при помощи такой штуки, как виртуальная память. - Состояние планировщика
В большинстве случаев мы предполагаем, что процессов может быть больше, чем ядер процессора. Ядро может выполнять только один поток команд за раз. Следовательно нам нужны механизмы мультиплексирования времени CPU (и следовательно у нас будет несколько потоков команд) для одновременного выполнения процессов. Задача планировщика состоит в определении того, какой процесс запускается и в какой момент это всё будет происходить. Для того, чтоб сделать это правильно, планировщик должен знать, готов ли какой либо процесс к планированию, либо нет. Состояние планировщика, которое хранится в каждом процессе — это именно то самое. - Состояние выполнения
Для того, чтоб правильно мультиплексировать время проца между несколькими процессами, нам нужно будет убедиться, что мы сохраняем состояние выполнения процесса, когда мы прекращаем выполнение этого процесса. Ну и не забываем о корректном восстановлении состояния в тот момент, когда мы включаем этот процесс обратно. По сути мы уже сделали всё необходимое для обработки этого состояния. Для этого нам и требовалось создатьTrapFrame
. Каждый процесс должен правильным способом хранить это состояние.
Стек, куча и код составляют всё физическое состояние процесса. Остальная часть состояния необходима для обеспечения изоляции, управления и защиты процесса.
Структура Process
из файла kernel/src/process/process.rs
будет содержать всю эту информацию. На текущий момент (в этой фазе) все процессы будут использовать общую память и там не будет полей для кода, кучи или виртуального адресного пространства. Но мы добавим их чуть позже.
Должен ли процесс доверять ядру? [kernel-distrust]
В целом очевидно, что ядро должно с явным недоверием относиться к процессам. Но должны ли процессы доверять ядру? Если да, то чего должны ожидать процессы от ядра?
Что может пойти не так, если два процесса разделяют стеки? [isolated-stacks]
Представьте два одновременно выполняемых процесса, которые совместно используют один стек. Во-первых: что будет означать одновременное использование стека? Во-вторых: почему с высокой вероятностью эти два процесса будут мешать друг другу и быстро разрушат друг друга? В-третьих: определите свойства процессов, которые в случае разделения одного стека будут необходимы для спокойного сосуществования двух процессов без из безвременной кончины. Другими словами какие правила должны соблюдать два таких процесса, чтоб использовать один и тот же стек и при этом не умирать?
Реализация
Настало время реализовать всё необходимое для Process
из файла kernel/src/process/process.rs
. Перед тем, как начать, прочитайте реализацию типа Stack
, которую можно найти в файлике kernel/src/process/stack.rs
. Убедитесь, что вы знаете, как использовать эту структуру для создания нового стека и получения указателя стека для только что созданного процесса. Затем прочитайте реализацию типа State
, которая будет использоваться для отслеживания состояния, относящегося к планировщику. Этот тип можно найти в файлике kernel/src/process/state.rs
. Попробуйте порассуждать о том, как интерпретировать различные варианты состояния в контексте планирования жизненного пути процессов.
В конце концов реализуйте метод Process::new()
. Реализация будет весьма простой. На самом деле нет ничего особо сложного в реализации отслеживания состояния процесса! Когда будете готовы — переходите к следующей подфазе.
Как восстанавливается память стека? [stack-drop]
Структура
Stack
выделяет 1MiB памяти под стек. При этом память выровнена по 16 байт. Откуда берутся гарантии освобождения этой памяти в тот момент, когда процесс, которому эта память принадлежит, героически заканчивает свою жизнь?
Каким образом можно лениво выделять память под стек? [lazy-stacks]
Структура
Stack
выделяет 1MiB памяти вне зависимости от реальных потребностей программы. Поразмышляйте на тему виртуальной памяти. Можно ли использовать виртуальную память для того, чтоб выделять настоящую физическую память под стек ровно в том объёме, который реально используется программой?
Каким образом процесс может увеличить размер стека? [stack-size]
Для некоторых процессов может потребоваться значительно большее пространство под стек. Но наша простая система выделяет жестко 1MiB. Предполагая, что процессы имеют доступ к распределению динамической памяти, каким образом процесс сможет увеличить размер стека? Конкретно, какие инструкции потребуются для того, чтоб реализовать это всё?
Субфаза B: Первый процесс
В этой подфазе мы выпустим наш первый процесс гулять по просторам пользовательского пространства (EL0
). Основная работа будет вестись в файлах kernel/src/process/scheduler.rs
и kernel/src/kmain.rs
.
Переключение контекстов процессов
Большая часть работы, связанной с переключением контекстов уже выполнена. Для того чтоб собсна переключить контекст при возникновении соответствующего исключения нам надо:
- Сохранить trap frame текущего процесса в поле
trap_frame
. - Восстановить trap frame из состояния следующего процесса из его поля
trap_frame
. - Изменить состояние планировщика, чтоб понимать, какой процесс выполняется.
К сожалению для первого процесса нам потребуется чуточку отклониться от этого плана. Будет неправильно выполнять все из этих шагов для самого первого процесса. Можете ли вы сказать, что именно тут не так?
Посмотрим, что произойдёт, ежели мы выполним все эти шаги для первого процесса. Начинается всё с возникновения исключения, которое вызывает переключение контекста. Например прерывание таймера. Затем происходит следующее. В ответ на исключительную ситуацию мы сохраняем всё состояние в поле trap_frame
. Вот только что там содержится в trap frame? Оно ведь не имеет никакого отношения к процессу! Чуть позже как часть шага 2 мы восстановим trap_frame
процесса, но там будет по сути мусор.
Поскольку исключение не было выполнено во время выполнения процесса (до первого процесса других нет), то сохранённое состояние будет иметь малое отношение к самому процессу. Мы связали наш первый процесс с нерелевантными данными. По идее при старте процесса оный должен содержать корректную стартовую информацию. Но в данном случае это определённо не так.
Для того, чтоб обойти это всё, мы собираемся перенастроить переключение контекстов с нуля. Вместо trap frame, пришедшего из context_save
мы будем использовать вручную созданный контекст, а затем вызовем context_restore
самостоятельно. Таким образом мы обойдём шаг 1 целиком. После того, как мы запустим первый процесс, все остальные переключения контекстов будут работать нормально.
Потоки ядра
Мы ещё не создали механизм загрузки кода с диска в память. Когда мы настроим виртуальную память, нам потребуется реализовать необходимые процедуры для этого всего. Но сейчас мы будем использовать ту же память, что использует ядро. Пока ядро и процессы не обмениваются локальными данными (стеком) и новая память для стека не выделяется, эти процессы будут работать без особых проблем. Более того. Rust нам гарантирует, что между процессами не будет существовать гонок данных.
Совместное использование памяти и других ресурсов между процессами является настолько распространённой концепцией, что эти типы процессов имеют своё специальное имя: нити/потоки/треды (threads). Поток по сути это не что иное, как процесс, который разделяет память и другие ресурсы с другим процессом.
Чуть позже мы запустим наш первый процесс. Поскольку этот процесс будет иметь общие ресурсы с ядром, то его можно назвать потоком ядра. Таким образом объём работы, необходимой для запуска первого процесса минимален ибо всё необходимое уже находится в памяти:
- Создаём «поддельный» trap frame для переключения контекста.
- Вызываем
context_restore
. - Переключаемся на уровень
EL0
.
Хотя для реализации этого всего потребуется минимальное количество строк кода, вы обнаружите, что для правильной работы потребуется всё сделать очень тщательно.
Термин поток ядра перегружен.
Термин поток ядра используется для ссылки на потоки, реализованные ядром (в отличии от потоков, реализованных целиком в пользовательском пространстве) и для ссылки на потоки, которые выполняются в контексте ядра. Немного неудачная коллизия, но обычно понятно из контекста, какой именно вариант подразумевается. Если обсуждение не касается разработки ОС, то следует в первую очередь предполагать, что речь идёт о потоках, которые реализуются ядром.
Реализация
Существует ещё одна новенькая глобальная переменная в kmain.rs
по имени SCHEDULER
с типом GlobalScheduler
, которая попросту оборачивает тип Scheduler
. Оба типа можно найти в файлике kernel/src/process/scheduler.rs
. Переменная SCHEDULER
будет служить дескриптором планировщика для всея системы.
Для того, чтоб правильно инициализировать планировщик и запустить первый процесс, следует вызвать метод start()
из типа GlobalScheduler
. Наша задача — реализовать метод start()
. Для этого нам необходимо:
- Написать
extern
-функцию без параметров, которая запускает командную оболочку.
Эта функция будет точкой входа для нашего первого процесса. Вы можете поместить эту функцию в любое место, какое захотите. Мы удалим эту функцию, как только сможем загружать двоичные файлы с диска. - Создать экземпляр
Process
и настроить ему чистый trap frame.
Нам нужно будет настроить trap frame, который будет потом восстанавливаться черезcontext_restore
позже. Прямо перед тем, как будет выполнятьсяextern
-функция. Для которой нам ещё надо настроить указатель на вершину стека. И только после этого переключить режим процессора вEL0
. - Настроить необходимые регистры и вызвать
context_restore
, а затемeret
для перехода вEL0
.
После настройки trap frame нам надо провести переключение контекста на этот процесс. Примерно таким образом:- Выполнить
context_restore
с соответствующим набором регистров.
Примечание: тут немного расплывчатая информация. И это сделано специально. Если это кажется совсем мутной информацией, то подумайте о том, что должна делатьcontext_restore
, о том, что вы хотите сделать и каким образом это осуществить. - Установить текущий указатель стека (
sp
) на его изначальное значение (адрес_start
). Это необходимо для того, чтоб мы могли использовать весь стек уровняEL1
при обработке исключений. Примечание: вы не можете напрямую использоватьldr
илиadr
вsp
. Для начала загрузите значение в какой либо регистр, а уже затем переместите это значение вsp
. - Сбросить все регистры в
0
. Вы не должны позволять любой информации утекать на пользовательский уровень. - Перейти на уровень
EL0
при помощи инструкцииeret
.
- Выполнить
Для реализации всего этого нам пригодится функционал ассемблерных вставок. В качестве примера, если переменная tf
является указателем на trap frame, то следующий код будет устанавливать значение из этой переменной в x0
, а затем скопирует это значение в x1
:
unsafe {
asm!("mov x0, $0
mov x1, x0"
:: "r"(tf)
:: "volatile");
}
Как только вы реализуете всё необходимое — добавьте вызов SCHEDULER.start()
в kmain
и удалите любые вызовы оболочки или точек останова. Теперь kmain
должна содержать три инициализирующих вызова. При чём планировщик в этой цепочке вызовов должен быть последним. Если всё работает правильно, то будет вызвана наша extern
-функция на уровне EL0
и запустит командную оболочку.
Прежде чем продолжать, убедитесь, что переключение контекста на один и тот же процесс работает правильно. Попробуйте добавить несколько вызовов brk
в свою extern
-функцию до и после запуска оболочки:
extern fn run_shell() {
unsafe { asm!("brk 1" :::: "volatile"); }
unsafe { asm!("brk 2" :::: "volatile"); }
shell::shell("user0> ");
unsafe { asm!("brk 3" :::: "volatile"); }
loop { shell::shell("user1> "); }
}
Вы должны иметь возможность успешно возвращаться после достижения каждой точки останова. Источником для каждого исключения точек останова должна быть LowerAArch64
, что показывает успешный переход в пользовательское пространство. Как только всё заработает так, как ожидается — переходите к следующей подфазе.
Подсказки:
Наша ассемблерная вставка состоит из 6 инструкций.
Для того, чтоб получить указатель типа
T
изBox
используйте&*box
.Помимо ассемблерной ставки, там не должно быть
unsafe
-блоков.
Субфаза C: Прерывание таймера
В этой подфазе мы будем реализовывать драйвер контроллера прерываний BCM2837. Помимо этого допилим существующий драйвер системного таймера, дабы включить туда настройку прерываний этого самого таймера. В итоге мы настроим прерывания таймера, которые нам потребуются для переключения контекста в планировщике. Основная работа будет вестись в файликах os/pi/src/interrupt.rs
, os/pi/src/timer.rs
и папке os/kernel/src/traps
.
Обработка прерываний
В архитектуре AArch64 прерывания — это не что иное, как исключения определённого класса. Ключевым отличием является их асинхронная природа. Они генерируются внешним источником в ответ на внешние события.
На приведенной ниже диаграмме показан путь, по которому прерывания передаются от внешнего источника до вектора исключения:
Прерывания могут быть выборочно отключены в любой точке программы. Для того, чтоб прерывание передавалось в вектор исключения, внешнее устройство, контроллер прерываний и центральный процессор должны быть должным образом настроенны для получения этого самого прерывания.
Что такое контроллер прерываний?
Контроллер прерываний — это одно из внешних устройств, которое можно назвать проксёй или шлюзом между устройствами, которые генерируют прерывания вроде таймера и процессором. Контроллер прерываний физически подключён к выводам прерываний проца. Когда появляется сигнал на входном контакте контроллера прерываний, этот самый контроллер передаёт этот сигнал процессору.
Этот дополнительный слой позволяет выборочно включать/выключать прерывания. Помимо этого он позволяет производителям процессоров выбирать, какие контроллеры прерываний они хотят связать в со своим процессором.
Внешнее устройство
Вы уже написали драйвер устройства для системного таймера. Теперь его надо расширить, чтоб включить настройку регистров сравнения. Системный таймер непрерывно сравнивает текущее время со значениями в регистрах сравнения и генерирует прерывание, когда значения равны.
Контроллер прерываний
Системный таймер передает прерывания контроллеру прерываний, который затем должен быть настроен для доставки прерываний в CPU. Вы будете писать драйвер устройства для контроллера прерываний, чтобы сделать именно это.
Как только контролер прерываний получает это самое прерывание, он отмечает прерывание, как ожидающее обработки и сообщает об этом процессору, удерживая состояние физического пина прерывания на высоком логическом уровне. Для некоторых прерываний, включая и прерывание системного таймера, пин удерживается на высоком уровне до тех пор, пока не поступит подтверждение об обработке этого прерывания. Это означает, что прерывание будет непрерывно доставляться до тех пор, пока оно не будет подтверждено. Как только прерывание будет подтверждено, пин прерывания будет освобождёт, а флаг ожидания будет снят.
Процессор
Прерывания должны быть разблокированы (unmasked) процессором для того, чтоб доставлять их векторам прерываний. По умолчанию прерывания заблокированны (masked) процессором. Следовательно доставлены не будут. Проц может доставлять прерывания, которые были получены, когда прерывания были заблокированны, в тот момент, когда они разблокируются. Когда проц вызывает вектор исключения, он автоматически блокирует все прерывания. Благодаря такому подходу прерывания не будут сразу приводить к циклу исключений.
В прошлой подфазе мы настроили получение исключений из EL0
, так что тут не должно быть особой дополнительной работы.
Когда следует разблокировать IRQ во время обработки IRQ? [reentrant-irq]
Оказывается разблокировка IRQ в то время работы обработчика IRQ является достаточно распространённым явлением. Можете ли вы придумать сценарий, в котором вы бы хотели сделать подобное? Кроме того что на счёт циклов IRQ?
Векторы исключений
Сами векторы исключений вы уже настроили. Осталось только правильно обрабатывать IRQ (прерывания). Потребуется дописать некоторое количество кода в функции handle_exception
из kernel/src/traps/mod.rs
для того, чтоб пересылать все прерывания функции handle_irq
из kernel/src/traps/irq.rs
. Для того, чтоб определить, какое именно прерывание произошло, вам потребуется проверить, какие прерывания активны в контроллере