В конце статьи про загрузку загрузчик передал ядру по части памяти две вещи: готовую, собранную вручную таблицу страниц и карту памяти от BIOS. Теперь посмотрим, что ядро из этого делает. Это обзор фундамента, а отдельные аллокаторы, которые сидят поверх него (куча, отображение MMIO, стеки потоков), позже получат каждый свою часть.
Три уровня
Память у меня имеет три слоя, снизу вверх:
1. Физическая память - микросхемы RAM, которые реально стоят в машине.
2. Виртуальная память (страничная) - иллюзия аккуратного, непрерывного адресного пространства, за которой процессор собирает физические страницы воедино.
3. Аллокаторы - куча, стеки, MMIO и так далее, которые нарезают из грубого материала удобные кусочки. Они идут отдельно.
Эта часть - про нижние два слоя.
Физическая память: список свободных страниц
От загрузчика пришла карта памяти BIOS: список областей со стартовым адресом, длиной и типом (свободно, зарезервировано, ...). init_memory_manager проходит по нему и превращает свободные области в связный список свободных блоков.
Изящный приём здесь: узлам списка не нужна своя память, они живут в самих свободных страницах. Каждый свободный блок несёт свой размер и указатели на соседей в начале своей же первой страницы:
typedef struct {
size_t size; // сколько страниц охватывает блок
phyaddr next; // следующий свободный блок ... который сам лежит в свободной странице
phyaddr prev;
} PhysMemoryBlock;
alloc_phys_pages(count) пробегает по списку, отрезает count страниц и возвращает их физический адрес. free_phys_pages "вцепляет" их обратно и сливает соседние блоки, чтобы память не рассыпалась на осколки.
Изящная проблема: как прочитать свободную страницу?
Тут спотыкаешься о то, что уже намекало в статье про загрузку: ядро живёт высоко по адресу 0xFFC00000 и в своём адресном пространстве видит лишь себя и горстку областей. Свободную физическую страницу где-нибудь по адресу, скажем 0x05000000 оно не может прочитать / записать напрямую, её просто нет в его виртуальной картине. Как же ему прочитать узел списка, который лежит ровно там?
Ответ - маленькое окно. temp_map_page(phys) на миг (или нет) направляет одну запись страницы на нужную физическую страницу, читает или пишет и снова освобождает окно:
void temp_map_page(phyaddr addr) {
uint32_t cpu = current_cpu_id(); // из регистра GS
void* window = (void*)(0xFFB80000 + cpu * 0x1000); // у этого CPU своё окно
set_pte(window, (addr & ~0xFFF) | PAGE_PRESENT | PAGE_WRITABLE);
flush_page_cache(window); // TLB только для этой одной страницы
}
Важен здесь cpu: у каждого ядра процессора своё окно (от 0xFFB80000 вверх, по 4 КБ на ядро). И это не ради скорости, а из самозащиты. Одно общее окно, и на двух ядрах это вело к тихой порче данных: ядро 0 отображает страницу A, ядро 1 в тот же миг отображает страницу B в то же окно, и ядро 0 вдруг читает B. Ошибка ровно такого рода долго меня мучила, пока окно не разделил по ядрам. Если вы попытаетесь защитить эту функцию мютексом (mutex) , то ошибку вы победите, но собственноручно создатите бутылочное горлышко - пока cpu0 что-то делает в этой функции, cpu1 будет тупо стоять и ждать пока она освободится.
Виртуальная память: картина, которую видит каждый процесс
Четыре гигабайта адресного пространства поделены жёстко. Внизу, от 0 до 0x80000000 (2 ГБ), живёт пространство пользователя, и тут у каждого процесса своя личная область. То есть у каждого пользовательского процесса есть свой адрес 0 или 0x1000. Наверху, от 0x80000000, сидит ядро со своими регионами: область под стеки потоков, под буферы IPC, большая куча, область MMIO под регистры оборудования, окна per-CPU из прошлого раздела и в самом верху сам код ядра.
Трюк с адресными пространствами: у каждого процесса свой каталог страниц, своя таблица трансляции. Но верхняя, ядровая часть везде вписана одинаково. Еще раз - кажды пользовательский процесс видит свое пространство внизу и ядро вверху! В каком бы процессе ни находился процессор, ядро находит свой код и свою кучу всегда на том же месте. Различается только нижняя половина, пространство пользователя, от процесса к процессу.
Как транслируется адрес
Виртуальный 32-битный адрес распадается на три части: верхние 10 бит выбирают запись в каталоге страниц, следующие 10 бит - запись в таблице страниц, а младшие 12 бит - это смещение внутри страницы в 4 КБ. Этот двухступенчатый путь процессор проходит сам при каждом обращении к памяти, в железе, как только подняты cr3 и страничная память.
Но иногда и самому ядру нужно перевести адрес, например, чтобы узнать, где виртуальный адрес лежит физически. Тогда оно проходит тот же путь программно, и тут снова видно окно из прошлого раздела в деле:
phyaddr get_page_info(phyaddr page_dir, void* vaddr) {
phyaddr table = page_dir;
// ступень 1: индекс PDE (биты 31:22), ступень 2: индекс PTE (биты 21:12)
for (int shift = 22; shift >= 12; shift -= 10) {
unsigned idx = ((size_t)vaddr >> shift) & 0x3FF;
temp_map_page(table); // вписать таблицу
phyaddr entry = ((phyaddr*)TEMP_PAGE)[idx];
if (shift == 12) return entry; // это уже PTE
if (!(entry & PAGE_PRESENT)) return 0; // дыра в адресном пространстве
table = entry & ~0xFFF; // на ступень глубже
}
return 0;
}
Сделать страницу видимой
Центральная функция в обратную сторону называется map_pages. Она вписывает для виртуального диапазона нужные записи таблицы страниц (и попутно создаёт недостающие таблицы). Флаги решают, что разрешено:
#define PAGE_PRESENT (1 << 0) // страница присутствует
#define PAGE_WRITABLE (1 << 1) // можно писать
#define PAGE_USER (1 << 2) // видна и из ring 3
// ... PAGE_CACHE_DISABLED (для MMIO), PAGE_GLOBAL, ...
Например сделать 4 физические страницы от физического адреса paddr видимыми в ядре по виртуальному адресу vaddr:
map_pages(kernel_page_dir, vaddr, paddr, 4, PAGE_PRESENT | PAGE_WRITABLE);
Почти всё остальное - куча, стеки, отображение MMIO, разделяемая память между процессами - в итоге проходит ровно через эту одну функцию. Это место, где сходятся физическая и виртуальная память.
Управление адресными пространствами
Каждый процесс получает AddressSpace: свой каталог страниц, список уже занятых виртуальных диапазонов и мьютекс (mutex), который делает всё это безопасным для SMP.
typedef struct {
phyaddr page_dir; // своя таблица трансляции
void *start, *end; // какой диапазон адресов управляется
VirtMemoryBlock *blocks; // какие куски уже розданы
size_t block_count;
Mutex mutex;
volatile uint8_t destroying; // прямо сейчас разбирается?
} AddressSpace;
create_page_directory строит свежий каталог и зеркалит в него ядровую часть, alloc_virt_pages резервирует в нём диапазон и отображает его на физические страницы, free_virt_pages отменяет(то есть освобождает занятые страницы) это. Когда процесс умирает, destroy_address_space вычищает весь его нижний регион, а ядровая часть остаётся, она ведь общая.
Чего здесь намеренно нет
Поверх этого фундамента сидят настоящие аллокаторы, и каждый достоин отдельной истории:
- куча (kmalloc / kfree), которая нарезает из большой области кучи маленькие кусочки произвольного размера,
- отображение MMIO, которое вписывает регистры оборудования в верхнюю область (с выключенным кэшем, иначе он видел бы устаревшие значения),
- аллокатор стеков, который выдаёт каждому потоку его 256 КБ в области стеков.
Их я приберегу для отдельных частей. Здесь речь шла только об основе: физические страницы, страничная память и адресные пространства.
Правда жизни
Ошибки в памяти - самые подлые из всех. Потерянная страница, неверно выставленный флаг, указатель на таблицу, которая уже не отображена, и падает не там, где ошибка, а где-то совсем в другом месте, гораздо позже, будто случайно. Окно per-CPU выше - лишь одна из нескольких вещей, которые я добавил только после того, как старый, попроще, вариант неделями донимал меня призрачными падениями #PF (page fault). Фундамент стоит и держит нагрузку, но я по сей день регулярно по нему постукиваю :-)
◀ Предыдущая статья Содержание Следующая статья ▶
*Система не стоит на месте, поэтому в дальнейшем тексты могут не совпадать с реальным положением.