• Приглашаем на KubanCTF

    Старт соревнований 14 сентября в 10:00 по москве

    Ссылка на регистрацию в соревнованиях Kuban CTF: kubanctf.ru

    Кодебай является технологическим партнером мероприятия

Настройка процессора x86 в 64-битном режиме

Люди говорят, что есть сложные вещи, а есть просто сложные. Сложность считается интересной, сложность считается вредной. Процесс настройки процессора x86_64 в основном сложен.

Я опишу один способ перехода от загрузочного сектора, загруженного BIOS с ЦП в 16-битном реальном режиме, к ЦП, настроенному в 64-битном длинном режиме. Настройка довольно простая, и нужно сделать еще кучу всего.

Для продолжения вам понадобится Руководство разработчика программного обеспечения для архитектур Intel 64 и IA-32 , ассемблер (я использовал nasm ) и QEMU . Если у вас нет процессора x86_64, вы все равно сможете запустить все, что я описываю, эмулируя процессор x86 в QEMU. Я предполагаю, что вы знаете ассемблер x86 и синтаксис, который использует nasm. Мне нравится руководство по nasm от Рэя Тоала для начала.

Я был удивлен, насколько легко читаются некоторые части руководства Intel. Начальные главы в томе 1 действительно хорошо справляются с обзором системы и объяснением терминов, используемых в других томах. Но том 3: Руководство по программированию системы наиболее актуален для этого обсуждения. Обзор всех режимов работы есть в томе 3, раздел 2.2 Режимы работы. Путь, по которому мы идем, выделен красным.

Обзор переходов между режимами x86

Для всего, что касается 32-битного режима, взгляните на «Написание простой операционной системы – с нуля» . Он незакончен, но все еще очень хорош.

Начальная точка: BIOS
После сброса процессор x86 находится в «реальном режиме». Этот режим имеет размер операнда по умолчанию 16 бит. Вы получаете 20-битное адресное пространство и, таким образом, возможность адресовать 1 МБ памяти с помощью сегментации. Реальный режим — это, по сути, режим обратной совместимости для чипа Intel 8086 1978 года.

После BIOS первым запускается код в загрузочном секторе. BIOS ищет в системе диск, где первый сектор заканчивается магическим числом 0xaa55(т. е. байтом 0x55, за которым следует байт 0xaa). Он загружает этот «загрузочный сектор» в память по адресу 0x7c00.

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

Настройка загрузочного сектора
Давайте настроим простой загрузочный сектор. Он просто выведет сообщение на экран с помощью процедур BIOS, а затем зависнет. Таким образом, мы узнаем, что инструментарий работает.

Вот какая сборка нам нужна:
Код:
;; src/boot_sector.s

    section .boot_sector
    global __start

    [bits 16]

__start:
    mov bx, hello_msg
    call print_string

end:
    hlt
    jmp end

;; Uses the BIOS to print a null-termianted string. The address of the
;; string is found in the bx register.
print_string:
    pusha
    mov ah, 0x0e ; BIOS "display character" function

print_string_loop:
    cmp byte [bx], 0
    je print_string_return

    mov al, [bx]
    int 0x10 ; BIOS video services

    inc bx
    jmp print_string_loop

print_string_return:
    popa
    ret

hello_msg: db "Hello, world!", 0


Плюс это Makefile:

Код:
# Makefile

.PHONY: all clean boot

NASM := nasm -f elf64

BUILD_DIR := build
SRC_DIR := src

SRC := $(wildcard $(SRC_DIR)/*.s)
OBJS := $(patsubst $(SRC_DIR)/%.s, $(BUILD_DIR)/%.o, $(SRC))
BOOT_IMAGE := $(BUILD_DIR)/boot_image

all: $(BOOT_IMAGE)

boot: $(BOOT_IMAGE)
qemu-system-x86_64 -no-reboot -drive file=$<,format=raw,index=0,media=disk

$(BOOT_IMAGE): $(BUILD_DIR)/linked.o
objcopy -O binary $< $@

$(BUILD_DIR)/linked.o: $(OBJS)
ld -T linker.ld -o $@ $^

$(BUILD_DIR)/%.o: $(SRC_DIR)/%.s
@mkdir -p $(dir $@)
$(NASM) $< -o $@

clean:
$(RM) -r $(BUILD_DIR)


Скрипт компоновщика linker.ldважен, поскольку он гарантирует, что код в нашем загрузочном секторе будет перемещен по правильному адресу в конечном образе. В частности, загрузчик загружает загрузочный сектор по адресу 0x7c00в памяти. Так что это базовый адрес для перемещения загрузочного сектора. Кроме того, компоновщик добавит магическое число в конец загрузочного сектора. Другие руководства, которые я видел, делают и смещение, и магическое число внутри исходного файла сборки загрузочного сектора, используя возможности ассемблера, но это несколько хакерски.

Код:
# linker.ld

MEMORY
{
    boot_sector (rwx) : ORIGIN = 0x7c00, LENGTH = 512
}

ENTRY(__start)
SECTIONS
{
    .boot_sector : { *(.boot_sector); } > boot_sector
    .bootsign (0x7c00 + 510) :
    {
        BYTE(0x55)
        BYTE(0xaa)
    } > boot_sector
}

В результате запуска make bootдолжно открыться окно QEMU и появиться сообщение «Hello, World!».

Скриншот QEMU с сообщением Hello World


Этап 1 – Загрузка этапа 2 с диска​

Мы можем разделить загрузчик на два этапа. Этап 1 — это код в загрузочном секторе. Это все, что BIOS загружает для нас. Единственная цель этапа 1 — загрузить этап 2 в память. Этап 1 делает это, используя процедуры, предоставляемые BIOS, для загрузки этапа 2 в память.

На этапе 2 мы перейдем из 16-битного реального режима в 32-битный защищенный режим. В защищенном режиме мы больше не можем использовать процедуры BIOS. Без процедур BIOS загрузка секторов с диска стала бы гораздо более сложной. Поэтому мы загрузим несколько секторов с диска в память и будем надеяться на лучшее. Конечно, это небезопасный метод, но пока он работает.

Вот как можно получить доступ к диску с помощью BIOS. Об этом есть .

Код:
;; src/boot_sector.s

;; ...

__start:
;; ...

mov si, disk_address_packet
mov ah, 0x42 ; BIOS "extended read" function
mov dl, 0x80 ; Drive number
int 0x13 ; BIOS disk services
jc error_reading_disk

ignore_disk_read_error:
SND_STAGE_ADDR equ (BOOT_LOAD_ADDR + SECTOR_SIZE)
jmp 0:SND_STAGE_ADDR

error_reading_disk:
;; We accept reading fewer sectors than requested
cmp word [dap_sectors_num], READ_SECTORS_NUM
jle ignore_disk_read_error

mov bx, error_reading_disk_msg
call print_string

end:
;; ...



И в конце boot_sector.sпоместите эти данные:

;; src/boot_sector.s

;; ...

align 4
disk_address_packet:
db 0x10 ; Size of packet
db 0 ; Reserved, always 0
dap_sectors_num:
dw READ_SECTORS_NUM ; Number of sectors read
dd (BOOT_LOAD_ADDR + SECTOR_SIZE) ; Destination address
dq 1 ; Sector to start at (0 is the boot sector)

READ_SECTORS_NUM equ 64
BOOT_LOAD_ADDR equ 0x7c00
SECTOR_SIZE equ 512

hello_msg: db "Hello, world!", 13, 10, 0
error_reading_disk_msg: db "Error: failed to read disk with 0x13/ah=0x42", 13, 10, 0



Наконец, нам нужен этап 2, на который можно перейти, и нам нужно обновить скрипт компоновщика. Он Makefileостается неизменным.

Код:
;; src/stage2.s

section .stage2

[bits 16]

mov bx, stage2_msg
call print_string

end:
hlt
jmp end

print_string:
;; ...

stage2_msg: db "Hello from stage 2", 13, 10, 0


Я просто скопировал print_stringфункцию, чтобы мы могли проверить, работает ли прыжок. Поскольку эта конкретная функция работает только с BIOS в реальном режиме, она не будет полезна для этапа 2 после того, как мы переключимся в защищенный режим.

Наконец, скрипт компоновщика:

Код:
[COLOR=rgb(44, 130, 201)]# linker.ld

MEMORY
{
    boot_sector (rwx) : ORIGIN = 0x7c00, LENGTH = 512
    stage2 (rwx) : ORIGIN = 0x7e00, LENGTH = 32768 # 512 * 64
}

ENTRY(__start)
SECTIONS
{
    .boot_sector : { *(.boot_sector); } > boot_sector
    .bootsign (0x7c00 + 510) :
    {
        BYTE(0x55)
        BYTE(0xaa)
    } > boot_sector
    .stage2 : { *(.stage2); } > stage2
}[/COLOR]



Скриншот QEMU сообщения этапа 2


32-битный защищенный режим​

Далее мы переключим ЦП из реального режима (16 бит) в защищенный режим (32 бита). В защищенном режиме сегментация используется по умолчанию для реализации защиты памяти. Перед переключением в защищенный режим вам необходимо определить глобальную таблицу дескрипторов (GDT), содержащую дескрипторы сегментов для всех сегментов, которые вы хотите определить. Обычно вместо сегментации используется подкачка страниц. Фактически, в 64-битном длинном режиме вам необходимо использовать подкачку страниц. Но для первоначального переключения в защищенный режим требуется сегментация.

Руководство Intel описывает «плоскую модель» как очень простую модель сегментации, которую можно реализовать в GDT. «Плоская модель» состоит из сегмента кода и сегмента данных. Оба эти сегмента отображаются на все линейное адресное пространство (их базовые адреса и пределы идентичны). Использование самой простой из всех моделей приемлемо, поскольку мы просто хотим перейти в длинный режим и отказаться от сегментации в пользу страничного режима.

GDT определяется как непрерывная структура в памяти. Вы заполняете фрагмент памяти правильными данными и даете процессору адрес и длину фрагмента памяти. Формат структуры GDT описан в руководстве Intel.


Скриншот макета дескриптора сегмента


GDT — это просто массив дескрипторов сегментов с «нулевым дескриптором» в начале, который используется для обнаружения недействительных переводов. Поля в дескрипторе сегмента подробно описаны в разделе «3.4.5 Дескрипторы сегментов» тома 3 руководства Intel.

Мы определяем GDT следующим образом:

Код:
;; include/gdt32.s


    ;; Base address of GDT should be aligned on an eight-byte boundary
    align 8


gdt32_start:
    ;; 8-byte null descriptor (index 0).
    ;; Used to catch translations with a null selector.
    dd 0x0
    dd 0x0


gdt32_code_segment:
    ;; 8-byte code segment descriptor (index 1).
    ;; First 16 bits of segment limit
    dw 0xffff
    ;; First 24 bits of segment base address
    dw 0x0000
    db 0x00
    ;; 0-3: segment type that specifies an execute/read code segment
    ;;   4: descriptor type flag indicating that this is a code/data segment
    ;; 5-6: Descriptor privilege level 0 (most privileged)
    ;;   7: Segment present flag set indicating that the segment is present
    db 10011010b
    ;; 0-3: last 4 bits of segment limit
    ;;   4: unused (available for use by system software)
    ;;   5: 64-bit code segment flag indicates that the segment doesn't contain 64-bit code
    ;;   6: default operation size of 32 bits
    ;;   7: granularity of 4 kilobyte units
    db 11001111b
    ;; Last 8 bits of segment base address
    db 0x00


gdt32_data_segment:
    ;; Only differences are explained ...
    dw 0xffff
    dw 0x0000
    db 0x00
    ;; 0-3: segment type that specifies a read/write data segment
    db 10010010b
    db 11001111b
    dw 0x00


gdt32_end:


;; Value for GDTR register that describes the above GDT
gdt32_pseudo_descriptor:
    ;; A limit value of 0 results in one valid byte. So, the limit value of our
    ;; GDT is its length in bytes minus 1.
    dw gdt32_end - gdt32_start - 1
    ;; Start address of the GDT
    dd gdt32_start

CODE_SEG32 equ gdt32_code_segment - gdt32_start
DATA_SEG32 equ gdt32_data_segment - gdt32_start
Переключиться в защищенный режим теперь очень просто. Мы загружаем псевдодескриптор GDT в регистр GDTR, чтобы базовый адрес и длина нашего GDT были известны системе. Наконец, мы делаем дальний прыжок, чтобы очистить конвейер инструкций.

Код:
;; src/stage2.s

    section .stage2

    [bits 16]

;; ...

    ;; Load GDT and switch to protected mode

    cli ; Can't have interrupts during the switch
    lgdt [gdt32_pseudo_descriptor]

    ;; Setting cr0.PE (bit 0) enables protected mode
    mov eax, cr0
    or eax, 1
    mov cr0, eax

    ;; The far jump into the code segment from the new GDT flushes
    ;; the CPU pipeline removing any 16-bit decoded instructions
    ;; and updates the cs register with the new code segment.
    jmp CODE_SEG32:start_prot_mode


    [bits 32]
start_prot_mode:
    ;; Old segments are now meaningless
    mov ax, DATA_SEG32
    mov ds, ax
    mov ss, ax
    mov es, ax
    mov fs, ax
    mov gs, ax

;; ...

%include "include/gdt32.s"

Прерывания отключаются во время переключения. После завершения всей настройки прерывания можно включить снова. Это потребует дополнительной работы по настройке.

Теперь, когда мы в защищенном режиме, мы больше не можем использовать процедуры BIOS. Чтобы напечатать текст, мы можем вместо этого писать прямо в буфер VGA.

Код:
;; src/stage2.s

;; ...

;; Writes a null-terminated string straight to the VGA buffer.
;; The address of the string is found in the bx register.
print_string32:
pusha

VGA_BUF equ 0xb8000
WB_COLOR equ 0xf

mov edx, VGA_BUF

print_string32_loop:
cmp byte [ebx], 0
je print_string32_return

mov al, [ebx]
mov ah, WB_COLOR
mov [edx], ax

add ebx, 1              ; Next character
add edx, 2              ; Next VGA buffer cell
jmp print_string32_loop

print_string32_return:
popa
ret


Лучше распечатать что-нибудь, чтобы мы знали, что переключатель сработал. Обратите внимание на сообщение в левом верхнем углу скриншота.

Скриншот QEMU сообщения защищенного режима


64-битный длинный режим​

Для этой части обратитесь к разделу «10.8.5 Инициализация режима IA-32e». Обратите внимание, что Intel называет 64-битный режим «IA-32e», а AMD называет его «длинным режимом» в руководстве AMD64.

Перед переключением в длинный режим ЦП должен находиться в защищенном режиме и должен быть включен подкачка. Теперь у нас есть защищенный режим, но нам не хватает подкачки.

Мне нравится пейджинг. Это просто очень круто. Но я бы плохо объяснил саму концепцию. Филиппа Опперманна из блога «Writing an OS in Rust» было очень полезным для меня лично. также говорит о пейджинге, начиная с главы 18, хотя он не вдается в подробности пейджинга на x86, как это делает пост Филиппа Опперманна.

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

Код:
;; src/stage2.s

;; Builds a 4 level page table starting at the address that's passed in ebx.
build_page_table:
pusha

PAGE64_PAGE_SIZE equ 0x1000
PAGE64_TAB_SIZE equ 0x1000
PAGE64_TAB_ENT_NUM equ 512

;; Initialize all four tables to 0. If the present flag is cleared, all other bits in any
;; entry are ignored. So by filling all entries with zeros, they are all "not present".
;; Each repetition zeros four bytes at once. That's why a number of repetitions equal to
;; the size of a single page table is enough to zero all four tables.
mov ecx, PAGE64_TAB_SIZE ; ecx stores the number of repetitions
mov edi, ebx             ; edi stores the base address
xor eax, eax             ; eax stores the value
rep stosd

;; Link first entry in PML4 table to the PDP table
mov edi, ebx
lea eax, [edi + (PAGE64_TAB_SIZE | 11b)] ; Set read/write and present flags
mov dword [edi], eax

;; Link first entry in PDP table to the PD table
add edi, PAGE64_TAB_SIZE
add eax, PAGE64_TAB_SIZE
mov dword [edi], eax

;; Link the first entry in the PD table to the page table
add edi, PAGE64_TAB_SIZE
add eax, PAGE64_TAB_SIZE
mov dword [edi], eax

;; Initialize only a single page on the lowest (page table) layer in
;; the four level page table.
add edi, PAGE64_TAB_SIZE
mov ebx, 11b
mov ecx, PAGE64_TAB_ENT_NUM
set_page_table_entry:
mov dword [edi], ebx
add ebx, PAGE64_PAGE_SIZE
add edi, 8
loop set_page_table_entry

popa
ret


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

Это еще один GDT, который также реализует плоскую модель. Он почти идентичен GDT для защищенного режима. Были изменены всего два бита.

Код:
;; include/gdt64.s

align 16
gdt64_start:
;; 8-byte null descriptor (index 0).
dd 0x0
dd 0x0

gdt64_code_segment:
dw 0xffff
dw 0x0000
db 0x00
db 10011010b
;;   5: 64-bit code segment flag indicates that this segment contains 64-bit code
;;   6: must be zero if L bit (bit 5) is set
db 10101111b
db 0x00

gdt64_data_segment:
dw 0xffff
dw 0x0000
db 0x00
;; 0-3: segment type that specifies a read/write data segment
db 10010010b
db 10101111b
dw 0x00

gdt64_end:

gdt64_pseudo_descriptor:
dw gdt64_end - gdt64_start - 1
dd gdt64_start

CODE_SEG64 equ gdt64_code_segment - gdt64_start
DATA_SEG64 equ gdt64_data_segment - gdt64_start


При наличии таблицы страниц и GDT можно выполнить переключение из защищенного режима в длинный режим.

Код:
;; src/stage2.s

;; ...

start_prot_mode:
;; ...

;; Build 4 level page table and switch to long mode
mov ebx, 0x1000
call build_page_table
mov cr3, ebx            ; MMU finds the PML4 table in cr3

;; Enable Physical Address Extension (PAE). This is needed to allow the switch
mov eax, cr4
or eax, 1 << 5
mov cr4, eax

;; The EFER (Extended Feature Enable Register) MSR (Model-Specific Register) contains fields
;; related to IA-32e mode operation. Bit 8 if this MSR is the LME (long mode enable) flag
;; that enables IA-32e operation.
mov ecx, 0xc0000080
rdmsr
or eax, 1 << 8
wrmsr

;; Enable paging (PG flag in cr0, bit 31)
mov eax, cr0
or eax, 1 << 31
mov cr0, eax

mov ebx, comp_mode_msg
call print_string32

;; New GDT has the 64-bit segment flag set. This makes the CPU switch from
;; IA-32e compatibility mode to 64-bit mode.
lgdt [gdt64_pseudo_descriptor]

jmp CODE_SEG64:start_long_mode

;; ...

[bits 64]

start_long_mode:
htl
jmp start_long_mode

;; ...

%include "include/gdt64.s"

;; ...

comp_mode_msg: db "Entered 64-bit compatibility mode", 0


Опять же, в левом верхнем углу должно появиться сообщение об успешном завершении. Напишите небольшой драйвер VGA, если вас это раздражает.

Скриншот QEMU с сообщением об успешном завершении


Используя С​


Код C можно легко интегрировать в эту установку. Например, это может стать ядром ОС.

Код:
/* src/kernel.c */

#define VGA_COLUMNS_NUM 80
#define VGA_ROWS_NUM 25

#define ARRAY_SIZE(arr) ((int)sizeof(arr) / (int)sizeof((arr)[0]))

void _start_kernel(void) {
volatile char *vga_buf = (char *)0xb8000;
const char msg[] = "Hello from C";
int i;

for (i = 0; i < VGA_COLUMNS_NUM * VGA_ROWS_NUM * 2; i++)
vga_buf[i] = '\0';

for (i = 0; i < ARRAY_SIZE(msg) - 1; i++) {
vga_buf[i * 2] = msg[i];
vga_buf[i * 2 + 1] = 0x07; /* White on black */
}
}


Обновлять src/stage2.s:

Код:
;; src/stage2.s

;; ...

[bits 64]

start_long_mode:
mov ebx, long_mode_msg
call print_string64

extern _start_kernel
call _start_kernel

end64:
hlt
jmp end64

;; ...
Скрипт компоновщика:

Код:
# linker.ld

MEMORY
{
    boot_sector (rwx) : ORIGIN = 0x7c00, LENGTH = 512
    stage2 (rwx) : ORIGIN = 0x7e00, LENGTH = 512
    kernel (rwx) : ORIGIN = 0x8000, LENGTH = 0x10000
}

ENTRY(__start)
SECTIONS
{
    .boot_sector : { *(.boot_sector); } > boot_sector
    .bootsign (0x7c00 + 510) :
    {
        BYTE(0x55)
        BYTE(0xaa)
    } > boot_sector
    .stage2 : { *(.stage2); } > stage2
    .text : { *(.text); } > kernel
    .data : { *(.data); } > kernel
    .rodata : { *(.rodata); } > kernel
    .bss :
    {
        *(.bss)
        *(COMMON)
    } > kernel
}

Наконец, Makefileпотребности в изменении. Здесь я включил только те строки, которые были изменены.

Код:
# Makefile

# ...

CC := gcc
CFLAGS := -std=c99 -ffreestanding -m64 -mno-red-zone -fno-builtin -nostdinc -Wall -Wextra

# ...

SRC := $(wildcard $(SRC_DIR)/*)
OBJS := $(patsubst $(SRC_DIR)/%, $(BUILD_DIR)/%.o, $(SRC))

# ...

$(BUILD_DIR)/%.s.o: $(SRC_DIR)/%.s
    @mkdir -p $(dir $@)
    $(NASM) $< -o $@

$(BUILD_DIR)/%.c.o: $(SRC_DIR)/%.c
    @mkdir -p $(dir $@)
    $(CC) $(CFLAGS) -c $< -o $@

# ...

Скриншот QEMU сообщения, выведенного кодом C


Круто, если вы действительно дошли до этого места. Код на GitHub.
 
  • Нравится
Реакции: Edmon Dantes
Мы в соцсетях:

Обучение наступательной кибербезопасности в игровой форме. Начать игру!