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

Пишем свою операционную систему. Переход в защищённый режим


После недельного перерыва вновь могу обрадовать вас новым выпуском своей рассылки.

Итак, как вы помните, в реальном режиме работы процессора нам доступен всего лишь 1 мегабайт адресного пространства (из которых обычной памятью является всего лишь 640 килобайт). Так это и было во времена первых процессоров вроде 8086, но постепенно объёма оперативной памяти стало не хватать. В то же время требовалось сохранить полную обратную совместимость, чтобы 16-разрядные операционные системы вроде DOS смогли нормально работать. Поэтому был введён новый режим работы процессора - защищённый режим. После перехода в него для адресации используется не 16, а 32 или даже 64 бита, а сегменты в старом понимании исчезают. Также добавляются защитные механизмы (именно поэтому защищённый режим), чтобы ядро ОС было изолированно от приложений и могло ими свободно управлять. Это необходимо любой полноценной многозадачной системе.

Начнём с того, что добавляется в реальном режиме на процессорах , которые поддерживают 32-битный адрес (i386 и выше) - добавляются новые регистры, вернее расширяются старые: EAX, EBX, ECX, EDX, ESP, EBP, EIP, ESI, EDI, EFLAGS. Как можно догадаться, это 32-битные версии обычных регистров реального режима (к имени регистра добавляется приставка "E"). Все эти 32-битные регистры кроме EIP доступны и в реальном режиме, но в таком случае будут занимать на 1 байт больше (к ним добавляется специальный префикс). На процессоре моложе 286 эти команды будут некорректны. Мы можем, например, написать mov eax, 0x12345678 и после этого в AX будет 0x5678, потому что он как бы является "окном" в младшую часть регистра EAX (аналогично, AL младшая часть AX). Регистра-отображения старшей части 32-битных регистров не существует - можно её извлечь только с помощью арифметики (например, сдвинуть EAX на 16 бит вправо с помощью shr eax, 16, тогда в AX будет старшая половина, но содержимое младших бит будет утеряно). Что характерно, в защищённом режиме наоборот, команды работы с 16-битными регистрами (но не 8-битными) требуют префикс, поэтому несмотря на то, что разрядность в два раза больше, в защищённом режиме быстрее выполняются и занимают меньше места именно команды 32-битной арифметики.

Также, теперь у нас на 2 сегментных регистра больше - GS и FS. Работа с ними полностью аналогична DS и ES и вы можете их свободно использовать в реальном режиме. Отличие только в том, что никакие команды их явно не подразумевают (DS используется по умолчанию практически всеми командами и некоторыми строковыми операциями, ES некоторыми строковыми операциями) и надо явно указывать, что вы хотите обращаться через них. Например, mov ax, [gs:0x1234]. 

Помимо этого расширения регистров, добавляются новые управляющие регистры (раньше влиял на режим работы процессора только FLAGS) - CR0, CR2, CR3 и CR4. Есть и другие (например, отладочные регистры), но они нас сейчас не интересуют. Именно с помощью этих регистров производится переключение процессора в защищённый режим и настройка новых функций вроде страничной адресации. Они доступны в реальном режиме.

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

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

Несмотря на поддержку сегментации, она считается устаревшей. Ни Windows, ни Linux не используют её в полной мере, а на отличных от x86 архитектуры (например, ARM) она вовсе отсутствует. Для разграничения доступа к памяти используется гораздо более гибкий механизм страничной адресации, который мы рассмотрим далее. Чтобы избавиться от сегментации ОС просто описывает таблицу из двух дескрипторов, у каждого из которых базовый адрес 0, а размер 4 ГБ (максимальный размер адресуемой памяти в 32-битном режиме). В таком случае говорят, что мы включили режим линейных адресов - смещение соответствует физическому адресу. Это очень удобно и я пойду по тому же пути. Не следует пытаться использовать сегментацию в своей операционной системе - это сильно усложняет код ядра, языки высокого уровня (например, С или С++) не поддерживают сегментацию (то есть вы сможете полноценно программировать только на Assembler) и, наконец, вы не сможете перенести систему на другую архитектуру, потому что x86 единственная, которая умеет этот механизм (и то, в 64-битном режиме поля базового адреса и размера сегмента игнорируются, а используется лишь информация о правах доступа).

Как я уже сказал, таблица дескрипторов сегментов формируется самой операционной системой. Чтобы указать процессору, где она находится используется специальная команда - lgdt (Load Global Descriptor Table). Она принимает 6-байтовую переменную в памяти. Первые её 16 бит содержат размер таблицы в байтах (таким образом, максимальное количество дескрипторов - 65536 / 8 = 8192), последующие 32 бита - базовый линейный адрес в памяти самой таблицы (то есть без учёта всех сегментов). Имеет смысл выравнять начало таблицы на 16 байт, потому что это улучшает скорость доступа к её элементам. Первый элемент таблицы всегда должен быть равен нулю и любое использование нулевого селектора (указатель на элемент таблицы дескрипторов в сегментном регистре называется так) приводит к ошибке. Значит более-менее работоспособная таблица дескрипторов должна содержать хотя бы три дескриптора - пустой, дескриптор кода, дескриптор данных.

Ну что ещё стоит рассказать, прежде, чем мы попробуем перейти в защищённый режим? Пожалуй, ещё стоит упомянуть про уровни доступа. Код ядра системы и код приложений отделены друг от друга с той целью, чтобы ядро могло полностью управлять процессором, а приложения не могли вмешаться в работу ядра (ведь у нас многозадачная ОС). Код исполняется с определённым уровнем привилегий. В x86 их целых 4 штуки - от 0 до 3. Нулевой уровень самый привилегированный (может выполнять любые команды и менять режимы работы процессора), третий самый "бесправный". Как и в случае с сегментацией, разработчики x86 переборщили с функционалом и все ОС используют лишь два уровня из четырёх возможных, а другие архитектуры процессора поддерживают только их. У каждого сегмента в его дескрипторе указан DPL (Descriptor privilege level) - уровень доступа необходимый для данного сегмента. Непривилегированный код не может получить доступ к сегменту с уровнем доступа 0, а привилегированный код может получить доступ ко всем сегментам.

Селектор сегмента, который содержится в сегментном регистре, является не просто номером элемента в таблице, но и указателем уровня доступа - младшие 2 бита содержат уровень привилегий (от 0 до 3), а уже старшие номер самой таблицы. Таким образом селектор = (индекс_дескриптора shl 2) + RPL. RPL - Requested privelege level - запрашиваемый уровень привилегий. При этом RPL должен быть больше или равен максимальному из DPL и CPL (Current privilege level). CPL равен RPL селектора в CS. Таким образом код не может получить доступа к сегментам, у которых уровень доступа в числовом виде ниже, чем у него самого. Я, вероятно, описал достаточно запутанно, но вполне можно обойтись RPL = DPL, как мы и поступим.

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

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

; Запуск 32-разрядного ядра
 .start32:
	; Выводим уведомление о запуске 32-битного ядра
	mov si, start32_msg
	call write_str
	; Загрузим значение в GDTR
	lgdt [gdtr32]
	; Запретим прерывания
	cli
	; Перейдём в защищённый режим
	mov eax, cr0
	or eax, 1
	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
	movzx esp, sp
	; Выводим символ на экран
	mov byte[0xB8000 + (25 * 80 - 1) * 2], "!"
	; Завершение
	jmp $ 

Этот код следует дописать к нашему начальному загрузчику.

Перед переходом в защищённый режим необходимо запретить приём аппаратных прерываний (клавиатура, мышь, таймер и другие устройства), потому что BIOS после перехода остаётся не у дел, а свои обработчики мы ещё не написали, поэтому первое же прерывание обрушит систему.

Непосредственно переход в защищённый режим осуществляет установка нулевого бита в CR0. Именно это мы и делаем (прямой доступ к CR0,2,3,4 невозможен так же как и к сегментным регистрам, поэтому используем EAX). Несмотря на то, что мы уже перешли в защищённый режим, код продолжает исполняться по-прежнему 16-битный. Для окончательного перехода нам нужно обновить содержимое сегментных регистров. Директива Ассемблера use32 говорит ему, что дальнейший код выполняется в защищённом режиме и необходимо переключиться в режим генерации команд для него, а не 16-битного (он используется по умолчанию) .

Команда movzx расширяет второй аргумент до первого. В смысле, что из 16 битного значения SP получается 32-битное. Старшие биты обнуляются (мало ли, что там было до нас). Предпоследняя команда демонстрирует нам возможности защищённого режима - мы обращаемся по абсолютному 32-битному адресу к видео-памяти текстового режима, выводя символ "!" в правый нижний угол экрана (текстовый экран имеет разрешение 80 x 25 символов, каждый символ занимает в памяти два байта - код символа и его атрибуты цвета).

Мы больше не можем обращаться к сервисам BIOS, теперь пришло время нам стать полностью самостоятельными и самим управлять всем оборудованием. Перезагружаться и ждать нажатия на клавишу мы пока не умеем, поэтому просто зависаем с помощью команды jmp $ (переход на ту же самую команду - бесконечный цикл).

В нашем boot.cfg команду S64 пока заменим на S32. Теперь, если вы всё правильно сделали, наш загрузчик будет завершать свою работу выводом восклицательного знака в угол экрана из защищённого режима. Это только начало. Мы наконец-то практически ушли из реального режима (на самом деле там ещё осталось немного дел) в защищённый. Поскольку наш загрузчик выполняется в нулевом сегменте реального режима, все смещения соответствуют физическим адресам и при переходе в защищённый режим, нам не пришлось ничего пересчитывать.

В завершение выпуска, пожалуй, добавлю последний штрих - проверку, что процессор поддерживает защищённый режим. Суть проверки в том, что не все биты FLAGS можно изменить программно. То есть регистр не совсем 16-битный. На новых процессорах доступно для изменения больше бит и это можно обнаружить. Разберите код ниже сами, скажу только, что команда pushf помещает регистр флагов в стек, а popf выталкивает содержимое стека во FLAGS. Таким образом его можно менять целиком, а не отдельными командами. Вот полный код нашего загрузчика:

org 0x7C00
	jmp boot
; Заголовок ListFS
align 4
fs_magic dd ?
fs_version dd ?
fs_flags dd ?
fs_base dq ?
fs_size dq ?
fs_map_base dq ?
fs_map_size dq ?
fs_first_file dq ?
fs_uid dq ?
fs_block_size dd ?
; Заголовок файла
virtual at 0x800
f_info:
	f_name rb 256
	f_next dq ?
	f_prev dq ?
	f_parent dq ?
	f_flags dq ?
	f_data dq ?
	f_size dq ?
	f_ctime dq ?
	f_mtime dq ?
	f_atime dq ?
end virtual
; Данные начального загрузчика
label sector_per_track word at $$
label head_count byte at $$ + 2
label disk_id byte at $$ + 3
reboot_msg db "Press any key...",13,10,0
boot_file_name db "boot.bin",0
; Вывод строки DS:SI на экран
write_str:
	push si
	mov ah, 0x0E
 @:
	lodsb
	test al, al
	jz @f
	int 0x10
	jmp @b
 @:
	pop si
	ret
; Критическая ошибка
error:
	pop si
	call write_str
; Перезагрузка
reboot:
	mov si, reboot_msg
	call write_str
	xor ah, ah
	int 0x16
	jmp 0xFFFF:0
; Загрузка сектора DX:AX в буфер ES:DI
load_sector:
	push dx
	add ax, word[fs_base]
	adc dx, word[fs_base + 2]
	cmp byte[sector_per_track], 0xFF
	je .use_EDD
	push bx cx si
	div [sector_per_track]
	mov cl, dl
	inc cl
	div [head_count]
	mov dh, ah
	mov ch, al
	mov dl, [disk_id]
	mov bx, di
	mov al, 1
	mov si, 3
 @:
	mov ah, 2
	int 0x13
	jnc @f
	xor ah, ah
	int 0x13
	dec si
	jnz @b
 .error:
	call error
	db "DISK ERROR",13,10,0
 @:
	pop si cx bx dx
	ret
 .use_EDD:
	push si
	mov byte[0x600], 0x10
	mov byte[0x601], 0
	mov word[0x602], 1
	mov [0x604], di
	push es
	pop word[0x606]
	mov [0x608], ax
	mov [0x60A], dx
	mov word[0x60C], 0
	mov word[0x60E], 0
	mov ah, 0x42
	mov dl, [disk_id]
	mov si, 0x600
	int 0x13
	jc .error
	pop si dx
	ret
; Поиск файла с именем DS:SI в каталоге DX:AX
find_file:
	push cx dx di
 .find:
	cmp ax, -1
	jne @f
	cmp dx, -1
	jne @f
 .not_found:
	call error
	db "NOT FOUND",13,10,0
 @:
	mov di, f_info
	call load_sector
	push di
	mov cx, 0xFFFF
	xor al, al
	repne scasb
	neg cx
	dec cx
	pop di
	push si
	repe cmpsb
	pop si
	je .found
	mov ax, word[f_next]
	mov dx, word[f_next + 2]
	jmp .find
 .found:
	pop di dx cx
	ret
; Загрузка текущего файла в память по адресу BX:0. Количество загруженных секторов возвращается в AX
load_file_data:
	push bx cx dx si di
	mov ax, word[f_data]
	mov dx, word[f_data + 2]
 .load_list:
	cmp ax, -1
	jne @f
	cmp dx, -1
	jne @f
 .file_end:
	pop di si dx cx
	mov ax, bx
	pop bx
	sub ax, bx
	shr ax, 9 - 4
	ret
 @:
	mov di, 0x8000 / 16
	call load_sector
	mov si, di
	mov cx, 512 / 8 - 1
 .load_sector:
	lodsw
	mov dx, [si]
	add si, 6
	cmp ax, -1
	jne @f
	cmp dx, -1
	je .file_end	
 @:
	push es
	mov es, bx
	xor di, di
	call load_sector
	add bx, 0x200 / 16
	pop es
	loop .load_sector
	lodsw
	mov dx, [si]
	jmp .load_list
; Точка входа в начальный загрузчик
boot:
	; Настроим сегментные регистры
	jmp 0:@f
 @:
	mov ax, cs
	mov ds, ax
	mov es, ax
	; Настроим стек
	mov ss, ax
	mov sp, $$
	; Разрешим прерывания
	sti
	; Запомним номер загрузочного диска
	mov [disk_id], dl
	; Определим параметры загрузочного диска
	mov ah, 0x41
	mov bx, 0x55AA
	int 0x13
	jc @f
	mov byte[sector_per_track], 0xFF
	jmp .disk_detected
 @:
	mov ah, 0x08
	xor di, di
	push es
	int 0x13
	pop es
	jc load_sector.error
	inc dh
	mov [head_count], dh
	and cx, 111111b
	mov [sector_per_track], cx
 .disk_detected:
	; Загрузим продолжение начального загрузчика
	mov si, boot_file_name
	mov ax, word[fs_first_file]
	mov dx, word[fs_first_file + 2]
	call find_file
	mov bx, 0x7E00 / 16
	call load_file_data
	; Переходим на продолжение
	jmp boot2
; Пустое пространство и сигнатура
rb 510 - ($ - $$)
db 0x55,0xAA
; Дополнительные данные загрузчика
load_msg_preffix db "Loading '",0
load_msg_suffix db "'...",0
ok_msg db "OK",13,10,0
config_file_name db "boot.cfg",0
start16_msg db "Starting 16 bit kernel...",13,10,0
start32_msg db "Starting 32 bit kernel...",13,10,0
; Разбиение строки DS:SI по символу слеша
split_file_name:
	push si
 @:
	lodsb
	cmp al, "/"
	je @f
	test al, al
	jz @f
	jmp @b
 @:
	mov byte[si - 1], 0
	mov ax, si
	pop si
	ret
; Загрузка файла с именем DS:SI в буфер BX:0. Размер файла в секторах возвращается в AX
load_file:
	push si
	mov si, load_msg_preffix
	call write_str
	pop si
	call write_str
	push si
	mov si, load_msg_suffix
	call write_str
	pop si
	push si bp
	mov dx, word[fs_first_file + 2]
	mov ax, word[fs_first_file]
 @:
	push ax
	call split_file_name
	mov bp, ax
	pop ax
	call find_file
	test byte[f_flags], 1
	jz @f
	mov si, bp
	mov dx, word[f_data + 2]
	mov ax, word[f_data]
	jmp @b	
 @:
	call load_file_data
	mov si, ok_msg
	call write_str
	pop bp si
	ret
; Продолжение начального загрузчика
boot2:
	; Загрузим конфигурационный файл загрузчика
	mov si, config_file_name
	mov bx, 0x1000 / 16
	call load_file
	; Выполним загрузочный скрипт
	mov bx, 0x9000 / 16
	mov bp, 0x6000
	mov dx, 0x1000
 .parse_line:
	mov si, dx
 .parse_char:
	lodsb
	test al, al
	jz .config_end
	cmp al, 10
	je .run_command
	cmp al, 13
	je .run_command
	jmp .parse_char
 .run_command:
	mov byte[si - 1], 0
	xchg dx, si
	cmp byte[si], 0
	je .parse_line ; Пустая строка
	cmp byte[si], "#"
	je .parse_line ; Комментарий
	cmp byte[si], "L"
	je .load_file ; Загрузка файла
	cmp byte[si], "S"
	je .start ; Запуск ядра
	; Неизвестная команда
	mov al, [si]
	mov [.cmd], al
	call error
	db "Unknown boot script command '"
	.cmd db ?
	db "'!",13,10,0
 .config_end: ; При правильном конфигурационном файле мы не должны сюда попасть
	; Завершение
	jmp reboot
; Загрузка файла
 .load_file:
	push dx
	inc si
	call load_file
	push ax
	mov cx, 512
	mul cx
	mov word[bp + 8], ax
	mov word[bp + 10], dx
	mov word[bp + 12], 0
	mov word[bp + 14], 0
	mov ax, bx
	mov cx, 16
	mul cx
	mov word[bp], ax
	mov word[bp + 2], dx
	mov word[bp + 4], 0
	mov word[bp + 6], 0
	pop ax
	shr ax, 9 - 4
	add bx, ax
	add bp, 16
	pop dx
	jmp .parse_line
; Запуск ядра
 .start:
	; Проверим, что загружен хотя бы один файл
	cmp bx, 0x9000 / 16
	ja @f
	call error
	db "NO KERNEL LOADED",13,10,0	
 @:
	; Заполняем последний элемент списка файлов
	xor ax, ax
	mov cx, 16
	mov di, bp
	rep stosw
	; Переходим к процедуре инициализации ядра для нужной разрядности
	inc si
	cmp word[si], "16"
	je .start16
	cmp word[si], "32"
	je .start32
	;cmp word[si], "64"
	;je start64
	; Неизвестная рязрядность ядра
	call error
	db "Invalid start command argument",13,10,0
; Запуск 16-разрядного ядра
 .start16:
	mov si, start16_msg
	mov bx, 0x6000
	mov dl, [disk_id]
	jmp 0x9000
; Запуск 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	
 @:
	; Загрузим значение в GDTR
	lgdt [gdtr32]
	; Запретим прерывания
	cli
	; Перейдём в защищённый режим
	mov eax, cr0
	or eax, 1
	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
	movzx esp, sp
	; Выводим символ на экран
	mov byte[0xB8000 + (25 * 80 - 1) * 2], "!"
	; Завершение
	jmp $ 

В следующем выпуске мы поговорим про страничную адресацию и наконец-то загрузим хоть какое-то ядро. До встречи! :-)


В избранное