Kernel
Автор: Виталий Антипович (tg: @vitalii_antipovich)
Для работы с компьютером требуется взаимодействовать с устройствами ввода/вывода.
Процессор и память подключены к общей системной шине. Черные стрелки - провода для передачи управления, выставления нужных адресов, передачи данных. Простыми словами, чтобы заставить компьютер что-то читать из памяти, надо, чтобы на контрольной шине был выставлен статус, соответствующий чтению из памяти, на адресной шине - интерсуемый адрес; далее ждем (пк ждет), что контрольная шина увидет адрес в адресной шине, возьмет байты по нужному адресу памяти и выставит эти байты в шине данных. На контрольной шине будет подан сигнал о готовности данных к чтению. Процессор возьмет эти данные с шины данных и воспользуется.
Создатели компьютера-основоположника x86 решили реализовать взаимодействие с устройствами ввода/вывода аналогично. На контрольной шине добавили отдельный бит, по которому понятно, к чему обращаемся: к памяти или к устройствам. Эти устройства тоже будут взаимодействовать с шиной, но у них будет отдельное, 16-битное адресное пространство. Эта концепция называется Port-mapped IO. Порт - номер ввода/вывода, не путать с портом в интернете. В языке ассемблера есть инструкции in/out для работы с ними. Например, PC-совместимая клавиатура позволяла считывать через порт 0x60 сканкоды, соответствующие последней нажатой или отпущенной клавише:
al, 0x60 # read scancode
Пусть некоторые диапазоны физ памяти относятся не к оперативке, а для ввода/вывода (какие - зависит от производителя компьютера). Это называется Memory-mapped IO - ввод/вывод с точки зрения процессора это работа с обычной памятью.
Например, текстовый режим VGA 80×25:
char* const video_memory = (unsigned short*) 0xb8000;
// Output a gray-on-black digit 4 at row 0, column 42 video_memory[42] = 0x0734; // 0x34 == '4', 07 == gray on black
Проблема: необходимость перодически опрашивать устройства (polling). А нам хочется не останавливать работу и опрашивать все устройства в наличии регулярно.
Хотим, чтобы устройства ввода могли активно сигнализировать о своей готовности к обмену данными.
// спокойно что-то считаем на процессоре, не опрашивая устройства ввода
sub ...
mov ... <---- eip // сохраняет текущий адрес в стек и прыгает по адресу обработчика add ...
...
// когда пользователь нажимает на клавишу, хотим выполнить этот код key_pressed:
in $KEYBOARD_PORT, %al // выясняем, какую клавишу нажали ... // сохраняем куда-то сканкод
iret // возвращаемся обратно
Чтобы прервать последовательное исполнение инструкций по сигналу извне, существует механизм прерываний. Он обслуживает следующие три вещи: • Exceptions (исключительные ситуации — например, деление на 0). • Hardware interrupts (аппаратные прерывания — например, событие от клавиатуры). • Software interrupts (программные прерывания).
Где взять адреса этих обработчиков прерываний? В специальном регистре idtr.
Нам не хотелось бы обрабатывать приоритеты и очереди прерываний в самом процессоре, тем самым загружая его данной работой, нам надо попроще архитектуру. Все эти обработки выносят в отдельную микросхему, которая называется interrupt controller. Она процессору посылает прерывания в порядке приоритета и уже в понятном ему формате номеров векторов прерываний.
IF — interrupt flag Если IF взведён (то есть равен 1), то после каждой инструкции у процессора происходит проверка на наличие прерываний - если они есть, то запускается вышеописанный механизм обработки прерывания.
Если же IF не взведён (=0), то процессор просто не реагирует на маскируемые аппаратные прерывания.
Для обработки прерывания процессор сохраняет в стеке регистры cs, eip и eflags, а затем загружает в cs и eip адрес обработчика, соответствующего номеру прерывания. IF временно выключается.
После обработки прерывания мы возвращаемся в наш код с помощью iret - инструкции, которая извлекает из стека три верхних значения и помещает их в регистры IP, CS и флагов.
Кольца защиты
На x86 уровни привилегированности исполняемого кода называются «кольцами защиты». Их 4 штуки.
В Unix-подобных ОС на x86 используются только два кольца: ядро исполняется в кольце 0 (наиболее привилегированном), а пользовательские программы (userspace) — в кольце 3.
Привилегии колец:
Ring 0:
- Доступ к железу (port mapped IO, memory mapped IO)
- Служебные регистры (GDTR, IDTR, контрольные регистры)
- Служебные инструкции (lgdt, lidt, cli/sti, …)
Ring 3:
- Доступ к инструкциям/регистрам общего назначения (полностью самостоятельно можно только пользоваться доступной памятью и что-то считать, за всем остальным придется звать ядро).
Запускать под sudo это всё еще кольцо 3, даже если привелегий больше, чем у остальных колец.
Есть специальные регистры cs, ds, es, fs, gs, ss. CS (code segment), точнее его младшие 2 бита - уровень привелегий.
Вообще, селектор сегмента (содержимое сегментного регистра) всегда устроен подобным образом:
Segment descriptor index - 13 bits, 1 bit empty, PL - 2 bits
В дескрипторе сегмента тоже записан уровень привилегий — DPL (D = descriptor). Если вы пытаетесь загрузить в сегментный регистр новый селектор, его PL называется RPL (R = requested). Загрузить новый селектор получится, если max(CPL, RPL) <= DPL. (Таким образом, вы не можете просто загрузить в cs селектор кода ядра и повысить себе привилегии).
Обработка прерываний
При возникновении прерывания в кольце 3 (аппаратного или программного), процессору надо повысить свой уровень привилегий, обработать прерывание, а после понизить его обратно. При этом мы не можем рассчитывать, что пользовательский код поддерживает осмысленное значение регистра esp, чтобы нам было куда сохранить регистры при обрабоке прерывания. Нам придётся сделать отдельный (доверенный) стек в kernelspace и пользоваться им.
Псевдокод обработки прерывания:
vector X; ←- наш вектор прерываний
gate = idt[X]; <-- берём gate из таблицы прерываний по этому вектору
selector = gate.selector;
dpl = selector.pl; <-- уровень привилегий на котором должен быть обработан X
if cpl > dpl { // cpl - current privilege level
switch_stack();
change_privilege_level();
}
Структура стека в момент начала обработки прерывания (cpu/isr.h):
uint32_t eip, cs, eflags; // Pushed by the processor automatically
uint32_t useresp, ss; // Pushed by the processor for userspace interrupts
Инструкция iret снимает со стека восстанавливаемое значение cs, и если при этом понижается уровень привилегий, то восстанавливает со стека также esp и ss (регистр с адресом этого нового стека).
Системный вызов Инструкция int $0x84 (Yabloko-specific, int $0x80 для Linux/i368) - обычное прерывание
%eax ← function
%ebx ← arg1
%ecx ← arg2
%edx ← arg3
Страничная виртуальная память
Как мы показываем память в кольце 3? Страничная виртуальная: отображение каждого “блока” памяти определенного размера на физическую память внутри процессора
- У каждого процесса свое отображение
- Стандартный размер страницы - 4KiB
- Первые10битадреса-индексpagedirectory(т.к.всегомаксимум2^104-байтныхзаписейвэтойPage Directory)
- Вторые10бит-индексвpagetable(проверяемвалидностьивуспешномслучаесобираемфизический адрес )
- Оставшиеся 12 бит - смещение внутри страницы памяти
- Процессорможетположитьотображениевсвойкэш(TLB-translationlookasidebuffer)→отображение 20-битного префикса адреса на физический адрес памяти. Сбрасывается после переключения процесса
Загрузка компьютера с BIOS
Часть адресов RAM отображена на ROM, где лежит firmware.
Структура адресуемой памяти x86 (https://wiki.osdev.org/Memory_Map_(x86))
При включении компьютера процессор работает в режиме совместимости с IBM PC (16-битный real mode), исполнение начинается по адресу 0xFFFF0 (reset vector).
Загрузка с диска BIOS инициализирует и тестирует оборудование компьютера, а затем читает с загрузочного диска первый сектор (512 байт) и передаёт ему управление.