Примечание. Авторы рекомендуют читать книгу вместе с исходным текстом xv6. Авторы подготовили и лабораторные работы по xv6.
Xv6 работает на RISC-V, поэтому для его сборки нужны RISC-V версии инструментов: QEMU 5.1+, GDB 8.3+, GCC, и Binutils. Инструкция поможет поставить инструменты.
Операционная система выполняет несколько процессов одновременно. ОС распределяет время работы с ресурсами компьютера между процессами. ОС даст каждому процессу шанс на выполнение, даже если число процессов больше числа процессоров.
ОС изолирует процессы друг от друга так, что ошибка в одном процессе не нарушит работу других.
ОС позволяет процессам взаимодействовать - обмениваться данными и работать совместно.
Глава 2 рассказывает, как xv6 выполняет эти требования, о процессах xv6 и как xv6 запускает первый процесс.
Xv6 работает на многоядерном RISC-V
микропроцессоре и низкоуровневые детали специфичны для RISC-V
. RISC-V
- 64-битный процессор, а код xv6 следует модели LP64 языка Си. LP64 определяет размеры типов данных:
Тип | char | short | int | long | pointer |
---|---|---|---|---|---|
Размер в битах | 8 | 16 | 32 | 64 | 64 |
Книга полагает, что читатель знаком с ассемблером. Текст указывает на особенности RISC-V
, когда это необходимо. Официальные документы подробно описывают архитектуру RISC-V
.
Xv6 работает под эмулятором QEMU
на плате RISC-V VirtIO
. Плата включает процессор, оперативную память, ПЗУ с кодом загрузчика ОС, последовательное подключение к клавиатуре и экрану и жесткий диск.
Многоядерный процессор - несколько процессоров, которые работают параллельно. Каждый процессор владеет собственным набором регистров, но работают процессоры совместно в одной оперативной памяти.
Инструкция расскажет, как установить и настроить QEMU для запуска xv6.
Абстракции ресурсов
Зачем нужна ОС? Почему не реализовать системные вызовы в библиотеке, которую подключает программа? Каждая программа реализует библиотеку, что напрямую работает с оборудованием и решает, как использовать оборудование эффективнее. Так работают ОС для встроенных систем и системы реального времени.
Такая схема работает, когда в ОС работает единственная программа, иначе программы должны доверять друг другу - каждая программа добровольно уступает процессор следующей. Ошибка в программе остановит работу системы.
ОС запрещает программам работать с оборудованием напрямую и предоставляет абстракции ресурсов. Unix-программы работают с файлами с помощью вызовов open
, read
, write
и close
вместо обращения к диску. ОС предоставляет программам файловую систему - иерархию директорий и файлы - а диском управляет сама.
ОС передает процессор от одной программы к другой, сохраняет и восстанавливает регистры процессора - программы не заботятся о переключении процессов. Система продолжит работу, даже если программа войдет в бесконечный цикл.
Unix-процессы вызывают exec, чтобы загрузить программу из файла в память, а не работают с микросхемой памяти. ОС сама решит, где разместить программу. ОС даже сбросит часть памяти на диск, если свободной памяти не хватает. Вызов exec
обращается к файловой системе, чтобы найти файл по указанному пути.
Процессы взаимодействуют через файловые дескрипторы - неважно, передаются ли данные по каналу или через файл. Ядро передаст символ конца файла следующей команде в конвейере, если предыдущая завершилась ошибкой.
Интерфейс системных вызовов Unix тщательно спроектирован ради удобства программиста и строгой изоляции процессов.
Режим пользователя, режим супервизора и системные вызовы
Разработчики проводят границу между программами и ядром ОС. Ошибка в программе не должна нарушить работу ОС и остальных программ. ОС завершит ошибочную программу и продолжит работу. ОС запрещает программе обращаться к памяти других программ и ядра.
Процессор помогает ОС изолировать программы. RISC-V
предлагает три режима работы процессора: машинный, супервизора и пользователя. Процессор начинает работу в машинном режиме с безграничными привилегиями. Машинный режим предназначен для настройки компьютера. Xv6 выполняет настройку в машинном режиме и переключается в режим супервизора.
Процессор в режиме супервизора выполняет привилегированные инструкции: включает и выключает прерывания, читает и записывает регистр адреса таблицы страниц и т.д. Процессор не выполнит привилегированную инструкцию в режиме пользователя, а переключится в режим супервизора и передаст управление ОС. ОС решит, завершить ли программу или сообщить об ошибке.
Программы работают в режиме пользователя, а ОС - в режиме ядра. Режим ядра на RISC-V
- режим супервизора.
Программа не вызывает функции ядра напрямую, а выполняет инструкцию ecall
, чтобы переключиться в режим ядра и выполнить системный вызов. Ядро проверит аргументы вызова и решит, выполнять ли код системного вызова. Важно, что ядро определяет адрес функции ядра, а не программа - иначе вредоносная программа обойдет защиту ядра.
Устройство ядра
Какая часть ОС работает в режиме ядра?
Монолитное ядро
ОС полностью работает в режиме ядра. Каждый системный вызов работает в режиме ядра. ОС работает с безграничными привилегиями. Разработчики любят монолитные ядра за то, что не приходится думать, какую часть кода ограничить в правах. Части кода ОС легче взаимодействуют, например, файловая система и виртуальная память совместно используют один буфер памяти.
Ошибка в коде монолитного ядра фатальна. Ошибка в режиме ядра потребует перезапустить компьютер.
Микроядро
Разработчики ОС уменьшают код, что работает в режиме ядра. Службы ОС работают в режиме пользователя - такие процессы называются серверами. Программа пользователя отправляет сообщение процессу-серверу, чтобы обратиться к службе, и ждет ответа. Пример: программа отправляет сообщение файл-серверу, когда обращается к файлу.
Интерфейс микроядра состоит из нескольких низкоуровневых функций для запуска процессов, отправки сообщений, доступа к устройствам и т.д. Микроядро - проще, чем монолитное. Большая часть кода микроядерной ОС - в пространстве пользователя.
Популярны и монолитные, и микроядра. Unix-ядра монолитны. Ядро Linux монолитно, но часть функций ОС работает в режиме пользователя, например, оконная система. Системные приложения Linux работают с высокой производительностью, так как тесно взаимодействуют с подсистемами ядра.
ОС Minix
, L4
и QNX
- микроядра - работают во встроенных системах. ОС seL4 прошла формальную математическую проверку и доказала, что безопасна.
Разработчики ОС спорят и по сей день, какой подход лучше. Ответ зависит от того, что измерять - быстродействие, размер кода, надежность ядра, отказоустойчивость ОС и т.д.
ОС используют и смешанный подход - реализуют микроядро, но выполняют важные процессы-серверы в режиме ядра ради быстродействия.
Монолитной ОС труднее стать микроядром - разработчикам придется переписать большую часть кода ОС, а это значит остановить работу над новыми функциями системы.
Эта книга описывает идеи общие для монолитных и микроядерных ОС - системные вызовы, виртуальную память, прерывания, процессы, блокировки, синхронизацию процессов, файловую систему и т.д.
Ядро xv6 - монолитное, как и большинство Unix. Интерфейс ядра xv6 - интерфейс ОС. Ядро xv6 полностью реализует операционную систему. Размер ядра xv6 меньше микроядра, но это совпадение - xv6 реализует мало функций.
![Микроядро и сервер файловой системы](https://habrastorage.org/webt/9l/aq/53/9laq53fyrmwdi1uwtvynmomhnh4.png)
Код: устройство xv6
Директория kernel содержит исходный текст ядра xv6. Текст разделен на модули по файлам. Файл kernel/defs.h
определяет интерфейсы модулей.
Файл | Описание |
---|---|
bio.c | Кеш блоков диска для файловой системы |
console.c | Клавиатура и экран |
entry.S | Инструкции для первичной загрузки ОС |
exec.c | Системный вызов exec |
file.c | Дескрипторы файлов |
fs.c | Файловая система |
kalloc.c | Аллокатор страниц физической памяти |
kernelvec.S | Прерывания от ядра и таймера |
log.c | Журнал файловой системы и восстановление после сбоев |
main.c | Настройка модулей ОС после загрузки |
pipe.c | Каналы |
plic.c | Контроллер прерываний |
printf.c | Форматированный вывод в терминал |
proc.c | Процессы |
sleeplock.c | Блокировки, что приостанавливают процессор |
spinlock.c | Блокировки, что не приостанавливают процессор |
start.c | Код настройки для машинного режима |
string.c | Работа со строками языка Си и массивами байтов |
swtch.S | Переключение потоков |
syscall.c | Передача управления коду системных вызовов в ядре |
sysfile.c | Системные вызовы для работы с файлами |
sysproc.c | Системные вызовы для работы с процессами |
trampoline.S | Переключение между режимами пользователя и ядра |
trap.c | Обработка прерываний |
uart.c | Драйвер последовательного порта терминала |
virtio_disk.c | Драйвер диска |
vm.c | Таблицы страниц и адресные пространства процессов |
Введение в процессы
Процесс - абстракция, которая помогает ОС изолировать программы. Процессу запрещено обращаться к памяти другого процесса, изменять чужие регистры процессора, дескрипторы файлов и т.д. Процессу запрещено изменять код и данные ядра - иначе это позволит процессу обойти защиту. Вредоносная программа захватит ОС и компьютер, поэтому разработчики ОС тщательно реализуют процессы.
ОС создает для каждого процесса иллюзию, что процесс - единственный на компьютере и владеет памятью и ресурсами компьютера. ОС защищает личное адресное пространство процесса от других процессов. Процесс предоставляет программе абстракцию личного процессора, на котором выполняет инструкции программы.
Xv6 предоставляет процессу личное адресное пространство с помощью виртуальной памяти. Виртуальное адресное пространство состоит из страниц виртуальной памяти, которые процессор отображает на страницы физической памяти. Страница - непрерывный блок памяти. Таблица страниц помогает процессору для каждой страницы виртуальной памяти найти страницу физической памяти. Программа работает с адресами виртуальной памяти, которые процессор отображает на адреса физической памяти.
ОС ведет таблицу страниц виртуальной памяти каждого процесса. Виртуальное адресное пространство непрерывно. Разрядность процессора определяет размер виртуальной памяти. Xv6 использует режим Sv39
виртуальной адресации процессора RISC-V
- режим работает с 39-битными виртуальными адресами, из которых xv6 использует 38 бит. Константа MAXVA
в файле kernel/riscv.h
определяет последний адрес виртуальной памяти: 2^38 - 1 = 0x3FFFFFFFFF
.
Xv6 резервирует две последние страницы виртуальной памяти каждого процесса - страница trampoline
содержит код переключения процесса в режим ядра и обратно, а страница trapframe
сохраняет состояние процесса из режима пользователя. Глава 4 расскажет о страницах trampoline
и trapframe
подробнее.
![Виртуальное адресное пространство процесса](https://habrastorage.org/webt/b9/rk/y4/b9rky4yokvao9kbpxa7bwdmzjom.png)
Ядро xv6 хранит состояние процесса в структуре proc
, что определена в файле kernel/proc.h
. Далее p->xxx
означает элемент структуры proc
, например, p->pagetable
- указатель на таблицу страниц.
Каждый процесс владеет потоком выполнения, который выполняет инструкции программы. Ядро приостанавливает один поток и возобновляет другой, когда переключается между процессами. Поток хранит состояние - локальные переменные, адреса возврата из функций - на стеке. Каждый процесс владеет стеком режима пользователя и стеком режима ядра p->kstack
. Стек ядра пуст, пока процесс работает в режиме пользователя. Процесс работает со стеком режима ядра, когда выполняет системный вызов или обрабатывает прерывание. Процесс не использует стек режима пользователя, пока выполняет код ядра. ОС защищает стек режима ядра, поэтому ядро работает, даже если процесс разрушил стек режима пользователя.
Инструкция ecall
переключает процессор в режим супервизора и записывает в регистр pc
адрес точки входа в код ядра. Этот код переключается на стек режима ядра, выполняет код системного вызова, переключается обратно на стек режима пользователя и выполняет инструкцию sret
, чтобы вернуться в режим пользователя и продолжить выполнение программы. Поток приостановится в ядре, если ожидает ввода-вывода - ОС возобновит поток, когда ввод-вывод завершится.
p->state
хранит состояние процесса - создан, готов к работе, работает, ожидает ввода-вывода, завершен.
p->pagetable
хранит адрес таблицы страниц. Xv6 загружает p->pagetable
в регистр satp
процессора, когда процесс работает в режиме пользователя. Ядро xv6 по таблице страниц знает, какие страницы физической памяти занимает процесс.
Абстракция процесса воплощает две идеи:
Адресное пространство создает иллюзию, что процесс владеет памятью компьютера
Поток выполнения создает иллюзию, что процесс владеет процессором
Процесс в xv6 владеет единственным адресным пространством и единственным потоком. Процессы в других ОС владеют несколькими потоками. Потоки работают одновременно на нескольких процессорах.
Код: запуск xv6, первый процесс и системный вызов
Этот раздел рассказывает, как ОС начинает работу и запускает первый процесс. Следующие главы опишут эти механизмы подробнее.
Сперва работает загрузчик ОС, код которого хранится в ПЗУ RISC-V
. Загрузчик копирует ядро xv6 в память, затем процессор в машинном режиме выполняет процедуру _entry
из файла kernel/entry.S
. RISC-V
начинает работу с выключенной виртуальной памятью - процессор работает с адресами физической памяти.
Загрузчик копирует ядро xv6 по адресу 0x80000000
, так как диапазон адресов с 0
по 0x80000000
занят устройствами ввода-вывода.
Ассемблерная процедура _entry
готовит стек, чтобы xv6 выполняла код на языке Си. Xv6 резервирует память под стек в переменной stack0
в файле kernel/start.c
. Код _entry
записывает в регистр sp
процессора адрес stack0 + 4096
вершины стека, потому что стек на RISC-V
растет сверху вниз. Затем _entry
вызывает процедуру start
на языке Си.
Процедура start
выполняет настройку компьютера, что доступна только в машинном режиме, и переключается в режим супервизора с помощью инструкции mret
. Инструкция mret
возвращается к режиму привилегий, что сохранен в регистре mstatus
, и переходит к выполнению инструкции по адресу из регистра mepc
. Процедура start
также отключает виртуальную память для режима супервизора - пишет 0
в регистр satp
- и направляет прерывания и исключения режиму супервизора.
Процедура start
также включает микросхему таймера, чтобы процессор получал прерывания по таймеру.
Процедура main
- определена в файле kernel/main.c
- настраивает необходимые устройства и подсистемы и запускает первый процесс вызовом userinit
. Первый процесс выполняет маленькую программу на ассемблере RISC-V
из файла user/initcode.S
, которая выполняет первый системный вызов. Программа записывает константу SYS_EXEC
- номер системного вызова exec
- в регистр a7
и выполняет инструкцию ecall
, чтобы передать управление ядру.
Ядро извлекает адрес функции sys_exec
из таблицы системных вызовов syscalls
- определена в kernel/syscall.c
- и передает управление sys_exec
. Глава 1 рассказывала, как exec
заменяет память и регистры процесса программой из файла. Программа initcode.S
передает вызову exec
имя файла /init
.
Системный вызов exec
возвращает управление режиму пользователя - программе /init
, которая создает файл устройства для терминала и связывает терминал со стандартным вводом, стандартным выводом и выводом ошибок. Затем /init
запускает программу shell
. Операционная система запущена.
Безопасность
Вредоносный код опаснее случайных ошибок в программе. Этот раздел рассказывает о проблемах безопасности при разработке ОС.
Вредоносная программа стремится нарушить работу ядра и остальных программ. Программа обращается по адресам вне адресного пространства, выполняет привилегированные инструкции, читает и записывает управляющие регистры процессора, напрямую обращается к оборудованию, выполняет системные вызовы с непредсказуемыми аргументами. Ядро следит, чтобы процесс работал в границах адресного пространства, использовал 32 регистра общего назначения RISC-V
, и проверяет аргументы системных вызовов. Ядро запрещает процессу делать то, что не разрешено. Безопасность - важнейшее требование к ядру ОС.
Требования к коду ядра строже, чем к программам пользователя. Опытные, внимательные и ответственные программисты пишут код ядра. Код ядра не должен содержать ошибок и вредоносных фрагментов. Функции ядра работают со спин-блокировками - компьютер зависнет, если в работе с блокировками допустить ошибку.
Разработчики ядра полагают, что оборудование - процессор, память, диск и т.д. - не содержит ошибок и работает согласно документации.
Программисты мечтают писать код без ошибок, но не умеют. Вредоносный код использует ошибки ядра, чтобы захватить управление системой. Аналитики постоянно находят новые уязвимости даже в зрелых ОС таких, как Linux. Разработчики страхуют ядро на случай ошибок - включают в код макросы assert
, проверки типов, защищают стек от переполнения и т.д. Граница между кодом ядра и пользователя размыта - одни процессы пользователя получают больше привилегий и становятся частью ОС, другие - внедряют код в ядро, как это делают загружаемые модули ядра Linux.
Реальность
Современные ОС реализуют процессы так же, как xv6. Многопоточные ОС поддерживают больше одного потока в процессе, чтобы процесс работал одновременно на нескольких процессорах. Xv6 - не многопоточная. Поддержка нескольких потоков усложнит код ядра, а системный вызов fork
изменится - пользователь решает, копировать ли потоки процесса. Linux предлагает системный вызов clone
- вариант fork
, поведение которого настраивает пользователь.
Упражнения
Добавьте в xv6 системный вызов, который возвращает размер свободной памяти.