Разработка BIOS на языках высокого уровня

zfozxarcy4uf1ok5njoox-vik4w.jpeg

Ничего лишнего: материнская плата, видеокарта и ROM-BIOS

Меня давно волнует вопрос, как подступиться к разработке на голом железе, на чистом си. Хотелось понять, каким же образом идёт запуск BIOS, u-boot, grub и прочих первичных загрузчиков. Ведь необходимо перейти от ассемблера к тёплому ламповому си и соблюсти условие, собрать всё это в линукс любимым компилятором gcc.

Хотя я и имею достаточный опыт BareMetal-разработки, тем не менее, всё это были чужие проекты со своим кодом. А мне хотелось понять, как начать свой проект с чистого листа, когда есть только чистая железка и идея. Толковых статей как подступится к этой задаче достаточно мало, при этом совершенно непонятно, с какого же края к ней подходить.

Здесь я хочу свести основные моменты разработки BIOS в одном месте и разобраться обо всех проблемах, которые я получил во время своих опытах в разработке (первая и вторая части).

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

Поскольку пересказать объёмы переработанного материала не представляется возможным, остановлюсь на некоторых ключевых моментах, которые помогут стартануть.
Помните: лучший способ начать программировать — это начать разбираться и править чужие проекты. Поэтому в качестве примера приведу обзор референсных проектов, с которых я черпал вдохновение.

Помните: лучший способ начать программировать — это начать разбираться и править чужие проекты. Поэтому в качестве примера приведу обзор референсных проектов, с которых я черпал вдохновение.

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


Википедия.

Референсные проекты


Возможно, корректнее было бы назвать их эталонными, но я смотрел на них скорее для вдохновения.

iPXE — open source boot firmware


Проект iPXE — это фактически и есть разработка ROM BIOS для сетевой карты, при этом iPXE уже имеет внутри себя поддержку достаточно обширного функционала.

pip77kluqufuuxajl4csuhdjfkc.png

Исходный код проекта я изучал пару недель, практически как открытую книгу, читал буквально как фантастический приключенческий роман. Он прекрасен всем, там можно посмотреть, как реализовывать свою библиотеку libc на голом железе, как производить копирование областей памяти в защищённом и реальном режиме, как осуществлять переключение между режимами, для различного функционала, как сделать свой tcp/ip cтек и многое другое. Для меня это эталонный проект, с которого можно начинать (правда, имея уже некоторый багаж знаний). Ещё он крут тем, что много кода можно позаимствовать из него (с соблюдением соответствующих лицензий).
К недостаткам проекта можно отнести только то, что он достаточно объёмный, хотя основные моменты можно изучить за пару недель, более глубокое погружение в него потребует большего количества времени. Но ценен он тем, что собирается gcc, а значит, может быть применен в будущих проектах в дальнейшем.
К плюсам можно отнести также то, что он достаточно неплохо документирован. В общем, это идеальный вариант для обучения.

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

BIOS Terminal


Поиск по github вывел меня на проект превращения старенького ПК в терминал. Для этого понадобится просто видеокарта, плата с ROM-BIOS и COM-порт.

mauzbrqy9gljks_9cqfimyfor08.png

Автор даже приводит фотографию самодельной платы расширения для запуска своего кода. Фактически он реализовал полноценный VT100 терминал. К сожалению, документация к этому проекту есть только на польском языке, но в целом понятная.

Проект очень полезен для изучения, но у него имеется два фатальных недостатка: он собран компилятором bcc, и фактически представляет собой один cи-файл (который просто собирается из инклудов).
Сам же компилятор bcc просто делает на выходе ассемблеровский файл, который потом уже транслируется в бинарный вид. Это замечательный лайфхак для таких систем, но для моих целей он не подходит, потому что я искал примеры с компилятором gcc.

В этом проекте есть интересный пример загрузчика кода loader.asm, на котором я подробнее остановлюсь ниже.

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

Как собрать досовский COM-файл компилятором GCC


Проект — прекрасный образец того, как можно с помощью gcc под linux собирать программы для других операционных систем, например DOS. Есть оригинальная статья на английском, но также на хабре был её перевод.

aynaqsaenvt32x5oa8y7z4fmnfy.gif

Есть несколько ценных моментов в этом проекте. К одним из них можно отнести примеры реализации «стандартной» библиотеки, которые позволяют использовать стандартный код. Другая полезная часть, которая будет полезна начинающим биосописателям — это скрипт линкера. Исполняемые COM-файлы имеют особенность: они загружаются строго по адресу 0×100, и далее идёт выполнение по этому адресу. Таким образом, при компиляции, необходимо указывать компоновщику, по каким адресам будет располагаться код, чтобы все переходы и вызовы функций работали корректно. Для этого и создаётся скрипт компоновщика.
Процитирую перевод оригинальной статьи, потому что это очень важно.

Параметр --script указывает, что мы хотим использовать особый скрипт компоновщика. Это позволяет точно разместить разделы (text, data, bss, rodata) нашей программы. Вот скрипт com.ld.
OUTPUT_FORMAT(binary)
SECTIONS
{
    . = 0x0100;
    .text :
    {
        *(.text);
    }
    .data :
    {
        *(.data);
        *(.bss);
        *(.rodata);
    }
    _heap = ALIGN(4);
}

OUTPUT_FORMAT(binary) говорит не помещать это в файл ELF (или PE и т. д.). Компоновщик должен просто сбросить чистый код. COM-файл — это просто чистый код, то есть мы даём команду компоновщику создать файл COM!

Я говорил, что COM-файлы загружаются в адрес 0x0100. Четвёртая строка смещает туда бинарник. Первый байт COM-файла по-прежнему остаётся первым байтом кода, но будет запускаться с этого смещения в памяти.

Далее следуют все разделы: text (программа), data (статичные данные), bss (данные с нулевой инициализацией), rodata (строки). Наконец, я отмечаю конец двоичного файла символом _heap. Это пригодится позже при написании sbrk(), когда мы закончим с «Hello, World». Я указал выровнять _heap по 4 байтам.


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

Магия скриптов такова, что можно «магические числа», объём кода, байт для crc, сразу включать в код с помощью линкер-скрипта. Например:

_rom_size_multiple_of = 512;
SECTIONS
{
    .header : 
    {
        SHORT(0xaa55); /* signature */
        BYTE(_bloks); /*  initialization size in 512 byte blocks */
    }
    .text :
…
    }
    _rom_end = .;
    _rom_size = (((_rom_end - 1)/_rom_size_multiple_of)+1)*_rom_size_multiple_of;
    _bloks = _rom_size / 512; /* initialization size in 512 byte blocks */
}


Это уже моя самодеятельность, которая тоже может применяться в ваших проектах. Но линкер-скрипта выше вполне хватит для цели создания своей программы на си в БИОС ПЗУ.

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

SeaBIOS


В этом списке нельзя не упомянуть самого большого мастодонта SeaBIOS — это BIOS, который используется в эмуляторе qemu.

xx0sxzxz7s69t7yo3wyeuhkaxmo.jpeg

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

С таким багажом знаний пришла пора делать свой BIOS. Но для начала надо поработать над ошибками, чтобы понять, где же я был не прав и избежать их повторения в дальнейшем.

Архитектурнозависимые проблемы и их решение


A: Такой понятный и простой этот ваш мир x86…
B: X86 == мир чудес, туда попал и там исчез…


Из Embedded Group чатика.

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

Особенности BIOS ROM на PCI


Если внимательно ознакомится со стандартом PCI, в частности, требования к формату BIOS, то можно узнать, что вначале ROM-BIOS должен ещё включать указатель на структуру данных PCI, и эта структура должна быть определена в ROM.

1-ufo9jzknyvreebthjpw-rbbbq.png
Пример структуры ROM BIOS для PCI карты

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

org 0
rom_size_multiple_of equ 512
bits 16
    ; PCI Expansion Rom Header
    ; ------------------------
    db 0x55, 0xAA ; signature
    db rom_size/512; initialization size in 512 byte blocks
entry_point: jmp start
    times 21 - ($ - entry_point) db 0
    dw pci_data_structure 

    ; PCI Data Structure
    ; ------------------
pci_data_structure:
    db 'P', 'C', 'I', 'R'
    dw PCI_VENDOR_ID
    dw PCI_DEVICE_ID
    dw 0 ; reserved
    dw pci_data_structure_end - pci_data_structure
    db 0 ; revision
    db 0x02, 0x00, 0x00 ; class code: ethernet
    dw rom_size/512
    dw 0 ; revision level of code / data
    db 0 ; code type => Intel x86 PC-AT compatible
    db (1<<7) ; this is the last image
    dw 0 ; reserved
pci_data_structure_end:


Где PCI_VENDOR_ID и PCI_DEVICE_ID подставляются макросами в Makefile:

nasm main.asm -fbin -o $@ -dPCI_VENDOR_ID=0x8086 -dPCI_DEVICE_ID=0x100E


И являются реальными ID-шниками сетевой карты Realtek RTL8139!

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

Ошибка расчёта контрольной суммы


Каким образом эта ошибка проявлялась на 386 машине, но при этом всё прекрасно работало на Pentium 4 и qemu, мне неясно. Полагаю, что там менее жёсткие требования к подсчёту контрольной суммы.

Я обнаружил эту багу, когда прошил BIOS Terminal, и он у меня таки без проблем заработал на 386 материнской плате. Хм, странно — работать не должно. После этого я начал разбираться, что же в этом проекте есть такого, что позволяет ему на ней запускаться.
Как оказалось, я считал контрольную сумму в последнем байте после всего кода. А остальное всё заполнял 0xFF. Разумеется, так контрольная сумма не могла сойтись. Чтобы всё было правильно, нужно весь бинарь, который будем зашивать на микросхему, дополнить до конца нулями.
Саму идею взял из проекта BIOS Terminal, о которой говорил выше. Автор, правда, контрольную сумму вообще делал со смещением в 6 байт от начала файла, я же решил оставить свой подход. Теперь ROM для прошивки формируется следующим образом (выдержка из Makefile):

result .rom: program.bin addchecksum
	dd if=/dev/zero of=$@ bs=1 count=32768 #Здесь формируем пустой файл 32 KiB
	dd if= program.bin of=$@ bs=1 conv=notrunc #Записываем вначале наш код
	./addchecksum $@ #И добавляем в конец контрольную сумму


После исправления этого досадного недоразумения у меня завелись практически все мои тестовые программы на 386 компьютере! Вроде простая, но такая важная мелочь.

Отличия в способе запуска кода на разной архитектуре


В своей последней статье «SSD технологии древних: DiskOnChip», когда я пытался запустить BASIC-ROM на 386 плате, то потерпел фиаско:

Изначально планировал попробовать точно так же запустить BASIC-ROM, но как я не бился, так и не смог его стартануть. То есть, видно, что происходит успешная инициализация, системный BIOS «зависает» без ошибок, значит переход на код ПЗУ состоялся, о чём также свидетельствовали POST-коды. Но ничего больше не происходило.


При этом BASIC-ROM прекрасно работал на более современной PCI-плате. К этому моменту я уже разобрался с ошибкой контрольной суммы, и дело было явно не в ней.
Самое забавное, что в эмуляторах и виртуальных машинах, таких как qemu, VirtualBox, Bochs всё прекрасно работало. В чём же дело?

Пришлось подключать тяжёлую артиллерию и ковырять всё это отладчиком. Все эти ночные посиделки с отладчиком gdb могут тянуть на ещё одну большую статью. Поэтому тезисно.

Для того чтобы прогнать всё это с помощью gdb, пришлось собрать из исходников эмулятор Bochs с поддержкой gdb. После этого начались ночные бдения с дизассемблированием и брекпоинтами. И вот что было удивительно! Брекпоинты на областях памяти, в которых мапился код — не срабатывали! А это означает то, что код выполняется из другого места!

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

Короче говоря, мне в эмбендеровском чате объяснили в чём же проблема. Если взглянуть на краткое описание, что такое BIOS Extension, то там сказано:

(3) Installation of extension BIOS

After a valid section of code is identifies, system control (BIOS program execution) jumps to the 4th byte in the extension BIOS and performs any functions specified in the machine language. Typically these instructions tell the BIOS how to install the extra code.

(3) Установка расширения BIOS

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


Проще говоря, код выполняется из ПЗУ! А вот в приложении BASIC-ROM у меня были досадные ошибки, когда переменные размещались в тех же областях памяти, где и сам код.
Как вы понимаете, ПЗУ, оно потому и ПЗУ, что эти области памяти не могут быть изменены, поэтому код работать НЕ ДОЛЖЕН (я не могу изменять переменные, они константы)! И он, справедливо не работает на старой 386 материнской плате.

Но позвольте, как же этот код работает на более современном железе, и во всех возможных эмуляторах?

4c7effiufvmijapwymg-_4f7yay.jpeg
ROM-BIOS на реальном железе Pentium4

Думаю, внимательный читатель сопоставил факт того, что я не смог в gdb поймать брекпоинт в месте расположения кода, потому что он исполняется в другом месте!

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

Если я вас окончательно запутал, то тезисно:

  • В старом железе код выполняется прямо в ПЗУ на плате расширения.
  • В более современном железе, а также эмуляторах типа qemu, VirtualBox, Bochs код предварительно копируется в область оперативной памяти и потом уже выполняется в области ОЗУ.


Годной документации, где это могло бы быть описано, я не нашёл (но и не особо искал).

Решение этой проблемы достаточно банальное: реализовать самостоятельное копирование своего кода в оперативную память и выполнять его уже там. Даже если этот код заранее скопируют в ОЗУ, небольшие накладные расходы на дополнительное копирование с лихвой компенсируют обратную аппаратную совместимость.

И так сделано в проекте bios-terminal, о котором я писал выше. Вся реализация идёт в файле loader.asm. Приведу основной кусок кода с моими комментариями и пояснениями.

	db 0x55		// Сигнатура BIOS
	db 0xaa
	db 32768/512	// BIOS занимает 32kB
	jmp _init //Переходим на код инициализации
	db 0x00		// байт контрольной суммы offset 0x0006

// копирование данных из сегмента d000: (ROM) до 8000: (RAM)
_init:
	cld //определяем направление копирования (очищаем флаг направления)
	xor si,si //очищаем сегменты смещения
	xor di,di //очищаем сегменты смещения
	mov cx,#0x8000 //загружаем счётчик копирования (32 KiB) и это же адрес
	mov es,cx //Загружаем адрес сегмента куда копировать
	mov ax,cs //Сохраняем через AX откуда копировать
	mov ds,ax
	rep //повторяем нижнюю команду CX-раз
	movsw //копируем слово из DS:DI в ES:SI инкрементируя адрес
	
//Ниже код инициализации стека, который не интересен
…
//Переходим по новому адресу со смещением main
	jmp 0x8000:_main


Ещё раз проговорю текстом: очищаем направление копирования, затем в CX загружаем сколько мы должны скопировать, и это же число является адресом для копирования. Затем в регистры DS загружаем откуда копировать (текущий сегмент), и куда копировать в ES (0x8000 из регистра CX).
Команда REP повторяет следующий за ней операнд CX раз, декрементируя регистр CX.

Последним шагом мы передаём управление нашей же программе, но уже по другому адресу, со смещением main. Обращаю внимание, что этот код тоже будет скопирован в те области, но никогда не будет выполнен.

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

Фух, вроде разобрал все ошибки. Но статья получилась столь большая, а я не рассказал и половины того, что собирался.

Выводы


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

Полезные ссылки


  1. Пишем свой ROM BIOS
  2. SSD технологии древних: DiskOnChip
  3. iPXE — open source boot firmware
  4. github iPXE
  5. BIOS Terminal
  6. How to build DOS COM files with GCC
  7. Как собрать досовский COM-файл компилятором GCC (перевод)
  8. The GNU linker — прекрасный мануал по скриптам компоновщика
  9. SeaBIOS для эмулятора qemu
  10. Github проекта SeaBIOS
  11. Malicious code execution in PCI expansion ROM
  12. Bochs x86 Emulator BIOS Source Code
  13. BIOS Extension


P.S. Если вам интересно моё творчество, вы можете следить за мной ещё в телеграмме.

mxuanbovcusqgmqdgugvpnql8vq.jpeg

© Habrahabr.ru