Этой статьей я хочу описать часть своих знаний, приобретенных на моем пути в OSDev и системном программировании. Параллельно я буду писать также документацию по CS, которую в скором времени выложу на свой GitHub.

Важно подметить, что мой путь ещё далеко не окончен и я, можно сказать, нахожусь в самом его начале. Зачем тогда я это делаю? Чтобы закрепить уже имеющиеся знания, получить критику от более опытных специалистов, а также, возможно, помочь некоторым новичкам подробно разобраться в теме.

Принцип работы процессора.

Почти все подобные статьи начинаются с краткой, часто даже с переупрощенной схемы «Кнопка запуска ПК -> Прошивка (BIOS/UEFI) -> загрузчик -> ОС». Я же хочу начать немного с другого: Как вообще работает процессор на базовом уровне?

Вероятно, все погруженные в IT знают что такое бит, байт и т.д. Но как именно процессор ими оперирует? При каких условиях? Есть ли ограничения? Если есть, то какие? Вот на это мы сейчас и ответим.

Как процессор оперирует битами?

Процессор осуществляет свою работу путем выполнения команд (или инструкций). Изначально команды довольно примитивные: простые логические (AND, OR, XOR и т.д.), математические (ADD, SUB и т.д.) операции, пересылки и т.п. Из них складываются более сложные и комплексные команды (INVPLG, CMPS и т.д.).

Процессор также гарантирует сохранение порядка выполнения команд программиста. Т.е. программист, написавший код, всегда уверен в том, что он будет выполнен в нужном порядке (program order). Хотя, современные процессоры суперскалярны и способны к внеочередному исполнению нескольких команд за такт. Но к теме статьи это не относится.

При выполнении команд процессор оперирует машинными словами: двумя байтами информации (исторически сложившееся для x86 название).

Как биты доставляются до процессора?

Путем взаимодействия процессора с шиной данных и шиной адреса. В старых процессорах это были физические дорожки «от процессора к остальным», и так их легче воспринимать новичку. Хотя, в современных процессорах они представляют из себя скорее интерфейс взаимодействия контроллера памяти и RAM.

Как следует из названий, по шине данных идут данные, а по шине адреса — адреса. Их размер, в совокупности с частотой — это своего рода пропускная способность подсистемы памяти.

Размеры шин определяются конкретным чипом. В разных моделях у разных производителей они могут быть разными, но режимы работы используют конкретные размеры виртуальных адресов и регистров. Важно понимать, что на физическом уровне процессор читает память фиксированными кусками. Даже если программе нужен один или несколько байт, процессор запрашивает у памяти всю кэш-линию. Поэтому, получив целую кэш-линию, он загружает её в кэш, а программе отдает только нужную ей часть, остальное остается в кэше. Если же шина меньше, то обращения к памяти происходят в несколько циклов (Пример: 8-битный 8088, создающий 16-битные обращения за 2 цикла). Шина адреса при этом всегда задействована полностью.

Режимы работы процессора.

Режим процессора — набор связанных параметров, включающий в себя размер регистров общего назначения (GPR), размер линейного адреса и доступные механизмы трансляции адреса. Всего существует несколько режимов:

16-битные real и protected mode

  • 16-битные регистры по умолчанию. Также доступны и 32-битные через 0x66-префикс, если их поддерживает кристалл

  • Для адресации используется 20 бит (для real mode) или 24 бита (для protected mode)

32-битный protected mode

  • 32-битные регистры

  • 32-битные виртуальные адреса (физические — 32 без PAE и 36 с PAE)

64-битный long mode

  • 64-битные регистры

  • 48- или 57-битные виртуальные адреса (физические — до 52 бит)

Unreal mode

  • Формально, это не отдельный режим, а метод совместимости 16-битных режимов с памятью выше 1МБ, вплоть до 4ГБ

V86 и SMM в статье рассматриваться не будут.

16-битный real mode.

Переходим к основной части статьи — режимы работы процессора. И начнем, конечно, с 16-битного real mode. Он является своего рода режимом совместимости с 8086 более новых процессоров.

Важной особенностью этого режима является то, что из него мы имеем косвенный доступ к BIOS, а точнее, можем обращаться к нему через специальные прерывания. Ни protected, ни long mode в силу архитектурных ограничений такой возможности не имеют. Речь именно о BIOS, а не о UEFI. Формально, некоторые системы предоставляют API для protected режимов, но такое есть далеко не на всех машинах, поэтому брать их за правило нельзя.

У многих вероятно уже возник вопрос: почему 16-битный режим использует 20-битные адреса, а главное — как? И тут не обойтись без исторической справки. 16-битная адресация обеспечивает доступ процессора всего лишь к 64КБ адресного пространства, что не давало прироста в сравнении с предыдущими 8-битными ПК. Перед инженерами Intel возникла задача — увеличить это число хоть как-нибудь. И они пришли к идее о сегментации, сделав в 8086 шину адреса 20-битной, что сразу расширило максимальный объем адресуемой памяти до 1МБ.

Сегментация.

Сегментация — модель адресного пространства, при котором память разделяется на сегменты. Сегмент — непрерывная область памяти, начало которой хранится в сегментном регистре. Нужные данные же хранятся внутри сегмента, а их позиция в нем задается смещением — числом, хранящемся в регистре общего назначения (GPR).

В этой модели памяти адрес задается не прямым “наименованием” байта, как в protected mode (flat model, хотя это выбор разработчика, а не условие), а формулой:

address = segment * 16 + offset

Почему умножаем на 16? Потому что x * 16 = x << 4. На деле получается:

Сегмент: 0bXXXX XXXX XXXX XXXX
* 16 (он же << 4): 0bXXXX XXXX XXXX XXXX 0000
Сдвиг: 0b0000 YYYY YYYY YYYY YYYY
Адрес: 0bXXXX XXXX XXXX XXXX 0000 + 0b0000 YYYY YYYY YYYY YYYY

А на конкретных цифрах:

Сегмент: 0b0010 1001 0110 1001
* 16: 0b0010 1001 0110 1001 0000
Сдвиг: 0b0000 1010 0011 1010 0001
Адрес: 0b0010 1001 0110 1001 0000 + 0b0000 1010 0011 1010 0001 = 0b0011 0011 1010 0011 0001

Т.е., как я думаю уже понятно, программе напрямую по конкретному адресу в нужное место не попасть:

bits 16
main:
    mov [0xB8000], 'A' ; На экран ничего не выведется, поскольку архитектура требует адрес вида сегмент:смещение.
    
	mov ax, 0xB800
	mov es, ax
	mov di, 0
	mov [es:di], 'A' ; На экран выведется A, поскольку 0xB800 * 16 + 0 = 0xB8000, что как раз адрес VGA text buffer.

A20.

Также одной из особенностей архитектуры x86 является наличие адресной линии А20 (с появлением 80286) на шине процессора, которая по умолчанию выключена, а без ее включения любые адреса выше 1МБ будут заворачиваться (wrap-around эффект). Если конкретнее — бит 20 (сама линия А20) и выше. Дальше все просто отбрасывается, и в итоге остаются только младшие 20 бит. Например:

Адрес: 0×10FFFF
Этот же адрес с выключенной A20 процессор читает как: 0×0FFFF

Поэтому, если программе нужен будет доступ к памяти выше 1МБ, эту шину обязательно нужно включить. Самый простой способ: Fast A20 gate, проворачиваемый всего в 3 строчки на ассемблере:

in al, 0×92 ; Сохраняем значение порта 0×92 — порта, 1 бит (если считать от 0) которого как раз и отвечает за нужную нам линию, хотя и другой функционал тоже имеет.
or al, 2 ; Выставляем тот же 1 бит в 1.
out 0×92, al ; Записываем получившееся обратно в порт.

На самом деле, этот вариант хоть и прост, но достаточно ненадежен, поскольку подразумевает, что бит 1 у порта всегда 0, а также не учитывает возможные ошибки. Мы платим надежностью за простоту.

16-битный protected mode.

16-битный protected mode — это первый шаг на пути к нормальной многозадачной ОС. Основное его отличие с 16-битным real mode заключается в изменении роли сегментации.

Сегментация.

Здесь сегментация из «адресного» принимает защитный характер, отсюда и название режима. В чем заключается «защитная роль»:

  • Сегментные регистры (CS, DS, ES и т.д.) теперь хранят не адрес, а селектор — ссылку на дескриптор, где теперь записаны база сегмента, лимит, права доступа и т.д. - При каждой загрузке селектора процессор сам проверяет все это и выдает исключения при несовпадении с ожидаемым. Все без участия ОС.

  • Это обеспечивает изоляцию, когда никто не может выйти за границы своего сегмента (лимит) или без должных прав делать что-либо.

Дескрипторы — это своего рода «описатели». «Записи», в которых описаны некоторые «правила». В GDT (Global Descriptors Table) — «правила» работы с памятью и сегментами, в IDT (Interrupt Descriptors Table) — «правила» реагирования процессора на те или иные прерывания.

Особенности режима.

Для работы protected mode требует заранее настроенной GDT. Это архитектурное ограничение. Поэтому перед переходом в него нужно настроить хотя бы базовую глобальную таблицу дескрипторов. Каждый дескриптор имеет формат:

  • Байты 0-1. Limit, “Где заканчивается сегмент”

  • Байты 2-4. Base, “Где начинается сегмент”

  • Байт 5. Access byte, права

  • Байт 6. Флаги (старшие 4 бита) + Limit (младшие 4 бита)

  • Байт 7. Base Такая “некрасивая” структура обусловлена историей и желанием совместимости новых процессоров со старой архитектурой и программами.

Сама GDT обязана начинаться с нулевого дескриптора. А максимальный размер - 64КБ (65535 байта из-за 16-битного поля лимита), т.е. 8191 дескриптора. И, поскольку первый дескриптор нулевой, в итоге их может быть до 8190. Хотя, на деле редко используется больше нескольких десятков.

Unreal mode

Снова же, это не режим, а метод взаимодействия системы в 16-битном режиме с дескрипторным кэшем. Это дает доступ к 32-битным смещениям для доступа к памяти выше 1МБ, оставаясь в 16-битном режиме.

Как?

Механизм основан на том, что начиная с 80286 процессор хранит параметры сегмента (база, лимит, права) не только в видимом сегментном регистре, но и в скрытом дескрипторном кэше. В real mode этот кэш обычно содержит лимит 64 КБ, но его можно изменить: войти в protected mode, загрузить сегментный регистр данных (обычно — FS или GS) селектором дескриптора с лимитом 4 ГБ и базой 0, а затем вернуться в real mode. Кэш при этом не сбрасывается, и сегмент продолжает работать с лимитом 4 ГБ.

Классификация

Их существует всего 2:

  • Big unreal mode. Затрагивает только сегменты данных. Код остается в пределах 64КБ.

  • Huge unreal mode. Затрагивает также сегментный регистр CS, что позволяет выполнять код выше 64КБ. Сложнее, поскольку требует работы с прерываниями.

32-битный protected mode.

Появление 32-битного режима ознаменовало появление такого явления, как flat model т.е. нулевое смещение, а значит — прямое обращение к памяти. В свою очередь, его распространение в различных ОС привело к концу сегментации, как к адресной модели памяти, хотя она еще оставалась возможной.

Также стандартные изменения в виде:

  • Увеличения размера регистров до 32-бит

  • 32-битный виртуальный адрес

Также в этом режиме появляется возможность включить paging — страничную модель адресации памяти. Это тема для отдельной большой статьи, но тут я кратко опишу суть.

Со включением paging (Бита PG в служебном регистре CR0) процессор уже оперирует не физическими, а виртуальными адресами. В системе еще до включения paging должны быть созданы таблицы страниц. Здесь они разделяются на 2-3 уровня:

  • PDPT (Page Directory Pointer Table). Уровень, который появляется при включении PAE. Без этого старшим остается PD.

  • PD (Page Directory)

  • PT (Page Table)

Записи в PT уже указывают не на следующий уровень таблиц, а на сами фреймы (4КБ) — физические области памяти, где фрейм по размеру равен странице — основной единице разбиения адресного пространства.

Каждая таблица содержит в себе 1024 (512 при включенном PAE) записей. Каждая запись указывает на следующую таблицу, каждая запись в которой — на страницу (на следующую таблицу при включенном PAE). Таким образом, целая PD таблица хранит записи, указывающие на отдельные таблицы PT, а она — записи, указывающие на страницы. Исключением является PDPT (при включении PAE) — в 32-битном режиме он имеет максимум всего 4 записи.

Т.е. это своего рода «пакет с пакетами», только в конце тебя ждет не то, что ты ищешь, а адрес того, что ты ищешь.

При этом «Активной» может быть только 1 таблица высшего уровня (PD для 32-битного режима без PAE; PDPT для него же, но с PAE; PML4 для 64-битного режима). Её адрес в памяти помещается в специальный регистр CR3. После этого каждый виртуальный адрес ищет записи именно в этой таблице

Таким образом адрес разбивается на блоки. Для выключенного PAE:

  • 10 бит указывают номер таблицы PT (индекс в PD)

  • 10 бит указывают номер страницы (индекс в PT)

  • 12 бит — это offset внутри страницы

И с PAE:

  • 2 бита указывают номер таблицы PD (индекс в PDPT)

  • 9 бит указывают номер таблицы PT (индекс в PD)

  • 9 бит указывают номер страницы (индекс в PT)

  • 12 бит — это offset внутри страницы

Это очень упрощенная схема, упускающая кучу деталей, особенностей, оставляющая кучу вопросов, которая просто в общих чертах описывает логику работы. Но в рамках конкретно этой статьи подробностей не будет.

64-битный режим.

И последнее — 64-битный режим. Сегментация здесь уже практически не используется, а для работы обязателен уже включенный paging. И снова к подробностям.

Конечно, меняется:

  • размер регистров до 64 бит

  • размер виртуального адреса до 48 бит (также возможна и 57-битная адресация)

Сегментация.

Сегментация в Long mode уже фактически не используется. Практически все сегментные регистры, которые использовались ранее, игнорируются процессором. «Активными» остаются только 2: FS и GS. Они нужны для доступа к TLS (Thread-Local Storage) и в разных ОС могут использоваться по разному.

Адресация.

Здесь сохраняется механизм страничной адресации, но при этом он значительно расширяется. Появляется новый уровень таблицы — PML4, записи в которой указывают на PDPT, который теперь существует всегда ввиду того, что в long mode включенный PAE обязателен. Также размер регистра/поля адреса увеличивается до 64, но при этом в адресации участвует только младшие 48 бит — старшие 16 при этом являются копией 47-го (см. Canonical address ниже).

Как я упоминал выше, несмотря на то, что предел физ. адреса тут достигает 52 бит, зачастую для адресации используется только 48 бит. Тут следует подробнее это описать, потому что это очень важно для понимания работы этого режима. Виртуальный адрес выглядит теперь так:

0x0000124836C9BEDF
=
0b0000 0000 0000 0000 0001 0010 0100 1000 0011 0110 1100 1001 1011 1110 1101 1111

Где старшие 16 бит должны быть копией 47 бита (canonical address). Младшие 12 - смещение внутри страницы/фрейма, а каждые 9 между ними - номер записи таблицы в вышестоящем уровне. Т.е.:

0b0000000000000000 000100100 100100000 110110110 010011011 111011011111
 |CanonicalAddress|  PML4  |   PDPT   |   PD    |   PT   |    offset   |

Где трансляция (определение нужного физического адреса из виртуального) выглядит так:

  1. PML4[36] -> PDPT;

  2. PDPT[288] -> PD;

  3. PD[438] -> PT;

  4. PT[155] -> Frame;

  5. Frame + 3807 = нужный байт.

Canonical address — разделение адресных пространств. Т.е. «нижнее» пространство, где старшие 16 бит =0, принадлежит пользователю, а «верхнее» пространство, где старшие 16 бит =1, принадлежит ядру ОС (higher half). Каноническая дыра, создаваемая таким механизмом, просто огромна и съедает больше 99.99% всех возможных адресов, но даже так каноническая адресация дает 128ТБ на пользовательское пространство и столько же на пространство ядра, итого 256ТБ на всю систему, что уже запас памяти на десятилетия вперед для домашних ПК. Но уже существует 5-уровневая адресация с виртуальным адресом 57 бит, и обсуждается 6-уровневая.

Переход между режимами.

16-битный real -> 16-битный protected

  1. Включение A20 line

  2. Адрес валидной GDT в регистре GDTR

  3. Выключенные прерывания

  4. Установленный бит PE в регистре CR0

  5. far jump

16-битный protected -> 32-битный protected

  1. Наличие 32-битного код-сегмента в GDT

  2. (Остальное уже выполнено ранее)

  3. far jump

32-битный protected -> 64-битный long

  1. Отключение paging (бит PG в CR0), если был включен

  2. Настройка 4-уровневого paging

  3. Установка бита PAE в CR4

  4. Установка валидной PML4 в CR3

  5. Установка бита LME в регистре EFER

  6. Установить бит PG в CR0

  7. far jump

Unreal mode

  1. Включение А20 line

  2. Настройка GDT, содержащей дескриптор данных (Лимит = 4ГБ, база = 0)

  3. Переход в protected mode

  4. Загрузить FS/GS селектором этого дескриптора

  5. Сбросить бит PE в CR0 (вернуться в 16-битный режим)

Заключение.

Опыта в написании подобных статей у меня нет, поэтому, думаю, она довольно дырявая. В будущем, по мере наработки опыта, статьи будут становиться качественнее. Что касается меня:

  • Веду GitHub

  • Иногда делаю посты в ТГК

  • Из опыта - основной проект: MistWorks

Буду рад любого рода конструктивной критике и поддержке.

Всем спасибо за внимание!