Добавить в корзинуПозвонить
Найти в Дзене
Триалогия

Пишем операционную систему Триалогия - Управление памятью и страничная память в ядре

В конце статьи про загрузку загрузчик передал ядру по части памяти две вещи: готовую, собранную вручную таблицу страниц и карту памяти от BIOS. Теперь посмотрим, что ядро из этого делает. Это обзор фундамента, а отдельные аллокаторы, которые сидят поверх него (куча, отображение MMIO, стеки потоков), позже получат каждый свою часть. Память у меня имеет три слоя, снизу вверх: 1. Физическая память - микросхемы RAM, которые реально стоят в машине. 2. Виртуальная память (страничная) - иллюзия аккуратного, непрерывного адресного пространства, за которой процессор собирает физические страницы воедино. 3. Аллокаторы - куча, стеки, MMIO и так далее, которые нарезают из грубого материала удобные кусочки. Они идут отдельно. Эта часть - про нижние два слоя. От загрузчика пришла карта памяти BIOS: список областей со стартовым адресом, длиной и типом (свободно, зарезервировано, ...). init_memory_manager проходит по нему и превращает свободные области в связный список свободных блоков. Изящный приём
Оглавление

В конце статьи про загрузку загрузчик передал ядру по части памяти две вещи: готовую, собранную вручную таблицу страниц и карту памяти от 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). Фундамент стоит и держит нагрузку, но я по сей день регулярно по нему постукиваю :-)

Предыдущая статья Содержание Следующая статья

*Система не стоит на месте, поэтому в дальнейшем тексты могут не совпадать с реальным положением.