Эта статья представляет собой ознакомительный материал о написании загрузчика на С и Ассемблере. Сразу скажу, что здесь я не буду вдаваться в сравнение производительности итогового кода, созданного на этих языках. В этой работе я просто вкратце изложу процесс создания загрузочного флоппи-образа путем написания собственного кода с последующим его внедрением в загрузочный сектор устройства. Все содержание будет разделено на цикл из трех статей, так как сразу сложно изложить всю нужную информацию и о компьютерах, и об устройствах загрузки, и о написании самого кода. В первой части я поясню наиболее общие аспекты компьютерной науки и суть процесса загрузки, а также обобщу значение и важность каждого этапа, чтобы упростить их понимание и запоминание.
О чем пойдет речь?
Мы рассмотрим написание кода программы и его копирование в загрузочный сектор образа флоппи-диска, после чего с помощью эмулятора bochs (x86) для Linux научимся проверять работоспособность полученной дискеты с загрузчиком.
О чем речь не пойдет
В этой статье я не рассказываю, почему загрузчик нельзя написать на других подобных ассемблеру языках, а также не говорю о недостатках его написания на одном языке по отношению к другому. Поскольку наша цель – познакомиться с написанием загрузочного кода, я не хочу нагружать вас более продвинутыми темами типа его скорости, уменьшения и т.д.
Структура статьи
Начнем мы со знакомства с основами, после чего перейдем к написанию самого кода. В целом план будет такой:
• Знакомство с загрузочными устройствами.
• Знакомсто со средой разработки.
• Знакомство с микропроцессором.
• Написание кода на ассемблере.
• Написание кода на С.
• Создание мини-программы для отображения прямоугольников.
К сведению: эта статья окажется наиболее полезной, если у вас уже есть хоть какой-то опыт программирования. Несмотря на ее ознакомительный характер, написание загрузочных программ на ассемблере и C может оказаться непростой задачей. Поэтому новичкам в программировании я рекомендую сначала ознакомиться с базовыми вводными материалами и уже потом возвращаться к этому.
Здесь я буду постепенно описывать процесс в форме вопросов и ответов, попутно приводя различную компьютерную терминологию. Вообще, я написал это руководство так, как будто обращаю его самому себе. A дискуссионный формат выбрал, потому что лично мне он помогает лучше понять важность и назначение рассматриваемого материала в повседневной жизни.
Знакомство с загрузочными устройствами
Что происходит при включении стандартного компьютера?
Обычно при нажатии кнопки включения питания от нее подается сигнал блоку питания о необходимости подачи необходимого напряжения на внутреннее и внешнее оборудование компьютера, такое как процессор, монитор, клавиатура и пр. Процессор при этом инициализирует ПЗУ-чип BIOS (базовую систему ввода/вывода) для загрузки содержащейся в нем исполняемой программы, именуемой также — BIOS.
После запуска BIOS выполняет следующие задачи:
• Тестирование оборудования при подаче питания (Power On self Test).
• Проверка частоты и доступности шин.
• Проверка системных часов и аппаратной информации в CMOS RAM.
• Проверка настроек системы, предустановок оборудования и т.д.
• Тестирование подключенного оборудования, начиная с RAM, дисководов, оптических приводов, HDD и т.д.
• В зависимости от определенной в разделе загрузочных устройств информации выполняет поиск загрузочного диска и переходит к его инициализации.
К сведению: все ЦПУ с архитектурой x86 в процессе загрузки запускаются в реальном режиме (Real Mode).
Что такое загрузочное устройство?
Загрузочным называется устройство, содержащее загрузочный сектор или блок загрузки. BIOS считывает это устройство, начиная с загрузки этого загрузочного сектора в RAM для выполнения, после чего переходит далее.
Что такое сектор?
Сектор – это особый раздел загрузочного диска, размер которого обычно составляет 512 байт. Чуть позже я подробнее поясню о том, как измеряется память компьютера и приведу сопутствующую терминологию.
Что такое загрузочный сектор?
Загрузочный сектор или блок загрузки – это область загрузочного устройства, в которой содержится загружаемый в RAM машинный код, за что отвечает встроенная в ПК прошивка на стадии инициализации. На флоппи-диске размер сектора составляет 512 байт. Чуть позже о байтах будет сказано дополнительно.
Как работает загрузочное устройство?
При его инициализации BIOS находит и загружает первый сектор (загрузочный) в RAM и начинает его выполнение. Расположенный в загрузочном секторе код является первой программой, которую можно отредактировать для определения дальнейшего функционирования компьютера после его запуска. Здесь я имею в виду, что вы можете написать собственный код и скопировать его в загрузочный сектор, чтобы система работала так, как вам нужно. Сам же этот код и будет называться тем самым начальным загрузчиком.
Что такое начальный загрузчик?
В компьютерной области загрузчиком называется программа, выполняемая при каждой инициализации загрузочного устройства во время запуска и перезагрузки ПК. Технически это выполняемый машинный код, соответствующий архитектуре типа используемого в системе ЦПУ.
Какие есть виды микропроцессоров?
Я приведу основные:
• 16 битные
• 32 битные
• 64 битные
Чем больше значение бит, тем к большему объему памяти имеют доступ программы, получая большую производительность в плане временного хранилища, обработки и пр. На сегодня микропроцессоры производят две основные компании – Intel и AMD. В этой же статьи я буду обращаться только к процессорам семейства Intel (x86).
В чем отличие процессоров Intel и AMD?
Каждый производитель использует свой уникальный способ проектирования микропроцессоров с аппаратной точки зрения и в плане используемых наборов инструкций.
Знакомство со средой разработки
Что такое реальный режим?
Я уже упоминал, что все процессоры с архитектурой x86 при загрузке с устройства запускаются в реальном режиме. Это очень важно иметь в виду при написании загрузочного кода для любого устройства. Реальный режим поддерживает только 16-битные инструкции. Поэтому создаваемый вами код для загрузки в загрузочную запись или сектор должен компилироваться в 16-битный формат. В реальном режиме инструкции могут работать только с 16 битами одновременно. Например, в 16-битном ЦПУ конкретная инструкция будет способна складывать в одном цикле два 16-битных числа. Если же для процесса будет необходимо сложить два 32-битных числа, то потребуется больше циклов, выполняющих сложение 16-битных чисел.
Что такое набор инструкций?
Это гетерогенная коллекция сущностей, ориентированных на конкретную архитектуру микропроцессора, с помощью которых пользователь может взаимодействовать с ним. Здесь я подразумеваю коллекцию сущностей, состоящую из внутренних типов данных, инструкций, регистров, режимов адресации, архитектуры памяти, обработки прерываний и исключений, а также внешнего I/O. Обычно для семейства микропроцессоров создаются общие наборы инструкций. Процессор Intel-8086 относится к семейству 8086, 80286, 80386, 80486, Pentium, Pentium I, II, III, которое также известно как семейство x86. В этой статье я будут использовать набор инструкций, относящийся именно к этому типу процессоров.
Как написать код для загрузочного сектора устройства?
Для реализации этой задачи необходимо иметь представление о:
• Операционной системе (GNU Linux).
• Ассемблере (GNU Assembler).
• Наборе инструкций (x86).
• Написании инструкций на GNU Assembler для x86 микропроцессоров.
• Компиляторе (как вариант язык C).
• Компоновщике (GNU linker ld)
• Эмуляторе x86, например bochs, используемом для тестирования.
Что такое операционная система?
Объясню очень просто. Это большой набор различных программ, написанных сотнями и даже тысячами профессионалов, которые помогают пользователям в решении их повседневных задач. К таким задам можно отнести подключение к интернету, общение в соцсетях, создание и редактирование файлов, работу с данными, игры и многое другое. Все это реализуется с помощью операционной системы. Помимо этого, ОС также регулирует функционирование аппаратных средств, обеспечивая для вас оптимальным режим работы.
Отдельно отмечу, что все современные ОС работают в защищенном режиме.
Какие виды ОС бывают?
• Windows
• Linux
• MAC
• …
Что значит защищенный режим?
В отличие от реального режима, защищенный поддерживает 32-битные инструкции. Но вам об этом задумываться не стоит, так как нас не особо волнует процесс функционирования ОС.
Что такое Ассемблер?
Ассемблер преобразует передаваемые пользователем инструкции в машинный код.
Разве компилятор делает не то же самое?
На более высоком уровне да, но, фактически, внутри компилятора этим занимается именно ассемблер.
Почему компилятор не может генерировать машинный код напрямую?
Основная задача компилятора состоит в преобразовании инструкций пользователя в их промежуточный набор, называемый инструкциями ассемблера, после чего ассемблер преобразует их в соответствующий машинный код.
Зачем нужна ОС для написания кода загрузочного сектора?
Прямо сейчас я не хочу вдаваться в подробности и ограничусь пояснением в рамках материала текущей статьи. Как я говорил, для написания инструкций, которые поймет микропроцессор, нам нужен компилятор. Он, в свою очередь, разрабатывается в качестве утилиты ОС и используется через нее, соответственно.
Какую ОС можно использовать?
Так как я писал загрузочные программы под Ubuntu, то и вам для ознакомления с данным руководством порекомендую именно эту ОС.
Какой следует использовать компилятор?
Я писал загрузчики при помощи GNU GCC и демонстрировать компиляцию кода я буду на нем же. Как протестировать рукописный код для загрузочного сектора? Я представлю вам эмулятор архитектуры x86, который помогает дорабатывать код, не требуя постоянной перезагрузки компьютера при редактировании загрузочного сектора устройства.
Знакомство с микропроцессором
Прежде чем изучать программирование микропроцессора, нам необходимо разобрать использование регистров.
Что такое регистры?
Регистры подобны утилитам микропроцессора, служащим для временного хранения данных и управления ими согласно нашим потребностям. Предположим, пользователь задает операцию сложения 2 и 3, для чего компьютер сохраняет число 3 в одном регистре, а 2 в другом, после чего складывает содержимое этих регистров. В итоге ЦПУ помещает результат в еще один регистр, который и представляет нужный пользователю вывод. Регистры разделяются на четыре основных типа:
• регистры общего назначения;
• сегментные регистры;
• индексные регистры;
• регистры стека.
Я дам краткое пояснение по каждому типу.
Регистры общего назначения используются для хранения временных данных, необходимых программе в процессе выполнения. Каждый такой регистр имеет емксоть 16 бит или 2 байта.
• AX – регистр сумматора;
• BX – регистр базового адреса;
• CX – регистр-счетчик;
• DX – регистр данных.
Сегментные регистры: служат для представления микропроцессору адреса памяти. Здесь нужно знать два термина:
• Сегмент: независимый блок памяти, поддерживаемый аппаратно. Обычно обозначается начальным адресом.
• Смещение: указывает индекс относительно начала сегмента.
Пример: у нас есть байт, представляющий значение “X” и расположенный в 10-й позиции от начала блока памяти со стартовым адресом 0x7c00
. В данной ситуации мы выразим сегмент как 0x7c00
, а смещение как 10.
Абсолютным адресом тогда будет 0x7c00 + 10
.
Здесь я хочу выделить четыре категории:
• CS – сегмент кода;
• SS – сегмент стека;
• DS – сегмент данных;
• ES – расширенный сегмент.
При этом нужно учитывать ограничения этих регистров, а именно невозможность прямого присваивания адреса. Вместо этого нам приходится копировать адрес сначала в регистры общего назначения, после чего снова копировать его уже в сегментные. Например, для решения задачи обнаружения байта “X” мы делаем следующее:
movw $0x07c0, %ax
movw %ax , %ds
movw (0x0A) , %ax
Здесь происходит:
• загрузка значения 0x07c0 * 16
в AX;
• загрузка содержимого AX в DS;
• установка 0x7c00 + 0x0a
в AX.
Регистры стека:
• BP – базовый указатель;
• SP – указатель стека.
Индексные регистры:
• SI: регистр индекса источника.
• DI: регистр индекса получателя.
• AX: используется ЦПУ для арифметических операций.
• BX: может содержать адрес процедуры или переменной (это также могут SI, DI и BP) и использоваться для выполнения арифметических операций и перемещения данных.
• CX: выступает в роли счетчика цикла при повторении инструкций.
• DX: содержит старшие 16 бит произведения при умножении, а также задействуется при делении.
• CS: содержит базовый адрес всех выполняемых инструкций программы.
• SS: содержит базовый адрес стека.
• DS: содержит предустановленный адрес переменных.
• ES: содержит дополнительный базовый адрес переменных памяти.
• BP: содержит предполагаемое смещение из регистра SS. Часто используется подпрограммами для обнаружения переменных, переданных в стек вызывающей программой.
• SP: содержит смещение вершины стека.
• SI: используется в инструкциях перемещения строк. При этом на исходную строку указывает регистр SI.
• DI: выступает в роли места назначения для инструкций перемещения строк.
Что такое бит?
В вычислительных средах бит является наименьшей единицей данных, представляющей их в двоичном формате, где 1 = да, а 0 = нет.
Дополнительно о регистрах:
Ниже описано дальнейшее подразделение регистров:
• AX: первые 8 бит AX обозначаются как AL, последние 8 бит как AH.
• BX: первые 8 бит BX обозначаются как BL, последние 8 как как BH.
• CX: первые 8 бит CX обозначаются как CL, последние 8 бит как CH.
• DX: первые 8 бит DX обозначаются как DL, последние 8 бит как DH.
Как обращаться к функциям BIOS?
BIOS предоставляет ряд функций, позволяющих распределять приоритеты ЦПУ. Доступ к этим возможностям BIOS можно получить с помощью прерываний.
Что такое прерывания?
Для приостановки стандартного потока программы и обработки событий, требующих быстрой реакции, используются прерывания. Например, при перемещении мыши соответствующее аппаратное обеспечение прерывает текущую программу для обработки этого перемещения. В результате прерывания управление передается специальной программе-обработчику. При этом каждому типу прерывания присваивается целое число. В начальной области физической памяти располагается таблица векторов прерываний, в которой находятся сегментированные адреса их обработчиков. Номер прерывания, по сути, является индексом из этой таблицы.
Какое прерывание будем использовать мы?
Прерывание INT 0x10.
Написание кода на Ассемблере
Какие типы данных доступны в GNU Assembler?
Типы данных определяют их характеристики и могут быть следующими:
• байт;
• слово;
• Int;
• ASCII;
• ASCIIZ.
Байт: состоит из восьми бит и считается наименьшей единицей хранения информации при программировании.
Слово: единица данных, состоящая из 16 бит.
Int: целочисленный тип данных, состоящий из 32 бит, которые могут быть представлены четырьмя байтами или двумя словами.
Прим. Справедливости ради, стоит отметить, что размер Int зависит от архитектуры и может составлять от 16 до 64 бит (а на некоторых системах даже 8 бит). То, очем говорит автор — это тип long. Подробнее о типах С можно прочесть по ссылке.
ASCII: представляет группу байтов без нулевого символа.
ASCIIZ: выражает группу байтов, завершающуюся нулевым символом.
Как генерировать код для реального режима в Ассемблере?
В процессе запуска ЦПУ в реальном режиме (16 бит) мы можем задействовать только встроенные функции BIOS. Я имею в виду, что с помощью этих функций можно написать собственный код загрузчика, поместить его в загрузочный сектор и выполнить загрузку. Давайте рассмотрим написание на Ассемблере небольшого фрагмента программы для генерации 16-битного кода ЦПУ через GNU Assembler.
Файл-образец: test.S
.code16 #генерирует 16-битный код
.text #расположение исполняемого кода
.globl _start;
_start: #точка входа
. = _start + 510 #перемещение из позиции 0 к 510-му байту
.byte 0x55 #добавление сигнатуры загрузки
.byte 0xaa #добавление сигнатуры загрузки
Пояснения:
• .code16
: это директива, отдаваемая ассемблеру для генерации не 32-, а 16-битного кода. Зачем это нужно? Ассемблер вы будете использовать через операционную систему, а код загрузчика будете писать с помощью компилятора. Но вы также наверняка помните, что ОС работает в защищенном 32-битном режиме. Поэтому, по умолчанию ассемблер в такой ОС будет производить 32-битный код, что не соответствует нашей задаче. Данная же директива исправляет этот нюанс, и мы получаем 16-битный код.
• .text
: этот раздел содержит фактические машинные инструкции, составляющие вашу программу.
• .globl _start
: .global <символ> делает символ видимым для компоновщика. При определении символа в подпрограмме его значение становится доступным для других связанных подпрограмм. Иначе говоря, символ получает атрибуты от символа с таким же именем, находящегося в другом файле, связанном с этой программой.
• _start
: точка входа в основной код, а также предустановленная точка входа для компоновщика.
• = _start + 510
: переход от начальной позиции к 510-му байту.
• .byte 0x55
: первый байт, определяемый как часть сигнатуры загрузки (511-й байт).
• .byte 0xaa
: последний байт, определяемый как часть сигнатуры загрузки (512-й байт).
Как скомпилировать программу ассемблера?
Сохраните код в файле test.S
и введите в командной строке:
• as test.S -o test.o
• ld –Ttext 0x7c00 --oformat=binary test.o –o test.bin
Что означают эти команды?
• as test.S –o test.o
: преобразует заданный код в промежуточную объектную программу, которая затем преобразуется уже в машинный код.
• --oformat=binary
сообщает компоновщику, что выходной двоичный файл должен быть простым двоичным образом, т.е. не иметь кода запуска, связывания адресов и пр.
• –Ttext 0x7c00
сообщает компоновщику, что для вычисления абсолютного адреса нужно загрузить адрес “text” (сегмент кода) в 0x7c00
.
Что такое сигнатура загрузки?
Давайте вспомним о загрузочном секторе, используемом BIOS для запуска системы, и подумаем, как BIOS узнает о наличии такого сектора на устройстве? Тут нужно пояснить, что состоит он из 512 байт, в которых для 510-го байта ожидается символ 0x55, а для 511-го символ 0xaa. Исходя из этого, BIOS проверяет соответствие двух последний байт загрузочного сектора этим значениям и либо продолжает загрузку, либо сообщает о ее невозможности. При помощи hex-редактора можно просматривать содержимое двоичного файла в более читабельном виде, и ниже в качестве примера я привел снимок этого файла.
Как скопировать исполняемый код на загрузочное устройство и протестировать его?
Чтобы создать образ для дискеты размером 1.4Мб, введите в командную строку следующее:
• dd if=/dev/zero of=floppy.img bs=512 count=2880
Чтобы скопировать этот код в загрузочный сектор файла образа, введите:
• dd if=test.bin of=floppy.img
Для проверки программы введите:
• bochs
Если bochs не установлен, тогда можно ввести следующее:
• sudo apt-get install bochs-x
Файл-образец: bochsrc.txt
megs: 32
#romimage: file=/usr/local/bochs/1.4.1/BIOS-bochs-latest, address=0xf0000
#vgaromimage: /usr/local/bochs/1.4.1/VGABIOS-elpin-2.40
floppya: 1_44=floppy.img, status=inserted
boot: a
log: bochsout.txt
mouse: enabled=0
В результате должно отобразиться стандартное окно эмуляции bochs:
Просмотр:
Если теперь заглянуть в файл test.bin
через hex-редактор, то вы увидите, что сигнатура загрузки находится после 510-го байта:
Пока что вы просто увидите сообщение “Booting from Floppy”, так как в коде мы еще ничего не прописали. Давайте рассмотрим пару других примеров создания кода на ассемблере.
Файл-образец: test2.S
.code16 #генерирует 16-битный код
.text #расположение исполняемого кода
.globl _start;
_start: #точка входа
movb $'X' , %al #выводимый символ
movb $0x0e, %ah #выводимый служебный код bios
int $0x10 #прерывание цпу
. = _start + 510 #перемещение из позиции 0 к 510-му байту
.byte 0x55 #добавление сигнатуры загрузки
.byte 0xaa #добавление сигнатуры загрузки
После ввода этого кода сохраните его в test2.S
и выполните действия согласно прежней инструкции, изменив имя исходного файла. После компиляции, копирования кода в загрузочный сектор и выполнения bochs вы должны увидеть следующий экран, где теперь отображается прописанная нами в коде буква X
.
Поздравляю, ваша первая программа в загрузочном секторе работает!
Просмотр:
В hex-редакторе вы увидите, что символ X
находится во второй позиции от начального адреса.
Теперь давайте выведем на экран текст побуквенно.
Файл-образец: test3.S
.code16 #генерирует 16-битный код
.text #расположение исполняемого кода
.globl _start;
_start: #точка входа
#выводит 'H'
movb $'H' , %al
movb $0x0e, %ah
int $0x10
#выводит 'e'
movb $'e' , %al
movb $0x0e, %ah
int $0x10
#выводит 'l'
movb $'l' , %al
movb $0x0e, %ah
int $0x10
#выводит 'l'
movb $'l' , %al
movb $0x0e, %ah
int $0x10
#выводит 'o'
movb $'o' , %al
movb $0x0e, %ah
int $0x10
#выводит ','
movb $',' , %al
movb $0x0e, %ah
int $0x10
#выводит ' '
movb $' ' , %al
movb $0x0e, %ah
int $0x10
#выводит 'W'
movb $'W' , %al
movb $0x0e, %ah
int $0x10
#выводит'o'
movb $'o' , %al
movb $0x0e, %ah
int $0x10
#выводит 'r'
movb $'r' , %al
movb $0x0e, %ah
int $0x10
#выводит 'l'
movb $'l' , %al
movb $0x0e, %ah
int $0x10
#выводит 'd'
movb $'d' , %al
movb $0x0e, %ah
int $0x10
. = _start + 510 #перемещение из позиции 0 к 510-му байту
.byte 0x55 #добавление сигнатуры загрузки
.byte 0xaa #добавление сигнатуры загрузки
Сохраните файл как test3.S
. После компиляции и всех сопутствующих действий перед вами отобразится следующий экран:
Просмотр:
Хорошо. Теперь давайте напишем программу, выводящую на экран фразу “Hello, World”.
При этом мы также определим функции и макросы, с помощью которых и будем выводить эту строку.
Файл-образец: test4.S
#генерирует 16-битный код
.code16
#расположение исполняемого кода
.text
.globl _start;
#точка входа загрузочного кода
_start:
jmp _boot #переход к загрузочному коду
welcome: .asciz "Hello, Worldnr" #здесь мы определяем строку
.macro mWriteString str #макрос, вызывающий функцию вывода строки
leaw str, %si
call .writeStringIn
.endm
#функция вывода строки
.writeStringIn:
lodsb
orb %al, %al
jz .writeStringOut
movb $0x0e, %ah
int $0x10
jmp .writeStringIn
.writeStringOut:
ret
_boot:
mWriteString welcome
#перемещение от начала к 510-му байту и присоединение сигнатуры загрузки
. = _start + 510
.byte 0x55
.byte 0xaa
Сохраните файл как test4.S
. Теперь после компиляции и всего за ней следующего вы увидите:
Отлично! Если вы поняли все проделанные мной действия и успешно создали аналогичную программу, то я вас поздравляю еще раз!
Просмотр:
Что такое функция?
Функция – это блок кода, имеющий имя и переиспользуемое свойство.
Что такое макрос?
Макрос – это фрагмент кода с присвоенным именем, на место использования которого подставляется содержимое этого макроса.
В чем синтаксическое отличие функции от макроса?
Для вызова функции используется следующий синтаксис:
• push <аргумент>
• call <имя функции>
А для макроса такой:
• macroname <аргумент>
И поскольку синтаксис вызова и применения макроса проще, чем функции, я предпочел использовать в основном коде именно его.
Написание кода в компиляторе С
Что такое C?
С – это язык программирования общего назначения, разработанный сотрудником Bell Labs Деннисом Ритчи в 1969-1973 годах.
Почему мы используем именно этот язык? Причина в том, что создаваемые на нем программы, как правило, не велики и отличаются высокой скоростью. Помимо этого, он включает низкоуровневые возможности, которые обычно доступны только в ассемблере или машинном языке. Ко всему прочему, С также является структурированным.
Что нужно для написания кода на С?
Мы будем использовать компилятор GNU C под названием GCC и разберем написание в нем программы на примере.
Файл-образец: test.c
__asm__(".code16n");
__asm__("jmpl $0x0000, $mainn");
void main() {
}
Файл: test.ld
ENTRY(main);
SECTIONS
{
. = 0x7C00;
.text : AT(0x7C00)
{
*(.text);
}
.sig : AT(0x7DFE)
{
SHORT(0xaa55);
}
}
Для компиляции программы введите в командной строке:
• gcc -c -g -Os -march=i686 -ffreestanding -Wall -Werror test.c -o test.o
• ld -static -Ttest.ld -nostdlib --nmagic -o test.elf test.o
• objcopy -O binary test.elf test.bin
Что значат эти команды?
Первая преобразует код C в промежуточную объектную программу, которая в последствии преобразуется в машинный код.
• gcc -c -g -Os -march=i686 -ffreestanding -Wall -Werror test.c -o test.o:
Что значат эти команды?
• -c
: используется для компиляции исходного кода без линковки.
• -g
: генерирует отладочную информацию для отладчика GDB.
• -Os
: оптимизация размера кода.
• -march
: генерирует код для конкретной архитектуры ЦПУ (в нашем случае i686).
• -ffreestanding
: в среде отдельных программ может отсутствовать стандартная библиотека, а инструкции запуска программы не обязательно располагаются в “main”.
• -Wall
: активирует все предупреждающие сообщения компилятора. Рекомендуется всегда использовать эту опцию.
• -Werror
: активирует трактовку предупреждений как ошибок.
• test.c
: имя входного исходного файла.
• -o
: генерация объектного кода.
• test.o
: имя выходного файла объектного кода.
С помощью всей этой комбинации флагов мы генерируем объектный код, помогающий нам в обнаружении ошибок и предупреждений, а также создаем более эффективный код для данного типа ЦПУ. Если не указать march=i686, будет сгенерирован код для используемой вами машины. В связи с этим нужно указывать, для какого именно типа ЦПУ он создается.
• ld -static -Ttest.ld -nostdlib --nmagic test.elf -o test.o:
Эта команда вызывает компоновщик из командной строки, и ниже я поясню, как именно мы его используем.
Что значат эти флаги?
• -static
: не линковать с общими библиотеками.
• -Ttest.ld
: разрешить компоновщику следовать командам из его скрипта.
• -nostdlib
: разрешить компоновщику генерировать код, не линкуя функции запуска стандартной библиотеки C.
• --nmagic
: разрешить компоновщику генерировать код без фрагментов _start_SECTION и _stop_SECTION.
• test.elf
: имя выходного файла (соответствующий платформе формат хранения исполняемых файлов. Windows: PE, Linux: ELF)
• -o
: генерация объектного кода.
• test.o
: имя входного файла объектного кода.
Что такое компоновщик?
Он выполняет последний этап компиляции. ld (компоновщик) получает один или более объектных файлов либо библиотек и совмещает их в один, как правило, исполняемый файл. В ходе этого процесса он обрабатывает ссылки на внешние символы, присваивает конечные адреса процедурам/функциям и переменным, а также корректирует код и данные для отражения актуальных адресов.
Не забывайте, что мы не используем в коде стандартные библиотеки и сложные функции.
• objcopy -O binary test.elf test.bin
Эта команда служит для генерации независимого от платформы кода. Обратите внимание, что в Linux исполняемые файлы хранятся не так, как в Windows. В каждой системе свой способ хранения, но мы создаем всего-навсего небольшой загрузочный код, который на данный момент не зависит от ОС.
Зачем в программе C использовать инструкции ассемблера?
В реальном режиме к функциям BIOS можно легко обратиться через прерывания при помощи именно инструкций ассемблера.
Как скопировать код на загрузочное устройство и проверить его?
Чтобы создать образ для дискеты размером 1.4Мб, введите в командную строку:
• dd if=/dev/zero of=floppy.img bs=512 count=2880
Чтобы скопировать код в загрузочный сектор файла образа, введите:
• dd if=test.bin of=floppy.img
Для проверки программы введите:
• bochs
Должно отобразиться стандартное окно эмуляции:
Что мы видим: как и в первом нашем примере, пока что здесь отображается только сообщение “Booting from Floppy”.
Для вложения инструкция ассемблера в программу C мы используем ключевое слово __asm__
.
• Дополнительно мы задействуем __volatile__
, указывая компилятору, что код нужно оставить как есть, без изменений.
Такой способ вложения называется встраивание ассемблерного кода.
Рассмотрим еще несколько примеров написания с помощью компилятора.
Пишем программу для вывода на экран ‘X’
Файл-образец: test2.c
__asm__(".code16n");
__asm__("jmpl $0x0000, $mainn");
void main() {
__asm__ __volatile__ ("movb $'X' , %aln");
__asm__ __volatile__ ("movb $0x0e, %ahn");
__asm__ __volatile__ ("int $0x10n");
}
Написав код, сохраните файл как test2.c
и скомпилируйте его согласно все тем же инструкциям, изменив исходное имя. После компиляции, копирования кода в загрузочный сектор и выполнения команды bochs вы снова увидите экран, где отображается буква X:
Теперь напишем код для показа фразы “Hello, World”
Для вывода данной строки мы также определим функции и макросы.
Файл-образец: test3.c
/* генерирует 16-битный код */
__asm__(".code16n");
/* переходит к точке входа загрузочного кода */
__asm__("jmpl $0x0000, $mainn");
void main() {
/* выводит 'H' */
__asm__ __volatile__("movb $'H' , %aln");
__asm__ __volatile__("movb $0x0e, %ahn");
__asm__ __volatile__("int $0x10n");
/* выводит 'e' */
__asm__ __volatile__("movb $'e' , %aln");
__asm__ __volatile__("movb $0x0e, %ahn");
__asm__ __volatile__("int $0x10n");
/* выводит 'l' */
__asm__ __volatile__("movb $'l' , %aln");
__asm__ __volatile__("movb $0x0e, %ahn");
__asm__ __volatile__("int $0x10n");
/* выводит 'l' */
__asm__ __volatile__("movb $'l' , %aln");
__asm__ __volatile__("movb $0x0e, %ahn");
__asm__ __volatile__("int $0x10n");
/* выводит 'o' */
__asm__ __volatile__("movb $'o' , %aln");
__asm__ __volatile__("movb $0x0e, %ahn");
__asm__ __volatile__("int $0x10n");
/* выводит ',' */
__asm__ __volatile__("movb $',' , %aln");
__asm__ __volatile__("movb $0x0e, %ahn");
__asm__ __volatile__("int $0x10n");
/* выводит ' ' */
__asm__ __volatile__("movb $' ' , %aln");
__asm__ __volatile__("movb $0x0e, %ahn");
__asm__ __volatile__("int $0x10n");
/* выводит 'W' */
__asm__ __volatile__("movb $'W' , %aln");
__asm__ __volatile__("movb $0x0e, %ahn");
__asm__ __volatile__("int $0x10n");
/* выводит 'o' */
__asm__ __volatile__("movb $'o' , %aln");
__asm__ __volatile__("movb $0x0e, %ahn");
__asm__ __volatile__("int $0x10n");
/* выводит 'r' */
__asm__ __volatile__("movb $'r' , %aln");
__asm__ __volatile__("movb $0x0e, %ahn");
__asm__ __volatile__("int $0x10n");
/* выводит 'l' */
__asm__ __volatile__("movb $'l' , %aln");
__asm__ __volatile__("movb $0x0e, %ahn");
__asm__ __volatile__("int $0x10n");
/* выводит 'd' */
__asm__ __volatile__("movb $'d' , %aln");
__asm__ __volatile__("movb $0x0e, %ahn");
__asm__ __volatile__("int $0x10n");
}
Сохраните этот код в файле test3.c
и следуйте уже знакомым вам инструкциям, изменив имя исходного файла и скопировав скомпилированный код в загрузочный сектор дискеты. Теперь на этапе проверки должна отобразиться надпись «Hello, World»:
Напишем на C программу для вывода строки “Hello, World”
При этом мы определим функцию, выводящую эту строку на экран.
Файл-образец: test4.c
/*генерирует 16-битный код*/
__asm__(".code16n");
/*переход к точке входа в загрузочный код*/
__asm__("jmpl $0x0000, $mainn");
/* пользовательская функция для вывода серии знаков, завершаемых нулевым символом*/
void printString(const char* pStr) {
while(*pStr) {
__asm__ __volatile__ (
"int $0x10" : : "a"(0x0e00 | *pStr), "b"(0x0007)
);
++pStr;
}
}
void main() {
/* вызов функции <code>printString</code> со строкой в качестве аргумента*/
printString("Hello, World");
}
Сохраните этот код в файле test4.c
и снова проследуйте всем инструкциям компиляции и загрузки, в результате чего на экране должно отобразиться следующее:
Все это время мы учились путем преобразования программ ассемблера в программы C. К настоящему моменту вы должны уже хорошо уяснить процесс их написания на обоих этих языках, а также уметь выполнять компиляцию и проверку.
Далее мы перейдем к написанию циклов и их использованию в функциях, а также познакомимся с другими службами BIOS.
Мини-проект отображения прямоугольников
Файл-образец: test5.c
/* генерирует 16-битный код */
__asm__(".code16n");
/* переход к главной функции или программному коду */
__asm__("jmpl $0x0000, $mainn");
#define MAX_COLS 320 /* количество столбцов экрана */
#define MAX_ROWS 200 /* количество строк экрана */
/* функция вывода строки*/
/* input ah = 0x0e*/
/* input al = <выводимый символ>*/
/* прерывание: 0x10*/
/* мы используем прерывание 0x10 с кодом функции 0x0e для вывода байта из al*/
/* эта функция получает в качестве аргумента строку и выводит символ за символом, пока не достигнет нуля*/
void printString(const char* pStr) {
while(*pStr) {
__asm__ __volatile__ (
"int $0x10" : : "a"(0x0e00 | *pStr), "b"(0x0007)
);
++pStr;
}
}
/* функция, получающая сигнал о нажатии клавиши на клавиатуре */
/* input ah = 0x00*/
/* input al = 0x00*/
/* прерывание: 0x10*/
/* эта функция регистрирует нажатие пользователем клавиши для продолжения выполнения */
void getch() {
__asm__ __volatile__ (
"xorw %ax, %axn"
"int $0x16n"
);
}
/* функция вывода на экран цветного пикселя в заданном столбце и строке */
/* входной ah = 0x0c*/
/* входной al = нужный цвет*/
/* входной cx = столбец*/
/* входной dx = строка*/
/* прерывание: 0x10*/
void drawPixel(unsigned char color, int col, int row) {
__asm__ __volatile__ (
"int $0x10" : : "a"(0x0c00 | color), "c"(col), "d"(row)
);
}
/* функции очистки экрана и установки видео-режима 320x200 пикселей*/
/* функция для очистки экрана */
/* входной ah = 0x00 */
/* входной al = 0x03 */
/* прерывание = 0x10 */
/* функция для установки видео режима */
/* входной ah = 0x00 */
/* входной al = 0x13 */
/* прерывание = 0x10 */
void initEnvironment() {
/* очистка экрана */
__asm__ __volatile__ (
"int $0x10" : : "a"(0x03)
);
__asm__ __volatile__ (
"int $0x10" : : "a"(0x0013)
);
}
/* функция вывода прямоугольников в порядке уменьшения их размера */
/* я выбрал следующую последовательность отрисовки: */
/* из левого верхнего угла в левый нижний, затем в правый нижний, оттуда в верхний правый и в завершении в верхний левый край */
void initGraphics() {
int i = 0, j = 0;
int m = 0;
int cnt1 = 0, cnt2 =0;
unsigned char color = 10;
for(;;) {
if(m < (MAX_ROWS - m)) {
++cnt1;
}
if(m < (MAX_COLS - m - 3)) {
++cnt2;
}
if(cnt1 != cnt2) {
cnt1 = 0;
cnt2 = 0;
m = 0;
if(++color > 255) color= 0;
}
/* верхний левый -> левый нижний */
j = 0;
for(i = m; i < MAX_ROWS - m; ++i) {
drawPixel(color, j+m, i);
}
/* левый нижний -> правый нижний */
for(j = m; j < MAX_COLS - m; ++j) {
drawPixel(color, j, i);
}
/* правый нижний -> правый верхний */
for(i = MAX_ROWS - m - 1 ; i >= m; --i) {
drawPixel(color, MAX_COLS - m - 1, i);
}
/* правый верхний -> левый верхний */
for(j = MAX_COLS - m - 1; j >= m; --j) {
drawPixel(color, j, m);
}
m += 6;
if(++color > 255) color = 0;
}
}
/* эта функция является загрузочным кодом и вызывает следующие функции: */
/* вывод на экран сообщения, предлагающего пользователю нажать любую клавишу для продолжения. После нажатия клавиши происходит отрисовка прямоугольников в порядке убывания их размера */
void main() {
printString("Now in bootloader...hit a key to continuenr");
getch();
initEnvironment();
initGraphics();
}
Сохраните все это в файле test5.c
и следуйте все тем же инструкциям компиляции с последующим копированием кода в загрузочный сектор дискеты.
Теперь в качестве результата вы увидите:
Нажмите любую клавишу.
Просмотр:
Если внимательно рассмотреть содержимое исполняемого файла, то можно заметить, что в нем практически закончилось свободное пространство. Поскольку размер загрузочного сектора ограничен 512 байтами, мы смогли вместить только несколько функций, а именно выполнить инициализацию среды и вывод цветных прямоугольников. Вот снимок содержимого файла:
На этом первая часть серии статей заканчивается. В следующей части я расскажу о режимах адресации, используемых для обращения к данным и чтения дискет. Помимо этого, мы рассмотрим, почему загрузчики обычно пишутся на ассемблере, а не на C, а также затронем ограничения последнего в контексте генерации кода.
Примечание переводчика: описание автором функционирования регистров и некоторых других технических деталей, по всей видимости, требует уточнения. В связи с этим по вызывающим сомнение вопросам рекомендуется обратиться к более авторитетным литературным источникам.
Перед началом статьи хочу сказать, что еще больше полезной и нужной информации вы найдете в нашем Telegram-канале. Подпишитесь, мне будет очень приятно.
Что вас здесь ждёт
Если вы так же любопытны, как я, вы наверняка задумывались о том, как работают операционные системы. Здесь я расскажу о некоторых исследованиях и экспериментах, которые я провёл, чтобы лучше понять, как работают вычислительные и операционные системы. После прочтения вы создадите свою загрузочную программу, которая будет работать в любом приложении виртуальных машин, например в Virtual Box.
Важное замечание
Эта статья не предназначена для того, чтобы объяснить работу загрузчика во всей его сложности. Этот пример — отправная точка для x86 архитектуры. Для понимания этой статьи требуется базовое знание микропроцессоров и программирования.
Что такое загрузчик?
Простыми словами загрузчик — это часть программы, загружаемая в рабочую память компьютера после загрузки.
После нажатия кнопки Пуск компьютеру предстоит многое сделать. Запускается и выполняет свою работу прошивка, называемая BIOS (базовая система ввода-вывода). После этого BIOS передаёт управление загрузчику, установленному на любом доступном носителе: USB, жёстком диске, CD и т.д. BIOS последовательно просматривает все носители, проверяя уникальную подпись, которую также называют записью загрузки. Когда она найдена и загружена в память компьютера, начинает работать процессор. Если быть более точным, эта запись располагается по адресу 0x7C00. Сохраните его, он нужен для написания загрузчика.
Работа внутри первого сектора всего с 512 байтами.
Как упоминалось выше, в процессе инициализации BIOS ищет в первом секторе загрузочного устройства уникальную подпись. Её значение равно 0xAA55 и должно находиться в последних двух байтах первого сектора. И хотя все 512 байт доступны в главной загрузочной записи, мы не можем использовать их все: мы должны вычесть схему и подпись таблицы раздела диска и подпись. Останется только 440 байт. Маловато. Но вы можете решить эту проблему, написав код для загрузки данных из других секторов в памяти.
Шаги инициализации в упрощённом виде
- BIOS загружает компьютеры и их периферийные устройства.
- BIOS ищет загрузочные устройства.
- Когда BIOS находит подпись 0xAA55 в MBR, он загружает этот сектор в память в позицию 0x7C00 и передаёт управление этой точке входа, то есть начинает выполнение инструкций с точки в памяти 0x7C00.
Пишем код
Код загрузчика на ассемблере:
bits 16
org 0x7c00
boot:
mov si, message
mov ah,0x0e
.loop:
lodsb
or al,al
jz halt
int 0x10
jmp .loop
halt:
cli
hlt
message: db «Hey! This code is my boot loader operating.»,0
times 510 — ($-$$) db 0
dw 0xaa55
Ассемблер необходимо скомпилировать в машинный код. Обратите внимание, что 512 в шестнадцатеричной системе — это 0x200, а последние два байта — 0x55 и 0xAA. Он инвертирован по сравнению с кодом ассемблера выше, что связано с системой порядка хранения, называемой порядком следования байтов. Например, в big-endian системе два байта, требуемых для шестнадцатеричного числа 0x55AA, будут храниться как 0x55AA (если 55 хранится по адресу 0x1FE, AA будет храниться 0x1FF). В little-endian системе это число будет храниться как 0xAA55 (AA по адресу 0x1FE, 55 в 0x1FF).
0000000 be 10 7c b4 0e ac 08 c0 74 04 cd 10 eb f7 fa f4
0000010 48 65 79 21 20 54 68 69 73 20 63 6f 64 65 20 69
0000020 73 20 6d 79 20 62 6f 6f 74 20 6c 6f 61 64 65 72
0000030 20 6f 70 65 72 61 74 69 6e 67 2e 00 00 00 00 00
0000040 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
*
00001f0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 55 aa
0000200
Машинный код после компиляции NASM
Как работает этот код
Я объясню этот код построчно в случае если вам не знаком ассемблер:
1. Если укажем целевой режим процессора, директива BITS укажет, где NASM следует сгенерировать код, предназначенный для работы на процессоре, поддерживающем 16-битный, 32-битный или 64-битный режим. Синтаксис — BITS XX, где XX это 16, 32 или 64.
2. Если укажем адрес начала программы в бинарном файла, директива ORG укажет начальный адрес, по которому NASM будет считать начало программы при её загрузке в память. Когда этот код переводится в машинный, компилятор и компоновщик определяют и организуют все структуры данных, необходимые программе. Для этой цели будет использован начальный адрес.
3. Это просто ярлык. Когда он определён в коде, то ссылается на позицию в памяти, которую вы можете указать. Он используется вместе с командами условного перехода для контроля потока приложения.
После разбора четвёртой строки нам необходимо описать концепцию регистров:
Регистр процессора — блок ячеек памяти, образующий сверхбыструю оперативную память (СОЗУ) внутри процессора. Используется самим процессором и большей частью недоступен программисту: например, при выборке из памяти очередной команды она помещается в регистр команд, к которому программист обратиться не может. Википедия
4. Назначение данных с помощью инструкции MOV, которая используется для перемещения данных. В данном случае мы перемещаем значение адреса в памяти ярлыка сообщения в регистр SI, который укажет на текст “Hey! This code is my boot loader operating”. На картинке ниже видим, что при переводе в машинный код этот текст хранится в позиции 0x7C10.
5. Мы будем использовать видеосервисы BIOS для отображения текста на экране, поэтому сейчас мы настраиваем отображение по своему желанию. Сервис перемещает байт 0x0E в регистр AH.
6. Ещё одна ссылка на метку, позволяющая управлять потоком выполнения. Позднее мы используем её для создания цикла.
7. Эта инструкция загружает байт из операнда-источника в регистр AL. Вспомните четвёртую строку, где регистру SI была задана позиция текстового адреса. Теперь эта инструкция получает символ, хранящийся в ячейке памяти 0x7C10. Важно заметить, что она ведёт себя как массив, и мы указываем на первую позицию, содержащую символ ‘H’, как видно на рисунке ниже. Этот текст будет представлен итеративно по вертикали, и каждый символ будет задаваться каждый раз. Кроме того, второй символ не был представлен снимком, извлечённым из программы IDA. 0x65 в ASCII отображает символ ‘e’:
8. Выполнение логической операции OR между (AL | AL) на первый взгляд кажется бессмысленным, однако это не так. Нам нужно проверить, равен ли результат этой операции нулю, основываясь на логическом булевом значении. После этой операции результат будет, например, [1 | 1 = 1] или [0 | 0 = 0].
9. Переход к метке остановки (строка 12), если результат последней операции OR равен нулю. В первый момент значение AL равно [0x48 = ‘H’] , основываясь на последней инструкции LODSB, помните строку 7? Значит, код не перейдёт к метке остановки в первый раз. Почему так? (0x48 OR 0x48) = 0x48, следовательно он переходит к следующей инструкции на следующей строке. Важно заметить, что инструкция JZ связана не только с инструкцией OR. Существует другой регистр, FLAGS, который наблюдается в процессе операций перехода, то есть результат операции OR хранится в этом регистре FLAG и наблюдается инструкцией JZ.
10. Вызывая прерывание BIOS, инструкция INT 0x10 отображает значение AL на экране. Вспомните строку 5, мы задали значение AH байтом 0x0E. Это комбинация для представления значения AL на экране.
11. Переход к метке loop, которая без всяких условий похожа на инструкцию GOTO в языках высокого уровня.
12. Мы снова на строке 7, LODSB перехватывает контроль. После того, как байт будет перемещён из адреса в памяти в регистр AL, регистр SI инкрементируется. Во второй раз он указывает на адрес 0x7C11 = [0x65 ‘e’], затем на экране отображается символ ‘e’. Этот цикл будет выполняться до тех пор, пока не достигнет адреса 0x7C3B = [0x00 0], и, когда JZ снова выполнится в строке 9, поток будет доведён до метки остановки.
13. Здесь мы заканчиваем наше путешествие. Выполнение останавливают инструкции CLI и HLT.
14. На строке 17 вы видите инструкцию, которая заполняет оставшиеся 510 байтов нулями после чего добавляет подпись загрузочной записи 0xAA55.
Компилируем и запускаем
Убедитесь, что компилятор NASM и эмулятор виртуальной машины QEMU установлены на ваш компьютер. Воспользуйтесь предпочтительным менеджер зависимостей или скачайте их из интернета.
Для Linux наберите в терминале:
sudo apt-get install nasm qemu
На Mac OS можно использовать homebrew:
brew install nasm qemu
После этого вам нужно создать файл с кодом сборки, представленным в коде загрузчика выше. Давайте назовём этот файл boot.asm и затем запустим команду NASM:
nasm -f bin boot.asm -o boot.bin
Будет создан двоичный файл, который нужно запустить на виртуальной машине. Давайте запустим на QEMU:
qemu-system-x86_64 -fda boot.bin
Вы увидите следующий экран:
Запуск из Virtual box
Сначала вам нужно создать виртуальный пустой флоппи диск:
dd if=/dev/zero bs=1024 count=0 > floppy.img
Затем добавить внутрь него двоичное содержимое:
cat boot.bin >> floppy.img
Теперь вы можете создать машину Virtual Box и запустить её, используя файл загрузки:
Многие вещи я не стал здесь рассматривать, чтобы не быть слишком многословным. Если вы новичок в этой непростой теме, у вас наверняка возникло множество вопросов, и это прекрасная отправная точка для исследований. Для лучшего понимания многих принципов вычислительных и операционных систем я рекомендую книгу Эндрю С. Таненбаума “Операционные системы. Разработка и реализация”.
Writing an x86 «Hello world» bootloader with assembly
TL;DR
After booting, the BIOS of the computer reads 512 bytes from the boot devices and, if it detects a two-byte «magic number» at the end of those 512 bytes, loads the data from these 512 bytes as code and runs it.
This kind of code is called a «bootloader» (or «boot sector») and we’re writing a tiny bit of assembly code to make a virtual machine run our code and display «Hello world» for the fun of it. Bootloaders are also the very first stage of booting an operating system.
What happens when your x86 computer starts
You might have wondered what happens when you press the «power» button on your computer. Well, without going into too much detail — after getting the hardware ready and launching the initial BIOS code to read the settings and check the system, the BIOS starts looking at the configured potential boot devices for something to execute.
It does that by reading the first 512 bytes from the boot devices and checks if the last two of these 512 bytes contain a magic number (0x55AA
). If that’s what these last two bytes are, the BIOS moves the 512 bytes to the memory address 0x7c00
and treats whatever was at the beginning of the 512 bytes as code, the so-called bootloader. In this article we will write such a piece of code, have it print the text «Hello World!» and then go into an infinite loop.
Real bootloaders usually load the actual operating system code into memory, change the CPU into the so-called protected mode and run the actual operating system code.
A primer on x86 assembly with the GNU assembler
To make our lives a little easier (sic!) and make it all more fun, we will use x86 assembly language for our bootloader. The article will use the GNU assembler to create the binary executable file from our code and the GNU assembler uses the «AT&T syntax» instead of the pretty widely-spread «Intel syntax». I will repeat the example in the Intel syntax at the end of the article.
For those of you, who are not familiar with x86 assembly language and/or the GNU assembler, I created this description that explains just enough assembly to get you up to speed for the rest of this article. The assembly code within this article will also be commented, so that you should be able to glance over the code snippets without knowing much about the details of assembly.
Getting our code ready
Okay, so far we know: We need to create a 512 byte binary file that contains 0x55AA
at its end. It’s also worth mentioning that no matter if you have a 32 or 64 bit x86 processor, at boot time the processor will run in the 16 bit real mode, so our program needs to deal with that.
Let’s create our boot.s
file for our assembly sourcecode and tell the GNU assembler that we’ll use 16 bits:
.code16 # tell the assembler that we're using 16 bit mode
Ah, this is going great! Next up we should give us a starting point for our program and make that available to the linker (more on that in a few moments):
.code16
.global init # makes our label "init" available to the outside
init: # this is the beginning of our binary later.
jmp init # jump to "init"
Note You can call your label whatever you wish. The standard would be _start
but I chose init
to illustrate that you can call it anything, really.
Nice, now we even got an infinite loop, because we keep jumping to the label, then jump to the label again…
Time to turn our code into some binary by running the GNU assembler (as
) and see what we got:
as -o boot.o boot.s
ls -lh .
784 boot.o
152 boot.s
Woah, hold on! Our output is already 784 bytes? But we only have 512 bytes for our bootloader!
Well, most of the time developers are probably interested in creating an executable file for the operating system they are targeting, i.e. an exe
(Windows), elf
(Unix) file. These files have a header (read: additional, preceeding bytes) and usually load a few system libraries to access operating system functionality.
Our case is different: We want none of that, just our code in binary for the bios to execute upon boot.
Usually, the assembler produces an ELF or EXE file that is ready to run but we need one additional step that strips the unwanted additional data in those files. We can use the linker (GNU’s linker is called ld
) for this step.
The linker is normally used to combine the various libraries and the binary executables from other tools such as compilers or assemblers into one final file. In our case we want to produce a «plain binary file», so we will pass --oformat binary
to ld
when we run it. We also want to specify where our program starts, so we tell the linker to use the starting label (I called it init
) in our code as the program’s entry point by using the -e init
flag.
When we run that, we get a better result:
as -o boot.o boot.s
ld -o boot.bin --oformat binary -e init boot.o
ls -lh .
3 boot.bin
784 boot.o
152 boot.s
(Typo spotted by xnumbersx)
Okay, three bytes sounds much better, but this won’t boot up, because it is missing the magic number 0x55AA
at bytes 511 and 512 of our binary…
Making it bootable
Luckily, we can just fill our binary with a bunch of zeroes and add the magic number as data at the end.
Let’s start with adding zeroes until our binary file is 510 bytes long (because the last two bytes will be the magic number).
We can use the the preprocessor directive .fill
from as
to do that. The syntax is .fill, count,size,value
— it adds count
times size
bytes with the value value
wherever we will write this directive into our assembly code in boot.s
.
But how do we know how many bytes we need to fill in? Conveniently, the assembler helps us again. We need a total number of 510 bytes so we will fill 510 — (byte size of our code) bytes with zeroes. But what is the «byte size of our code»? Luckily as
has a helper that tells us the current byte position within the generated binary: .
— and we can get the position of the labels, too. So our code size will be whatever the current position .
is after our code minus the positon of the first statement in our code (which is the position of init
). So .-init
returns the number of generated bytes of our code in the final binary file…
.code16
.global init # makes our label "init" available to the outside
init: # this is the beginning of our binary later.
jmp init # jump to "init"
.fill 510-(.-init), 1, 0 # add zeroes to make it 510 bytes long
as -o boot.o boot.s
ld -o boot.bin --oformat binary -e init boot.s
ls -lh .
510 boot.bin
1.3k boot.o
176 boot.s
We’re getting there — still missing the final two bytes of our magic word:
.code16
.global init # makes our label "init" available to the outside
init: # this is the beginning of our binary later.
jmp init # jump to "init"
.fill 510-(.-init), 1, 0 # add zeroes to make it 510 bytes long
.word 0xaa55 # magic bytes that tell BIOS that this is bootable
Oh wait… if the magic bytes are 0x55aa
, why are we swapping them here?
That is because x86 is little endian, so the bytes get swapped in memory.
Now if we produce an updated binary file, it is 512 bytes long.
Booting our bootloader
You could theoretically write this binary into the first 512 byte on a USB drive, a floppy disk or whatever else your computer is happy booting from, but let’s use a simple x86 emulator (it’s like a virtual machine) instead.
I will use QEmu with an x86 system architecture for this:
qemu-system-x86_64 boot.bin
Running this command produces something relatively unspectacular:
The fact that QEmu stops looking for bootable devices means that our bootloader worked — but it doesn’t do anything yet!
To prove that, we can cause a reboot loop instead of an infinite loop that does nothing by changing our assembly code to this:
.code16
.global init # makes our label "init" available to the outside
init: # this is the beginning of our binary later.
ljmpw $0xFFFF, $0 # jumps to the "reset vector", doing a reboot
.fill 510-(.-init), 1, 0 # add zeroes to make it 510 bytes long
.word 0xaa55 # magic bytes that tell BIOS that this is bootable
This new command ljmpw $0xFFFF, $0
jumps to the so-called reset vector.
This effectively means re-executing the first instruction after the system boots again without actually rebooting. It’s sometimes referred to as a «warm reboot».
Using the BIOS to print text
Okay, let’s start with printing a single character.
We don’t have any operating system or libraries available, so we can’t just call printf
or one of its friends and be done.
Luckily, we have the BIOS still around and reachable, so we can make use of its functions. These functions (along with a bunch of functions that different hardware provides) are available to us via the so-called interrupts.
In Ralf Brown’s interrupt list we can find the video interrupt 0x10.
A single interrupt can carry out many different functions which are usually selected by setting the AX register to a specific value. In our case the function «Teletype» sounds like a good match — it prints a character given in al
and automatically advances the cursor. Nifty! We can select that function by setting ah
to 0xe
, put the ASCII code we want to print into al
and then call int 0x10
:
.code16
.global init # makes our label "init" available to the outside
init: # this is the beginning of our binary later.
mov $0x0e41, %ax # sets AH to 0xe (function teletype) and al to 0x41 (ASCII "A")
int $0x10 # call the function in ah from interrupt 0x10
hlt # stops executing
.fill 510-(.-init), 1, 0 # add zeroes to make it 510 bytes long
.word 0xaa55 # magic bytes that tell BIOS that this is bootable
Now we’re loading the necessary value into the ax
register, call interrupt 0x10 and halt the execution (using hlt
).
When we run as
and ld
to get our updated bootloader, QEmu shows us this:
We can even see that the cursor blinks at the next position, so this function should be easy to use with longer messages, right?
Our final hello-world-bootloader
To get a full message to display, we will need a way to store this information in our binary. We can do that similar to how we store the magic word at the end of our binary, but we’ll use a different directive than .byte
as we wanna store a full string. as
luckily comes with .ascii
and .asciz
for strings. The difference between them is that .asciz
automatically adds another byte that is set to zero. This will come in handy in a moment, so we chose .asciz
for our data.
Also, we will use a label to give us access to the address:
.code16
.global init # makes our label "init" available to the outside
init: # this is the beginning of our binary later.
mov $0x0e, %ah # sets AH to 0xe (function teletype)
mov $msg, %bx # sets BX to the address of the first byte of our message
mov (%bx), %al # sets AL to the first byte of our message
int $0x10 # call the function in ah from interrupt 0x10
hlt # stops executing
msg: .asciz "Hello world!" # stores the string (plus a byte with value "0") and gives us access via $msg
.fill 510-(.-init), 1, 0 # add zeroes to make it 510 bytes long
.word 0xaa55 # magic bytes that tell BIOS that this is bootable
(Typo spotted by xnumbersx)
We have one new feature in there:
mov $msg, %bx
mov (%bx), %al
The first line loads the address of the first byte into the register bx
(we use the entire register because addresses are 16 bit long).
The second line then loads the value that is stored at the address from bx
into al
, so the first character of the message ends up in al
, because bx
points to its address.
But now we get an error when running ld
:
as -o boot.o boot.s
ld -o boot.bin --oformat binary -e init -o boot.bin boot.o
boot.o: In function `init':
(.text+0x3): relocation truncated to fit: R_X86_64_16 against `.text'+a
Dang, what does that mean?
Well it turns out that the address at which msg
is moved in the ELF file (boot.o
) doesn’t fit in our 16 bit address space. We can fix that by telling ld
where our program memory should start. The BIOS will load our code at address 0x7c00
, so we will make that our starting address by specifying -Ttext 0x7c00
when we call the linker:
as -o boot.o boot.s
ld -o boot.bin --oformat binary -e init -Ttext 0x7c00 -o boot.bin boot.o
QEmu will now print «H», the first character of our message text.
We could now print the entire string by doing the following:
- Put the address of the first byte of the string (i.e.
msg
) into any register exceptax
(because we use that for the actual printing), say we usecx
. - Load the byte at the address in
cx
intoal
- Compare the value in
al
with 0 (end of string, thanks to.asciz
) - If AL contains 0, go to the end of our program
- Call interrupt 0x10
- Increment the address in
cx
by one - Repeat from step 2
What is also useful is the fact that x86 has a special register and a bunch of special instructions to deal with strings.
In order to use these instructions, we will load the address of our string (msg
) into the special register si
which allows us to use the convenient lodsb
instruction that loads a byte from the address that si
points to into al
and increments the address in si
at the same time.
Let’s put it all together:
.code16 # use 16 bits
.global init
init:
mov $msg, %si # loads the address of msg into si
mov $0xe, %ah # loads 0xe (function number for int 0x10) into ah
print_char:
lodsb # loads the byte from the address in si into al and increments si
cmp $0, %al # compares content in AL with zero
je done # if al == 0, go to "done"
int $0x10 # prints the character in al to screen
jmp print_char # repeat with next byte
done:
hlt # stop execution
msg: .asciz "Hello world!"
.fill 510-(.-init), 1, 0 # add zeroes to make it 510 bytes long
.word 0xaa55 # magic bytes that tell BIOS that this is bootable
Let’s look at this new code in QEmu:
🎉 Yay! 🎉
It prints our message by looping from print_char
to jmp print_char
until we hit a zero-byte (which is right after the last character of our message) in si
. Once we find the zero byte, we jump to done
and halt execution.
The Intel syntax edition and nasm
As promised, I will also show you the alternative way of using nasm
instead of the GNU assembler.
First things first: nasm
can produce a raw binary by itself and it uses the Intel Syntax:
operation target, source
— I remember the order with «W,T,F» — «What, To, From»
So here is the nasm-compatible version of the previous code:
[bits 16] ; use 16 bits
[org 0x7c00] ; sets the start address
init:
mov si, msg ; loads the address of "msg" into SI register
mov ah, 0x0e ; sets AH to 0xe (function teletype)
print_char:
lodsb ; loads the current byte from SI into AL and increments the address in SI
cmp al, 0 ; compares AL to zero
je done ; if AL == 0, jump to "done"
int 0x10 ; print to screen using function 0xe of interrupt 0x10
jmp print_char ; repeat with next byte
done:
hlt ; stop execution
msg: db "Hello world!", 0 ; we need to explicitely put the zero byte here
times 510-($-$$) db 0 ; fill the output file with zeroes until 510 bytes are full
dw 0xaa55 ; magic number that tells the BIOS this is bootable
(Thanks to Reddit user pahefu for pointing out a typo here!
After saving it as boot.asm
it can be compiled by running nasm -o boot2.bin boot.asm
.
Note that the order of arguments for cmp
are the opposite of the order that as
uses and [org]
in nasm and .org
in as
are not the same thing!
nasm does not do the extra step via the ELF file (boot.o
), so it won’t move our msg
around in memory like as
and ld
did.
Yet, if we forget to set the start address of our code to 0x7c00
, the address that the binary uses for msg
will still be wrong, because nasm assumes a different start address by default. When we explicitly set it to 0x7c00
(where the BIOS loads our code), the addresses will be correctly calculated in the binary and the code works just like the other version does.
Задание
- В загрузочный сектор поместить программу вывода на экран произвольного сообщения. Программу написать на ассемблере (предпочтительно встраиваемом в стандартную конфигурацию ) и на С (при необходимости допустимы ассемблерные вставки), убедиться в работоспособности обоих вариантов. См. пример кода ниже. В качестве носителя предпочтителен выбор флэш. Начать можно с экспериментов над виртуальной дискетой, как показано ниже.
- Создать первичный загрузчик для виртуального, а затем реального носителя, (пример показан для виртуальной дискеты и ФС FAT12/16), который будет находить файл программы на носителе и загружать ее на выполнение. Сделать 2 варианта на разных языках (asm и С). Привести весь план экспериментов и результаты их проведения в виде «логфайлов» и screenshot –ов. ФС можно выбрать на свое усмотрение.
Описание для выполнения задания
Загрузка системы 1)начинается с того, что BIOS считывает первый сектор блочного носителя, например, жесткого диска, 2)размещает его в памяти (ОЗУ) по адресу 0000:7С00h и 3)передает туда управление.
Этот сектор называется главным загрузочным сектором (Master Boot Record, сокращенно MBR). В начале записи MBR расположен код загрузчика.
Следом за ним идет таблица разделов, описывающая схему разбиения логических дисков.
В конце сектора находится сигнатура 55h AAh, подтверждающая, что это действительно MBR.
Если хотя бы один из двух последних байтов отличается по значению, считается, что первый сектор не является MBR и не содержит осмысленной информации.
BIOS загружает MBR по адресу 7C00h, поэтому в начале ассемблерного кода должна стоять директива ORG 7C00h. Кроме того, необходимо указать директиву USE16, потому что загрузчик выполняется в 16-разряном режиме.
Попробуем реализовать на ассемблере простейшую программу, которая выводит приветственное сообщение на экран, и поместим ее в загрузочный сектор.
1. Пример реализации вывода сообщения из MBR
Текст программы:
use16
org 0x7C00
; Очищаем регистры
xor ax, ax
mov es, ax
mov ds, ax
mov ss, ax
mov sp, 0x1000
; Установим видеорежим, очистка экрана
mov ax, 3
int 10h
; Вывод приветственного сообщения
mov si, mHello
call print
; Зависаем в бесконечном цикле
die: jmp short die
mHello db 'Hello, world - i was booted!',10,13,0
; Подпрограммавывода
print:
cld
pusha
.PrintChar:
lodsb
test al, al
jz short .Exit
mov ah, 0eh
mov bl, 7
int 10h
jmp short .PrintChar
.Exit:
popa
ret
Компилируем программу:
>nasm -f bin boothi.asm -o boothi
Мы получили исполняемый код (файл boothi ). Теперь можно записать его в MBR раздел дискеты и, при загрузке с этой дискеты, мы увидим на экране приветственное сообщение.
Реализованная программа делает то, что нам надо – выводит приветственное сообщение на экран при загрузке. Однако загрузчиком она не является.
2. Пример реализации первичного загрузчика для дискеты с поиском файла в ФС FAT и загрузкой на выполнение
Рассмотрим теперь реализацию первичного загрузчика для дискеты, который будет находить файл этой программы и загружать ее на выполнение.
Для этого необходимо работать с файловой системой. Заголовок файловой системы FAT содержит всю необходимую информацию о файловой системе и находится в первом секторе дискеты, благодаря чему он загружается вместе с загрузчиком. Первичный загрузчик будет искать в корневом каталоге исполняемый файл программы, загрузит его в память и передаст управление на его начало.
В начале загрузочного сектора располагается заголовок FAT:
; Общая часть для всех типов FAT
BS_jmpBoot:
jmp short BootStart ; Переходим на код загрузчика
nop
BS_OEMName db '*-v4VIHC' ; 8 байт, (что было на моей дискете)
BPB_BytsPerSec dw 0x200 ; Байт на сектор
BPB_SecPerClus db 1 ; Секторов на кластер
BPB_RsvdSecCnt dw 1 ; Число резервных секторов
BPB_NumFATs db 2 ; Количектво копий FAT
BPB_RootEntCnt dw 224 ; Элементов в корневом катологе (max)
BPB_TotSec16 dw 2880 ; Всего секторов или 0
BPB_Media db 0xF0 ; код типа устройства
BPB_FATsz16 dw 9 ; Секторов на элемент таблицы FAT
BPB_SecPerTrk dw 18 ; Секторов на дорожку
BPB_NumHeads dw 2 ; Число головок
BPB_HiddSec dd 0 ; Скрытых секторов
BPB_TotSec32 dd 0 ; Всего секторов или 0
; Заголовок для FAT12 и FAT16
BS_DrvNum db 0 ; Номер дика для прерывания int 0x13
BS_ResNT db 0 ; Зарезервировано для Windows NT
BS_BootSig db 29h ; Сигнатура расширения
BS_VolID dd 2a876CE1h ; Серийный номер тома
BS_VolLab db 'X boot disk' ; 11 байт, метка тома
BS_FilSysType db 'FAT12 ' ; 8 байт, типФС
; Структура элемента каталога
struc DirItem
DIR_Name: resb 11
DIR_Attr: resb 1
DIR_ResNT: resb 1
DIR_CrtTimeTenth resb 1
DIR_CrtTime: resw 1
DIR_CrtDate: resw 1
DIR_LstAccDate: resw 1
DIR_FstClusHi: resw 1
DIR_WrtTime: resw 1
DIR_WrtDate: resw 1
DIR_FstClusLow: resw 1
DIR_FileSize: resd 1
endstruc ;DirItem
Загрузчик BIOS передает управление на начало загрузочного сектора, то есть на BS_jmpBoot, поэтому в начале заголовка FAT отводится 3 байта для короткой или длинной инструкции jmp. В данном случае используется короткая и пустая операция nop. Таким образом, мы «прыгаем» на наш код.
Описываем переменные и очищаем регистры:
; Не инициализированные переменные
; При инициализации они затрут не нужные поля заголовка FAT: BS_jmpBoot и BS_OEMName
struc NotInitData
SysSize: resd 1 ; Размер системной области FAT
fails: resd 1 ; Число неудачных попыток при чтении
fat: resd 1 ; Номер загруженного сектора с элементами FAT
endstruc ;NotInitData
; По этому адресу мы будем загружать загрузчик
%define SETUP_ADDR 0x1000
; А по этому адресу нас должны были загрузить
%define BOOT_ADDR 0x7C00
%define BUF 0x500
BootStart:
cld
xor cx, cx
mov ss, cx
mov es, cx
mov ds, cx
mov sp, BOOT_ADDR
mov bp, sp
; Сообщим о том что мы загружаемся
mov si, BOOT_ADDR + mLoading
call print
Все сегментные регистры настраиваем на начало физической памяти, вершину стека настраиваем на начало сектора.
(Процедуру print, выводящую сообщение на экран, см.ниже)
Теперь нужно вычислить номера первых секторов корневого каталога и данных файлов:
mov al, [byte bp+BPB_NumFATs]
cbw
mul word [byte bp+BPB_FATsz16]
add ax, [byte bp+BPB_HiddSec]
adc dx, [byte bp+BPB_HiddSec+2]
add ax, [byte bp+BPB_RsvdSecCnt]
adc dx, cx
mov si, [byte bp+BPB_RootEntCnt]
; dx:ax - Номер первого сектора корневого каталога
; si - Количество элементов в корневом каталоге
pusha
; Вычислим размер системной области FAT = резервные сектора +
; все копии FAT + корневой каталог
mov [bp+SysSize], ax ; осталось добавить размер каталога
mov [bp+SysSize+2], dx
; Вычислим размер корневого каталога
mov ax, 32
mul si
; dx:ax - размер корневого каталога в байтах, а надо в секторах
mov bx, [byte bp+BPB_BytsPerSec]
add ax, bx
dec ax
div bx
; ax - размер корневого каталога в секторах
add [bp+SysSize], ax ; Теперь мы знаем размер системной
adc [bp+SysSize+2], cx ; области FAT, и начало области данных
popa
; В dx:ax - снова номер первого сектора корневого каталога
; si - количество элементов в корневом каталоге
Просматриваем корневой каталог в поисках нужного файла:
NextDirSector:
; Загрузим очередной сектор каталога во временный буфер
mov bx, 700h ; es:bx - буфер для считываемого сектора
mov di, bx ; указатель текущего элемента каталога
mov cx, 1 ; количество секторов для чтения
call ReadSectors
jc near DiskError ; ошибкапричтении
RootDirLoop:
; Ищемнашфайл
; cx = 0 послефункции ReadSectors
cmp [di], ch ; byte ptr [di] = 0?
jz near NotFound ; Да, это последний элемент в каталоге
; Нет, не последний, сравним имя файла
pusha
mov cl, 11 ; длина имени файла с расширением
mov si, BOOT_ADDR + LoaderName ; указатель на имя искомого файла
rep cmpsb ; сравниваем
popa
jz short Found ; Нашли, выходим из цикла
; Нет, ищем дальше
dec si ; RootEntCnt
jz near NotFound ; Это был последний элемент каталога
add di, 32 ; Переходим к следующему элементу каталога
; bx указывает на конец прочтенного сектора после call ReadSectors
cmp di, bx ; Последний элемент в буфере?
jb short RootDirLoop ; Нет, проверим следующий элемент
jmp short NextDirSector ; Да последний, загрузим следующий сектор
В результате возможны следующие варианты: ошибка при чтении (метка DiskError), файл найден (метка Found), файл не найден (метка NotFound).
Если файл найден, то загружаем его в память и передаем управление на его начало:
Found:
; Загрузка загрузчика (извините, каламбур)
mov bx, SETUP_ADDR
mov ax, [byte di+DIR_FstClusLow] ; Номер первого кластера файла
; Загружаем сектор с элемнтами FAT, среди которых есть FAT[ax]
; LoadFAT сохраняет значения всех регистров
call LoadFAT
ReadCluster:
; ax - Номер очередного кластера
; Загрузим его в память
push ax
; Первые два элемента FAT служебные
dec ax
dec ax
; Число секторов для чтения
; cx = 0 после ReadSectors
mov cl, [byte bp+BPB_SecPerClus] ; Секторов на кластер
mul cx
; dx:ax - Смещение кластера относительно области данных
add ax, [byte bp+SysSize]
adc dx, [byte bp+SysSize+2]
; dx:ax - Номер первого сектора требуемого кластера
; cx еще хранит количество секторов на кластер
; es:bx - конец прошлого кластера и начало нового
call ReadSectors ; читаем кластер
jc near DiskError ; Увы, ошибка чтения
pop ax ; Номер кластера
; Это конец файла?
; Получим значение следующего элемента FAT
pusha
; Вычислим адрес элемента FAT
mov bx, ax
shl ax, 1
add ax, bx
shr ax, 1
; Получим номер сектора, в котором находится текущий элемент FAT
cwd
div word [byte bp+BPB_BytsPerSec]
cmp ax, [bp+fat] ; Мы уже читали этот сектор?
popa
je Checked ; Да, читали
; Нет, надо загрузить этот сектор
call LoadFAT
Checked:
; Вычислим адрес элемента FAT в буфере
push bx
mov bx, ax
shl bx, 1
add bx, ax
shr bx, 1
and bx, 511 ; остаток от деления на 512
mov bx, [bx+0x700] ; а вот и адрес
; Извлечем следующий элемент FAT
; В FAT16 и FAT32 все немного проще :(
test al, 1
jnz odd
and bx, 0xFFF
jmp short done
odd:
shr bx, 4
done:
mov ax, bx
pop bx
; bx - новыйэлемент FAT
cmp ax, 0xFF8 ; EOF - конецфайла?
jb ReadCluster ; Нет, читаем следующий кластер
; Наконец-то загрузили
mov ax, SETUP_ADDR>>4 ; SETUP_SEG
mov es, ax
mov ds, ax
; Передаем управление, наше дело сделано :)
jmp SETUP_ADDR>>4:0
LoadFAT:
; Процедура для загрузки сектора с элементами FAT
; Элемент ax должен находится в этом секторе
; Процедура не должна менять никаких регистров
pusha
; Вычисляем адрес слова содержащего нужный элемент
mov bx, ax
shl ax, 1
add ax, bx
shr ax, 1
cwd
div word [byte bp+BPB_BytsPerSec]
; ax - смещение сектора относительно начала таблицы FAT
mov [bp+fat], ax ; Запомним это смещение, dx = 0
cwd ; dx:ax - номер сектора, содержащего FAT[?]
; Добавим смещение к первой копии таблицы FAT
add ax, [byte bp+BPB_RsvdSecCnt]
adc dx, 0
add ax, [byte bp+BPB_HiddSec]
adc dx, [byte bp+BPB_HiddSec+2]
mov cx, 1 ; Читаем один сектор. Можно было бы и больше, но не быстрее
mov bx, 700h ; Адресбуфера
call ReadSectors
jc DiskError ; Ошибочкавышла
popa
ret
Теперь разберем процедуру загрузки секторов. Процедура получает номер сектора в dx:ax (нумерация с нуля) и преобразует его к формату CSH (цилиндр, сектор, сторона), который используется прерыванием BIOS int 0x13.
;
; *************************************************
; Чтение секторов с диска
;
; Входные параметры:
; dx:ax - (LBA) номер сектора
; cx - количество секторов для чтения
; es:bx - адрес буфера
;
; Выходные параметры:
; cx - Количество не прочтенных секторов
; es:bx - Указывает на конец буфера
; cf = 1 - Произошла ошибка при чтении
; *************************************************
ReadSectors:
next_sector:
; Читаем очередной сектор
mov byte [bp+fails], 3 ; Количество попыток прочесть сектор
try:
; Очередная попытка
pusha
; Преобразуем линейный адрес в CSH
; dx:ax = a1:a0
xchg ax, cx ; cx = a0
mov ax, [byte bp+BPB_SecPerTrk]
xchg ax, si ; si = Scnt
xchg ax, dx ; ax = a1
xor dx, dx
; dx:ax = 0:a1
div si ; ax = q1, dx = c1
xchg ax, cx ; cx = q1, ax = a0
; dx:ax = c1:a0
div si ; ax = q2, dx = c2 = c
inc dx ; dx = Sector?
xchg cx, dx ; cx = c, dx = q1
; dx:ax = q1:q2
div word [byte bp+BPB_NumHeads] ; ax = C (track), dx = H
mov dh, dl ; dh = H
mov ch, al
ror ah, 2
or cl, ah
mov ax, 0201h ; ah=2 - номерфункции, al = 1 сектор
mov dl, [byte bp+BS_DrvNum]
int 13h
popa
jc Failure ; Ошибкапричтении
; Номер следующего сектора
inc ax
jnz next
inc dx
next:
add bx, [byte bp+BPB_BytsPerSec]
dec cx ; Все сектора прочтены?
jnz next_sector ; Нет, читаем дальше
return:
ret
Failure:
dec byte [bp+fails] ; Последняяпопытка?
jnz try ; Нет, еще раз
; Последняя, выходим с ошибкой
stc
ret
Если файл не был найден или произошла ошибка, выводим соответствующее сообщение и уходим на бесконечный цикл:
; Сообщения об ошибках
NotFound: ; Файл не найден
mov si, BOOT_ADDR + mLoaderNotFound
call print
jmp short die
DiskError: ; Ошибкачтения
mov si, BOOT_ADDR + mDiskError
call print
;jmp short die
die: ; Простоошибка
mov si, BOOT_ADDR + mReboot
call print
_die: jmp short _die
Собственно сама подпрограмма вывода сообщения на экран и строковые сообщения :
; Вывод строки на экран
; ds:si - адрес строки
print:
pusha
print_char:
lodsb ; Читаемочереднойсимвол
test al, al ; 0 - конец?
jz shortpr_exit ; Да конец
; Нет, выводим этот символ
mov ah, 0eh
mov bl, 7
int 10h
jmp shortprint_char ; Выводим следующий символ
pr_exit:
popa
ret
;print endp
; Строковые сообщения
mLoading db 'Loading...', 10,13,0
mDiskError db 'Disk I/O error', 10,13,0
mLoaderNotFound db 'Loader not found', 10,13,0
mReboot db 'Reboot system', 10,13,0
; Выравнивание размера образа на 512 байт
times 499-($-$$) db 0
**В последних двух строках задается имя файла программы, которую мы ищем** , и пишется **сигнатура загрузочного сектора** :
LoaderName db 'BOOTHI ' ; Имя файла загрузчика
BootMagic dw 0xAA55 ; Сигнатура загрузочного сектора
Компилируем данную программу:
>nasm -fbinboot.asm -oboot
Мы получили файл с машинным кодом boot. Теперь можно записать его в MBR раздел дискеты. Также необходимо скомпилированный файл предыдущей программы расположить в корне дискеты. Текст программы останется неизменным, за исключением того, что нет необходимости располагать ее по адресу 0x7C00 (убираем директиву org).
Теперь при помощи HEX-редактора записываем boot в загрузочный сектор дискеты и, при попытке загрузиться с нее, видим приветственное сообщение:
Это сообщение свидетельствует о том, что наш первичный загрузчик отработал правильно, нашел нашу программу на дискете и загрузил на выполнение. Далее целесообразно программу вывода сообщения заменить на более содержательный исполняемый код для демонстрации возможностей разработанного загрузчика и для выполнения следующих пунктов задания.
Для контроля можно в тексте самого загрузчика предусмотреть вывод дополнительного сообщения и проследить этапность выполнения загрузчика и загружаемой программы.
Задание (продолжение)
- Разработать первичный загрузчик ОС.
Это задача аналогичная предыдущей (п.2) с той разницей, что в качестве исполняемого файла, искомого на носителе выступает файл с ядром ОС. - Создать мультизагрузчик , обеспечивающий варианты выбора загружаемой на исполнение программы или ОС.
Для эксперимента можно использовать несколько собственных программ, включая программы из п.1 и 2. - Предложить загрузчик любой прикладной программы в стандартном режиме (не из MBR) (в ОС Linux). Для этого сначала проанализировать загрузку программы и порождение процесса (/потока) из консоли и из процесса. Оценить эффективность реализации на 2-х языковых уровнях (asm, C). Попытаться заменить (перехватить) системный загрузчик на свой собственный. Описать эксперимент.
Чтобы не было обвинений в плагиате предупреждаю сразу, всё нижеследующее взято на сайте asmdev.narod.ru, автор Андрей Валяев <dron@infosec.ru>, материалы были в форме рассылки, поэтому подвергнуты минимальной литобработке.
Создание операционной системы на ассемблере
- введение / основные сведения о ядре
- организация работы с памятью
- этапы загрузки различных ОС
- создание bootsector’а
- основы защищенного режима
- шлюзы / виртуальный режим процессора 8086
- исключения защищенного режима / микроядерные системы
- файловые системы
- чтение ext2fs
- форматы файлов ELF и PE
- процесс загрузки
- определение количества памяти
В этой работе будут использоваться:
- Многоплатформенный ассемблер nasm (есть версии для UNIX, DOS и Windows), поддерживающий команды практически всех современных процессоров и многообразием понимаемых форматов.
- любой ANSI C компилятор.
Глава #1
В этой главе вы не увидите исходных текстов готовых программ, это все еще только предстоит написать при вашем активном участии.
Начнем с написания ядра. Ядро будет ориентированно на UNIX-подобные операционные системы. Для простоты с самого начала будем стремиться к совместимости с существующими системами.
Задача состоит в следующем:
Сделать, по возможности, компактное, надежное и быстрое ядро, с максимальным эффектом используя возможности процессора. Писать будем в основном на Ассемблере.
Для начала разберемся, как устроены системы
.
Ядро состоит из следующих компонентов:
- «Собственно ядро»
- Драйвера устройств
- Системные вызовы
В зависимости от организации внутренних взаимодействий, ядра подразделяются на «микроядра» (microkernel) и монолитные ядра.
Системы с «микроядром» строятся по модульному принципу, имеют обособленное ядро, и механизм взаимодействия между драйверами устройств и процессами. По такому принципу строятся системы реального времени. Примерно так сделан QNX или HURD.
Монолитное ядро имеет более жесткую внутреннюю структуру. Все установленные драйвера жестко связываются между собой, обычно прямыми вызовами. По таким принципам строятся обыкновенные операционные системы типа Linux, FreeBSD.
Естественно, не все так четко, идеального монолитного или «микроядра» нет, наверное, ни в одной системе, просто системы приближаются к тому или иному типу ядра.
Очень хотелось бы, чтобы то, что делаем, больше походило на первый тип ядер.
Немного углубляемся в аппаратные возможности компьютеров
.
Один, отдельно взятый, процессор, в один момент времени, может исполнять только одну программу. Но к компьютерам предъявляются более широкие требования. Мало кто, в настоящее время, удовлетворился однозадачной операционной системой (к каким относился DOS, например). В связи с этим разработчики процессоров предусмотрели мультизадачные возможности.
Возможность эта заключается в том, что процессор выполняет какую-то одну программу (их еще называют процессами или задачами). Затем, по истечении некоторого времени (обычно это время меряется микросекундами), операционная система переключает процессор на другую программу. При этом все регистры текущей программы сохраняются. Это необходимо для того, чтобы через некоторое время вновь передать управление этой программе. Программа при этом не замечает каких либо изменений, для нее процесс переключения остается незаметен.
Для того чтобы программа не могла, каким либо образом, нарушить работоспособность системы или других программ, разработчики процессоров предусмотрели механизмы защиты.
Процессор предоставляет 4 «кольца защиты» (уровня привилегий), можно было бы использовать все, но это связано со сложностями взаимодействия программ разного уровня защиты. Поэтому в большинстве существующих систем используют два уровня. 0 — привилегированный уровень (ядро) и 3 — непривилегированный (пользовательские программы).
Всем этим обеспечивается надежное функционирование системы и независимость программ друг от друга.
Теперь немного поподробнее про устройство ядра.
На «Собственно ядро» возлагаются функции менеджера памяти и процессов. Переключение процессов — это основной момент нормального функционирования системы. Драйвера не должны «тормозить», а тем более блокировать работу ядра. Windows — наглядный пример того, что этого нельзя допустить!
Теперь о драйверах. Драйвера — это специальные программы, обеспечивающие работу устройств компьютера. В существующих системах (во FreeBSD это точно есть, про Linux не уверен) предусматриваются механизмы прерывания работы драйверов по истечении какого-то времени. Правда, все зависит от того, как написан драйвер. Можно написать драйвер под FreeBSD или Linux, который полностью блокирует работу системы.
Избежать этого при двухуровневой защите не представляется возможным, поэтому драйвера надо будет тщательно программировать. В нашей работе драйверам уделим очень много внимания, поскольку от этого в основном зависит общая производительность системы.
Системные вызовы — это интерфейс между процессами и ядром (читайте-железом). Никаких других методов взаимодействия процессов с устройствами компьютера быть не должно. Системных вызовов достаточно много, на Linux их 190, на FreeBSD их порядка 350, причем большей частью они совпадают, соответствуя стандарту POSIX (стандарт, описывающий системные вызовы в UNIX). Разница заключается в передаче параметров, что легко будет предусмотреть. Естественно, нельзя сделать ядро, работающее одновременно на Linux и на FreeBSD, но по отдельности совместимость вполне реализуема.
Прикладным программам абсолютно безразлично, как системные вызовы реализуются в ядре. Это облегчает для нас обеспечение совместимости с существующими системами.
В следующей главе поговорим о защищенном режиме процессора, распределении памяти, менеджере задач и рассмотрим, как это сделано в существующих системах.
Вопросы
- Какой бы вы хотели видеть СВОЮ систему?
- На какую систему она должна походить?
- Сколько места на винчестере занимать?
- Сколько памяти требовать для работы?
Глава #2
В этой главе поговорим об архитектуре современных процессоров и о предоставляемых средствах защиты. Понимание этого будет необходимо нам, когда перейдем непосредственно к программированию операционной системы.
Как процессор работает с памятью?
Для начала небольшое предисловие.
В процессорах имеются базовые регистры, которые могут задавать смещение. На 16-битной архитектуре максимальное смещение могло быть до 64 килобайт, что, в общем-то, не много и вызывало определенные трудности (разные модели памяти, разные форматы файлов). Так же, в 16-битной архитектуре присутствовали сегментные регистры, которые указывали адрес сегмента в памяти. В процессорах, начиная с i386, базовые регистры стали 32-х битными, что позволяет адресовать до 4 гигабайт. Сегментные регистры остались 16-битными, и в защищенном режиме они не содержат адреса! они содержат индекс дескриптора. В реальном режиме сегментные регистры работают так же, как и на 16-битных процессорах.
В реальном режиме сегментные регистры непосредственно указывают на адрес начала сегмента в памяти. Это позволяет нам, без каких либо преград, адресовать 1 мегабайт памяти. Но создает определенные трудности для защиты. А защищать нужно многое. Например, не можем пользовательским программам дать возможность непосредственно обращаться к коду или данным ядра. Так же нельзя давать возможность пользовательским программам обращаться к коду или данным других пользовательских программ, поскольку это может нарушить их работоспособность.
Для этого был изобретен защищенный режим работы процессора, который появился в процессорах i286.
Защищенность этого режима заключается в следующем:
Сегментный регистр больше не указывает на адрес в памяти. В этом регистре теперь задается индекс в таблице дескрипторов.
Таблица дескрипторов может быть глобальная или локальная (применяется в многозадачных системах для изоляции адресного пространства задач) и представляет собой массив записей, по 8 байт в каждой, где описываются адреса, пределы и права доступа к сегментам.
Про адрес ничего не буду говорить, и так все ясно. Что такое предел? В этом Поле описывается размер сегмента. При обращении за пределы сегмента процессор генерирует исключение (специальное прерывание защищенного режима). Так же исключение генерируется в случае нарушения прав доступа к сегменту. Поле прав доступа описывает возможность чтения/записи сегмента, возможность выполнения кода сегмента, уровень привилегий для доступа к сегменту.
При обращении к сегменту из дескриптора берется базовый адрес сегмента и складывается со смещением сегмента. Так получается линейный 32-х разрядный (в i286 — 24-х разрядный) адрес. Для i286 на этом процесс получения адреса завершается, линейный адрес там равен физическому. Для i386 или выше это справедливо не всегда.
Страничная организация памяти.
В процессорах, начиная с i386, появилась, так называемая, страничная организация памяти. Страница имеет размер 4 килобайта или 4 мегабайта. Большие страницы могут быть только в pentium или выше. Не знаю только, какой толк от таких страниц.
Если возможность страничной адресации не используется, то линейный адрес, как и на i286, равен физическому. Если используется — то линейный адрес разбивается на три части. Первая, 10-битная, часть адреса является индексом в каталоге страниц, который адресуется системным регистром CR3. Запись в каталоге страниц указывает адрес таблицы страниц. Вторая, 10-битная, часть адреса является индексом в таблице страниц. Запись в таблице страниц указывает физический адрес нахождения страницы в памяти. последние 12 бит адреса указывают смещение в этой странице.
В страничных записях, как и в дескрипторных записях, есть служебные биты, описывающие права доступа, и некоторые другие тонкости страниц. Одной из важных тонкостей является бит присутствия страницы в памяти. В случае не присутствия страницы, процессор генерирует исключение, в котором можно считать данную страницу из файла или из swap раздела. Это сильно облегчает реализацию виртуальной памяти. Более подробно про все это можно прочитать в книгах по архитектуре процессоров. Вернемся к операционным системам.
Многозадачность.
Многозадачные возможности в процессорах так же появились в процессорах, начиная с i286. Для реализации этого, процессор для каждой задачи использует, так называемый, «сегмент состояния задачи» («Task State Segment», сокращенно TSS). В этом сегменте, при переключении задач, сохраняются все базовые регистры процессора, сегменты и указатели стека для трех уровней защиты (для каждого уровня используется свой стек), сегментный адрес локальной таблицы дескрипторов («Local descriptor table», сокращенно LDT). В процессорах, начиная с i386, там еще хранится адрес каталога страниц (регистр CR3). Так же этот сегмент обеспечивает некоторые другие механизмы защиты, но о них пока не будем говорить.
Операционная система может расширить TSS, и использовать его для хранения регистров и состояния сопроцессора. Процессор при переключении задач не сохраняет этого. Так же возможны другие применения.
Что из всего этого следует?
Не будем ориентироваться на процессор i286, поскольку 16-битная архитектура и отсутствие механизма страничного преобразования сильно усложняет программирование операционной системы. К тому же, таких процессоров давно уже никто не использует.
Ориентируемся на i386 или более старшие модели процессоров, вплоть до последних.
Ядро системы при распределении памяти оперирует 4-х килобайтными страницами.
Страницы могут использоваться самим ядром, для нужд драйверов (кэширование, например), или для процессов.
Программа или процесс состоит из следующих частей:
- Сегмент кода. Может только выполняться, сама программа его не прочитать, не переписать не может! Использовать для этого сегмента swap не нужно, при необходимости код считывается прямо из файла;
- Сегмент данных состоит из трех частей:
- Константные данные, их тоже можно загружать из файла, так как они не меняются при работе программы;
- Инициализированные данные. Участвует в процессе свопинга;
- Не инициализированные данные. Так же участвует в свопинге;
- Сегмент стека. Так же участвует в свопинге.
Но, обычно, системы делят сегмент данных на две части: инициализированные данные и не инициализированные данные.
Все сегменты разбиваются на страницы. Сегмент кода имеет постоянный размер. Сегмент данных может увеличиваться в сторону больших адресов. Сегмент стека, поскольку растет вниз, увеличивается в сторону уменьшения адресов. Страницы памяти для дополнительных данных или стека выделяются системой по мере необходимости.
Очень интересный момент
При выполнении программы операционная система делает следующие действия:
- Готовит для программы локальную таблицу дескрипторов;
- Готовит для программы каталог страниц, все страницы помечаются как не присутствующие в памяти.
При передаче управления этой программе процессор генерирует исключение по отсутствию страницы, в котором нужная страница загружается из файла или инициализируется.
Еще один интересный момент
Когда в системе загружается две или более одинаковых программы — нет необходимости для каждой из них выделять место для кодового сегмента, они спокойно могут использовать один код на всех.
Глава #3
В этой главе поговорим, о порядке загрузки операционных систем.
Процесс загрузки, естественно, начинается с BIOS.
При старте процессор находится в реальном режиме, следовательно больше одного мегабайта памяти адресовать не может. Но это и не обязательно.
BIOS проверяет устройства, с которых может производиться загрузка. Порядок проверки в современных BIOS устанавливается. В список устройств могут входить Floppy disk, IDE disk, CDROM, SCSI disk…
Вне зависимости от типа устройства суть загрузки одна…
На устройстве обнаруживается boot sector. Для CDROM это не совсем справедливо, но про них пока не будем говорить. BootSector загружается в память по адресу 0:7с00. Дальнейшее поведение BootSector’а зависит от системы.
Загрузка Linux.
Для Linux свойственно два способа загрузки:
- Загрузка через boot sector ядра;
- Загрузка через boot manager LILO (Linux Loader);
Процесс загрузки через ядро используется обычно на Floppy дисках и происходит в следующем порядке:
- boot sector переписывает свой код по адресу 9000h:0;
- Загружает с диска Setup, который записан в нескольких последующих секторах, по адресу: 9000h:0200h;
- Загружает ядро по адресу 1000h:0. Ядро так же следует в последующих секторах за Setup. Ядро не может быть больше чем 508 килобайт, но так как оно, чаще всего, архивируется — это не страшно;
- Запускается Setup;
- Проверяется корректность Setup;
- Производится проверка оборудования средствами BIOS. Определяется размер памяти, инициализируется клавиатура и видеосистема, наличие жестких дисков, наличие шины MCA (Micro channel bus), PC/2 mouse, APM BIOS (Advanced power management);
- Производится переход в защищенный режим;
- Управление передается по адресу 1000h:0 на ядро;
- Если ядро архивировано, оно разархивируется. иначе просто переписывается по адресу 100000h (за пределы первого мегабайта);
- Управление передается по этому адресу;
- Активируется страничная адресация;
- Инициализируются idt и gdt, при этом в кодовый сегмент и в сегмент данных ядра входит вся виртуальная память;
- Инициализируются драйвера;
- Управление передается неуничтожимому процессу init;
- init запускает все остальные необходимые программы в соответствии с файлами конфигурации;
В случае загрузки через LILO:
- boot sector LILO переписывает свой код по адресу 9a00h:0;
- До адреса 9b00h:0 размещает свой стек;
- Загружает вторичный загрузчик по адресу 9b00h:0 и передает ему управление;
- Вторичный загрузчик загружает boot sector ядра по адресу 9000h:0;
- Загружает Setup по адресу 9000h:0200h;
- Загружает ядро по адресу 1000h:0;
- Управление передается программе Setup. Зачем загружает boot sector из ядра? не понятно;
В Linux есть такое понятие как «big kernel». Такой kernel сразу загружается по адресу 100000h.
Загрузка FreeBSD.
Принципиальных отличий для FreeBSD, конечно, нет. основное отличие состоит в том, что ядро, как и модули ядра являются перемещаемыми и могут быть загружены или выгружены в процессе загрузки системы.
Порядок загрузки примерно следующий:
- BootSector загружает вторичный загрузчик;
- Вторичный загрузчик переводит систему в защищенный режим и запускает loader;
- loader предоставляет пользователю возможность выбрать необходимые модули или запустить другое ядро;
- После чего управление передается ядру и начинается инициализация драйверов;
Давайте по порядку рассмотрим, как грузятся системы от Microsoft.
Загрузка DOS.
boot sector DOS загружает в память два файла: io.sys и msdos.sys. Названия этих файлов в разных версиях DOS различались, не важно. Файл io.sys содержит в себе функции прерывания int 21h, файл msdos.sys обрабатывает config.sys, и запускает командный интерпретатор command.com, который в свою очередь обрабатывает командный файл autoexec.bat.
Загрузка Windows 9x.
Отличие от DOS заключается в том, что функции msdos.sys взял на себя io.sys. msdos.sys остался ради совместимости как конфигурационный файл. После того как командный интерпретатор command.com обрабатывает autoexec.bat вызывается программа win.com, которая осуществляет перевод системы в защищенный режим, и запускает различные другие программы, обеспечивающие работу системы.
Загрузка Windows NT.
boot sector NT — зависти от формата FS, для FAT устанавливается один, для NTFS — другой, в нем содержиться код чтения FS, без обработки подкаталогов.
- boot sector загружает NTLDR из корневой директории, который запускается в real mode;
- NTLDR певодит систему в защищенный режим;
- Создаются необходимые таблицы страниц для доступа к первому мегабайту памяти;
- Активируется механизм страничного преобразования;
- Далее NTLDR читает файл boot.ini, для этого он использует встроенный read only код FS. В отличии от кода бутсектора он может читать подкаталоги;
- На экране выводится меню выбора вида загрузки;
- После выбора, или по истечении таймаута, NTLDR из файла boot.ini определяет нахождение системной директории Windows, она может находиться в другом разделе, но обязательно должна быть корневой;
- Если в boot.ini указана загрузка DOS (или Win9x), то файл bootsect.dos загружается в память и выполняется горячая перезагрузка;
- Далее обрабатывается boot.ini;
- Загружается ntdetect.com, который выводит сообщение «NTDETECT V4.0 Checking Hardware», и детектит различные устройства… Вся информация собирается во внешней структуре данных, которая в дальнейшем становиться ключем реестра «HKEY_LOCAL_MACHINEHARDWAREDESCRIPTION»;
- NTLDR выводит сообщение «OSLOADER V4.0»;
- Из директории winntsystem32 загружается ntoskrnl.exe, содержащий в себе ядро и подсистемы выполнения (менеджер памяти, кэш менеджер, менеджер объектов), и файл hal.dll, который содержит в себе интерфейс с аппаратным обеспечением;
- Далее NTLDR предоставляет возможность выбрать «последние известные хорошие» конфигурации. В зависимости от выбора выбираются копии реестра используемые для запуска;
- Загружает все драйвера и другие необходимые для загрузки файлы;
- В завершение он запускает функцию main из ntoskrnl.exe и завершает свою работу;
Не могу гарантировать полную достоверность представленной информации, NT я знаю плохо, тем более не знаю что у нее внутри. Так же не могу что-либо более конкретного сказать про распределение памяти в процессе загрузки Windows NT. некоторые неточности могут быть связаны с моим плохим знанием английского, желающие могут посмотреть на оригинал по адресу: Inside the Boot Process, Part 1
Узнали как загружаются системы? В своей системе не будем слепо следовать какому либо из представленных здесь путей. Ради совместимости обеспечим формат ядра, аналогичный Linux. В этой системе все сделано достаточно понятно и просто. Ориентируемся на Linux.
А в следующей главе поговорим о распределении памяти в системе и начнем писать свой boot sector.
Глава #4
Начнаем писать свой загрузочный сектор (boot sector). Сразу скажу, что в этом исходнике опытные люди не увидят ничего особенного, может быть даже наоборот, кому-то покажется что все можно было сделать гораздо лучше, не спорю. Я не очень старался. Про законченность говорить пока рано, это все еще неоднократно будет меняться.
boot sector загружается в память по адресу 0:7c00h и имеет длину 512 байт. Это не слишком много, поэтому возможности boot sector’a ограничиваются загрузкой какого либо вторичного загрузчика.
Наш boot sector, по образу и подобию linux, будет загружать в память два блока. Первым является тот самый вторичный загрузчик, у нас он, как и в linux, называется setup. Вторым является собственно ядро.
Этот boot sector служит для загрузки ядра с дискет, поэтому, на первых порах, он жестко привязан к диску «a:».
BIOS предоставляет возможность читать по нескольку секторов сразу, но не более чем до границы дорожки. Такая возможность, конечно, ускоряет чтение с диска, но представляет собой большие сложности в программировании, так как надо учитывать границы сегментов (в реальном режиме сегмент может быть не больше, чем 64к) и границы дорожек, получается достаточно хитрый алгоритм.
Пойдем немного другим путем. Читаем с диска по секторам. Это, конечно, медленнее, но здесь скорость не очень критична. За то это гораздо проще и компактнее реализуется.
А теперь давайте разбираться, как это все работает.
Assembler | ||
|
Для начала описываем место и размер для каждого загружаемого блока.
Размеры пока произвольные, поскольку все остальное еще предстоит написать.
Assembler | ||
|
boot sector загружается и запускается по адресу 0:7c00h Содержимое регистров при старте таково:
- cs содержит 0
- ip содержит 7с00h
Прерывания запрещены! Про содержание остальных регистров пока ничего не известно. Остальные регистры инициализируем самостоятельно.
Assembler | ||
|
Стек у нас будет располагаться перед программой, до служебной области BIOS еще остается порядка 30 килобайт, для стека больше чем достаточно. Прерывания изначально запрещены, но я все равно сделаю это самостоятельно, на всякий случай. и разрешу после установки стека. Никаких проблем это вызвать, по-моему, не должно.
Так же, нулевым значением, инициализируем сегментный регистр ds.
Assembler | ||
|
Чтобы все было красиво и радовало глаз, на время чтения отключаем курсор. Иначе он будет мелькать на экране. Чтобы его потом восстановить сохраним его форму в стеке.
Assembler | ||
|
Загружаем первый блок (setup). Процедуру загрузки блока рассмотрим немного позже.
Assembler | ||
|
Загружаем второй блок (kernel). Здесь все аналогично первому блоку.
Assembler | ||
|
Восстанавливаем форму курсора.
Assembler | ||
|
На этом работа boot sector’а заканчивается. Дальним переходом передаем управление программе setup.
Далее располагаются функции.
Assembler | ||
|
Функция загрузки блока. Она же занимается выводом на экран процентного счетчика.
Assembler | ||
|
В этой функции ничего сложного нет. Обыкновенный цикл.
А вот следующая функция загружает с диска отдельный сектор, при этом оперируя его линейным адресом.
Есть так называемое int13 extension, разработанное совместно фирмами MicroSoft и Intel. Это расширение BIOS работает почти аналогичным образом, Считывая сектора по их линейным адресам, но оно поддерживается не всеми BIOS, имеет несколько разновидностей и работает в основном для жестких дисков. Поэтому нам не подходит.
В своей работе ориентируемся пока только на чтение с floppy диска, размером 1,4 мегабайта. Поэтому будем использовать функцию, которой в качестве параметров задается номер дорожки, головки и сектора.
Assembler | ||
|
Абсолютный номеp сектоpа вычисляется по фоpмуле:
AbsSectNo = (CylNo * SectPerTrack * Heads) + (HeadNo * SectPerTrack) + (SectNo — 1)
Значит обpатное спpаведливо:
CylNo = AbsSectNo / (SectPerTrack * Heads) HeadNo = остаток / SectorPerTrack SectNo = остаток + 1
Assembler | ||
|
Поделив номер сектора на количество секторов на дорожке, в остатке получаем номер сектора на дорожке. Это значение хранится в 6 младших битах регистра cl.
Assembler | ||
|
Номер диска храниться в dl и устанавливается в 0 (это диск a: )
Assembler | ||
|
Младший бит частного определяет для нас номер головки. (0 или 1)
Assembler | ||
|
Оставшиеся биты частного определяют номер цилиндра (или дорожки).
восемь младших бит номера хранятся в регистре ch, два старших бита номера хранятся в двух старших битах регистра cl.
Assembler | ||
|
В случае ошибки чтения не будем возвращать из функции какие-либо результаты, а повторяем чтение, пока оно не окажется успешным. В случае неуспешного чтения все равно ничего не будет работать! Для верности, в случае сбоя, производим сброс устройства.
Assembler | ||
|
Далее идет две интерфейсные функции, обеспечивающие вывод на экран строк и десятичных цифр. Ничего особенного они из себя не представляют а для вывода пользуются телетайпным прерыванием BIOS (ah = 0eh, int 10h), которое обеспечивает вывод одного символа с обработкой некоторых служебных кодов.
Assembler | ||
|
Эта функция ограничена выводом чисел до 99 включительно, случай с большим числом обрабатывается как переполнение и отображается как ‘##’.
Assembler | ||
|
Далее располагаются несколько служебных сообщений.
Assembler | ||
|
Эта комбинация заполняет оставшееся место в секторе нулями. А остается у нас еще около 200 байт.
Последние два байта называются «Partition table signature», что не совсем корректно. Фактически эта сигнатура говорит BIOS’у о том, что этот сектор является загрузочным.
Этот boot sector, помимо того, что читает по секторам, отличается от линуксового еще и размещением в памяти. После загрузки он не перемещает себя в памяти, и работает по тому же адресу, по которому его загрузил BIOS. Так же setup загружается непосредственно следом за boot sector’ом, с адреса 7e00h, что в принципе не помешает ему работать в других адресах, если будем загружать наше ядро через LILO, например.
Скомпилированную версию boot sector’а вы можете найти в файловом архиве (секция «наработки»).
В следующей главе переходим к программе setup и рассматриваем порядок перехода в защищенный режим.
Глава #5
Рассмотрим немного подробнее организацию памяти в защищенном режиме и поговорим о концепциях защиты.
История организации памяти.
Ранние модели процессоров от Intel имели 16 бит шины данных и 20 бит шины адреса. Это налагало определенные ограничения на адресацию памяти, ибо 16-бинтный регистр невозможно было использовать для адресации более чем 64 килобайт памяти. Чтобы обойти это препятствие разработчики предусмотрели сегментные регистры. Сегментный регистр хранит в себе старшие 16 бит адреса и для получения полного адреса к сегментному адресу прибавляется смещение в сегменте.
19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Сегмент | |||||||||||||||||||
Смещение | |||||||||||||||||||
Линейный адрес |
Таким образом, стало возможным адресовать до 1 мегабайта памяти. Это же позволило делать программы, не настолько привязанными к памяти и упростило адресацию. Сегменты могут начинаться с любого адреса, кратного 16 байтам, эти 16-байтные блоки памяти получили название параграфов. Но это и создает определенные неудобства. Первое неудобство состоит в том, что на один адрес памяти указывает 4096 различных комбинаций сегмент/смещение. Второе неудобство состоит в том, что нет возможности ограничить программам доступ к тем или иным частям памяти, что в некоторых случаях может быть существенно!
Введение защищенного режима решило эти проблемы, но ради совместимости любой из современных процессоров может работать в реальном или виртуальном режиме процессора i8086.
Защита.
Для обеспечения надежной работы операционных систем и прикладных программ разработчики процессоров предусмотрели в них механизмы защиты. В процессорах фирмы Intel предусмотрено четыре уровня привилегий для программ и данных. Нулевой уровень считается наиболее привилегированным, третий уровень — наименее.
Так же в защищенном режиме совсем иначе работает механизм преобразования адресов. в сегментном регистре теперь хранится не старшие биты адреса, а селектор. селектор представляет из себя индекс в таблице дескрипторов. И кроме этого содержит в себе несколько служебных бит. Формат селектора такой:
15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Index | TI | RPL |
Поле Index определяет индекс в дескрипторной таблице.
В процессорах Intel одновременно в системе может существовать две дескрипторных таблицы: Глобальная (Global descriptor table или GDT) и Локальная (Local descriptor table или LDT).
GDT существует в единственном экземпляре. Адрес и предел GDT хранятся в специальном системном регистре (GDTR) в 48 бит длиной (6 байт).
LDT может быть индивидуальная для каждой задачи, или общая для системы, или же ее вообще может не быть. Адрес и размер LDT определяется в GDT, для обращения к LDT в процессоре существует специальный регистр (LDTR), но в отличии от GDTR он имеет размер 16 бит и содержит в себе селектор из GDT.
Поле TI (Table indicator) селектора определяет принадлежность селектора GDT (0) или LDT (1).
Поле RPL (Requested privilege level) определяет запрашиваемые привилегии.
Дескрипторы сегментов
Дескрипторные таблицы состоят из записей по 64 бита (8 байт) в каждой. Формат дескриптора таков:
7 | 6 | 5 | 4 |
Базовый адрес 31-24 | Предел 19-16 | Права доступа | Базовый адрес 23-16 |
3 | 2 | 1 | 0 |
Базовый адрес 15-0 | Предел 15-0 |
Сразу бросается в глаза очень странная организация дескриптора, но это связано с совместимостью с процессором i286, формат дескриптора в котором был таков:
7 | 6 | 5 | 4 |
Зарезервировано | Права доступа | Базовый адрес 23-16 | |
3 | 2 | 1 | 0 |
Базовый адрес 15-0 | Предел 15-0 |
Что же содержится в дескрипторе:
Базовый адрес — 32 бита (24 бита для i286). Определяет линейный адрес памяти, с которого начинается сегмент. В отличие от реального режима этот адрес может быть указан с точностью до байта.
Предел — 20 бит (16 бит для i286). Определяет размер сегмента (максимальный адрес, по которому может быть произведено обращение, это справедливо не всегда но об этом чуть позже). 20-битное поле может показаться не очень то большим для 32-х битного процессора, но это не так. Оно не всегда показывает размер в байтах. Но и об этом чуть позже.
Байт прав доступа:
- Бит P (present) — Указывает на присутствие сегмента в памяти. обращение к отсутствующему сегменту вызывает особый случай не присутствия сегмента в памяти.
- Двух битное поле DPL определяет уровень привилегий сегмента. Про Уровни привилегий поговорим чуть позже.
- Бит S (Segment)- Будучи установленным в 1, определяет сегмент памяти, к которому может быть получен доступ на чтение (запись) или выполнение.
- Три бита Type — в зависимости от бита S определяет либо возможности чтения/записи, выполнения сегмента или определяет тип системных данных, хранимых в селекторе. Подробнее это выглядит так:
Если бит S установлен в 1, то поле Type делится на биты:2 1 0 1 — код Подчиненный сегмент кода Допустимо считывание 0 — данные Расширяется вниз Допустима запись Если сегмент расширяется вниз (это используется для стековых сегментов) то поле предела показывает адрес, выше которого допустима запись. ниже запись недопустима и вызовет нарушение пределов сегмента.
- Бит А (Accessed) устанавливается в 1, если к сегменту производилось обращение.
Если бит S установлен в 0, то в сегменте находится служебная информация определяемая полем Typе и битом A.
TYPE | A | Описание |
---|---|---|
000 | 1 | TSS для i286 |
001 | 0 | LDT |
001 | 1 | Занятый TSS для i286 |
010 | 0 | Шлюз вызова i286 |
010 | 1 | Шлюз задачи |
011 | 0 | Шлюз прерывания i286 |
011 | 1 | Шлюз исключения i286 |
100 | 1 | TSS для i386 |
101 | 1 | Занятый TSS i386 |
110 | 0 | Шлюз вызова i386 |
111 | 0 | Шлюз прерывания i386 |
111 | 1 | Шлюз ловушки i386 |
Остальные комбинации либо недопустимы, либо зарезервированы.
TSS — это сегмент состояния задачи (Task state segment) о них поговорим в следующей главе.
Шестой байт дескриптора, помимо старших бит предела, содержит в себе несколько битовых полей.
- Бит G (Granularity) — определяет размер элементов, в которых измеряется предел. если 0 — предел в байтах, если 1 — размер в страницах.
- Бит D (Default size) — размер операндов в сегменте. Если 0 — 16 бит. если 1 — 32 бита.
- Бит U (User) — доступен для пользователя (вернее для программиста операционной системы)
И снова защита.
Немного терминологии:
Уровень привилегий может быть от 0(высший) до 3(низший). Следовательно повышение уровня привилегий соответствует его уменьшению в численном эквиваленте, понижение — наоборот.
В дескрипторе содержатся биты DPL, которые определяют максимальный уровень привелегий для доступа к сегменту.
В селекторе содержится RPL — то есть запрашиваемый уровень привилегий.
RPL секущего кодового сегмента (хранится в регистре cs) является уровнем привилегий данного процесса и называется текущим уровнем привилегий (CPL)
Прямые обращения к сегментам возможны при соблюдении следующих условий:
- В случае если запрашиваемый уровень привилегий больше текущего, то запрашиваемый уровень понижается до текущего.
- При обращении к сегменту данных RPL селектора должен быть не ниже DPL сегмента.
- При обращении к сегменту кода возможно только при равенстве CPL, RPL и DPL.
- Если сегмент кода помечен как подчиненный, то для обращения к нему необходимо иметь уровень привилегий не ниже уровня сегмента. При этом выполнение сегмента происходит с текущим уровнем привилегий.
Косвенные вызовы возможны только через шлюзы при соблюдении следующих условий:
- DPL шлюза должен быть не выше, чем CPL сегмента, из которого производится вызов шлюза.
- DPL сегмента, на который указывает шлюз, должно быть не ниже чем DPL шлюза.
Эпилог.
Тема конечно очень сложна для понимания. В следующих главах концентрируем внимание на таких моментах. Интересующиеся люди могут почитать дополнительную, более подробную информацию по защите в литературе по микропроцессорам фирмы Intel.
Глава #6
В этой главе продолжим разговор о защищенном режиме процессора, узнаете, что такое шлюзы. А так же вкратце поговорим о виртуальном режиме процессора 8086.
В 4-ой главе, когда я расписывал вам, как писать boot sector, я допустил одну достаточно серьезную ошибку, которую признаю и благодарю Bug Maker’а, за то, что обратил на это мое внимание. В процедуре load_sector я, первым делом, делю номер сектора на количество секторов на дорожке. Для деления используя беззнаковую команду div, предварительно расширяя ax в dx:ax знаковой командой cwd. Правда если учесть что максимальное количество секторов на гибком диске не превышает 2880, то старший, знаковый, бит ax всегда нулевой. Но, тем не менее, ошибка потенциальная. Этот фрагмент кода стоит писать так:
Assembler | ||
|
Исправившись, я вообще убрал команду cwd, и теперь делю на байт cl. Все это, к тому же, сэкономило мне два байта.
Но это еще не все.. при написании boot sector’а я говорил, что это совсем не окончательная версия. Так оно и получается. Из бутсектора мы уберем код загрузки kernel. Этим будет заниматься программа setup. Следовательно, boot sector’у осанется только считать setup и запусить его… Даже если сделать более корректную обработку ошибок чтения, у нас остается около 250 байт на всякие развлечения…
А setup должен будет уметь достаточно многое. В него будет встроена поддержка файловой системы, поддержка выполняемых форматов файлов. Мы собираемся делать микроядро, и setup’у придется загружать помимо ядра еще несколько дополнительных программ, которые понадобятся нам для нормального старта системы.
Но об этом позже. А теперь продолжаем разбираться с защищенным режимом.
Шлюзы
В предыдущей главе, когда говорили о дескрипторах и дескрипторных таблицах ни словом не упомянули о дескрипторной таблице прерываний (Interrupt description table или IDT). Эта таблица так же состоит из дескрипторов, но в отличии от LDT и GDT в этой таблице могут размечаться только шлюзы. В защищенном режиме все прерывания происходят через IDT. Традиционная таблица векторов прерываний здесь не используется.
Формат дескрипторов шлюзов отличается от дескриптора сегмента.
Для начала рассмотрим шлюз вызова.
7 | 6 | 5 | 4 |
Смещение 31-16 | Права доступа | Количество слов стека | |
3 | 2 | 1 | 0 |
Селектор | Смещение 15-0 |
В поле прав доступа задается уровень привилегий, который должен быть ниже CPL текущего процесса, бит присутствия и соответствующий тип в остальных полях.
Селектор и смещение задают адрес вызываемой функции, при этом селектор должен присутствовать либо в GDT либо в активной LDT.
Параметр «Количество слов стека» служит для передачи аргументов в вызываемую функцию, при этом соответствующее количество слов копируется из стека текущего уровня привилегий в стек уровня привилегий вызываемой функции. Это поле использует только младшие 5 бит четвертого байта. Остальные биты должны быть нулевыми.
Обращаться к такому шлюзу, если дескриптор не расположен в IDT, можно только командой call far, при этом указываемое в команде смещение игнорируется. А селектор должен указывать на дескриптор шлюза вызова.
Шлюз прерывания и шлюз ловушки имеют одинаковый формат, отличаются между собой типами в байте прав доступа. В отличии от шлюза вызова эти шлюзы не содержат в себе Количества слов стека, поскольку прерывания бывают аппаратными и передача в них параметров через стек — бессмысленна. Эти шлюзы используются обычно только в IDT.
Шлюз задачи содержит в себе значительно меньше информации.
Во втором и третьем байте дескриптора записывается селектор TSS (Сегмента состояния задачи). Поле прав доступа заполняется аналогично другим шлюзам, но с соответствующим типом. Остальные поля дескриптора не используются.
При вызове такого шлюза происходит переключение контекста задачи. При этом вызывающая задача блокируется и не может быть вызвана до тех пор, пока вызванная задача не вернет ей управление командой iret.
Про правила доступа к шлюзам я говорил в прошлой главе, и в этот раз я закончу на этом. в следующей главе расскажу про прерывания более подробно.
Виртуальный режим процессора 8086.
Для возможности запуска из защищенного режима программ, предназначенных для реального, существует так называемый «Виртуальный режим процессора 8086». При этом полноценно работают механизмы преобразования адресов защищенного режима. А так же многозадачные системы, которые могут одновременно выполнять как защищенные задачи, так и виртуальные. При этом адресация в виртуальной задаче осуществляется традиционным для 8086 методом — сегмент/смещение.
Обращение к прерываниям осуществляется через IDT, но таблица прерываний реального режима может быть обработана из функций, шлюзы которых размещаются в IDT.
Обращение виртуальной задачи к портам так же может быть отслежено через прерывания защищенного режима. При обращении к запрещенным портам происходит исключение.
При желании может быть обеспечена абсолютно прозрачная работа нескольких виртуальных задач в одной мультизадачной среде. Но мы этой возможностью не будем пользоваться, и в своей работе будем рассчитывать на программы исключительно защищенного режима.
Глава #7
Исключения защищенного режима
Исключения или системные прерывания существовали еще в самых первых моделях процессоров от Intel. Вот их список:
0 | Division by zero (деление на ноль или переполнение при делении); |
1 | Single step (пошаговая отладка); |
3 | Breakpoint; |
4 | Overflow (срабатывает при команде into в случае установленного флага overflow в регистре flags); |
6 | Invalid opcode (i286+); |
7 | No math chip; |
Исключения располагаются в начале таблицы прерываний. В реальном режиме занимают 8 первых векторов прерываний.
Введение защищенного режима потребовало введения дополнительных исключений. В защищенном режиме первые 32 вектора прерываний зарезервированы для исключений. Не все они используются в существующих процессорах, в будующем возможно их будет больше. Системные прерывания в защищенном режиме делятся на три типа: нарушения (fault), ловушки (trap) и аварии (abort). Итак в защищенном режиме у нас существуют следующие исключения:
0 | Divide error | fault |
1 | Debug | fault/trap |
3 | Breakpoint | trap |
4 | Overflow | trap |
5 | Bounds check | fault |
6 | Invalid opcode | fault |
7 | Coprocessor not available | fault |
8 | Double fault | abort |
9 | Coprocessor segment overrun | fault |
10 | Invalid tss | fault |
11 | Segment not present | fault |
12 | Stack fault | fault |
13 | General protection fault | fault |
14 | Page fault | fault |
16 | Coprocessor error | fault |
17 | Alignument check | fault (i486+); |
18 | Hardware check | abort (Pentium+); |
19 | SIMD | fault (Pentium III+) |
.
Нарушения возникают вследствии несанкционированных или неправильных действий программы, предполагается, что ошибки можно исправить и продолжить выполнение программы с инструкции, которая вызвала ошибку.
Ловушки возникают после выполнения инструкции, но тоже подразумевают исправление ошибочной ситуации и дальнейшую работу программы.
Аварии возникают в случае критических нарушений, после этого программа уже не может быть перезапущена и должна быть закрыта.
Но иногда в случае ошибки или ловушки программа тем не менее не может продолжить свое выполнение. Это зависит от тяжести нарушения и от организации операционной системы, которая обрабатывает исключения. И если ошибка или ловушка не может быть исправлена, программу так же следует закрыть.
При возникновении исключения процессор иногда помещает в стек код ошибки, по которому обработчик исключения может проанализировать и, возможно, исправить возникшую ошибку.
Все исключения обрабатываются операционной системой. В случае микроядерных систем этим занимается микроядро.
Микроядерные системы
В первых главах уже касались этой темы, но тогда ограничились буквально несколькими словами. Теперь двигаемся именно в сторону микроядерности, значит стоит поподробнее рассказать, что это такое.
Принцип микроядерности заключается в том, что ядро практически не выполняет операций, связанных с обслуживанием внешних устройств. Эту функцию выполняют специальные программы-сервера. Ядро лишь предоставляет им возможность обращаться к устройствам. Помимо этого ядро обеспечивает многозадачность (параллельное выполнение программных потоков), межпроцессное взаимодействие и менеджмент памяти.
Приложения (как и сервера) у нас работают на третьем, непривилегированном кольце и не могут свободно обращаться к портам ввода/вывода или dma памяти. Тем более не могут сами устанавливать свои обработчики прерываний. Для использования ресурсов процессы обращаются к ядру с просьбой выделить необходимые ресурсы в их распоряжение. Осуществляется это следующим образом:
Для обеспечения доступа к портам ввода/вывода используются возможности процессоров, впервые появившиеся intel 80386. У каждой задачи (в сегменте состояния задачи (TSS)) существует карта доступности портов ввода/вывода. Приложение обращается к ядру с «просьбой» зарегистрировать для нее диапазон портов. Если эти порты до тех пор никем не были заняты, то ядро предоставляет их в распоряжение процесса, помечая их как доступные в карте доступности ввода/вывода этого процесса.
DMA память, опять таки после запроса у ядра, с помощью страничного преобразования подключается к адресному пространству процесса. Настройка каналов осуществляется ядром по «просьбе» процесса.
Доступ к аппаратным прерываниям (IRQ) осуществляется сложнее. Для этого процесс порождает в себе поток (thread), и сообщает ядру, что этот поток будет обрабатывать какое-то IRQ. При возникновении аппаратного прерывания, которое обрабатывает всетаки ядро, данный процесс выходит из состояния спячки, в котором он находился в ожидании прерывания, и ставится в очередь к менеджеру процессов. Такие потоки должны иметь более высокий приоритет, чем все остальные, дабы вызываться как можно скорее.
Но, как я говорил, ядро выполняет еще некоторые функции, немаловажная из которых — это межпроцессное взаимодействие. Оно представляет из себя возможность процессов обмениваться сообщениями между собой. В отличии от монолитных систем в микроядерных системах межпроцессное взаимодействие (Inter Process Communication или IPC) это едва ли не основное средство общения между процессами, и поскольку все драйвера у нас такие же процессы, микроядерное IPC должно быть очень быстрым. Быстродействие IPC достигается за счет передачи сообщений без промежуточного буферизирования в ядре. Либо непосредственным переписыванием процессу-получателю, либо с помощью маппинга страниц (если сообщения большого размера).
Менеджер памяти имеет как бы две стороны. Первая сторона — внутренняя, распределение памяти между приложениями, организация свопинга (который тоже осуществляется не ядром непосредственно, а специальной программой-сервером) никаким образом не интересует остальные программы. Но другая сторона — внешняя служит именно для них. Программы могут запросить у ядра во временное пользование некоторое количество памяти, которое ядро им обязательно предоставит (в разумных пределах… гигабайта два… не больше… . Или же программы могут запросить у ядра какой-то определенный участок памяти. Это бывает необходимо программам-серверам. И это требование ядром также вполне может быть удовлетворено при условии, что никакая другая программа до того не забронировала этот участок памяти для себя.
Глава #8
В этой главе поговорим о файловых системах.
Файловая система — это немаловажный момент в операционной системе, они бывают разные, различаются по производительности, надежности, по-разному экономно используют пространство.
Есть много файловых систем, которые нам, в принципе, подойдут (EXT2FS, FFS, NTFS, RaiserFS и много других), есть так же файловые системы, которые нам вообще не подойдут (FAT). В процессе развития нашей операционной системы мы создадим поддержку и для них, но для начала надо остановиться на чем-то одном. Этой одной файловой системой будет EXT2FS.
В этой главе подробно рассмотрим файловые системы FAT, и более подробно файловую систему Linux (ext2). Поскольку наша операционная система будет юниксоподобная, то файловые системы FAT нам никак не подходят, поскольку они не обеспечивают мер ограничения доступа, и по сути своей не являются многопользовательскими. Про остальные файловые системы я ограничусь лишь основными моментами.
Так же я не стану затрагивать тему разделов диска. Обсудим это в другой раз.
Основные принципы файловых систем
Все устройства блочного доступа (к которым относятся жесткие или гибкие диски, компакт диски) при чтении/записи информации оперируют секторами. Для жестких или гибких дисков размер сектора равен 512 байт, в компакт-дисках размер сектора равен 2048 байт. Сектора являются физической единицей информации для носителя.
Для файловых систем такое распределение часто бывает не очень удобно, и в них вводится понятие кластера. Кластеры часто бывают больше по размеру, чем сектора носителя. Кластеры являются логической единицей файловых систем. Правда, не всегда они называются кластерами. В ext2 кластеры называются просто блоками, но это не столь важно. Для организации кластеров файловые системы хранят таблицы кластеров. Таблицы кластеров, естественно, расходуют дисковое пространство. Помимо этого, дополнительное дисковое пространство расходуется под каталоги файлов. Эти неизбежные расходы в разных файловых системах имеют разную величину. Но об этом мы поговорим ниже.
Файловые системы на базе FAT (File Allocation Table)
Этот тип файловых систем разработала фирма Microsoft достаточно давно. Вместе с первыми DOS… С тех пор неоднократно натыкались на различные препятствия и дорабатывались в соответствии с требованиями времени.
Теперь пойдет небольшой экскурс в историю.
- В 1977 году Биллом Гейтсом и Марком МакДональдом была разработана первая файловая система FAT. Ради совместимости с CP/M в ней было ограничено имя файла. Максимальная длина имени составляла 8 символов, и 3 символа можно было использовать для расширения файла. Регистр букв не различался и не сохранялся. Размер кластера не превышал 4 килобайта. Размер диска не мог превышать 16 мегабайт.
- В 1981 году вышла первая версия MSDOS, которая базировалась на FAT.
- Начиная с MSDOS версии 3.0, в файловой системе появилось понятие каталога.
- Для поддержки разделов более 16 мегабайт размер элемента FAT был увеличен до 16 бит, (первая версия была 12-битная) а максимальный размер кластера увеличен до 32 килобайт. Это позволило создавать разделы до 2 гигабайт.
- В таком состоянии FAT просуществовал до появления VFAT, появившегося вместе с выходом Windows’95, в которой появилась поддержка длинных имен файлов. Теперь имя файлов могло иметь длину до 255 символов, но ради совместимости старый формат имен так же остался существовать.
- Немного позже FAT был еще расширен, размер элемента FAT стал 32 бита, при этом максимальный размер кластера вновь уменьшился до 4 килобайт, но это позволило создавать разделы до 2 терабайт. Кроме того, была расширена информация о файлах. Теперь она позволяли хранить помимо времени создания файла время модификации и время последнего обращения к файлу.
Ну а теперь подробнее рассмотрим структуру этой файловой системы.
Общий формат файловой системы на базе FAT таков:
- Boot sector (в нем так же содержится «Блок параметров FS»)
- Reserved Sectors (могут отсутствовать)
- FAT (Таблица размещения файлов)
- FAT (вторая копия таблицы размещения файлов, может отсутствовать)
- Root directory (корневая директория)
- Область файлов. (Кластеры файловой системы)
Boot sector имеет размер 512 байт, как мы уже знаем, может содержать в себе загрузчик системы, но помимо этого для FAT он содержит Блок параметров. Блок параметров размещается в boot sector’е по смещению 0x0b и содержит в себе следующую информацию:
C | ||
|
Размер кластера можно вычислить, умножив Sector_Size на Sectors_Per_Cluster.
Общий размер диска определяется следующим образом: Если значение Total_Sectors равно 0, то раздел более 32 мегабайт и его длина в секторах храниться в Big_Total_Sectors. Иначе размер раздела показан в Total_Sectors.
Таблица FAT начинается с сектора, номер которого храниться в Reserved_Sectors и имеет длину Sectors_Per_FAT; при 16-битном FAT размер таблицы может составлять до 132 килобайт (или 256 секторов) (в FAT12 до 12 килобайт).
Вторая копия FAT служит для надежности системы… но может отсутствовать.
После таблицы FAT следует корневая директория диска. Размер этой директории ограничен Root_Entries записями. Формат записи в директории таков:
C | ||
|
Размер записи — 32 байта, следовательно, общий размер корневой директории можно вычислить, умножив Root_Entries на 32.
Далее на диске следуют кластеры файловой системы. Из записи в директории берется первый номер кластера, с него начинается файл. В FAT под этим номером может содержаться либо код последнего кластера (0xffff или 0xfff для FAT12) либо номер кластера, следующего за этим.
При записи файла из FAT выбираются свободные кластеры по порядку от начала. В результате возникает фрагментация файловой системы, и существенно замедляется ее работа. Но это уже выходит за тему рассылки.
Все выше сказанное про FAT справедливо для FAT12 и FAT16. FAT32 более существенно отличается, но общие принципы организации для нее примерно такие же. VFAT ничем не отличается от FAT16, для хранения длинных имен там используется однеа запись в директории для хранения короткого имени файла и несколько записей для хранения длинного. Длинное имя храниться в unicode, и на запись в директории приходится 13 символов длинного имени, причем они разбросаны по некоторым полям записи, остальные поля заполняются с таким расчетом, чтобы старые программы не реагировали на такую запись.
С первого взгляда видна не высокая производительность таких файловых систем. Не буду поливать грязью Microsoft, у них и без меня достаточно проблем… К тому же и у них есть другие разработки, которые не столь плохи. Но о них мы поговорим ниже… А сейчас давайте посмотрим на ext2fs. Правда, эта файловая система несколько другого уровня, и сравнивать ее с FAT — нельзя. Но обо всем по порядку.
Ext2fs (Расширенная файловая система версия 2)
Linux разрабатывался на операционной системе Minix. В ней была (да и есть) файловая система minixfs. Система не очень гибкая и достаточно ограниченная. После появления Linux была разработана (на базе minixfs) файловая система extfs, которую в скором времени заменила ext2fs, которая и используется в большинстве Linux, по сей день.
Для начала давайте рассмотрим основное устройство этой файловой системы:
- Boot sector (1 сектор)
- Свободно (1 сектор, может быть использован для расширения Boot sector’а до килобайта)
- Super block (2 сектора или 1024 байта длиной)
- Group descriptors (2 сектора максимум)
- Group 1
- Group 2
- … и так далее… до Group 32 если необходимо
.Если ext2fs находится на каком ни будь разделе жесткого диска, или является не загрузочной, то boot sector’а там может вообще не быть.
Super block содержит в себе информацию о файловой системе и имеет следующий формат:
C | ||
|
Не буду описывать значение всех полей этой структуры, ограничусь основными. Размер блока файловой системы можно вычислить так: 1024 * s_log_block_size. Размер блока может быть 1, 2 или 4 килобайта размером.
Об остальных полях чуть попозже.
А теперь рассмотрим группы дескрипторов файловой системы.
Формат дескриптора группы таков:
C | ||
|
Содержимое группы таково:
- Block bitmap (Битовая карта занятости блоков)
- Inode bitmap (Битовая карта занятости inode)
- Inode table (Таблица inode)
- Available blocks (блоки, доступные для размещения файлов)
Блоки в файловой системе отсчитываются с начала раздела. В дескрипторе группы содержаться номер блока с битовой картой блоков группы, номер блока с битовой картой инодов, и номер блока с которого начинается таблица inode. Про inode мы поговорим чуть попозже, а сперва разберемся с битовыми картами.
В суперблоке храниться количество блоков в группе (s_blocks_per_group). Битовая карта имеет соответствующий размер в битах (занимает она не более блока). и в зависимости от размера блока может содержать информацию об использовании 8, 32 или 132 мегабайт максимум. Дисковое пространство раздела разбивается на группы в соответствии с этими значениями. А групп, как я уже упоминал, может быть до 32… что позволяет создавать разделы, в зависимости от размера блока, 256, 1024 или 4096 мегабайт соответственно.
В битовую карту блоков группы входят так же те блоки, которые используются под саму карту, под карту inode и под таблицу inode. Они сразу помечаются как занятые.
Теперь давайте разберемся, что такое inode. В отличии от FAT информация о файле здесь храниться не в директории, а в специальной структуре, которая носит название inode (информационный узел). В записи директории содержится только адрес inode и имя файла. При этом на один inode могут ссылаться несколько записей директории. Это называется hard link.
Формат inode таков:
C | ||
|
Как видно из приведенной выше структуры в inode содержится следующая информация:
- Тип и права доступа файла (i_mode)
- идентификатор хозяина файла (i_uid)
- Размер (i_size)
- Время доступа, создания, модификации и удаления файла (после удаления inode не удаляется, а просто перестает занимать блоки файловой системы)
- Идентификатор группы
- Количество записей в директориях, указывающих на этот inode…
- Количество занимаемых блоков fs
- дополнительные флаги ext2fs
- таблица занимаемых блоков
- Ну и другая, не столь существенная в данных момент информация.
Остановимся поподробнее на таблице занимаемых блоков. Как видите там всего 14 записей. Но 14 блоков — это мало для одного файла. Дело в том, что не все записи содержат номера блоков. 13-я запись содержит косвенный блок, то есть блок, в котором содержится таблица блоков. А 14-я запись содержит номер блока в котором содержится таблица номеров блоков, в которых содержаться таблицы блоков занимаемых файлом… так что размер файла практически ничто не ограничивает.
Первые 10 inode зарезервированы для специфического использования.
Для корневой директории в этой файловой системе не отводится заранее отведенного места. Любая, в том числе и корневая директория в этой файловой системе является по сути своей обыкновенным файлом. Но для облегчения поиска корневой директории для нее зарезервирован inode номер 2.
В этой файловой системе в отличие от FAT существуют методы защиты файлов, которые обеспечиваются указанием идентификаторов пользователя и группы, а так же правами доступа, которые указываются в inode в поле i_mode.
За счет нескольких групп блоков уменьшается перемещение головки носителя при обращении к файлам, что увеличивает скорость обращения и уменьшает износ носителя. Да и сама файловая система организована так, что для чтения файлов не требуется загрузка больших объемов служебной информации, Что тоже не может не сказаться на производительности.
Примерно так же устроены файловые системы FFS, HPFS, NTFS. Но в их устройство я не буду вдаваться. И так уже глава очень большой получается.
Но в недавнее время появился еще один тип файловых систем. Эти системы унаследовали некоторые черты от баз данных и получили общее название «Журналируемые файловые системы». Особенность их заключается в том что все действия, производимые в файловой системе фиксируются в журнале, который правда съедает некоторый объем диска, но это позволяет значительно повысит надежность систем. В случае сбоя проверяется состояние файловой системы и сверяется с записями в журнале. В случае обнаружения несоответствий довести операцию до конца не составляет проблем, и отпадает необходимость в ремонте файловой системы. К таким файловым системам относятся ext3fs, RaiserFS и еще некоторые.
Глава #9
Эта глава посвящена чтению файлов с файловой системы ext2fs. Эту систему мы, скорее всего, возьмем за базовую для начала. FAT для наших целей мало подходит. Тот boot sector, который публиковался до этого — можно забыть, от него уже почти ничего не осталось. Связано это с тем, что мы отошли от linux, наша система будет совсем другой. Я, конечно, предвижу трудности связанные с переносом программного обеспечения. Возможно продумаем возможность эмуляции существующих операционных систем. Время покажет.
Так же прошу меня простить, что мы занимаемся тут всякой ерундой с бутсекторами и файловыми системами, но до сих пор так и не начали писать собственно ядро. Задача эта не столь, тривиальна и нужно многое продумать, чтобы не было потом горько и обидно за бесцельно написанный код.
Чтение ext2fs
В предыдущей главе описывалась структура этой файловой системы. В файловой системе присутствует Super Block и дескрипторы групп. Эта информация хранится в начале раздела. Super Block во 2-м килобайте, дескрипторы групп — в третьем.
Первый килобайт для нужд файловой системы не используется и может быть целиком использован для boot sector’а (правда он уже будет не сектор, а килобайт . Но для этого следует подгрузить второй сектор boot’а.
А для инициализации файловой системы нам нужно загрузить super block и дескрипторы групп, они же понадобятся нам для работы с файловой системой.
Это все можно загрузить одновременно, как мы и сделаем.
Assembler | ||
|
Для этого мы используем уже знакомую процедуру загрузки блока, но эта процедура станет значительно короче, потому что никаких процентов мы больше не будем выводить.
В es засылается адрес, следующий за загруженным загрузочным сектором (Загружается он, как мы помним, по адресу 7c00h, и имеет длину 200h байт, следовательно свободная память начинается с адреса 7e00h, а сегмент для этого адреса равен 7e0h). В ax засылается номер сектора с которого начинается блок (в нашем случае это первый сектор, загрузочный сектор является нулевым). в cx засылается длина загружаемых данных в секторах (1 — дополнительная часть boot sector’а, 2 — Super Block ext2, 2 — дескрипторы групп. Всего 5 секторов).
Теперь вызовем процедуру инициализации файловой системы. Эта процедура достаточно проста, и проверяет только соответствие magic номера файловой системы и вычисляет размеры блока для работы.
Assembler | ||
|
В случае несоответствия magic номера происходит вывод сообщения об ошибке и выход из подпрограммы. Чтобы сигнализировать об ошибке используется бит C регистра flags.
Assembler | ||
|
Все эти значения нам понадобятся для работы. А теперь рассмотрим процедуру загрузки одного блока файловой системы.
Assembler | ||
|
При входе в эту процедуру ax содержит номер блока (блоки нумеруются с нуля), es содержит адрес памяти для загрузки содержимого блока.
Номер блока нам надо преобразовать в номер сектора, для этого мы умножаем его на длину блока в секторах. А в cx у нас уже записана длина блока в секторах, то есть все готово для вызова процедуры load_block.
После считывания блока мы модифицируем регистр es, чтобы последующие блоки грузить следом за этим… в принципе модифицирование указателя можно перенести в другое место, в процедуру загрузки файла, это будет наверное даже проще и компактнее, но сразу я об этом не подумал.
Но пошли дальше… основной структурой описывающей файл в ext2fs является inode. Inode хранятся в таблицах, по одной таблице на каждую группу. Количество inode в группе зафиксировано в супер блоке. Итак, процедура загрузки inode:
Assembler | ||
|
Поделив номер inode на количество inode в группе, в ax мы получаем номер группы, в которой находится inode, в dx получаем номер inode в группе.
Assembler | ||
|
ax умножаем на размер записи о группе (делается это сдвигом, но, по сути, то же самое умножение) и получаем смещение группы в таблице дескрипторов групп. gd — базовый адрес таблицы групп. Последняя операция извлекает из дескриптора группы адрес таблицы inode этой группы (адрес задается в блоках файловой системы) который у нас пока будет храниться в bx.
Assembler | ||
|
Теперь разберемся с inode. Определим его смещение в таблице inode группы.
Assembler | ||
|
Поделив это значение на размер блока мы получим номер блока относительно начала таблицы inode (ax), и смещение inode в блоке (dx). К номеру блока (bx) прибавим блок, в котором находится inode.
Assembler | ||
|
Загрузим этот блок в память.
Assembler | ||
|
Восстановим содержимое сегментного регистра es и перепишем inode из блока в отведенное для него место.
Assembler | ||
|
Inode загружен. Теперь по нему можно загружать файл. Здесь все не столь однозначно. Процедура загрузки файла состоит из нескольких модулей. Потому что помимо прямых ссылок inode может содержать косвенные ссылки на блоки. В принципе можно ограничить возможности считывающей подпрограммы необходимым минимумом, полная поддержка обеспечивает загрузку файлов до 4 гигабайт размером. Естественно в реальном режиме мы такими файлами оперировать не сможем, да это и не нужно. Но сейчас мы рассмотрим полную поддержку:
Assembler | ||
|
В inode хранятся прямые ссылки на 12 блоков файловой системы. Такие блоки мы загружаем с помощью процедуры dir_blocks (она будет описана ниже). Данный этап может загрузить максимум 12/24/48 килобайт файла (в зависимости от размера блока fs 1/2/4 килобайта). После окончания работы процедуры проверяем, все ли содержимое файла уже загружено или еще нет. Если нет, то загрузка продолжается по косвенной таблице блоков. Косвенная таблица — это отдельный блок в файловой системе, который содержит в себе таблицу блоков.
Assembler | ||
|
В inode только одна косвенная таблица первого уровня (cx=1). Для загрузки блоков из такой таблицы мы используем процедуру idir_blocks. Это позволяет нам, в зависимости от размера блока загрузить 268/1048/4144 килобайта файла. Если файл еще не загружен до конца, то используется косвенная таблица второго уровня.
Assembler | ||
|
В inode также только одна косвенная таблица второго уровня (cx=1). Для загрузки блоков из такой таблицы мы используем процедуру ddir_blocks. Это позволяет нам, в зависимости от размера блока загрузить 64.2/513/4100 мегабайт файла. Если файл опять не загружен до конца (где же столько памяти взять??), то используется косвенная таблица третьего уровня. Ради этого мы уже не будем вызывать подпрограмм, а обработаем ее в этой процедуре.
Assembler | ||
|
В inode и эта таблица присутствует только в одном экземпляре (куда же больше?). Это, крайняя возможность, позволяет нам, в зависимости от размера блока, загрузить 16/256.5/4100 гигабайт файла. Что уже является пределом даже для размера файловой системы (4 терабайта).
Assembler | ||
|
Конечно, такие крайности нам при старте будут не к чему, с учетом, что мы находимся в реальном режиме и не можем адресовать больше ~600к памяти.
Кратко рассмотрю вспомогательные функции:
Assembler | ||
|
Эта функция загружает прямые блоки. Ради простоты я пока не обрабатывал блоки номер которых превышает 16 бит. Это создает ограничение на размер файловой системы в 65 мегабайт, а реально еще меньше, поскольку load_block у нас тоже не оперирует с секторами, номер которых больше 16 бит, ограничение по размеру уменьшается до 32 мегабайт. В дальнейшем эти ограничения мы конечно обойдем, а пока достаточно.
В этой функции стоит проверка количества загруженных блоков, для того чтобы вовремя выйти из процедуры считывания.
Assembler | ||
|
Эта функция обращается в свою очередь к функции dir_blocks, предварительно загрузив в память содержимое косвенного блока. так же имеет контроль длины файла.
Функция ddir_blocks в точности аналогична этой, только для считывания вызывает не dir_blocks, а idir_blocks, поскольку адреса блоков в ней дважды косвенны.
Но мы еще не рассмотрели самого главного. Процедуры, которая по пути файла может загрузить его с диска. Начнем.
Assembler | ||
|
Если путь файла не начинается со слэш, то это в данном случае является ошибкой. Мы не оперируем понятием текущий каталог!
Assembler | ||
|
Загружаем корневой inode — он имеет номер 2.
Assembler | ||
|
Уберем лидирующий слэш… или несколько слэшей, такое не является ошибкой.
Assembler | ||
|
Загрузим содержимое файла. Директории, в том числе и корневая, являются такими же файлами, как и все остальные, только содержат в себе записи о находящихся в директории файлах.
Assembler | ||
|
По inode установим тип файла.
Если файл не регулярный, то это может быть директорией. Это проконтролируем ниже.
Assembler | ||
|
Если это файл, который нам надлежит скачать — то в [si] будет содержаться 0, означающий что мы обработали весь путь.
Assembler | ||
|
А поскольку содержимое файла уже загружено, то можем со спокойной совестью вернуть управление. Битом C сообщив, что все закончилось хорошо.
Assembler | ||
|
Если этот inode не является директорией, то это или не поддерживаемый тип файла или ошибка в пути.
Assembler | ||
|
Если то, что мы загрузили, является директорией, то со смещения 0 (bx) в этом файле содержится список записей о файлах. Нам нужно выбрать среди них нужную. В dx сохраним длину файла, по ней будем определять коней директории.
Assembler | ||
|
Сравниваем имена из директории с именем, на которое указывает si. Если не совпадает — перейдем на следующую запись (чуть ниже)
Assembler | ||
|
Если совпал, то в пути после имени должно содержаться либо ‘/’ либо 0 — символ конца строки. Если это не так, значит это не подходящий файл.
Assembler | ||
|
Загружаем очередной inode.
Assembler | ||
|
И переходим к его обработке. Это продолжается до тех пор, пока не пройдем весь путь.
Assembler | ||
|
Если путь не совпадает, и если в директории еще есть записи — продолжаем проверку.
Assembler | ||
|
Иначе выводим сообщение об ошибке
Assembler | ||
|
И прекращаем работу.
Вот и весь алгоритм. Не смотря на большой размер этого повествования, код занимает всего около 450 байт. А если убрать параноидальные функции, то и того меньше. Не стоит пытаться откомпилировать этот код, все эти модули вы сможете найти на нашем сайте, ссылка на который приведена ниже. Здесь я все это привел для того чтобы объяснить как и что. Надеюсь у меня это получается хоть как-то. Если кто-то что-то не понимает — пишите мне, мой адрес вы всегда можете найти чуть ниже.
В следующей главе рассмотрим форматы выполняемых файлов, используемые в unix. Это нам тоже потребуется на этапе загрузки.
В этой главе речь пойдет о форматах выполняемых файлов. Будут рассмотрены два формата: ELF и PE, и немного коснемся распределения памяти.
Формат ELF
В данном обзоре мы будем говорить только о 32-х битной версии этого формата, ибо 64-х битная нам пока ни к чему.
Любой файл формата ELF (в том числе и объектные модули этого формата) состоит из следующих частей:
- Заголовок ELF файла;
- Таблица программных секций (в объектных модулях может отсутствовать);
- Секции ELF файла;
- Таблица секций (в выполняемом модуле может отсутствовать);
Ради производительности в формате ELF не используются битовые поля. И все структуры обычно выравниваются на 4 байта.
Теперь рассмотрим типы, используемые в заголовках ELF файлов:
Тип | Размер | Выравнивание | Комментарий |
Elf32_Addr | 4 | 4 | Адрес |
Elf32_Half | 2 | 2 | Беззнаковое короткое целое |
Elf32_Off | 4 | 4 | Смещение |
Elf32_SWord | 4 | 4 | Знаковое целое |
Elf32_Word | 4 | 4 | Беззнаковое целое |
unsigned char | 1 | 1 | Безнаковое байтовое целое |
Теперь рассмотрим заголовок файла:
C | ||
|
Массив e_ident содержит в себе информацию о системе и состоит из нескольких подполей.
C | ||
|
- ei_magic — постоянное значение для всех ELF файлов, равное { 0x7f, ‘E’, ‘L’, ‘F’}
- ei_class — класс ELF файла (1 — 32 бита, 2 — 64 бита который мы не рассматриваем)
- ei_data — определяет порядок следования байт для данного файла (этот порядок зависит от платформы и может быть прямым (LSB или 1) или обратным (MSB или 2)) Для процессоров Intel допустимо только значение 1.
- ei_version — достаточно бесполезное поле, и если не равно 1 (EV_CURRENT) то файл считается некорректным.
- В поле ei_pad операционные системы хранят свою идентификационную информацию. Это поле может быть пустым. Для нас оно тоже не важно.
- Поле заголовка e_type может содержать несколько значений, для выполняемых файлов оно должно быть ET_EXEC равное 2
- e_machine — определяет процессор на котором может работать данный выполняемый файл (Для нас допустимо значение EM_386 равное 3)
- Поле e_version соответствует полю ei_version из заголовка.
- Поле e_entry определяет стартовый адрес программы, который перед стартом программы размещается в eip.
- Поле e_phoff определяет смещение от начала файла, по которому располагается таблица программных секций, используемая для загрузки программ в память.
Не буду перечислять назначение всех полей, не все нужны для загрузки. Лишь еще два опишу.
- Поле e_phentsize определяет размер записи в таблице программных секций.
- И поле e_phnum определяет количество записей в таблице программных секций.
Таблица секций (не программных) используется для линковки программ. мы ее рассматривать не будем. Так же мы не будем рассматривать динамически линкуемые модули. Тема эта достаточно сложная, для первого знакомства не подходящая.
Теперь про программные секции. Формат записи таблицы программных секций таков:
C | ||
|
Подробнее о полях.
- p_type — определяет тип программной секции. Может принимать несколько значений, но нас интересует только одно. PT_LOAD (1). Если секция именно этого типа, то она предназначена для загрузки в память.
- p_offset — определяет смещение в файле, с которого начинается данная секция.
- p_vaddr — определяет виртуальный адрес, по которому эта секция должна быть загружена в память.
- p_paddr — определяет физический адрес, по которому необходимо загружать данную секцию. Это поле не обязательно должно использоваться и имеет смысл лишь для некоторых платформ.
- p_filesz — определяет размер секции в файле.
- p_memsz — определяет размер секции в памяти. Это значение может быть больше предыдущего.
- Поле p_flag определяет тип доступа к секциям в памяти. Некоторые секции допускается выполнять, некоторые записывать. Для чтения в существующих системах доступны все.
Загрузка формата ELF
С заголовком мы немного разобрались. Теперь я приведу алгоритм загрузки бинарного файла формата ELF. Алгоритм схематический, не стоит рассматривать его как работающую программу.
int LoadELF (unsigned char *bin)
C | ||
|
По серьезному стоит еще проанализировать поля EPH->p_flags, и расставить на соответствующие страницы права доступа, да и просто копирование здесь не подойдет, но это уже не относится к формату, а к распределению памяти. Поэтому сейчас об этом не будем говорить.
Формат PE
Во многом он аналогичен формату ELF, ну и не удивительно, там так же должны быть секции, доступные для загрузки.
Как и все в Microsoft формат PE базируется на формате EXE. Структура файла такова:
- 00h — EXE заголовок (не буду его рассматривать, он стар как Дос.
- 20h — OEM заголовок (ничего существенного в нем нет);
- 3сh — смещение реального PE заголовка в файле (dword).
- таблица перемещения stub;
- stub;
- PE заголовок;
- таблица объектов;
- объекты файла;
stub — это программа, выполняющаяся в реальном режиме и производящая какие-либо предварительные действия. Может и отсутствовать, но иногда может быть нужна.
Нас интересует немного другое, заголовок PE.
Структура его такая:
C | ||
|
Много всякого там находится. Достаточно сказать, что размер этого заголовка — 248 байт.
И главное что большинство из этих полей не используется. (Кто так строит?) Нет, они, конечно, имеют назначение, вполне известное, но моя тестовая программа, например, в полях pe_code_base, pe_code_size и тд содержит нули но при этом прекрасно работает. Напрашивается вывод, что загрузка файла осуществляется на основе таблицы объектов. Вот о ней то мы и поговорим.
Таблица объектов следует непосредственно после PE заголовка. Записи в этой таблице имеют следующий формат:
C | ||
|
- o_name — имя секции, для загрузки абсолютно безразлично;
- o_vsize — размер секции в памяти;
- o_vaddr — адрес в памяти относительно ImageBase;
- o_psize — размер секции в файле;
- o_poff — смещение секции в файле;
- o_flags — флаги секции;
Вот на флагах стоит остановиться поподробнее.
00000004h | используется для кода с 16 битными смещениями |
00000020h | секция кода |
00000040h | секция инициализированных данных |
00000080h | секция неинициализированных данных |
00000200h | комментарии или любой другой тип информации |
00000400h | оверлейная секция |
00000800h | не будет являться частью образа программы |
00001000h | общие данные |
00500000h | выравнивание по умолчанию, если не указано иное |
02000000h | может быть выгружен из памяти |
04000000h | не кэшируется |
08000000h | не подвергается страничному преобразованию |
10000000h | разделяемый |
20000000h | выполнимый |
40000000h | можно читать |
80000000h | можно писать |
Опять таки не буду с разделяемыми и оверлейными секциями, нас интересуют код, данные и права доступа.
В общем, этой информации уже достаточно для загрузки бинарного файла.
Загрузка формата PE
C | ||
|
Это опять таки не готовая программа, а алгоритм загрузки.
И опять таки многие моменты не освещаются, так как выходят за пределы темы.
Но теперь стоит немного поговорить про существующие системные особенности.
Системные особенности
Не смотря на гибкость средств защиты, имеющихся в процессорах (защита на уровне таблиц дескрипторов, защита на уровне сегментов, защита на уровне страниц) в существующих системах (как в Windows, так и в Unix) полноценено используется только страничная защита, которая хотя и может уберечь код от записи, но не может уберечь данные от выполнения. (Может быть, с этим и связано изобилие уязвимостей систем?)
Все сегменты адресуются с нулевого линейного адреса и простираются до конца линейной памяти. Разграничение процессов производится только на уровне страничных таблиц.
В связи с этим все модули линкуются не с начальных адресов, а с достаточно большим смещением в сегменте. В Windows используется базовый адрес в сегменте — 0x400000, в юникс (Linux или FreeBSD) — 0x8048000.
Некоторые особенности так же связаны со страничной организацией памяти.
ELF файлы линкуются таким образом, что границы и размеры секций приходятся на 4-х килобайтные блоки файла.
А в PE формате, не смотря на то, что сам формат позволяет выравнивать секции на 512 байт, используется выравнивание секций на 4к, меньшее выравнивание в Windows не считается корректным.
продолжение
4
В предыдущей заметке мы успешно перешли в защищенный режим процессора Intel x86. Прежде чем нам двинуться дальше в изучении защищенного режима, нам надо решить одну проблему. Загрузочный сектор, который загружается в оперативную память при старте компьютера и в котором находится наша программа, имеет размер всего 512 байт. Скоро нам перестанет хватать этого размера. Поэтому мы должны научиться загружать в оперативную память секторы с дискеты, на которой находится наша программа. Программа, которая умеет это делать, называется bootloader. Вот его мы и напишем. Работать будем в реальном режиме, чтобы пользоваться сервисами BIOS для работы с дисками.
Процедурное прогарммирование
Исходный код загрузчика обещал быть довольно большим, поэтому, чтобы не запутаться, я написал его в стиле процедурного программирования. Подробный рассказ о функциях и о том, как они пишутся на языке ассемблера, вы найдете в книге Kip Irvine — Assembly Language for x86 Processors, 7th edition — 2014 Глава 5 — Procedures.
Структура программы
Задача представленной ниже программы — загрузить 2-й сектор с дискеты в оперативную память и передать управление находящемуся там (во 2-ом секторе) машинному коду, который всего лишь выводит на экран текстовое сообщение, чтобы показать, что 2-й сектор действительно загружен. В BIOS есть функции, которые умеют загружать информацию с диска в оперативную память, другое дело, что вызвать эти функции не так просто, как хотелось бы, в силу их заковыристого интерфейса. Поэтому я написал несколько функций-оберток над нужными мне сервисами BIOS и пометил их в отдельный заголовочный файл bios_services.inc. Функции, которые я поместил в заголовочный файл, позволяют загрузить с любого диска любые сектора в оперативную память по любому адресу (в пределах адресного пространства реального режима, разумеется). Причем функции эти принимают свои параметры привычным для меня образом — через стек (а не через регистры, как большинство функций BIOS). В основном же файле программы boot_sector.asm я директивой include подключаю файл bios_services.inc и просто вызываю из него одну из функций, которая загружает с дискеты 2-й сектор. Ниже показано содержимое файлов boot_sector.asm и bios_services.inc.
Исходный код загрузчика
; boot_sector.asm
; Real mode bootloader
; ============================================== CODE ==========================================================
use16 ; generate 16-bit code
org 7C00h ; the code starts at 0x7C00 memory address
start:
jmp far dword 0x0000:entr ; makes CS=0, IP=entr
entr:
xor ax, ax ; ax = 0
mov ds, ax ; setup data segment ds=ax=0
cli ; when we set up stack we need disable interrupts because stack is involved in interrupts handling
mov ss, ax ; setup stack segment ss=ax=0
mov sp, 0x7C00 ; stack will grow starting from 0x7C00 memory address
sti ; enable interrupts
mov byte [disk_id], dl ; save disk identifier (it appears in dl after the start of PC)
; Print message
push message
call BIOS_PrintString
; Reset disk subsystem
push [disk_id]
call BIOS_ResetDiskSubsystem
push [disk_id] ; push disk identifier
push dword 1 ; push LBA of the 1st sector to load
push 1 ; push number of sectors to load
push second_sector ; push memory address at which sectors are to be loaded
call BIOS_LoadSectorsSmart
jmp second_sector
include ‘bios_services.inc’
; =============================================== DATA =========================================================
disk_id dw 0x0000 ; disk identifier (0…3 — floppy disks; greater or equal to 0x80 — other disk types)
message db ‘Starting bootloader…’, 0x0D, 0x0A, 0
finish:
; The size of a disk sector is 512 bytes. Boot sector signature occupies the two last bytes.
; The gap between the end of the source code and the boot sector signature is filled with zeroes.
times 510—finish+start db 0
db 55h, 0AAh ; boot sector signature
; =============================================== 2nd SECTOR ===================================================
second_sector:
; Print message from the 2nd sector
push message_sector2
call BIOS_PrintString
cli ; disable maskable interrupts
hlt ; halt the processor
message_sector2 db ‘The second sector has been loaded successfully!’, 0x0D, 0x0A, 0
; bios_services.inc
use16 ; generate 16-bit code
; <—- BIOS_ClearScreen ———-
BIOS_ClearScreen:
mov ax, 0x0003 ; ah=0 means clear screen and setup video mode, al=3 means text mode, 80×25 screen, CGA/EGA adapter, etc.
int 0x10 ; call BIOS standard video service function
ret
; ———————————>
; <—- BIOS_PrintString ———-
; Prints out a string. The string must be zero-terminated.
; Parameters: word [bp + 4] — address of the string
BIOS_PrintString:
push bp
mov bp, sp
; save registers in stack
push ax
push bx
push si
mov si, [bp + 4] ; si = string address
cld ; clear direction flag (DF is a flag used for string operations)
mov ah, 0x0E ; (int 0x10) BIOS function index (write a charachter to the active video page)
mov bh, 0x00 ; (int 0x10) video page number
.puts_loop:
lodsb ; load to al the next charachter located at [si] address in memory (si is incremented automatically because the direction flag DF = 0)
test al, al ; zero in al means the end of the string
jz .puts_loop_exit
int 0x10 ; call BIOS standard video service
jmp .puts_loop
.puts_loop_exit:
; restore registers from stack
pop si
pop bx
pop ax
mov sp, bp
pop bp
ret 2
; ———————————>
; <—- BIOS_ResetDiskSubsystem —
; Resets disk subsystem.
; Parameters: word [bp + 4] — disk identifier
BIOS_ResetDiskSubsystem:
push bp
mov bp, sp
push dx ; save dx in stack
mov dl, [bp + 4]
.reset:
mov ah, 0 ; (int 0x13) Device reset. Causes controller recalibration.
int 0x13 ; call disk input/output service
jc .reset ; if CF=1 an error has happened and we try again.
pop dx ; restore dx
mov sp, bp
pop bp
ret 2
; ———————————>
; <—- BIOS_GetDiskParameters —-
; Determines parameters of a specified disk.
; Parameters: word [bp + 4] — disk identifier
; Returns: dl — overall number of disks in the system
; dh — maximum head index
; ch — maximum cylinder index
; cl — number of sectors per a track
; Remarks: floppy disk 1.44 MB has 2 heads (0 and 1), 80 tracks (0…79) and 18 sectors per a track (1…18)
BIOS_GetDiskParameters:
push bp
mov bp, sp
push es ; save es in stack
; set ES:DI to 0000h:0000h to work around some buggy BIOS
;(http://en.wikipedia.org/wiki/INT_13H)
xor ax, ax ; ax = 0
mov es, ax ; es = 0
xor di, di ; di = 0
mov dl, [bp + 4] ; drive index
mov ah, 0x08 ; read drive parameters
int 0x13 ; call disk input/output service
pop es ; restore es
mov sp, bp
pop bp
ret 2
; ———————————>
; <—- BIOS_IsExDiskServiceSupported —
; Returns 1 (true) if BIOS supports extended disk service and 0 (false) otherwise.
; Returns: ax=1 or ax=0
BIOS_IsExDiskServiceSupported:
mov ah, 0x41 ; check extensions present
mov bx, 0x55AA ; signature
int 0x13 ; call disk input/output service
jc .not_supported ; if extended disk service is not supported CF flag will be set to 1
mov ax, 1 ; ax = 1
ret
.not_supported:
xor ax, ax ; ax = 0
ret
; ———————————>
; <—- LBAtoCHS ——————
; Function accepts linear sector number (Linear Block Address — LBA) and converts it into a format CYLINDER:HEAD:SECTOR (CHS).
; Parameters: dword [bp + 10] — LBA
; word [bp + 6] — Sectors per Track (SPT)
; word [bp + 4] — Heads per Cylinder (HPC)
; Returns: ch — CYLINDER
; dh — HEAD
; cl — SECTOR
; Remarks: LBA = ((CYLINDER * HPC + HEAD) * SPT) + SECTOR — 1
; CYLINDER = LBA / ( HPC * SPT )
; temp = LBA % ( HPC * SPT )
; HEAD = temp / SPT
; SECTOR = temp % SPT + 1
LBAtoCHS:
push bp
mov bp, sp
push ax ; save ax in stack
; dx:ax = LBA
mov dx, [bp + 10]
mov ax, [bp + 8]
movzx cx, byte [ebp + 6] ; cx = SPT
div cx ; divide dx:ax (LBA) by cx (SPT) (AX = quotient, DX = remainder)
mov cl, dl ; CL = SECTOR = remainder
inc cl ; sectors are indexed starting from 1
div byte [bp + 4] ; AL = LBA % HPC; AH = remainder
mov dh, ah ; DH = HEAD = remainder
mov ch, al ; CH = CYLINDER = quotient
pop ax ; restore ax from stack
mov sp, bp
pop bp
ret 8
; ———————————>
; <—- BIOS_LoadSectors ———-
; Loads sequential sectors from disk into RAM.
; Parameters: word [bp + 12] — disk identifier
; dword [bp + 8] — Linear Block Address (LBA) of the first sector to load
; word [bp + 6] — number of sectors to load
; word [bp + 4] — memory address (in data segment) at which sectors are to be loaded
; Remarks: this function allows for loading sectors from disk within first 8 GiB
BIOS_LoadSectors:
push bp
mov bp, sp
; save necessary registers to stack
push es
push ax
push bx
push dx
mov ax, ds
mov es, ax
mov dl, [bp + 12] ; DL = disk identifier
call BIOS_GetDiskParameters
push dword [bp + 8] ; push LBA
push cx ; push Sectors Per Track
push dx ; push Heads Per Cylinder
call LBAtoCHS ; ch:dh:cl = Cylinder:Head:Sector
.read:
mov dl, [bp + 12] ; DL = disk identifier
mov bx, [bp + 4] ; ES:BX = memory address at which sectors are to be loaded
mov al, [bp + 6] ; AL = number of sectors to load
mov ah, 0x02 ; (int 0x13) Read Sectors From Drive function
int 0x13 ; call disk input/output service
jc .read ; if CF=1, an error occured and we try again
; restore registers from stack
pop dx
pop bx
pop ax
pop es
mov sp, bp
pop bp
ret 10
; ———————————>
; <—- BIOS_LoadSectorsEx ———
; Loads sequential sectors from disk into RAM (uses extended disk input/output service).
; Parameters: word [bp + 12] — disk identifier
; dword [bp + 8] — Linear Block Address (LBA) of the first sector to load
; word [bp + 6] — number of sectors to load
; word [bp + 4] — memory address (in data segment) at which sectors are to be loaded
; Remarks: this function assumes that stack segment and data segment are the same (ss == ds)
; this function allows for loading sectors from disk within first 2 TiB
BIOS_LoadSectorsEx:
push bp
mov bp, sp
sub sp, 16 ; allocate memory (16 bytes) in stack for storing a special
; structure needed for calling bios load-from-disk service
; save necessary registers to stack
push ax
push dx
push si
mov byte[bp — 16], 16 ; structure size = 16 bytes
mov byte[bp — 15], 0 ; unused, should be zero
mov ax, [bp + 6] ; number of sectors to load
mov [bp — 14], ax
mov ax, [bp + 4] ; memory address at which sectors are to be loaded
mov [bp — 12], ax
mov ax, ds ; segment to which sectors are to be loaded
mov [bp — 10], ax
mov ax, [bp + 8] ; 64-bit sector number (1st word)
mov [bp — 8], ax
mov ax, [bp + 10] ; 64-bit sector number (2nd word)
mov [bp — 6], ax
mov word[bp — 4], 0 ; 64-bit sector number (3rd word)
mov word[bp — 2], 0 ; 64-bit sector number (4th word)
mov dl, [bp + 12] ; DL = disk identifier
lea si, [bp — 16] ; si = structure’s address
mov ah, 0x42 ; (int 0x13) Extended Read Sectors From Drive function
int 0x13 ; call disk input/output service
; restore registers from stack
pop si
pop dx
pop ax
mov sp, bp
pop bp
ret 10
; ———————————>
; <—- BIOS_LoadSectorsSmart ——
; Loads sequential sectors from disk into RAM.
; Parameters: word [bp + 12] — disk identifier
; dword [bp + 8] — Linear Block Address (LBA) of the first sector to load
; word [bp + 6] — number of sectors to load
; word [bp + 4] — memory address (in data segment) at which sectors are to be loaded
; Remarks: this function assumes that stack segment and data segment are the same (ss == ds)
BIOS_LoadSectorsSmart:
call BIOS_IsExDiskServiceSupported
cmp ax, 0
je BIOS_LoadSectors
jmp BIOS_LoadSectorsEx
; ———————————>
Объяснение работы программы
Идентификатор диска
Как вам уже известно, первые 512 байт нашей программы загружаются с дискеты. Но ведь мы могли бы записать нашу программу не только на дискету, но и на CD или даже на жесткий диск. Чтобы программа могла узнать, с какого именно носителя она была скопирована в оперативную память, BIOS помещает в регистр dl целое число — так называемый идентификатор диска. Зачем программе знать, с какого носителя она была загружена? Чтобы дозагрузить свою часть, оставшуюся на этом носителе. Как мы можем использовать идентификатор диска? В BIOS есть функции, которые относятся к т. н. сервису дискового ввода-вывода (disk input/output service) — прерывание 0x13. Эти функции принимают идентификатор диска в качестве параметра. Для дискет идентификатор может принимать значения от 0 до 3, для всех остальных дисков — значения от 128 и выше.
Дисковый сервис
Чтобы скопировать сектора с диска в оперативную память, мы можем воспользоваться функцией 0x02 (ah=0x02) дискового сервиса BIOS (int 0x13). Но тут есть одна проблема: функция, которая загружает сектора в память, принимает в качестве параметра адрес 1-ого загружаемого сектора в формате Цилиндр:Головка:Сектор (Cylinder:Head:Sector — CHS). Программисту оперировать адресами секторов в таком формате неудобно, ему более привычен т. н. формат LBA (Linear Block Address), в котором сектора диска предстают в виде однородного массива. Поэтому программисту приходится преобразовывать один формат в другой (LBA to CHS).
Преобразование линейного адреса сектора (LBA) в формат Цилиндр:Головка:Сектор (CHS)
Прежде всего, вы должны понимать, как устроен диск. Например, жесткий диск — это набор круглых стеклянных пластин, покрытых ферромагнитным материалом и нанизанных на вращающийся шпиндель (ось). У каждой пластины есть две поверхности, и над каждой поверхностью расположена считывающая/записывающая головка (head). Поверхность состоит из концентрических кругов, которые называются дорожками (tracks). Совокупность дорожек одинакового радиуса, расположенных на разных пластинах, называется цилиндром (cylinder). Дорожки делятся на сектора размером 512 байт. В процессе считывания или записи головка располагается над нужной дорожкой и ждет, когда под ней проедет нужный сектор. Формат жесткого и гибкого диска одинаков. Подробнее читайте тут: Wikipedia — Cylinder-head-sector. Есть простые формулы преобразования LBA в CHS и наоборот, они приведены в исходном коде функции LBAtoCHS нашей программы.
Определение параметров диска
Определение параметров диска (количество головок, цилиндров и секторов на одну дорожку) необходимо, чтобы мы могли преобразовать адрес LBA в адрес CHS. Параметры диска можно получить при помощи функции 0x08 прерывания 0x13. Входной параметр для этой функции — идентификатор диска. Я поместил определение параметров диска в функцию BIOS_GetDiskParameters. Однако есть в BIOS функция, которая избавит нас от необходимости получать параметры диска и преобразовывать LBA в CHS — она называется «расширенный дисковый сервис».
Расширенный дисковый сервис
Расширенный дисковый сервис может поддерживаться либо не поддерживаться той или иной версией BIOS. Узнать, поддерживается ли он можно, вызвав функцию 0x41 прерывания 0x13. Если сервис поддерживается, то флаг переноса CF будет сброшен в 0, в противном случае — установлен в 1. Все это я поместил в функцию BIOS_IsExDiskServiceSupported. Загрузить секторы диска в память при помощи расширенного дискового сервиса можно, вызвав функцию 0x42 прерывания 0x13. Функция принимает в качестве параметра указатель на специальную структуру, которую мы должны создать в оперативной памяти (целесообразно делать это в стеке). Структура содержит LBA первого сектора и количество секторов для загрузки, а также адрес в памяти, по которому надо загружать сектора. Вызов функции расширенного дискового сервиса я поместил в функцию BIOS_LoadSectorsEx.
Отладчик Bochs
Программа стала достаточно сложной, поэтому при ее написании я допускал различные ошибки. О наличии ошибок программист узнает обычно, когда программа у него не работает. Как узнать, в чем конкретно состоит ошибка? Для этого надо выполнить программу пошагово (т. е. с остановкой после каждой машинной команды) и выяснить, в каком месте программа делает не то, что нужно. Этот процесс называется отладкой. Виртуальная машина Bochs позволяет программисту выполнять отладку — т. е. пошагово выполнять программу, ставить точки останова, манипулировать регистрами и оперативной памятью компьютера.
В папке установки Bochs (у меня это C:Program Files (x86)Bochs-2.6.9) находится файл bochsdbg.exe. Это версия виртуальной машины, предназначенная специально для отладки программ. После ее запуска и нажатия вами в окне Bochs Start Menu кнопки Start, машина остановится на первой же машинной инструкции BIOS, и в окне Bochs for Windows — Console вы увидите приглашение bochs:1>
. Далее вы можете вводить различные команды для отладки, их полный список приведен в разделе Using Bochs internal debugger документации, которая поставляется в составе дистрибутива Bochs (у меня документация находится в папке C:Program Files (x86)Bochs-2.6.9docs). Вы можете например пошагово выполнять программу при помощи команды step
, ставить точки останова при помощи команды lbreak
, просматривать содержимое регистров при помощи команды registers
и многое другое (обратите внимание, что у команд есть сокращенные варианты).
После старта машина останавливается на первой команде BIOS. Нас программа в BIOS вряд ли интересует, нас интересует наша собственная программа, поэтому после запуска отладчика полезно установить точку останова на машинную команду с линейным адресом 0x7c00 (адрес, по которому с диска в память копируется наша программа) и продолжить выполнение программы до тех пор, пока не встретится точка останова:
Файл листинга
Если вы отлаживаете программу в Bochs, то вам очень пригодится файл листинга — файл, который содержит в человекочитаемом виде следующую информацию:
- исходный код вашей программы на ассемблере
- машинные коды ассемблерных команд в шестнадцатеричном виде
- данные в шестнадцатеричном виде
- адреса машинных команд в памяти и адреса относительно начала двоичного файла (Relative Virtual Address — RVA)
Без этой информации отлаживать сколько-нибудь сложную программу в Bochs практически невозможно. Вы ведь должны знать, по каким адресам в памяти ставить точки останова и по каким адресам в памяти расположены ваши переменные.
Если вы программируете на FASM’е, то для того чтобы сгенерировать листинг, вам надо сначала сгенерировать так называемый файл символов — symbolic information file. Это можно сделать, указав ключ -s
в командной строке компилятора fasm.exe, например:
fasm source.asm -s source.fas
Здесь source.asm — файл исходного кода, source.fas — файл символов, который генерируется компилятором из файла исходного кода.
Затем вы вызываете утилиту listing.exe, которая и генерирует файл листинга:
listing -a source.fas source.lst
Здесь source.fas — файл символов, source.lst — файл листинга, который нам и нужен.
Но тут есть небольшая проблема: утилита listing.exe поставляется в составе дистрибутива FASM, но не в виде скомпилированной программы, а в виде исходного кода на ассемблере, который вы должны сами скомпилировать. Исходный код программы listing.exe расположен в файле C:FASMTOOLSWIN32LISTING.ASM
. Этот исходный код написан на ассемблере FASM. Попытка скомпилировать этот файл компилятором fasm.exe из командной строки не приводит к успеху — компилятор не может найти заголовочные файлы (с расширением .inc
), на которые ссылался файл исходного кода. Оказывается, в этом файле есть директивы INCLUDE, которые ссылаются на различные заголовочные файлы, причем путь к этим заголовочным файлам не указан (поиск по папке установки FASM показал, что нужные заголовочные файлы лежат в папках TOOLS
и INCLUDE
). Решение проблемы нашлось в файле руководства C:FASMFASM.PDF
в разделе 1.1.1 System requirements: надо либо создать переменную окружения INCLUDE и присвоить ей путь к папке C:FASMINCLUDE
, либо запустить графический вариант компилятора, который называется fasmw.exe. После первого запуска fasmw.exe в той же папке появится файл FASMW.INI. В этом файле надо добавить в самое начало следующий текст:
[Environment]
Include=C:FASMINCLUDE
Затем в программе fasmw.exe мы открываем файл C:FASMTOOLSWIN32LISTING.ASM
и щелкаем пункт меню Run > Compile
, после чего в папке C:FASMTOOLSWIN32
появится файл LISTING.EXE.
Про генерацию листинга всё. Информация была почерпнута мной из файлов C:FASMTOOLSREADME.TXT
и C:FASMFASM.PDF
(раздел 1.1.1 System requirements).
Makefile
Поскольку теперь я хотел, чтобы при построении программы генерировался файл листинга, я должен был написать новый makefile, текст которого приведен ниже:
all: floppy.img boot_sector.lst
floppy.img : boot_sector.bin
dd if=«/dev/zero» of=«floppy.img» bs=1024 count=1440
dd if=boot_sector.bin of=floppy.img conv=notrunc
boot_sector.bin : boot_sector.asm bios_services.inc
fasm boot_sector.asm boot_sector.bin
boot_sector.lst: boot_sector.fas
listing —a boot_sector.fas boot_sector.lst
boot_sector.fas: boot_sector.asm bios_services.inc
fasm boot_sector.asm —s boot_sector.fas
В следующей заметке мы займемся обработкой прерываний в защищенном режиме.
Что вас здесь ждёт
Если вы так же любопытны, как я, вы наверняка задумывались о том, как работают операционные системы. Здесь я расскажу о некоторых исследованиях и экспериментах, которые я провёл, чтобы лучше понять, как работают вычислительные и операционные системы. После прочтения вы создадите свою загрузочную программу, которая будет работать в любом приложении виртуальных машин, например в Virtual Box.
Важное замечание
Эта статья не предназначена для того, чтобы объяснить работу загрузчика во всей его сложности. Этот пример — отправная точка для x86 архитектуры. Для понимания этой статьи требуется базовое знание микропроцессоров и программирования.
Что такое загрузчик?
Простыми словами загрузчик — это часть программы, загружаемая в рабочую память компьютера после загрузки.
После нажатия кнопки Пуск компьютеру предстоит многое сделать. Запускается и выполняет свою работу прошивка, называемая BIOS (базовая система ввода-вывода). После этого BIOS передаёт управление загрузчику, установленному на любом доступном носителе: USB, жёстком диске, CD и т.д. BIOS последовательно просматривает все носители, проверяя уникальную подпись, которую также называют записью загрузки. Когда она найдена и загружена в память компьютера, начинает работать процессор. Если быть более точным, эта запись располагается по адресу 0x7C00. Сохраните его, он нужен для написания загрузчика.
Работа внутри первого сектора всего с 512 байтами.
Как упоминалось выше, в процессе инициализации BIOS ищет в первом секторе загрузочного устройства уникальную подпись. Её значение равно 0xAA55 и должно находиться в последних двух байтах первого сектора. И хотя все 512 байт доступны в главной загрузочной записи, мы не можем использовать их все: мы должны вычесть схему и подпись таблицы раздела диска и подпись. Останется только 440 байт. Маловато. Но вы можете решить эту проблему, написав код для загрузки данных из других секторов в памяти.
Шаги инициализации в упрощённом виде
- BIOS загружает компьютеры и их периферийные устройства.
- BIOS ищет загрузочные устройства.
- Когда BIOS находит подпись 0xAA55 в MBR, он загружает этот сектор в память в позицию 0x7C00 и передаёт управление этой точке входа, то есть начинает выполнение инструкций с точки в памяти 0x7C00.
Пишем код
Код загрузчика на ассемблере:
bits 16
org 0x7c00
boot:
mov si, message
mov ah,0x0e
.loop:
lodsb
or al,al
jz halt
int 0x10
jmp .loop
halt:
cli
hlt
message: db "Hey! This code is my boot loader operating.",0
times 510 - ($-$$) db 0
dw 0xaa55
Ассемблер необходимо скомпилировать в машинный код. Обратите внимание, что 512 в шестнадцатеричной системе — это 0x200, а последние два байта — 0x55 и 0xAA. Он инвертирован по сравнению с кодом ассемблера выше, что связано с системой порядка хранения, называемой порядком следования байтов. Например, в big-endian системе два байта, требуемых для шестнадцатеричного числа 0x55AA, будут храниться как 0x55AA (если 55 хранится по адресу 0x1FE, AA будет храниться 0x1FF). В little-endian системе это число будет храниться как 0xAA55 (AA по адресу 0x1FE, 55 в 0x1FF).
0000000 be 10 7c b4 0e ac 08 c0 74 04 cd 10 eb f7 fa f4
0000010 48 65 79 21 20 54 68 69 73 20 63 6f 64 65 20 69
0000020 73 20 6d 79 20 62 6f 6f 74 20 6c 6f 61 64 65 72
0000030 20 6f 70 65 72 61 74 69 6e 67 2e 00 00 00 00 00
0000040 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
*
00001f0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 55 aa
0000200
Машинный код после компиляции NASM
Как работает этот код
Я объясню этот код построчно в случае если вам не знаком ассемблер:
1. Если укажем целевой режим процессора, директива BITS укажет, где NASM следует сгенерировать код, предназначенный для работы на процессоре, поддерживающем 16-битный, 32-битный или 64-битный режим. Синтаксис — BITS XX, где XX это 16, 32 или 64.
2. Если укажем адрес начала программы в бинарном файла, директива ORG укажет начальный адрес, по которому NASM будет считать начало программы при её загрузке в память. Когда этот код переводится в машинный, компилятор и компоновщик определяют и организуют все структуры данных, необходимые программе. Для этой цели будет использован начальный адрес.
3. Это просто ярлык. Когда он определён в коде, то ссылается на позицию в памяти, которую вы можете указать. Он используется вместе с командами условного перехода для контроля потока приложения.
После разбора четвёртой строки нам необходимо описать концепцию регистров:
Регистр процессора — блок ячеек памяти, образующий сверхбыструю оперативную память (СОЗУ) внутри процессора. Используется самим процессором и большей частью недоступен программисту: например, при выборке из памяти очередной команды она помещается в регистр команд, к которому программист обратиться не может. Википедия
4. Назначение данных с помощью инструкции MOV, которая используется для перемещения данных. В данном случае мы перемещаем значение адреса в памяти ярлыка сообщения в регистр SI, который укажет на текст “Hey! This code is my boot loader operating”. На картинке ниже видим, что при переводе в машинный код этот текст хранится в позиции 0x7C10.
5. Мы будем использовать видеосервисы BIOS для отображения текста на экране, поэтому сейчас мы настраиваем отображение по своему желанию. Сервис перемещает байт 0x0E в регистр AH.
6. Ещё одна ссылка на метку, позволяющая управлять потоком выполнения. Позднее мы используем её для создания цикла.
7. Эта инструкция загружает байт из операнда-источника в регистр AL. Вспомните четвёртую строку, где регистру SI была задана позиция текстового адреса. Теперь эта инструкция получает символ, хранящийся в ячейке памяти 0x7C10. Важно заметить, что она ведёт себя как массив, и мы указываем на первую позицию, содержащую символ ‘H’, как видно на рисунке ниже. Этот текст будет представлен итеративно по вертикали, и каждый символ будет задаваться каждый раз. Кроме того, второй символ не был представлен снимком, извлечённым из программы IDA. 0x65 в ASCII отображает символ ‘e’:
8. Выполнение логической операции OR между (AL | AL) на первый взгляд кажется бессмысленным, однако это не так. Нам нужно проверить, равен ли результат этой операции нулю, основываясь на логическом булевом значении. После этой операции результат будет, например, [1 | 1 = 1] или [0 | 0 = 0].
9. Переход к метке остановки (строка 12), если результат последней операции OR равен нулю. В первый момент значение AL равно [0x48 = ‘H’] , основываясь на последней инструкции LODSB, помните строку 7? Значит, код не перейдёт к метке остановки в первый раз. Почему так? (0x48 OR 0x48) = 0x48, следовательно он переходит к следующей инструкции на следующей строке. Важно заметить, что инструкция JZ связана не только с инструкцией OR. Существует другой регистр, FLAGS, который наблюдается в процессе операций перехода, то есть результат операции OR хранится в этом регистре FLAG и наблюдается инструкцией JZ.
10. Вызывая прерывание BIOS, инструкция INT 0x10 отображает значение AL на экране. Вспомните строку 5, мы задали значение AH байтом 0x0E. Это комбинация для представления значения AL на экране.
11. Переход к метке loop, которая без всяких условий похожа на инструкцию GOTO в языках высокого уровня.
12. Мы снова на строке 7, LODSB перехватывает контроль. После того, как байт будет перемещён из адреса в памяти в регистр AL, регистр SI инкрементируется. Во второй раз он указывает на адрес 0x7C11 = [0x65 ‘e’], затем на экране отображается символ ‘e’. Этот цикл будет выполняться до тех пор, пока не достигнет адреса 0x7C3B = [0x00 0], и, когда JZ снова выполнится в строке 9, поток будет доведён до метки остановки.
13. Здесь мы заканчиваем наше путешествие. Выполнение останавливают инструкции CLI и HLT.
14. На строке 17 вы видите инструкцию, которая заполняет оставшиеся 510 байтов нулями после чего добавляет подпись загрузочной записи 0xAA55.
Компилируем и запускаем
Убедитесь, что компилятор NASM и эмулятор виртуальной машины QEMU установлены на ваш компьютер. Воспользуйтесь предпочтительным менеджер зависимостей или скачайте их из интернета.
Для Linux наберите в терминале:
sudo apt-get install nasm qemu
На Mac OS можно использовать homebrew:
brew install nasm qemu
После этого вам нужно создать файл с кодом сборки, представленным в коде загрузчика выше. Давайте назовём этот файл boot.asm и затем запустим команду NASM:
nasm -f bin boot.asm -o boot.bin
Будет создан двоичный файл, который нужно запустить на виртуальной машине. Давайте запустим на QEMU:
qemu-system-x86_64 -fda boot.bin
Вы увидите следующий экран:
Запуск из Virtual box
Сначала вам нужно создать виртуальный пустой флоппи диск:
dd if=/dev/zero bs=1024 count=0 > floppy.img
Затем добавить внутрь него двоичное содержимое:
cat boot.bin >> floppy.img
Теперь вы можете создать машину Virtual Box и запустить её, используя файл загрузки:
Многие вещи я не стал здесь рассматривать, чтобы не быть слишком многословным. Если вы новичок в этой непростой теме, у вас наверняка возникло множество вопросов, и это прекрасная отправная точка для исследований. Для лучшего понимания многих принципов вычислительных и операционных систем я рекомендую книгу Эндрю С. Таненбаума “Операционные системы. Разработка и реализация”.
Читайте также:
- 5 подводных камней нереляционных баз данных
- 10 Графовых алгоритмов
- ML-инженер или специалист по обработке данных? (Закат науки о данных?)
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи Anderson Santos Gusmão: Build and run a boot-loader