Примечание. Авторы рекомендуют читать книгу вместе с исходным текстом xv6. Авторы подготовили и лабораторные работы по xv6.
Xv6 работает на RISC-V, поэтому для его сборки нужны RISC-V версии инструментов: QEMU 5.1+, GDB 8.3+, GCC, и Binutils. Инструкция поможет поставить инструменты.
Процессор прерывает работу и передает управление ядру, когда:
Программа выполняет системный вызов.
Инструкция вызвала ошибку, например, деление на ноль. Такая ошибка называется исключением.
Устройство требует внимания процессора, например, диск завершил чтение данных, которые требовала программа.
Программа не замечает, что прервана - процессор сохранит состояние программы, обработает прерывание и продолжит выполнять программу.
Xv6 обрабатывает прерывания в режиме ядра. Ядро выполняет код системных вызовов, работает с устройствами и обрабатывает исключения.
Xv6 содержит код для трех сценариев:
Прерывания режима пользователя
Прерывания режима ядра
Прерывания таймера
Прерывания на RISC-V
Каждый RISC-V процессор владеет набором управляющих регистров, которые определяют, как процессор реагирует на прерывания. Документация RISC-V подробно рассказывает о них. XV6 использует следующие регистры:
stvec
хранит адрес обработчика прерываний - процессор передаст управление по этому адресу.sepc
- процессор сохраняет счетчик инструкцийpc
программы в регистреsepc
, прежде чем передать управление обработчику прерываний. Инструкция возврата из обработчика прерыванийsret
восстановит значение регистраpc
изsepc
. Ядро заставит обработчик передать управление другому коду, если изменит регистрsepc
.scause
хранит номер прерывания - причину прерывания.sscratch
. Обработчик прерываний сохраняет регистры процессора в памяти. Инструкция записи в памятьstore
требует указать адрес памяти в регистре процессора, но регистры заняты. Обработчик сохранит регистрa0
вsscratch
и используетa0
в инструкцииstore
.sstatus
определяет состояние процессора при обработке прерывания:Бит
SIE
регистраsstatus
определяет, реагирует ли процессор на прерывания в режиме ядра.Бит
SPP
регистраsstatus
определяет, возникло ли прерывание в режиме пользователя или ядра. Инструкцияsret
вернет процессор в режим, который определяет битSPP
.
Процессор работает с этими регистрами только в режиме ядра - из режима пользователя регистры недоступны.
Ядро использует аналогичный набор регистров mtvec
, mepc
, mcause
, mscratch
, mstatus
для обработки прерываний таймера в машинном режиме.
Каждый процессор владеет личным набором регистров и обрабатывает прерывания независимо от других процессоров.
Процессор действует так, когда реагирует на прерывание, кроме прерываний таймера:
Проверяет флаг
SIE
регистраsstatus
, если прерывание пришло от устройства. Ничего не делает, если флаг сброшен, иначе - переходит к следующему шагуСбрасывает флаг
SIE
регистраsstatus
Присваивает
sepc = pc
Устанавливает флаг
SPP
регистраsstatus
в1
, если процессор работает в режиме ядра, иначе - сбрасывает флаг в0
Пишет причину прерывания в
scause
Переключается в режим ядра
Продолжает работу с адреса из регистра
pc
Процессор не переключается на таблицу страниц ядра, стек ядра и не сохраняет регистры процессора, кроме pc
. Процессор оставляет эти задачи обработчику прерывания. Другие ОС оптимизируют обработку прерываний, например, не переключаются на таблицу страниц ядра.
Подумайте, какие шаги можно пропустить так, чтобы безопасность не пострадала. Например, программа способна нарушить работу ядра, если процессор не присвоит регистр pc = stvec
и продолжит выполнять инструкции программы в режиме ядра.
Прерывания режима пользователя
Xv6 обрабатывает прерывания режима пользователя не так, как режима ядра. Этот раздел расскажет о прерываниях режима пользователя.
Процессор прерывает работу в режиме пользователя, если:
Программа выполняет системный вызов
Программа допускает ошибку - провоцирует исключение
Устройство требует внимания процессора
Процессор переключается на таблицу страниц ядра и стек ядра в ассемблерной процедуре uservec
, прежде чем вызовет обработчик прерывания usertrap
на языке Си. Затем процессор выполнит usertrapret
и вернется на стек режима пользователя и таблицу страниц процесса в ассемблерной процедуре userret
.
Процессор не переключается на таблицу страниц ядра, когда реагирует на прерывание, поэтому таблица страниц процесса содержит страницу trampoline
с кодом uservec
. Процедура uservec
переключает процессор на таблицу страниц ядра, поэтому таблица страниц ядра отображает страницу trampoline
на тот же виртуальный адрес, чтобы регистр pc
указывал на корректный виртуальный адрес следующей инструкции и uservec
продолжила выполнение после смены таблиц. Страница trampoline
содержит и процедуру userret
, которая возвращает процессор к таблице страниц процесса.
Флаг PTE_U
у страницы trampoline
сброшен, поэтому uservec
и userret
работают только в режиме ядра.
Процедура uservec
сохраняет состояние программы - 32 регистра - на странице trapframe
, переключается на таблицу страниц ядра и стек ядра и передает управление usertrap
. Процесс хранит адрес стека ядра и таблицы страниц ядра на странице trapframe
.
Процедура usertrap
определяет причину прерывания, обрабатывает прерывание и передает управление usertrapret
. Процедура usertrap
назначает обработчиком прерываний процедуру kernelvec
, затем сохраняет sepc
в trapframe
, так как прерывание таймера заставит переключить поток выполнения вызовом yield
, а другой поток изменит sepc
, когда вернется из режима ядра в режим пользователя. Процедура usertrap
вызывает syscall
, если прерывание - системный вызов, devintr
, если прерывание - от устройства, иначе завершает процесс, реагируя на исключение. Процедура usertrap
добавляет 4 к сохраненному pc
, когда обрабатывает системный вызов, чтобы процесс продолжил работу со следующей за ecall
инструкции.
Процедура usertrapret
пишет в stvec
адрес обработчика прерываний режима пользователя uservec
, пишет в trapframe
адреса таблицы страниц ядра, стека ядра, которые нужны uservec
, и восстанавливает регистр sepc
из trapframe
. Затем usertrapret
передает управление userret
вместе с адресом таблицы страниц процесса.
Процедура userret
переключается на таблицу страниц процесса и стек режима пользователя, восстанавливает регистры процессора из trapframe
и выполняет инструкцию sret
, чтобы вернуться в режим пользователя и продолжить выполнение программы.
Код: системные вызовы
Глава 2 рассказывала, как xv6 выполняет первый системный вызов exec
. Этот раздел расскажет, как ядро выполняет код exec
.
Программа initcode.S
помещает номер системного вызова в регистр a7
процессора, а аргументы вызова - в регистры a0
и a1
. Номер системного вызова - индекс элемента массива syscalls
- массива указателей на функции. Инструкция ecall
прерывает процессор - заставляет переключиться в режим ядра и выполнить обработчик прерываний uservec
, затем функции usertrap
и syscall
.
Функция syscall
получает номер системного вызова из сохраненного в trapframe
регистра a7
. Константа SYS_exec
определяет номер системного вызова exec
, а элемент массива syscalls[SYS_exec]
указывает на функцию sys_exec
.
Функция syscall
пишет в p->trapframe->a0
значение, которое возвращает sys_exec
, чтобы вызов exec
в программе вернул это значение. Соглашение о вызовах языка Си на RISC-V говорит, что функции пишут возвращаемое значение в регистр a0
. Системные вызовы возвращают 0
, когда завершаются успешно, или отрицательное число, чтобы сообщить об ошибке. Функция syscall
напечатает сообщение об ошибке и вернет -1
, если программа передала неправильный номер системного вызова.
Код: аргументы системных вызовов
Код системных вызовов использует функции argint
, argaddr
и argfd
, чтобы добраться до аргументов, сохраненных в trapframe
. Программа передает аргументы через регистры процессора, а обработчик прерывания uservec
сохраняет регистры в trapframe
. Функции argint
, argaddr
и argfd
вызывают argraw
, чтобы извлечь n
-й аргумент из trapframe
и вернуть его как число, адрес и файловый дескриптор соответственно.
Ядро не способно обратиться по адресу памяти процесса, так как работает с таблицей страниц ядра, поэтому реализует функции копирования из памяти процесса в память ядра.
Функция fetchstr
копирует строку из памяти процесса в память ядра. Функция fetchstr
вызывает copyinstr
, которая копирует до max
байтов в буфер по адресу dst
из буфера по виртуальному адресу srcva
в таблице страниц процесса pagetable
. Функция copyinstr
использует walkaddr
, чтобы найти физический адрес pa0
по виртуальному srcva
в таблице страниц pagetable
. Таблица страниц ядра отображает виртуальные адреса на те же физические, поэтому copyinstr
копирует байты из pa0
в dst
. Функция walkaddr
проверяет, что виртуальный адрес принадлежит памяти процесса, поэтому программа не обманет ядро подменой адреса. Аналогичная функция copyout
копирует байты из памяти ядра в память процесса.
Прерывания режима ядра
Xv6 назначает обработчиком прерываний процедуру kernelvec
, когда входит в режим ядра. Процедура kernelvec
знает, что работает с таблицей страниц ядра и стеком ядра. Прерывание таймера переключит процессор на другой поток, поэтому kernelvec
сохраняет регистры процессора на стеке ядра.
Процедура kernelvec
вызывает процедуру kerneltrap
, которая обрабатывает прерывания от устройств и исключения. Процедура kerneltrap
вызывает devintr
, чтобы опознать прерывание от устройства. Ядро вызовет panic
и остановит работу, если произошло исключение в ядре.
Прерывание таймера заставит kerneltrap
вызвать yield
, чтобы уступить процессор другому потоку. Каждый поток вызывает yield
по таймеру, поэтому kerneltrap
продолжит работу позже. Глава 7 расскажет о планировании процессов и работе yield
.
Процедура kerneltrap
сохраняет регистр sepc
в локальной переменной на стеке ядра, чтобы защитить от переключения потоков.
Процедура kerneltrap
возвращает управление kernelvec
, которая восстанавливает регистры процессора и возвращает управление коду ядра, что работал до прерывания.
Процессор отключает прерывания - сбрасывает бит SIE
- когда реагирует на прерывание. Обработчик прерываний режима пользователя назначает обработчиком kernelvec
и включает прерывания, поэтому процессор не вызовет uservec
дважды. Процедура kernelvec
не включает прерывания, поэтому процессор не вызовет kernelvec
дважды. Инструкция sret
вернет флаг SIE
в значение до прерывания SIE = SPIE
, то есть включит прерывания снова.
Ошибки доступа к страницам
Xv6 завершает процесс, который провоцирует исключение, и останавливает работу, если ядро вызвало исключение.
Другие ОС используют ошибки доступа к страницам, чтобы реализовать приемы:
Копирование при записи
Ленивая выдача памяти
Выдача страниц по необходимости
Сброс страниц на диск
Процессор сообщит об ошибке доступа к странице, если:
Процессор не нашел виртуальный адрес в таблице страниц процесса - флаг
PTE_V
у записи сброшенИнструкция выполняет запрещенное для страницы действие: чтение, запись, выполнение кода на странице или доступ из режима пользователя
RISC-V различает три вида ошибок доступа к странице:
Инструкция
load
не может обратиться по виртуальному адресуИнструкция
store
не может обратиться по виртуальному адресуРегистр
pc
содержит недоступный виртуальный адрес
Регистр scause
указывает на вид ошибки, а stval
содержит недоступный виртуальный адрес.
Копирование при записи
Системный вызов fork
не копирует память родительского процесса в память дочернего, пока родительский или дочерний процесс не пишет в память. Такой fork
отнимет разрешение на запись у страниц родительского процесса и отдаст копию таблицы страниц дочернему процессу. Запись в страницу провоцирует исключение - тогда ядро выдаст новую страницу, скопирует содержимое, добавит в таблицу страниц дочернего процесса и вернет обеим страницам разрешение на запись.
ОС следит за вызовами fork
, exec
, exit
и ошибками доступа к страницам, когда оптимизирует работу с помощью копирования при записи. Одна и та же физическая страница попадает во множество таблиц страниц после вызовов fork
, а вызовы exec
и exit
освобождают виртуальные страницы, которые ссылаются на эту физическую страницу.
Копирование при записи ускоряет программы, которые после fork
вызывают exec
- fork
не копирует ни байта, а exec
заменяет память программой из файла.
Ленивая выдача памяти
Ядро не выдает страницы памяти, когда программа вызывает sbrk
, а запоминает увеличение памяти и ожидает обращения к новой памяти. Ядро выдаст страницу памяти, когда обработает ошибку доступа к странице.
Такой подход экономит память, когда программа просит памяти больше, чем использует. Ядро не выдает страницы, к которым программа не обращается.
Программа не ждет ни секунды, если запрашивает большой объем памяти, благодаря ленивой выдаче. Вызов sbrk
на гигабайт памяти заставил бы программу ждать, пока ядро выдаст 262144
страниц по 4096
байтов. Ленивая выдача равномерно распределит время ожидания. Ядро ускорит работу, если при ошибке доступа к странице выдаст не одну, а последовательность страниц.
Выдача страниц по необходимости
Выдача страниц по необходимости ускоряет запуск программ. Вызов exec
для большой программы займет много времени если exec
загружает программу сразу. Ядро ускорит запуск, если создаст пустую таблицу страниц, а страницы из файла загрузит при первом обращении.
Сброс страниц на диск
Программы работают, даже если размер виртуальной памяти больше размера физической, благодаря сбросу страниц на диск. ОС хранит часть страниц в оперативной памяти, а остальные хранит на диске. Ядро сбрасывает флаг PTE_V
для сброшенных на диск страниц и загружает страницу в память при ошибке доступа к странице.
Ядро замещает страницу в оперативной памяти той, что читает с диска, когда свободная память закончилась. Диск работает медленнее, чем оперативная память, поэтому чем реже ОС замещает страницы, тем быстрее программы работают. Замещать страницы не потребуется, если каждая программа работает с подмножеством страниц, которые умещаются в оперативную память.
Облачные провайдеры занимают как можно больше свободной памяти компьютеров, чтобы окупить затраты. Десятки приложений одновременно работают на смартфонах и не умещаются в оперативную память. Сброс страниц на диск поможет и тем, и другим.
Ленивая выдача памяти и выдача страниц по необходимости помогают, когда свободной памяти мало - без них жадная программа займет свободную память и заставит остальные программы постоянно сбрасывать страницы на диск.
ОС автоматически расширяет стек программы и отображает файлы в память при помощи ошибок доступа к страницам.
Реальность
Обработка прерываний кажется излишне сложной, потому что процессор RISC-V выполняет автоматически только необходимые действия, но другие ОС пользуются этим, чтобы ускорить обработку прерываний.
Другие ОС отображают страницы памяти ядра в таблицы страниц процессов, поэтому не используют страницу trampoline
, не переключаются на таблицу страниц ядра, а код системных вызовов работает с адресами памяти процесса. Xv6 отказывается от такой оптимизации, чтобы избежать ошибок безопасности ядра из-за неверной работы с адресами памяти пользователя.
Современные ОС реализуют копирование при записи, ленивую выдачу памяти, выдачу страниц по необходимости, сброс страниц на диск, отображение файлов в память и т.д. Современные ОС в отличие от xv6 стремятся использовать как можно больше свободной памяти - размещают в памяти кеш диска, файловой системы и т.д. Xv6 не сбрасывает страницы на диск и завершит программу, если для нее не найдется свободной памяти.
Упражнения
Функции
copyin
иcopyinstr
обращаются к таблице страниц процесса. Отобразите память процесса в таблицу страниц ядра, чтобыcopyin
иcopyinstr
вызывалиmemcpy
для копирования аргументов системного вызова в память ядра, оставив работу с таблицей страниц процессору.Реализуйте ленивую выдачу памяти
Реализуйте
fork
с копированием при записиМожно ли избавиться от страницы
trapframe
в таблицах страниц процессов? Может лиuservec
сохранять 32 регистра процессора на стеке ядра или в структуреproc
?Как избавить xv6 от страницы
trampoline
в таблицах страниц?