Перейдем сразу к делу. В начале советую прочитать первую часть. Эта статья служит шпаргалкой по памяти — все запоминать и усваивать, если собираетесь идти по курсу, совершенно необязательно. Когда будем пробовать это использовать на практике, я еще раз уделю внимание теории, но кратко и почти без деталей.
Каков же будет наш следующий шаг?
Написав «Hello, world» вместо загрузки реальной ОС, мы не сильно порадуем пользователя, и пока что не сможем, ведь мы все еще в 16 битном режиме процессора, который будет нам постоянно стрелять по ногам своими ограничениями и не позволит создать похожую ОС на современные. На самом деле, в разработке своего ядра не стоит вопрос целесообразности, но это было просто более аккуратное подведение к началу теории.
Не спорю, в 16 битном режиме можно написать реально хорошее ядро, и я даже знаю пару таких людей, но мы не преследуем цели оставаться на ассемблере и довольствоваться 1МБ памяти.
В этой статье будет пласт теории, что является аккуратненькой подводкой к тому, что в следующей мы используем часть этой теории и перейдем в 64-битный режим процессора.
Почему 16 битный, незащищенный (реальный) так называется?
Перед тем как я начну грузить вас, читателей, теорией про страничную адресацию, попутно объясняя, как и почему, я бы хотел ответить на тот вопрос, который особо никто не задавал, но почитать про него интересно.
Количество этих самых бит зависит от размеров его внутренних регистров общего назначения, разрядности указателя инструкций и размера дефолтного операнда. Но самое интересное кроется в слове «незащищенный», и это не про отсутствие антивируса.
Иерархия в кремниевом городе
В процессоре есть кольца защиты. Каждое кольцо — набор привилегий, и чем номер кольца ниже, тем больше коду дозволено. В этой статье я буду называть их ringN, где N — номер кольца.
Ядро обычно работает на ring0, что дает ему почти полные права над компьютером — оно может напрямую управлять внешними устройствами, получать без проблем доступ к любой памяти, читать и писать в регистры, которые управляют компьютером от А до Я.
Изначально в процессорах не было никаких колец, но потом Intel дала целых 4 кольца защиты, предполагая, что на ring0 будет работать ядро, на ring1 — драйверы, на ring2 — системные службы, а на ring3 — пользовательские программы.
Обычные пользовательские программы (и даже запущенные от имени администратора) работают на ring3, что не позволяет им взаимодействовать с компьютером без умного секретаря (ядра).
Как наши пароли все еще не гуляют по интернету
Ни для кого не секрет, что обычно приложения хранят временные данные в оперативной памяти (даже если они отправляются на сервер, они всё равно сначала проходят через неё). Современные ядра всегда включают в себя защиту памяти между процессами, которая работает на страничной памяти.
Адреса делятся на два основных типа: виртуальный и физический. Страничная память позволяет ядру отображать (в просторечии — маппить) виртуальные адреса на физические. Виртуальный адрес — адрес с точки зрения программы, и зачастую он вообще не похож на реальный адрес в заветной плашке оперативной памяти. Физический же адрес представляет из себя настоящий адрес ячейки оперативной памяти.
Ядро, перед тем как включить страничную память, должно отобразить в первую очередь себя на физическую память, чтобы процессор мог исполнять его код. В современных 64-битных системах принято переносить ядро после включения страничной памяти в верхнюю половину адресного пространства, которая начинается с 0xFFFF'8000'0000'0000, а сам код ядра часто размещают еще выше, ближе к концу диапазона.
Немного про кэш
В современных процессорах размер кэш‑линии (или строки) составляет 64 байта и жестко зафиксирован. Весь кэш делится на уровни:
Level 1 (L1) — Самый быстрый и самый маленький (обычно от 32КБ до 96КБ на ядро). Скорость почти наравне с процессором — время доступа обычно 4–5 тактов. Находится прямо внутри вычислительного конвейера. Делится на два независимых кэша: L1D (L1 Data), хранящий только данные, и L1I (L1 Instruction), хранящий только код (это герой, который позволяет работать процессору не простаивая, ожидая оперативную память).
Level 2 (L2) — Средний по скорости и размеру. Обычно составляет по 1–2 МБ на ядро, не делится на код и данные. Задержка обычно составляет 12–14 тактов.
Level 3 (L3) или Last Level Cache (LLC) — Самый большой уровень кэша, но платит за это он своей скоростью — 40–60 тактов. Его размер от десятков до сотен мегабайт, и он один на все ядра процессора. В 4–6 раз быстрее ОЗУ, а иногда вообще в 10 раз.
Если бы процессор раскидывал память по кэш-строкам как попало, то он бы постоянно искал нужную память по своему кэшу, поэтому инженеры придумали решение: множественно-ассоциативный кэш (N-way Set-Associative Cache) - вся кэш-память делится на наборы, а каждый набор содержит N каналов (в канал помещается кэш-строка), а каждый конкретный физический адрес претендует на место только в одном наборе, но может занять любой из N каналов в этом наборе.
Еще есть TLB (Translation Lookaside Buffer) — это кэш, служащий одним из главных напарником процессора. Он хранит в себе готовые результаты перевода виртуального адреса в физический, чтобы постоянно не обращаться к памяти при попытке доступа к виртуальному адресу. Делится на ITLB (Instruction TLB) и DTLB (Data TLB). Спойлер: он тупее, чем обычные кэши, и от этого опаснее.
Как устроена страничная память изнутри
Все держится на таблицах по 512 записей (по , это важно). Запись хранит в себе либо конечный физический адрес, либо физический адрес таблицы уровня ниже. Есть 4 уровня таблиц: PML4, PDPT, PD, PT. За весь этот цирк отвечает конкретный юнит — MMU (Memory Management Unit).
Познакомимся немного с 14 вектором (исключением) — Page Fault. Означает внутренний сбой страницы, имеет код ошибки. Код ошибки является 32-битным полем из масок, указывающих на конкретную причину возникновения исключения.
У любой записи есть куча флагов, которые, например, позволяют ядру защитить свои "личные границы":
Present. Позволяет MMU понять, существует ли эта запись вообще. Иногда используется, чтобы запретить чтение/запись в адрес 0, что помогает ловить ошибки.
Read/Write. Если он установлен, мы можем читать и писать в подвластную записи область памяти. Если бит сброшен — можно только читать. Эти ограничения накладываются только для ring3, если не установлен бит WP (Write Protect) в CR0.
User/Supervisor. Установлен — доступно на всех кольцах. Сброшен — Доступно на всех, кроме ring3.
PWT (Page-level Write‑Through). Установлен — запись происходит в ОЗУ и обновляет строку кэша (если она есть). Сброшен — включает стандартную обратную запись (Write-Back): если ядру нужно изменить строку, оно сначала согласует эксклюзивные права доступа с другими ядрами по протоколу когерентности MESI (инвалидируя их устаревшие копии), пишет данные в свой кэш, а в ОЗУ они сбрасываются гораздо позже — только при вытеснении строки.
PCD (Page‑level Cache‑disable). Установлен — полностью запрещает ядру процессора кэшировать подвластную этой записи область памяти. Сброшен — не накладывает никаких ограничений на кэш.
Accessed. Автоматически выставляет процессор при любом обращении к странице, будь то чтение или запись. Используется продвинутыми ребятами для отправки редко используемых страниц в своп (использовать часть диска)
Dirty. Автоматически выставляет процессор при записи в эту страницу. Позволяет тем же продвинутым ребятам понять, нужно ли обновить копию на диске, если планируется свопнуть. Работа с диском происходит намного дольше, чем с ОЗУ, поэтому столько нюансов и возможностей.
PAT (Page Attribute Table). Дополнительный бит, работает в связке с PWT и PCD, образуют втроем 3-битный указатель на тип памяти в регистре
IA32_PAT MSR. Этот бит, к сожалению, поссорился со своими двумя братьями, и находится на либо 7 бите, либо на 12, в то время как его братья на 3 и 4 битах.Global. При смене контекста запись не выкидывается из TLB, что кратно повышает скорость смены контекста (защита памяти как раз работает через отдельные CR3 на каждый процесс). Дело в том, что при смене CR3 (физический адрес PML4) ядро процессора принудительно сбрасывает весь TLB (кэш записей), кроме тех, что помечены как Global. Не освобождает ОС от обязанности размечать такую память для каждого процесса. Требует установки PGE (Page Global Enable) бита (7) в CR4
AVL (Available for software). 3 свободных бита, оставленных чисто под нужды ОС.
PK (Protection Keys). 4 бита, позволяют присвоить странице «цвет», чтобы потом сразу ко всем страницам определенного цвета разрешать и запрещать доступ всего одной командой. Требует установки PKE (Protection Keys for User-mode Pages Enable) бита (22) в CR4
NX (No Execute) или XD (Execution Disable). Установлен — полностью запрещает исполнять код внутри этой страницы. Обычно используется в связке с Read/Write, чтобы исключить запись в код программы, сохранив возможность его исполнения, и одновременно заблокировать запуск инструкций из областей данных, куда писать разрешено. JIT все еще работает, но ядро должно само снять NX флаг с памяти, где и будет располагаться код программы, собираемой JIT компилятором. Требует установки NXE (Execute-Disable Bit Enable) бита (11) в
IA32_EFER MSR.
PAT, PCD и PWT образуют 3-битный индекс типа памяти (число от 0 до 7, ). Указывает на 1 из 8 строк
IA32_PAT MSR, позволяя ОС точно указывать режим работы кэша. Три цифры в скобках означают PAT, PCD и PWT, например (000) означает, что все три бита сброшены. Все режимы указаны ниже:
Индекс 0 (000). WB (Write‑Back) — При чтении данные загружаются в кэш, при записи данные изменяются только в кэше, получая Modified (строка становится грязной). В ОЗУ данные попадают только при вытеснении строки из кэша.
Индекс 1 (001). WT (Write‑Through) — Запись происходит в ОЗУ и обновляет строку кэша (если она есть). Внешние устройства сразу увидят изменения.
Индекс 2 (010). UC‑ (Uncacheable‑minus) — Поведение идентично индексу 3. Был введен Intel для разрешения конфликтов с регистрами MTRR (которые размечают память не по страницам, а физическими кусками). Если на уровне MTRR этот кусок памяти будет помечен как WC (Write‑Combining), то UC‑ полностью ему уступит. Обычный UC никогда ничему не уступает.
Индекс 3 (011). UC (Uncacheable) — Кэш полностью игнорируется. Абсолютно. Вся работа исключительно с реальной ОЗУ.
Индекс 4 (100). WB — Дубликат.
Индекс 5 (101). WT — Дубликат.
Индекс 6 (110). WC (Write‑Combining) — Чтение всегда обходит кэш, работая только с ОЗУ. Но при записи создаются отдельные буферы по 64 байта. Когда этот буфер наполнится, процессор пуляет (записывает) весь этот буфер на системную шину (в ОЗУ или внешнему устройству, например, видеокарте). Используется, когда например, данные изменяются по байту последовательно.
Индекс 7 (111). UC — Дубликат.
MTRR. Что это?
MTRR расшифровывается как Memory Type Range Registers. Позволяет разметить куски физической памяти для всех ядер процессора целиком. Используется обычно еще до включения виртуальной памяти, позволяя BIOS/UEFI пометить, где в адресном пространстве находится реальная ОЗУ, а где находится флэш-память BIOS и видеопамять, физически расположенная не на плашках оперативки, а на самой видеокарте. В этой статье я не считаю нужным полностью рассматривать и MTRR, поэтому я решил оставить на следующие статьи, чтобы не раздувать текущий текст.
Теперь про сами таблицы
Те самые младшие 48 бит играют ключевую роль в виртуальном адресе, которые делятся на 4 индекса по 9 бит и на смещение внутри конечной физической страницы размером в 12 бит. Именно поэтому я сделал чуть выше акцент на то, что . Эти 9 бит служат просто индексом внутри каждой таблицы в этой иерархии.
PML4 — Page Map Level 4
Самая главная таблица. Каждая в ней запись позволяет адресовать целых 512 гигабайт пространства. Ее физический адрес хранится в специальном регистре CR3. При записи в CR3 процессор принудительно сбрасывает TLB. В современных ОС вся эта таблица делится на 2 участка: первые 256 записей для памяти приложений, вторые 256 записей для памяти ядра, отсюда и образуется Higher Half (Верхняя половина).
Есть небольшой прикол, который мне как-то раз выстрелил в ногу, когда я делал ленивую аллокацию и защиту памяти в своем ядре. Я не знал про то, что есть канонические адреса и неканонические. Они взялись из-за того, что указывается все 64 бит адреса, но реально могут быть разными только 48 младших. Биты с 48 по 63 должны дублировать значение 47 бита, иначе процессор любезно (или не очень) наградит вас General Protection Fault, Stack Fault или Page Fault.
PDPT — Page Directory Pointer Table
Как и во всех остальных таблицах, в ней ровно 512 записей, но каждая запись уже покрывает не 512ГБ, как в PML4, а 1ГБ.
В каждую запись в этой таблице добавляется еще один бит — Page Size, что позволяет провернуть один очень вкусный трюк: если этот бит не установлен, то процессор послушно спускается дальше по этой иерархии, а если установлен, то MMU прекращает спуск и рассматривает эту запись как одну целую страницу размером 1ГБ, склеивая 18 оставшихся индексных бит со стандартным 12-битным смещением, в смещение внутри этой огромной страницы. Это так и называется — Huge Page.
Но не все так радужно. Поддержку Huge Page всегда надо проверять через CPUID.
PD — Page Directory
Позволяет адресовать уже 2МБ памяти. В ней тоже 512 записей, и она тоже позволяет останавливать MMU, чтобы склеить оставшиеся 9 бит индекса, которые должны были уйти на индекс внутри таблицы PT, и те самые 12 бит. Несет на себе клеймо Large Page.
В чем же ее преимущество перед Huge Page? Ее поддержку не надо проверять через CPUID, в отличие от PDPT, ибо она в архитектуре зашита намертво.
PT — Page Table
Является конечным этапом преобразования виртуального адреса в физический. Тот самый бит, который раньше отвечал за Page Size, теперь является PAT, о котором я писал выше, и именно здесь этот бит налаживает свои отношения с братьями PCD и PWT. Адресует 4КБ памяти, используя младшие 12 бит виртуального адреса в качестве смещения внутри этих 4КБ, ведь 2^12 = 4096.
Заметили? Запись в каждой таблице позволяет адресовать в 512 раз меньше пространства, чем на предыдущем уровне, так как в одной таблице всегда 512 записей.
Помните, я говорил, что TLB немного тупее, чем обычный кэш? Так вот, когда вы обновляете запись, всегда нужно сбросить кэш чисто для этого адреса с помощью invlpg.
В конце концов, наконец конец!
Если у вас кипит мозг — у меня тоже. В этой статье мы изучили, как работает память современных процессоров. Я писал эту статью глубокой ночью, поэтому могут быть какие‑либо ошибки, но я постарался проверить весь материал. Все было взято из официальных спецификаций от Intel как от самого доверенного источника. Все, чего здесь нет, будет рассмотрено в следующей части, где мы уже применим свои знания и используем теорию в реальных задачах. Саму статью я выложил днем, чтобы быстро среагировать на комментарии и исправить неточности.
И, как обычно, жду ваших мнений, исправлений, критики и в целом обратной связи.

