Отправляет email-рассылки с помощью сервиса Sendsay
  Все выпуски  

Пишем свою операционную систему. Страничная адресация


Доброго времени суток!

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

Для начала познакомимся с тремя видами адреса - физический адрес, линейный адрес и виртуальный адрес. Во-первых, физический адрес - это непосредственно адрес в оперативной памяти. Именно он будет послан контроллеру памяти при обращении к переменной, про остальные адреса знает только процессор. В реальном режиме все обращения производятся по физическим адресам. То есть, если мы обратились по адресу 1000:FFFF, то мы будем читать или писать байт в оперативной памяти именно с адресом 0x1FFFF и никакой другой. В самом простом случае физическое адресное пространство более-менее непрерывно - сначала первый непрерывный блок в 640 КБ, потом 384 КБ памяти устройств (память видео-карты для текстового режима, BIOS), потом ещё один непрерывный блок адресов оперативной памяти. Затем идёт "дыра" несуществующей памяти (запись данных туда игнорируется, а чтение возвращает 0x00 или 0xFF), в которой встречаются блоки памяти других устройств (например, память видео-карты для графического режима). Если оперативной памяти достаточно много (более 3 ГБ), то она уже не будет образовывать непрерывный блок - адрес-то 32-битный (значит можно адресовать лишь 4 ГБ), а устройства тоже хотят адресов. Поэтому "лишняя" память будет размещена за пределом 4 ГБ и доступна лишь для ОС с поддержкой 64 бит или PAE. Положение участков оперативной памяти жёстко задано и не меняется ОС.

Второй тип адресов - линейный. Без использования страничной адресации он полностью совпадает с физическим, но она всё меняет. Любой адрес в линейом адресном пространстве может на самом деле ссылаться на любой физический адрес. То есть мы можем как бы сказать процессору "адреса с 0 до 0x100000 должны является проекцией адресов 0x2000000 - 0x2100000". В это виртуальное адресное пространство можно спроецировать любое количество блоков физической памяти, в том числе и один и тот же несколько раз. Объём виртуальной памяти может быть меньше физической (не все физические адреса спроецированы в линейные), а может быть и больше (некоторые адреса спроецированы более одно раза). Зачем это нужно? Каждый процесс получает своё личное, независимое адресное пространство. Переключение между процессами осуществляется заменой таблиц преобразования. Таким образом достигается две хороших вещи:

1) Каждый процесс формирует своё адресное пространство исходя из своих предпочтений. По удобному для него адресу будет располагаться то, что ему нужно, несмотря на то, что другой процесс разметил в своём адресном пространстве совсем другие данные. Ну и наконец все структуры можно разместить по одним и тем же линейным адресам вне зависимости от объёма и расположения блоков физической памяти.

2) Каждый процесс изолирован от других и существует в своей своеобразной песочнице. Он не может случайно или умышленно изменить данные чужого процесса. Всё взаимодействие лишь с помощью сервисов ядра (которое обычно во всех адресных пространствах спроецировано по одним и тем же адресам, недоступным для приложения).

Именно этот механизм позволяет строить эффективные и защищённые многозадачные среды. Также страницы памяти (переадресовать каждый байт было бы слишком накладно, поэтому вся физическая и виртуальная память поделена на блоки по 4 КБ - страницы. Переадресация осуществляется на уровне целых страниц вроде - сотая страница виртуальной памяти является проекцией десятой физической) имеют свои атрибуты доступа. Например, страницы с кодом можно защитить от записи, а страницы с данными и кодом ядра - от доступа со стороны приложения.

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

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

Виртуальный адрес (аргумент ассемблерной команды) -> Прибавление базы сегмента, проверка границы и прав доступа к сегменту -> Линейный адрес -> Преобразование по таблицам страниц и проверка прав доступа к страницам -> Физический адрес (передаётся контроллеру памяти).

Страничное преобразование осуществляется с помощью специальных таблиц страниц. Каждый элемент таблицы страниц занимает 32 или 64 бита в зависимости от разрядности системы. Младшие 12 бит содержат атрибуты страницы, старшие - физический адрес, на который эта страница должна быть переадресована. При этом в атрибутах может быть указано, что страница не существует, в таком случае при попытке обращения произойдёт исключение, зато и заполнять физический адрес не надо.

Если бы таблица страниц являлась простым линейным массивом, то она заняла бы в памяти целых 4 мегабайта. Это не очень приятно для 32-битной системы. Ну а в 64-битной (даже с учётом того, что значащими являются только 48 бит виртуального адреса) системе, такая таблица страниц была бы уже на 64 ГБ, что часто превышает весь объём оперативной памяти компьютера. Эта проблема решается достаточно просто - совсем не все страницы должны быть спроецированы. Даже как правило бОльшая их часть не нужна. Многие приложения занимают в памяти лишь несколько десятков мегабайт, зачем хранить информацию о том, что кучи страниц просто?

Для этого были придуманы многоуровневые таблицы страничной трансляции. 2-х уровневая для 32 бит и 4-х уровневая для 64 бит. Рассмотрим 32-битную.

Первый уровень называется каталогом страниц. Именно его адрес мы сообщаем процессору, когда настраиваем страничную адресацию. Каталог таблиц (как, впрочем, и таблицы) занимает одну страницу памяти. Соответственно он содержит 1024 элемента (4096 / 4 = 1024). Старшие 10 бит адреса являются индексом в каталоге страниц. Полученный элемент является физическим адресом таблицы. А уже следующие 10 бит адреса являются номером страницы в этой таблице. Каждый элемент каталога имеет те же 12 бит атрибутов, поэтому любая таблица тоже может отсутствовать. Младшие 12 бит адреса используются как смещение на странице.

В 64-битном режиме в каталоге и каждой таблице не 1024, а 512 элементов (ведь они в два раза больше, а размер страницы тот же), а выше каталога страниц есть ещё каталог каталогов страниц и каталог страниц 4-ого уровня. При этом на любом уровне таблица может отсутствовать и тогда процессор не пойдёт дальше вглубь, а сразу кинет исключение.

Возможно, эта тема не самая простая для понимания, дальше, пожалуй, будет проще. Если возникнут вопросы - вы можете задавать их мне по электронной почте. Основную информации я постарался рассказать как мог.

Итак, произведём первичное планирование адресного пространства нашей ОС. Я думаю разделить его пополам. Нижние 2 ГБ (0x00000000 - 0x7FFFFFFF) у каждого процесса свои и полностью им настраиваются. Верхние 2 ГБ (0x80000000 - 0xFFFFFFFF) - принадлежат системе и общие у всех процессов. Там находится код ядра, данные для межпроцессного взаимодействия и т. д. Сам код ядра и его самые важные данные - последние 4 МБ 0xFFC00000 - 0xFFFFFFFF (последние 2 для 64-битной версии), потому что ровно столько памяти переадресует одна таблица страниц самого нижнего уровня.

Базовый физический адрес каталога страниц хранится в регистре CR3. И таблицы, и каталог выровнены на размер страницы.

Мы подготовим таблицы страниц и загрузим адрес каталога в CR3 в реальном режиме. Переход в защищённый режим мы выполним одновременно с включением страничной адресации (бит 31 регистра CR0). Главное позаботится о том, чтобы первый мегабайт был тоже примонтирован, причём в соответствии с физическими адресами, чтобы наш код продолжил правильно выполняться до прыжка на ядро.

Нам надо будет создать две таблицы страниц - первую (чтобы примонтировать первый мегабайт адрес-в-адрес) и последнюю (для ядра). Помимо проекции загруженного файла ядра, в этой таблице страниц будет ещё стек и самое главное - сама таблица. Зачем это нужно? сейчас объясню.

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

1) Проецировать все таблицы всех уровней в виртуальную память. Достаточно неудобно и запутанно. Придётся отрезать кусок виртуальной памяти, следить за примонтированностью нужных данных.

2) Сделать специальную временную страницу, которую мы сможем монтировать когда нам захочется. В моём случае это реализуется за счёт того факта, что последняя таблица страниц спроецирована. Мы меняем в ней последний элемент и в итоге проецируем любой нужный физический адрес по виртуальному адресу 0xFFFFF000. Таким образом мы имеем "окно", через которое смотрим на непримонтированную физическую память. На самом деле реализовать функции монтирования при такой системе очень просто, хотя может показаться труднее. В этом мы убедимся позднее.

Итак, пришло время воплотить всё это в коде:

; Запуск 32-разрядного ядра
 .start32:
	; Выводим уведомление о запуске 32-битного ядра
	mov si, start32_msg
	call write_str
	; Проверим, что процессор не хуже i386
	mov ax, 0x7202
	push ax
	popf
	pushf
	pop bx
	cmp ax, bx
	je @f
	call error
	db "Required i386 or better",13,10,0	
 @:
	; Очистим таблицы страниц
	xor ax, ax
	mov cx, 3 * 4096 / 2
	mov di, 0x1000
	rep stosw
	; Заполним каталог страниц
	mov word[0x1000], 0x2000 + 111b
	mov word[0x1FFC], 0x3000 + 111b
	; Заполним первую таблицу страниц
	mov eax, 11b
	mov cx, 0x100000 / 4096
	mov di, 0x2000
 @:
	stosd
	add eax, 0x1000
	loop @b
	; Заполним последнюю таблицу страниц
	mov di, 0x3000
	mov eax, dword[0x6000]
	or eax, 11b
	mov ecx, dword[0x6008]
	shr ecx, 12
 @:
	stosd
	add eax, 0x1000
	loop @b
	mov word[0x3FF4], 0x4000 + 11b ; Kernel stack
	mov word[0x3FF8], 0x3000 + 11b ; Kernel page table
	; Загрузим значение в CR3
	mov eax, 0x1000
	mov cr3, eax
	; Загрузим значение в GDTR
	lgdt [gdtr32]
	; Запретим прерывания
	cli
	; Перейдём в защищённый режим
	mov eax, cr0
	or eax, 0x80000001
	mov cr0, eax
	; Перейдём на 32-битный код
	jmp 8:start32
; Таблица дескрипторов сегментов для 32-битного ядра
align 16
gdt32:
	dq 0                  ; NULL - 0
	dq 0x00CF9A000000FFFF ; CODE - 8
	dq 0x00CF92000000FFFF ; DATA - 16
gdtr32:
	dw $ - gdt32 - 1
	dd gdt32
; 32-битный код
use32
start32:
	; Настроим сегментные регистры и стек
	mov eax, 16
	mov ds, ax
	mov es, ax
	mov fs, ax
	mov gs, ax
	mov ss, ax
	mov esp, 0xFFFFDFFC
	; Выводим символы на экран
	mov byte[0xB8000 + (25 * 80 - 1) * 2], "K"
	mov dword[0xFFFFEFFC], 0xB8000 + 11b
	mov byte[0xFFFFF000 + (25 * 80 - 2) * 2], "O"
	; Завершение
	jmp $ 

 Последние строки демонстрируют возможности страничной адресации - символ "O" мы выводим через проекцию страницы 0xB8000 на адрес 0xFFFFF000.

Первый загруженный файл примонтирован по адресу 0xFFC00000. Если там есть корректный 32-битный код, то можно смело заменять команду jmp $ на jmp 0xFFC00000 и наш загрузчик будет запускать ядро. Как производить временное монтирование страниц показано в команде между выводом "K" и "O" (в итоге получится "OK" :-) ).

Ну вот и всё на сегодня... В следующем выпуске мы наконец-то попробуем написать код ядра на Си. Это, наверное, самый запутанный выпуск, поэтому не стесняйтесь задавать вопросы. Дальше будет уже попроще, пожалуй. 


В избранное