Введение
Эта статья представляет собой ознакомительный материал о написании загрузчика на С и Ассемблере, но здесь я не буду вдаваться в сравнение производительности итогового кода, созданного на этих языках. В этой работе я просто вкратце изложу процесс загрузки флоппи-образа через написание собственного кода с последующим его внедрением в загрузочный сектор устройства. Все содержание статьи будет разделено на несколько частей, так как за раз достаточно сложно изложить всю нужную информацию и о компьютерах, и об устройствах загрузки, и о написании самого кода. Здесь я поясню наиболее общие аспекты компьютерной науки и суть процесса загрузки, а также обобщил значение и важность каждого этапа, чтобы упростить их понимание и запоминание. Если же вам потребуется более подробное объяснение, то можете дополнительно почитать другие статьи, которых в интернете нынче предостаточно.
О чем пойдет речь?
В этой статье мы рассмотрим написание кода программы и его копирование в загрузочный сектор образа флоппи-диска, после чего с помощью x86-эмулятора bochs для Linux научимся проверять работоспособность дискеты с загрузчиком.
О чем речь не пойдет
В этой статье я не объясняю, почему загрузчик нельзя написать на других подобных Ассемблеру языках, а также не говорю о недостатках его написания на одном языке в отношении другого. Поскольку наша цель – познакомиться с написанием загрузочного кода, я не хочу нагружать вас более продвинутыми темами типа скорости, уменьшения кода и т.д.
Структура статьи
В начале мы начнем со знакомства с основами, после чего перейдем к написанию самого кода. В общем структура будет такова:
• Знакомство с Bootable Devices (загрузочными устройствами).
• Введение в среду разработки.
• Знакомство с микропроцессором.
• Написание кода на Ассемблере.
• Написание кода на компиляторе.
• Разработка мини-программы для отображения прямоугольников.
К сведению:
• Эта статья окажется наиболее полезной, если у вас уже есть хоть какой-то опыт программирования. Несмотря на ее ознакомительный характер, написание загрузочных программ на Ассемблере и C может оказаться непростой задачей. Поэтому новичкам в программировании я рекомендую сначала ознакомиться с несколькими более базовыми вводными материалами и уже потом возвращаться к этому.
• На протяжении статьи я буду постепенно представлять в форме вопросов и ответов различную компьютерную терминологию. Вообще, я написал это руководство так, как будто обращаю его самому себе. А роль таких дискуссионных вставок в виде вопрос-ответ – помочь мне лучше понять важность и назначение рассматриваемого материала в повседневной жизни. Вот пример: Что вы подразумеваете под компьютерами? или Зачем они мне нужны, ведь я намного умнее их?
Что ж, будем начинать
Знакомство с загрузочными устройствами
Что происходит при включении стандартного компьютера?
Обычно при нажатии кнопки включения питания от нее подается сигнал блоку питания о необходимости подачи необходимого напряжения на внутреннее и внешнее оборудование компьютера, такое как процессор, монитор, клавиатура и пр. Процессор инициализирует ПЗУ-чип BIOS (базовую систему ввода/вывода) для загрузки содержащейся в нем исполняемой программы, также именуемой BIOS.
После запуска BIOS выполняет следующие задачи:
• Тестирование оборудования при подаче питания (Power On self Test).
• Проверка частоты и доступности шин.
• Проверка системных часов и аппаратной информации в CMOS RAM.
• Проверка настроек системы, предустановок оборудования и т.д.
• Тестирование подключенного оборудования, начиная с RAM, дисководов, оптических приводов, HDD и т.д.
• В зависимости от определенной в разделе загрузочных устройств информации выполняет поиск загрузочного диска и переходит к его инициализации.
К сведению: все x86-CPU в процессе загрузки запускаются в рабочем режиме, называемом Real Mode.
Что такое загрузочное устройство?
Загрузочным называется устройство, содержащее загрузочный сектор или блок загрузки. BIOS считывает это устройство, начиная с загрузки этого загрузочного сектора в RAM для выполнения, после чего переходит далее.
Что такое сектор?
Сектор – это особый раздел загрузочного диска, размер которого обычно составляет 512 байт. Чуть позже я подробнее поясню о том, как измеряется память компьютера и приведу сопутствующую терминологию.
Что такое загрузочный сектор?
Загрузочный сектор или блок загрузки – это область загрузочного устройства, в которой содержится загружаемый в RAM машинный код, за что отвечает встроенная в ПК прошивка на стадии инициализации. На флоппи-диске размер сектора составляет 512 байт. Чуть позже о байтах будет сказано дополнительно.
Как работает загрузочное устройство?
При его инициализации BIOS ищет и загружает первый сектор (загрузочный) в RAM и начинает его выполнение. Расположенный в загрузочном секторе код является первой программой, которую можно отредактировать для определения дальнейшего функционирования компьютера после его запуска. Здесь я имею в виду, что вы можете написать собственный код и скопировать его в загрузочный сектор, чтобы система работала так, как вам нужно. Сам же этот код и будет называться тем самым начальным загрузчиком.
Что такое начальный загрузчик?
В компьютерной области загрузчиком называется программа, выполняемая при каждой инициализации загрузочного устройства при запуске и перезагрузке ПК. Технически это выполняемый машинный код, соответствующий архитектуре типа используемого в системе CPU или микропроцессора.
Какие есть виды микропроцессоров?
Я приведу основные:
• 16 битные
• 32 битные
• 64 битные
Чем больше значение бит, тем к большему объему памяти имеют доступ программы, получая большую производительность с точки зрения временного хранилища и пр. На сегодня есть два основных производителя микропроцессоров – Intel и AMD. На протяжении статьи я буду обращаться только к процессорам семейства Intel (x86).
В чем отличие процессоров Intel и AMD?
Каждый производитель использует свой уникальный способ проектирования микропроцессоров с аппаратной точки зрения и в плане используемых для взаимодействий наборов инструкций.
Знакомство со средой разработки
Что такое реальный режим?
Я уже упоминал, что все процессоры x86 при загрузке с устройства запускаются в реальном режиме. Это очень важно иметь в виду при написании загрузочного кода для любого устройства. Реальный режим поддерживает только 16-битные инструкции. Поэтому создаваемый вами код для загрузки в загрузочную запись или сектор должен компилироваться в 16-битный формат. В реальном режиме инструкции могут работать только с 16 битами одновременно. Например, у 16-битного CPU будет конкретная инструкция, способная складывать в одном цикле два 16-битных числа. Если же для процесса будет необходимо сложить два 32-битных числа, то потребуется больше циклов, выполняющих сложение 16 битных чисел.
Что такое набор инструкций?
Это гетерогенная коллекция сущностей, ориентированных на конкретную архитектуру микропроцессора, с помощью которых пользователь может взаимодействовать с ним. Здесь я подразумеваю коллекцию сущностей, состоящую из внутренних типов данных, инструкций, регистров, режимов адресации, архитектуры памяти, обработки прерываний и исключений, а также внешнего I/O. Обычно для семейства микропроцессоров создаются общие наборы инструкций. Процессор Intel-8086 относится к семейству 8086, 80286, 80386, 80486, Pentium, Pentium I, II, III, которое также известно как семейство x86. В этой статье я будут использовать набор инструкций, относящийся именно к этому типу процессоров.
Как написать код для загрузочного сектора устройства?
Для реализации этой задачи необходимо иметь представление о:
• Операционной системе (GNU Linux).
• Ассемблере (GNU Assembler).
• Наборе инструкций (семейство x86).
• Написании инструкций x86 на GNU Assembler для x86 микропроцессоров.
• Компиляторе (как вариант язык C).
• Линкер (GNU linker ld)
• Эмулятор x86, наподобие bochs, для тестирования.
Что такое операционная система?
Объясню очень просто. Это большой набор различных программ, написанных сотнями и даже тысячами профессионалов, которые помогают пользователям в решении их повседневных задач. К таким задам можно отнести подключение к интернету, общение в соцсетях, создание и редактирование файлов, работу с данными, игры и многое другое. Все это реализуется с помощью операционной системы. Помимо этого, ОС также регулирует функционирование аппаратных средств, обеспечивая для вас оптимальным режим работы.
Отдельно отмечу, что все современные ОС работают в защищенном режиме.
Какие виды ОС бывают?
• Windows
• Linux
• MAC
• …
Что значит защищенный режим?
В отличие от реального режима, защищенный поддерживает 32-битные инструкции. Но вам об этом задумываться не стоит, так как нас не особо волнует процесс функционирования ОС.
Что такое Ассемблер?
Ассемблер преобразует передаваемые пользователем инструкции в машинный код.
Разве компилятор делает не то же самое?
На более высоком уровне да, но, фактически, внутри компилятора этим занимается именно ассемблер.
А почему компилятор не может генерировать машинный код напрямую?
Основная задача компилятора состоит в преобразовании инструкций пользователя в их промежуточный набор, называемый инструкциями ассемблера, после чего ассемблер преобразует их в соответствующий машинный код.
Зачем нужна ОС для написания кода загрузочного сектора?
Прямо сейчас я не хочу вдаваться в подробности и ограничусь пояснением в рамках материала текущей статьи. Как я говорил, для написания инструкций, которые поймет микропроцессор, нам нужен компилятор. Он, в свою очередь, разрабатывается в качестве утилиты ОС и используется через нее, соответственно.
Какую ОС можно использовать?
Так как я писал загрузочные программы под Ubuntu, то и вам для ознакомления с данным руководством порекомендую именно эту ОС.
Какой следует использовать компилятор?
Я писал загрузчики при помощи GNU GCC и демонстрировать компиляцию кода я буду на нем же. Как протестировать рукописный код для загрузочного сектора? Я представлю вам эмулятор x86, который существенно помогает дорабатывать код, не требуя постоянной перезагрузки компьютера при редактировании загрузочного сектора устройства.
Знакомство с микропроцессором
Прежде чем изучать программирование микропроцессора, нам необходимо разобрать использование регистров.
Что такое регистры?
Регистры подобны утилитам микропроцессора, служащим для временного хранения данных и управления ими согласно нашим потребностям. Предположим, пользователь хочет сложить 2 и 3, для чего он просит компьютер сохранить число 3 в одном регистре, а 2 в другом, после чего сложить содержимое этих регистров. В итоге CPU помещает результат в еще один регистр, который и представляет нужным пользователю вывод. Всего существует четыре типа регистров:
• регистры общего назначения;
• сегментные регистры;
• индексные регистры;
• регистры стека.
Я дам краткое пояснение по каждому типу.
Регистры общего назначения: используются для хранения временных данных, необходимых программе в процессе выполнения. Каждый такой регистр имеет 16 бит в ширину и 2 байта в длину.
• AX – регистр сумматора;
• BX – регистр базового адреса;
• CX – регистр-счетчик;
• DX – регистр данных.
Сегментные регистры: служат для представления микропроцессору адреса памяти. Здесь нам нужно знать два термина:
• Сегмент: как правило, это начало блока памяти.
• Смещение: It is the index of memory block onto it.
Пример: у нас есть байт, представляющий значение “X” и расположенный в блоке памяти со стартовым адресом 0x7c00 в 10-й позиции от начала этого блока. В данной ситуации мы выразим сегмент как 0x7c00, а смещение как 10.
Абсолютным адресом тогда будет 0x7c00 + 10.
Они делятся на четыре категории:
• CS – сегмент кода;
• SS – сегмент стека;
• DS – сегмент данных;
• ES – расширенный сегмент.
При этом нужно учитывать ограничения этих регистров, а именно невозможность прямого присваивания адреса. Вместо этого нам приходится копировать адрес сначала в регистры общего назначения, после чего снова копировать его уже в сегментные. Например, для решения задачи обнаружения байта “X” мы делаем следующее:
movw $0x07c0, %ax
movw %ax , %ds
movw (0x0A) , %ax
Происходит же здесь вот что:
• set 0x07c0 * 16 in AX
• set DS = AX = 0x7c00
• set 0x7c00 + 0x0a to ax
Я дам описание разных режимов адресации, понимание которых нам потребуется при написании программ.
Регистры стека:
• BP – базовый указатель;
• SP – указатель стека.
Индексные регистры:
• SI – регистр индекса источника.
• DI – регистр индекса получателя.
• AX: используется CPU для арифметических операций.
• BX: может содержать адрес процедуры или переменной (это также могут SI, DI и BP) и использоваться для выполнения арифметических операций и перемещения данных.
• CX: выступает в роли счетчика цикла при повторении инструкций.
• DX: It holds the high 16 bits of the product in multiply (also handles divide operations).
• CS: хранит базовый адрес всех выполняемых инструкций программы.
• SS: хранит базовый адрес стека.
• DS: хранит предустановленный адрес переменных.
• ES: хранит дополнительный базовый адрес переменных памяти.
• BP: содержит предполагаемое смещение из регистра SS. Часто используется подпрограммами для обнаружения переменных, переданных в стек вызывающей программой. an assumed offset from the SS register.
• 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 можно получить с помощью прерываний.
Что такое прерывания?
Для приостановки стандартного потока программы и обработки событий, требующих быстрой реакции, используются прерывания. Например, при перемещении мыши, соответствующее аппаратное обеспечение прерывает текущую программу для обработки этого перемещения. В результате прерывания управление передается специальной программе-обработчику. Каждому типу прерывание присваивается целое число. В начальной области физической памяти располагается таблица векторов прерываний, в которой находятся сегментированные адреса их обработчиков. Номер прерывания, по сути, является индексом из этой таблицы.
Какую службу прерываний будем использовать мы?
Bios interrupt 0x10.
Написание кода в Ассемблере
Какие типы данных доступны в GNU Assembler?
Типы данных используются для определения их характеристик и могут быть следующими:
• байт
• слово
• целочисленными (int)
• ascii
• asciz
байт: состоит из восьми бит. Байт считается наименьшей единицей хранения информации в процессе программирования.
слово: единица данных, состоящая из 16 бит.
Что такое int?
Int – это целочисленный тип данных, состоящий из 32 бит, которые могут быть представлены четырьмя байтами или двумя словами.
Что такое ascii?
Тип данных, представляющий группу байтов без нулевого символа.
Что такое asciiz?
Тип данных, выражающий группу байтов, завершающуюся символом нуль.
Как генерировать код для реального режима в Ассемблере?
В процессе запуска ЦПУ в реальном режиме (16 бит) мы можем задействовать только встроенные функции BIOS. Я имею в виду, что мы можем с помощью этих функций написать собственный код загрузчика, поместить его в загрузочный сектор и выполнить загрузку. Давайте рассмотрим написание на Ассемблере небольшого фрагмента кода, который генерирует код 16-битный код ЦПУ через GNU Assembler.
Let us see how to write a small piece of code in assembler that generates 16-bit CPU code through GNU Assembler.
Example: 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 проверяет соответствие двух последний байт загрузочного сектора этим значениям и либо продолжает загрузку, либо сообщает о ее невозможности. При помощи шестнадцатеричного редактора можно просматривать содержимое двоичного файла в более читабельном виде, и ниже я привел снимок этого файла в качестве примера.
Как скопировать исполняемый код на загрузочное устройство и протестировать его?
Чтобы создать образ для дискеты размером 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.
Как писать программы в компиляторе GCC на C?
Давайте разберем это на примере.
Образец: test.c
__asm__(".code16n");
__asm__("jmpl $0x0000, $mainn");
void main() {
}
File: 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() {
/* вызов функции printString со строкой в качестве аргумента*/
printString("Hello, World");
}
Сохраните этот код в файле test3.c и снова проследуйте всем инструкциям компиляции. В результате после выполнения bochs на экране отобразится следующее:
Все это время мы учились путем преобразования программ ассемблера в программы 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.
В одной из прошлых статей описывал как прошить STM32 с помощью бутлоадера и объяснял принцип его работы. Для тех кто её не читал напомню, что бутлоадер, по сути, это программа, которая живёт в МК и может перезаписывать его память, то есть обновлять прошивку.
Думаю у кого-то мог возникнуть вопрос, зачем писать бутлоадер самому, если можно пользоваться встроенным?
Ответ на этот вопрос очень прост, пока своими поделками на МК пользуешься сам — писать бутлоадер нет надобности, в любой момент можно взять поделку и залить в неё новую прошивку. Но если устройство становится коммерческим, то встаёт вопрос как обновлять прошивку пользователям?
Если выложить прошивку в открытый доступ, то велика вероятность того, что кто-то скопирует устройство и организует продажи. Разработчику такой ситуации хотелось бы избежать, но в то же время хотелось бы радовать пользователей новыми фичами и исправлением старых багов, для решения этих вопросов и нужен самописный бутлоадер.
Используя самописный бутлоадер, ничто не мешает нам в нём инициализировать часть периферии, необходимой для работы устройства и не инициализировать её в прошивке. Таким образом, прошивка, отправляемая пользователю, получается неполноценной и на МК без нашего бутлоадера работать не будет. Для повышения безопасности прошивку можно шифровать, но ни один из способов не поможет если кому-то действительна понадобиться ваша прошивка. Судя по данным, найденным в интернете, есть конторы которые за определённую плату(в районе 1000$) извлекают прошивку из залоченного МК.
С теорией разобрались, предлагаю перейти к практике. Условно наш бутлоадер будет иметь следующую структуру
//если найдена новая прошивка
if(new_firmware)
{
//обновляемся
}
Go_To_Application();//прыгаем в основную программу
В зависимости от того с какого носителя или каким способом мы будем обновляться, код в скобках условного оператора будет меняться, неизменным будет только переход в основную программу, с него предлагаю и начать.
Сразу хотелось бы отметить, что наш бутлоадер будет располагаться во флэше и в дальнейших рассуждениях это подразумевается по умолчанию.
При включении питания МК всегда стартует с начала флэш памяти, а именно с адреса 0х0800000. Далее, из бутлоадера надо прыгнуть в основную программу, расположение которой зависит от размера бутлоадера. Для того чтобы вычислить адрес начала основной программы надо к 0х0800000 прибавить размер бутлоадера.
В общем перед нами стоит два вопроса:
На какой адрес необходимо прыгнуть ?
Как реализовать прыжок ?
Прыжок реализуется с помощью указателя на функцию. Как мы знаем указатель — это адрес, а в указателе на функцию хранится адрес, с которого начинается функция.
Для ответа на первый вопрос надо вспомнить как происходит загрузка МК.
При включении питания МК читает значение по адресу 0x08000000 и записывает его в SP (SP – регистр, который указывает на вершину стека), после чего читает значение по адресу 0x08000004 и записывает его в PC (PC – регистр, который указывает на текущую инструкцию + 4 байта).
Получается, что адрес перехода хранится в векторе Reset Handler. Функция, реализующая прыжок, выглядит следующим образом.
#define APPLICATION_ADDRESS 0x08008400//адрес начала программы
void Go_To_User_App(void)
{
uint32_t app_jump_address;
typedef void(*pFunction)(void);//объявляем пользовательский тип
pFunction Jump_To_Application;//и создаём переменную этого типа
__disable_irq();//запрещаем прерывания
app_jump_address = *( uint32_t*) (APPLICATION_ADDRESS + 4); //извлекаем адрес перехода из вектора Reset
Jump_To_Application = (pFunction)app_jump_address; //приводим его к пользовательскому типу
__set_MSP(*(__IO uint32_t*) APPLICATION_ADDRESS); //устанавливаем SP приложения
Jump_To_Application(); //запускаем приложение
}
С переходом в основную программу разобрались, осталось научиться обновлять прошивку. Тут важно помнить, что писать можно только в чистые страницы, то есть перед обновлением прошивки, отведённые под неё страницы надо очистить.
Остался не раскрыт еще один вопрос, как передавать прошивку?
Изначально решал эту задачу в лоб, передавая бинарник, в начале которого указывал размер прошивки, но позже понял, что этот вариант неудобный и неправильный. Неудобный он тем, что после генерации бинарника из него надо удалять всё лишнее, то есть если основная программа начинается с адреса 0x08008400, то все пространство до этого адреса будет заполнена FF.
Из скриншота понятно, сколько занимает основная программа и сколько в ней мусора. А неправильный он тем, что прошивка пишется в МК последовательно, но ведь отдельные её части могут лежать где угодно, например картинка, может храниться в конце флеша, тогда получается мы вместо того, чтобы записать картинку по нужному адресу, должны будем последовательно заполнять память МК значениями FF, пока не дойдем до нужного адреса.
Гораздо проще передавать хекс потому, что он состоит из записей(на самом деле их 5 типов, тут описывается структура записи с данными) и в каждой записи указывается размер данных, адрес по которому должны быть записаны данные и сами данные, то есть все ясно и понятно.
Хорошо было бы закончить статью рабочим примером, но мы не разобрали типы записей и не определились со способом обновления. По этому ниже будет описана структура бутлоадера с учетом полученных знаний, а в следующей статье мы разберемся с типами записей и дополним её.
#include "stm32f10x.h"
#define APPLICATION_ADDRESS 0x08008400//адрес начала программы
//выбираем размер страницы в зависимости от модели МК
#if defined (STM32F10X_HD) || defined (STM32F10X_HD_VL) || defined (STM32F10X_CL) || defined (STM32F10X_XL)
#define FLASH_PAGE_SIZE ((uint16_t)0x800)
#else
#define FLASH_PAGE_SIZE ((uint16_t)0x400)
#endif
void Go_To_User_App(void)
{
uint32_t app_jump_address;
typedef void(*pFunction)(void);//объявляем пользовательский тип
pFunction Jump_To_Application;//и создаём переменную этого типа
__disable_irq();//запрещаем прерывания
app_jump_address = *( uint32_t*) (APPLICATION_ADDRESS + 4); //извлекаем адрес перехода из вектора Reset
Jump_To_Application = (pFunction)app_jump_address; //приводим его к пользовательскому типу
__set_MSP(*(__IO uint32_t*) APPLICATION_ADDRESS); //устанавливаем SP приложения
Jump_To_Application(); //запускаем приложение
}
int main(void)
{
uint16_t count_page = 20;//кол-во страниц которые надо стереть
uint16_t erase_counter = 0x00;//счетчик
volatile FLASH_Status FLASHStatus = FLASH_COMPLETE;//статус работы с флэш памятью
//если найдена новая прошивка
if(new_firmware)
{
// для работы с флешем используем StdPeriph, не забудь ее подключить
//разблокируем флэш,
FLASH_Unlock();
//очищаем флаги
FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR);
//стираем страницы
for(erase_counter = 0; (erase_counter <= count_page) && (FLASHStatus == FLASH_COMPLETE); erase_counter++)
{
FLASHStatus = FLASH_ErasePage(APPLICATION_ADDRESS + (FLASH_PAGE_SIZE * erase_counter));
}
/*обновляемся*/
//блокируем флэш
FLASH_Lock();
}
Go_To_User_App();//прыгаем в основную программу
}