Unix2019b/Организация памяти на x86-64
Содержание
Режимы работы процессора
Процессоры архитектуры x86-64 поддерживают два основных режима работы: Long mode («длинный» режим) и Legacy mode («унаследованный», режим совместимости с 32-битным x86).
Long mode
«Длинный» режим — «родной» для процессоров x86-64. Этот режим даёт возможность воспользоваться всеми дополнительными преимуществами, предоставляемыми архитектурой. Для использования этого режима необходима 64-битная операционная система.
Этот режим позволяет выполнять 64-битные программы; также (для обратной совместимости) предоставляется поддержка выполнения 32-битного кода, например, 32-битных приложений, хотя 32-битные программы не смогут использовать 64-битные системные библиотеки, и наоборот. Чтобы справиться с этой проблемой, большинство 64-разрядных операционных систем предоставляют два набора необходимых системных файлов: один — для родных 64-битных приложений, и другой — для 32-битных программ.
Когда вы используете 64-битную операционную систему (Windows, Linux или какую-либо другую), ваш процессор работает в длинном режиме. 32-битные ОС теряют популярность и используются всё реже, так как не позволяют использовать весь потенциал современного железа. Так, дистрибутив Ubuntu Linux уже начиная с версии 17.10 не выпускается в 32-битном исполнении. Windows Server 2008 стала последней серверной ОС от Microsoft, которая имела 32-битную версию, и Server 2012 существует только 64-битная.
Legacy mode
Данный «унаследованный» режим позволяет процессору выполнять инструкции, рассчитанные для процессоров x86, и предоставляет полную совместимость с 32-битным кодом и операционными системами. В этом режиме процессор ведёт себя точно так же, как x86-процессор, например Athlon или Pentium III, и дополнительные функции, предоставляемые архитектурой x86-64 (например, дополнительные регистры), недоступны. В этом режиме 64-битные программы и операционные системы работать не будут.
Этот режим включает в себя подрежимы:
- Реальный режим (real mode)
- Защищённый режим (protected mode)
- Режим виртуального 8086 (virtual 8086 mode)
Реальный режим использовался в MS-DOS, в реальном режиме выполнялся код BIOS при загрузке компьютера.
Защищённый режим используется в 32-битных версиях современных многозадачных операционных систем (например, обычная 32-битная Windows XP работает в защищённом режиме, как и 32-битная версия Ubuntu 16.04).
Режим виртуального 8086 — подрежим защищённого, предназначался главным образом для создания т. н. «виртуальных DOS-машин». Если из 32-битной версии Windows вы запускаете 16-битное DOS-приложение, то работает эмулятор NTVDM (NT Virtual DOS Machine), который использует этот режим процессора. Другой эмулятор, DOSBox, не использует этот режим V86, а выполняет полную эмуляцию. Заметим, что в 64-битных версиях Windows эмулятор NTVDM был исключён, поэтому напрямую запустить на выполнение 16-битный com- или exe-файл стало невозможно (тем не менее, можно использовать тот же DOSBox или другой гипервизор для полной эмуляции реального режима).
Переход между режимами
Из длинного режима нельзя перейти в реальный или режим виртуального 8086 без перезагрузки. Поэтому, как уже отмечено, в 64-битных версиях Windows не работает NTVDM и нельзя запускать 16-битные программы.
Самый современный процессор x86-64 полностью поддерживает реальный режим. Если загрузка выполняется через BIOS, то код загрузчика (из сектора #0) исполняется в реальном режиме. Однако если вместо BIOS используется UEFI, то переход в Long mode происходит ещё раньше, и никакого кода в реальном режиме уже не выполняется. Можно считать, что современный компьютер сразу начинает работать в 64-битном длинном режиме.
Поэтому далее нас будет интересовать только длинный режим.
Трансляция адресов в памяти
Упрощённо говоря, процессор обращается к памяти через шину. Адресами памяти, которыми обмениваются в шине, являются физические адреса, то есть сырые числа от нуля до верхней границы доступной физической памяти (например, до 233, если у вас установлено 8 ГБ оперативки). Ранее между процессором и микросхемами памяти располагался северный мост — отдельный чип, но в реализации Intel начиная с микроархитектуры Sandy Bridge он интегрирован на кристалл процессора.
Физические адреса являются конкретными и окончательными — без трансляции, без подкачки, без проверки привилегий. Вы выставляете их на шину и всё: выполняется чтение или запись.
Однако в современной операционной системе программы используют абстрацкию — виртуальное адресное пространство. Каждая программа пишется в такой модели, что она выполняется одна, всё пространство принадлежит ей, код использует адреса логической памяти, которые должны быть оттранслированы в физические адреса до того, как будет выполнен доступ к памяти. Концептуально трансляция выглядит следующим образом:
Это не физическая схема, а только описание процесса преобразования адресов. Такая трансляция осуществляется всякий раз, когда CPU выполняет инструкцию, которая ссылается на адрес памяти.
Логический адрес на x86 состоит из двух частей: селектора сегмента и смещения внутри сегмента. Процесс трансляции включает два шага:
- учёт сегментного селектора и переход от смещения внутри сегмента к некоторому линейному адресу;
- перевод линейного адреса в физический.
Спрашивается, зачем нужен первый шаг и зачем нужны эти сегменты, почему бы напрямую не использовать линейные адреса в программе? Это результат эволюции. Чтобы действительно понять смысл сегментации x86, нам нужно вернуться в 1978 год.
Сегментация
Реальный режим
16-битный процессор 8086 использовал 16-битные регистры и мог напрямую адресовать только 216 байт памяти. Инженеры придумывали, как же можно заставить его работать с большим объёмом памяти, не расширяя разрядность регистров.
Были придуманы сегментные регистры, которые должны были задавать, к какому именно 64-килобайтному куску памяти относится данный 16-битный адрес.
Решение выглядит логичным: сначала вы устанавливаете сегментный регистр, по сути говоря “так, я хочу работать с куском памяти начиная с адреса X”; затем 16-битный адрес уже используется как смещение в рамках этого куска.
Всего предусматривалось сначала четыре 16-битных сегментных регистра, потом добавили ещё два:
- CS = Code Segment
- DS = Data Segment
- ES = Extra (или Destination) Segment
- SS = Stack Segment
- FS
- GS
Названия этих регистров связаны с назначением. При выполнении инструкций они загружаются из сегмента кода. При обращении к стеку (инструкции push/pop) неявно используется сегмент стека (при работе с регистрами SP и BP). Некоторые инструкции (так называемые «строковые») используют фиксированные сегменты, например инструкция movs копирует из DS:(E)SI в ES:(E)DI.
Для вычисления линейного адреса ячейки памяти процессор вычисляет физический адрес начала сегмента — умножает сегментную часть виртуального адреса на число 16 (или, что то же самое, сдвигает её влево на 4 бита), а затем складывает полученное число со смещением от начала сегмента. Таким образом, сегменты частично перекрывались, и всего можно было адресовать около 1 МБ физической памяти. Спрашивается, почему не умножать значение сегментного регистра сразу на 65536, ведь тогда можно было бы адресовать 4 ГБ памяти. Тогда это было не нужно и только растило стоимость чипа.
В реальном режиме отсутствует защита памяти и разграничение прав доступа.
Программы были маленькие, поэтому их стек и код полностью помещались в 64 КБ, не было проблем. В языке C тех древних времён обычный указатель был 16-битный и указывал относительно сегмента по умолчанию, однако существовали также far-указатели, которые включали в себя значение сегментного регистра. Призраки этих far-указателей преследуют нас в названиях типов в WinAPI (например LPVOID — long (far) pointer to void).
#include <stdio.h> int main(){ char far *p =(char far *)0x55550005; char far *q =(char far *)0x53332225; *p = 80; (*p)++; printf("%d",*q); return 0; }
Тут оба указателя указывают на один и тот же физический адрес 0x55555.
Защищённый режим
В 32-битном защищенном режиме также используется сегментированная модель памяти, однако уже организованная по другому принципу: расположение сегментов описывается специальными структурами (таблицами дескрипторов), расположенными в оперативной памяти.
Сегменты памяти также выбираются все теми же сегментными регистрами. Значение сегментного регистра (сегментный селектор) больше не является сырым адресом, но вместо этого представляет собой структуру такого вида:
Существует два типа дескрипторных таблиц: глобальная (GDT) и локальная (LDT). Глобальная таблица описывает сегменты операционной системы и разделяемых структур данных, у каждого ядра своя. Локальная таблица может быть определена для каждой конкретной задачи (процесса). Бит TI равен 0 для GDT и 1 для LDT. Индекс задаёт номер дескриптора в таблице дескрипторов сегмента. Поле RPL расшифровывается как Requested Privilege Level.
Сама таблица представляет собой просто массив, содержащий 8-байтные записи (дескрипторы сегмента), где каждая запись описывает один сегмент и выглядит так:
Помимо базового адреса сегмента дескрипторы содержат размер сегмента (точнее, максимально доступное смещение) и различные атрибуты сегментов, использующиеся для защиты памяти и определения прав доступа к сегменту для различных программных модулей. Базовый адрес представляет собой 32-битный линейный адрес, указывающий на начало сегмента, а лимит определяет, насколько большой сегмент. Добавление базового адреса к адресу логической памяти дает линейный адрес (никакого умножения на 16 уже нет). DPL (Descriptor Privilege Level) — уровень привилегий дескриптора; это число от 0 (наиболее привилегированный, режим ядра) до 3 (наименее привилегированный, пользовательский режим), которое контролирует доступ к сегменту.
Когда CPU находится в 32-битных режимах, регистры и инструкции могут в любом случае адресовать всё линейное адресное пространство. Итак, почему бы не установить базовый адрес в ноль и позволить логическим адресам совпадать с линейными адресами? Intel называет это «плоской моделью», и это именно то, что делают современные ядра операционных систем под x86. Это эквивалентно отключению сегментации.
Понятно, что раз таблицы GDT и LDT лежат в памяти, каждый раз ходить в них за базовым адресом долго. Поэтому сегментные дескрипторы кешируются в специальных регистрах в момент загрузки (в тот момент, когда происходит запись в сегментный селектор).
Местоположение GDT в памяти указывается процессору посредством инструкции lgdt.
Длинный режим
На архитектуре x86-64 в длинном (64-битном) режиме сегментация не используется. Для четырёх сегментных регистров (CS, SS, DS и ES) базовый адрес принудительно выставляются в 0. Сегментные регистры FS и GS по-прежнему могут иметь ненулевой базовый адрес (но он стал 64-битным и может быть установлен через отдельные моделезависимые регистры (MSR)). Это позволяет ОС использовать их для служебных целей.
Например, Microsoft Windows на x86-64 использует GS для указания на Thread Environment Block, маленькую структурку для каждого потока, которая содержит информацию об обработке исключений, thread-local-переменных и прочих per-thread-сведений. Аналогично, ядро Linux использует GS-сегмент для хранения данных per-CPU.
Посмотрим на таблицы сегментных дескрипторов. Таблица LDT на самом деле вышла из употребления и сейчас не используется. В таблице GDT в современных системах есть как минимум пять записей:
- Null — первая ячейка не используется (сделано, чтобы нулевое значение селектора было зарезервированным [1]);
- Kernel Code;
- Kernel Data;
- User Code;
- User Data.
Практика: просмотр регистров
(gdb) info registers rax 0x40052d 4195629 rbx 0x0 0 rcx 0x0 0 rdx 0x7fffffffde78 140737488346744 rsi 0x7fffffffde68 140737488346728 rdi 0x1 1 rbp 0x7fffffffdd80 0x7fffffffdd80 rsp 0x7fffffffdd80 0x7fffffffdd80 r8 0x7ffff7dd4e80 140737351863936 r9 0x7ffff7dea700 140737351952128 r10 0x7fffffffdc10 140737488346128 r11 0x7ffff7a32e50 140737348054608 r12 0x400440 4195392 r13 0x7fffffffde60 140737488346720 r14 0x0 0 r15 0x0 0 rip 0x400531 0x400531 <main+4> eflags 0x246 [ PF ZF IF ] cs 0x33 51 ss 0x2b 43 ds 0x0 0 es 0x0 0 fs 0x0 0 gs 0x0 0
Мифы о сегментных регистрах
Сегментные регистры остались в прошлом, в 64-битном длинном режиме они окончательно выпилены. Это неправда. Регистры существуют, даже GDB их печатает.
Раньше сегментные регистры были 16-битные, а сейчас они уже 64-битные. Это неправда, регистры CS, SS, DS, ES и пр. всегда были 16-битными и остаются такими, у них нет расширенных E- или R-версий.
Сегодня в этих регистрах всегда записан ноль. Это тоже неправда, там записан индекс сегментного дескриптора в таблице, а уже в этом дескрипторе базовый адрес равен тождественно нулю. Из приведённого вывода GDB очевидно, что CS и SS не равны нулю. К тому же биты RPL играют свою роль.
Раз сегментные регистры не нужны, я могу в своей программе делать с ними разные арифметические операции. Это неправда, регистры не относятся к регистрам общего назначения и их не получится использовать в качестве операндов в любых инструкциях.
Ладно, я смогу использовать их для хранения произвольных данных, а именно 16-битных чисел. Так тоже не выйдет, потому что при любом обращении к памяти процессор всё равно обращается к сегментному селектору и проверяет сегментный дескриптор.
Кольца защиты
Вы, вероятно, знаете интуитивно, что приложения имеют ограниченные полномочия на компьютерах Intel x86 и что только код операционной системы может выполнять определенные задачи. Как это работает? Уровни привилегий x86 — механизм, с помощью которого ОС и ЦП ограничивают возможности программ пользовательского режима.
Существует четыре уровня привилегий: от 0 (наиболее привилегированных) до 3 (наименее привилегированных). В любой момент времени процессор x86 работает на определенном уровне привилегий, который определяет, что код может и не может сделать. Эти уровни привилегий часто описываются как защитные кольца, причем самое внутреннее кольцо соответствует самым высоким привилегиям. [2]
Большинство современных ОС на x86 используют только Ring 0 и Ring 3.
Кольца управляют доступом к памяти. Код с уровнем привилегий i может смотреть только данные уровня i и выше (менее привилегированных).
На кольце 0 можно делать всё. На кольце 3, например, нельзя:
- изменить текущее кольцо защиты (иначе весь механизм был бы бесполезен);
- изменить таблицу страниц;
- зарегистрировать обработчик прерываний;
- выполнить ввод-вывод инструкциями in и out;
- ...
Текущий уровень привилегий (CPL) определяется сегментным дескриптором кода. Если сейчас исполняется сегмент кода с уровнем привилегий 3, значит, исполняется пользовательский код. Если исполняется код с уровнем привилегий 0 — исполняется код ядра.
При обращении к памяти проверяется неравенство
где
- CPL — текущий уровень привилегий,
- RPL — записан в сегментном регистре (селекторе),
- DPL — записан в сегментном дескрипторе.
Еси неравенство ложно, генерируется ошибка general protection fault (GPF).
Как мы ранее выяснили, системные вызовы позволяют менять текущий уровень привилегий, поэтому эта операция достаточно тяжёлая.
Страничная организация памяти
Страничная память — способ организации виртуальной памяти, при котором виртуальные адреса отображаются на физические постранично.
В семействе x86 поддержка появилась с поколения 386, оно же первое 32-битное поколение.
Если сегментация сейчас практически не используется, то таблицы страниц, наоборот, используются вовсю во всех современных операционных системах. Его важно понимать, так как с особенностями страничной организации можно прямо или косвенно столкнуться при решении прикладных задач.
Страницы
Виртуальная память делится на страницы. Размер размера страницы задается процессором и обычно на x86-64 составляет 4 KiB. Это означает, что управление памятью в ядре выполняется с точностью до страницы. Когда вам понадобится новая память, ядро предоставит вам одну или несколько страниц. При освобождении памяти вы вернёте одну или несколько страниц... Каждый более гранулярный API (например malloc) реализуется в пространстве пользователя.
Физическая память также поделена на страницы.
Виртуальное адресное пространство
Хотя виртуальные адреса имеют разрядность в 64 бита, текущие реализации (и все чипы, которые находятся на стадии проектирования) не позволяют использовать всё виртуальное адресное пространство из 264 байт (16 экзабайт). Это будет примерно в четыре миллиарда раз больше виртуального адресного пространства на 32-битных машинах. В обозримом будущем большинству операционных систем и приложений не потребуется такое большое адресное пространство, поэтому внедрение таких широких виртуальных адресов просто увеличит сложность и расходы на трансляцию адреса без реальной выгоды. Поэтому AMD решила, что в первых реализациях архитектуры фактически при трансляции адресов будут использоваться только младшие 48 бит виртуального адреса.
Кроме того, спецификация AMD требует, что старшие 16 бит любого виртуального адреса, биты с 48-го по 63-й, должны быть копиями бита 47 (по принципу sign extension). Если это требование не выполняется, процессор будет вызывать исключение. Адреса, соответствующие этому правилу, называются «канонической формой». Канонические адреса в общей сложности составляют 256 терабайт полезного виртуального адресного пространства. Это по-прежнему в 65536 раз больше, чем 4 ГБ виртуального адресного пространства 32-битных машин.
Это соглашение допускает при необходимости масштабируемость до истинной 64-разрядной адресации. Многие операционные системы (включая семейство Windows NT и GNU/Linux) берут себе старшую половину адресного пространства (пространство ядра) и оставляют младшую половину (пользовательское пространство) для кода приложения, стека пользовательского режима, кучи и других областей данных. Конструкция «канонического адреса» гарантирует, что каждая совместимая с AMD64 реализация имеет, по сути, две половины памяти: нижняя половина «растет вверх» по мере того, как становится доступнее больше виртуальных битов адреса, а верхняя половина — наоборот, вверху адресного пространства и растет вниз.
Первые версии Windows для x64 даже не использовали все 256 ТБ; они были ограничены только 8 ТБ пользовательского пространства и 8 ТБ пространства ядра. Всё 48-битное адресное пространство стало поддерживаться в Windows 8.1, которая была выпущена в октябре 2013 года.
Структура таблицы страниц
Ставится задача транслировать 48-битный виртуальный адрес в физический. Она решается аппаратным обеспечением — блоком управления памятью (memory management unit, MMU). Этот блок является частью процессора. Чтобы транслировать адреса, он использует структуры данных в оперативной памяти, называемые таблицами страниц.
Вместо двухуровневой системы таблиц страниц, используемой системами с 32-битной архитектурой x86, системы, работающие в длинном режиме, используют четыре уровня таблицы страниц.
Возможные размеры страниц:
- 4 КБ (212 байт) — наиболее часто используется (как и в x86)
- 2 МБ (221 байт)
- 1 ГБ (230 байт)
Пусть для определённости размер страницы равен 4 КБ. Значит, младшие 12 битов адреса кодируют смещение внутри страницы и не изменяются, а старшие биты используются для определения адреса начала страницы.
CR3 — это специальный регистр процессора. В записях каждой таблицы лежит физический адрес начала таблицы следующего уровня.
Полная иерархия сопоставления страниц размером 4 КБ для всего 48-битного пространства займет немногим больше 512 ГБ ОЗУ (около 0.195% от виртуального пространства 256 ТБ).
Кеширование
Таблицы страниц хранятся в оперативной памяти. Если при каждом обращении по виртуальному адресу выполнять полностью трансляцию адресов, это будет работать очень медленно. Поэтому в процессоре реализуется специальный кеш под названием «буфер ассоциативной трансляции» (Translation lookaside buffer, TLB).
На практике вероятность промаха TLB невысока и составляет в среднем от 0,01% до 1%.
Практика: как скоро оно упадёт?
Понятно, что данный код по стандарту некорректен, содержит Undefined Behavior, а раз так, то компилятор может сделать что угодно, например не упасть вообще. Но тем не менее, если запускать на x86-64, то падает оно в определённый момент...
#include <stdio.h> #include <stdint.h> char buf[1]; #define PAGE_SIZE 4096 int main() { char* ptr = buf; for (;;) { int offset = (intptr_t)ptr % PAGE_SIZE; printf("%p: offset = %d\n", ptr, offset); *ptr = 'a'; // Segmentation fault expected! ++ptr; } return 0; }