Наши партнеры

UnixForum





Библиотека сайта rus-linux.net

Начинаем программировать на языке ассемблера: переход на уровень аппаратного обеспечения

Оригинал: AsmSchool: GETTING Down to the bare METAL
Автор: Mike Saunders
Дата публикации: 1 февраля 2016 г.
Перевод: А.Панин
Дата перевода: 7 марта 2016 г.

Часть 3: Пришло время попрощаться с операционной системой и использовать собственный код для загрузки компьютера.

Для чего это нужно?

  1. Для понимания принципов работы компиляторов.
  2. Для понимания инструкций центрального процессора.
  3. Для оптимизации вашего кода в плане производительности.

В двух предыдущих статьях серии мы рассмотрели такие фундаментальные темы, связанные с использованием языка ассемблера, как работа с регистрами, реализация циклов, использование условных переходов, работа со стеком, а также некоторые другие. Теперь вы обладаете достаточными знаниями для разработки простых программ на языке ассемблера для Linux, но на этот раз мы постараемся разобраться с более низкоуровневой задачей. Да, мы попробуем отказаться от операционной системы и поработать непосредственно с аппаратным обеспечением персонального компьютера. Вы сможете разработать код, который будет исполняться непосредственно на уровне центрального процессора и получать полное управление над системой без какого-либо взаимодействия с операционной системой, которая попросту отсутствует. Довольно увлекательное занятие, не так ли?

Для этого вам придется понять суть процесса загрузки ПК и реализовать соответствующий код для каждого из его этапов. После этого мы создадим простой системный загрузчик, который будет выводить сообщение на экран и разберемся с тем, как запустить его в эмуляторе. Также мы рассмотрим вопрос записи данного загрузчика на съемный носитель, такой, как флеш-накопитель с интерфейсом USB для тестирования созданного загрузчика на реальных машинах и значительного поднятия своего авторитета в рядах гиков.

Процесс загрузки ПК с центральным процессором архитектуры x86

После нажатия кнопки включения ПК перед загрузкой кода ядра операционной системы в оперативную память и его исполнения выполняется ряд важных операций. Разумеется, ПК является всего лишь набором микросхем и не имеет никакого представления о том, чем является ядро операционной системы, как его найти или как прочитать данные из файловой системы с диска. Сам по себе ПК мог бы быть полностью бесполезным, если бы не было прошивки BIOS ("Basic Input/Output System" - "базовая система ввода-вывода"), которая используется практически в каждом из них. (В многих современных компьютерах используется либо режим эмуляции BIOS, либо прошивка UEFI, пришедшая на смену BIOS, поэтому в том случае, если вы используете компьютер с прошивкой UEFI, вам придется использовать эмулятор ПК для тестирования описанного в статье системного загрузчика, о чем будет сказано позднее.)

BIOS по своей сути является прошивкой, поставляемой в комплекте с ПК и содержащей программный код, который исполняется центральным процессором сразу же после включения ПК. Обычно BIOS выполняет множество проверок для того, чтобы гарантировать исправность аппаратного обеспечения перед загрузкой операционной системы, например, проверяет наличие модулей памяти и выводит классическое сообщение "Press F1 to continue" в том случае, если вы забыли подключить к компьютеру клавиатуру.

После этого BIOS пытается загрузить данные с какого-либо носителя. Большинство из существующих прошивок BIOS имеет доступ к содержимому гибких дисков, жестких дисков, оптических дисков CD/DVD-ROM, а также в некоторых случаях флеш-накопителей с интерфейсом USB. Но при этом прошивки BIOS имеют малый размер и не содержат драйверов для доступа к содержимому большого количества файловых систем. Таким образом, BIOS не понимает, как получить доступ к содержимому файловых систем ext4 и Btrfs, используемых в Linux, и, следовательно, не может получить доступ к содержимому раздела жесткого диска для чтения файла ядра Linux, но при этом может считать блок размером в 512 байт с диска, загрузить его в память и выполнить его.

Это наш код, исполняющийся в эмуляторе ПК - для этого не требуется операционной системы

Это наш код, исполняющийся в эмуляторе ПК - для этого не требуется операционной системы

Многоуровневый процесс загрузки

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

Теперь мы можем разработать свой собственный код, который уместится в выделенные для него 512 байт и получит полный контроль над системой. Но у вас может возникнуть закономерный вопрос: "Как без операционной системы вывести сообщение на экран? Не придется ли нам разрабатывать сложный драйвер для видеоадаптера с функциями для отображения пикселей на экране и описаниями шрифтов, которые гарантированно не уместятся в 512 байт?"

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

Ознакомьтесь со списком процедур BIOS, приведенным на странице www.ctyme.com/intr/int-10.htm

Ознакомьтесь со списком процедур BIOS, приведенным на странице www.ctyme.com/intr/int-10.htm

Разработка кода для непосредственного взаимодействия с аппаратным обеспечением

Что же, давайте разработаем код, который поместится в доступное 512-байтовое хранилище. Ниже приведена простая программа, которая будет бесконечно выводить разноцветные сообщения на экран - ну, или до того момента, когда вы выключите компьютер, что более реалистично. Сохраните приведенный ниже код в файле с именем boot.asm или загрузите его по адресу www.linuxvoice.com/code/lv014/boot.asm.

BITS 16
mov ax, 07C0h ; Адрес, по которому код загрузчика размещается в памяти
mov ds, ax ; Сегмент данных
mov ax, 9000h ; Подготовка стека
mov ss, ax
mov sp, 0FFFFh ; Стек растет вниз!
mov ah, 0 ; Процедура установки видеорежима
mov al, 0Dh ; 320x200x16 цветов
int 10h ; Вызов процедуры BIOS
loop:
mov si, text_string
call print_string
inc bl ; Изменение цвета текста
jmp loop
text_string db  Bare metal rules!  , 0
print_string:
mov ah, 0Eh ; Процедура печати символа
.repeat:
lodsb
cmp al, 0
je .done
int 10h ; Вызов процедуры BIOS
jmp .repeat
.done:
ret
times 510-($-$$) db 0
dw 0AA55h ; Последовательность символов, указывающая на окончание кода загрузчика

Если вы читали предыдущие статьи серии, вы сможете узнать некоторые рассмотренные ранее инструкции, но в данном коде есть и много нового. Это объясняется тем, что мы больше не имеем доступа к функциям ядра Linux для выполнения различных задач, но при этом мы можем беспрепятственно взаимодействовать с BIOS. Первая строка, BITS 16, является директивой, которая сообщает ассемблеру NASM (то есть, программе, преобразующей код на языке ассемблера в бинарный код, который может исполняться центральным процессором), что необходимо генерировать 16-битный код. При включении компьютера с центральным процессором с архитектурой x86, он начинает работу в 16-битном режиме точно так же, как и все ПК, выпущенные в начале 1980-х годов, по соображениям обратной совместимости. Современные операционные системы, такие, как Linux и Windows, используют различные инструкции для перевода центрального процессора в 32-битный (или 64-битный) режим, но в данном случае они нам не нужны, ведь мы будем просто выводить текст на экран.

В результате BIOS загрузит нашу 512-байтную программу в оперативную память по адресу 07C0 (в шестнадцатеричном формате), эквивалентному 1984 в десятичном формате. (Она не сможет загрузить программу по адресу 0, так как по этому адресу хранятся важные системные данные.) С помощью двух первых инструкций mov в рамках нашего кода мы добавляем в регистр сегмента данных (DS) указатель на этот адрес 07C0. Сегменты являются рудиментами 16-битных систем и мы не будем подробно рассматривать их в рамках данной статьи, но в любом случае стоит упомянуть о следующей особенности этих систем: в 16-битном регистре могут храниться лишь числовые значения из диапазона от 0 до 65535. Исходя из этого, при использовании 16-битной адресации памяти вы можете осуществлять доступ лишь к 65535 позициям в памяти, то есть к 64 килобайтам. Этого объема памяти не хватало для выполнения различных задач, поэтому до момента массового выпуска 32-битных центральных процессоров для доступа к большему объему оперативной памяти в условиях использования 16-битных центральных процессоров использовались "сегменты".

Концепция сегментов значительно осложнила программирование под 16-битные центральные процессоры, поэтому все без исключения были рады перейти к использованию 32-битных центральных процессоров и получить доступ к 4 ГБ оперативной памяти. Исходя из того, что нашей программе не нужен большой объем оперативной памяти, нам не придется осуществлять сложные операции с сегментами, причем высока вероятность того, что вам также не придется работать с ними в обозримом будущем, конечно же, если вы не планируете разрабатывать 16-битную программу, которой потребуется более 64 КБ оперативной памяти.

В любом случае, мы используем еще три инструкции mov для подготовки стека. Мы размещаем стек в определенном сегменте с помощью регистра SS (stack segment - регистр сегмента стека), после чего помещаем указатель на начало стека FFFFh в регистр SP (stack pointer - регистр указателя на начало стека). Если вы хорошо разобрались с описанной в прошлой статье серии методике перевода значений из шестнадцатеричной системы счисления в десятичную и обратно, вы наверняка догадались, что значение FFFFh в шестнадцатеричной системе счисления эквивалнтно значению 65535 в десятичной системе счисления. Но почему мы помещаем указатель на начало стека в самую последнюю позицию сегмента? Не наступит ли переполнения стека и аварийного завершения работы программы, если мы поместим что-либо в стек?

Ну, ответ достаточно прост: нет. Известно, что в системах с центральными процессорами архитектуры x86 стек растет вниз, поэтому при помещении в него, к примеру, 16-битного (двухбайтового) числового значения, значение указателя стека уменьшится на два байта. Если вы извлечете это двухбайтовое значение из стека, значение указателя стека будет восстановлено. (Если вы продолжите извлекать из стека значения, которые не были добавлены в него, произойдет переход значения указателя стека через значение 65535, после чего вы сможете вдоволь повеселиться, отлаживая свою программу )

А это наш код, загруженный с флеш-накопителя (который работает в режиме эмуляции флоппи-дисковода) и исполняющийся на ноутбуке Asus. Серьезное доказательство его работоспособности, не правда ли?

А это наш код, загруженный с флеш-накопителя (который работает в режиме эмуляции флоппи-дисковода) и исполняющийся на ноутбуке Asus. Серьезное доказательство его работоспособности, не правда ли?

Прикосновение к радуге

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

mov ah, 0 ; Процедура установки видеорежима
mov al, 0Dh ; 320x200x16 цветов
int 10h ; Вызов процедуры BIOS

Помните, как в предыдущих статьях серии мы использовали инструкцию int 80h для вызова функций ядра Linux? Для вызова функций BIOS мы используем аналогичную инструкцию int 10h, причем BIOS также требует передачи различных параметров посредством регистров. В обычных условиях вы можете поместить идентификатор необходимой процедуры BIOS в регистр AH, а дополнительные параметры - в другие регистры. Например, для изменения видеорежима нам пришлось поместить значение 0 в регистр AH, но откуда взято это значение? В прошлом нам пришлось бы обратиться к книге с описанием внутреннего устройства BIOS, но сегодня список подпрограмм BIOS несложно найти в сети, например, по следующему адресу: www.ctyme.com/intr/int-10.htm.

Как вы видите, BIOS позволяет использовать такие подпрограммы, как "Set video mode", "Wrte graphcs pixel", "Teletype output" (которые мы будем использовать позднее) и другие. Если вы перейдете по ссылке "int 10/AH=00h", вы увидите список видеорежимов, в котором несложно обнаружить используемый нами видеорежим 0Dh с разрешением экрана 320 x 240 пикселей и глубиной цвета в 16 бит. Это смехотворно малое разрешение экрана по сегодняшним стандартам, которое, тем не менее, позволяет гарантировать работоспособность результирующего кода на любых системах, в том числе на компьютерах конца 80-х годов, собирающих пыль на чердаках домов.

После этого в коде реализован следующий цикл:

loop:
mov si, text_string
call print_string
inc bl ; Изменение цвета текста
jmp loop

В рамках данного цикла осуществляется вызов процедуры print_string (которая описана ниже). Процедура принимает указатель на начало завершающейся нулем строки посредством регистра SI, а также значение цвета посредством регистра BL. Данный цикл выполняется бесконечно, причем при каждой итерации мы увеличиваем значение в регистре BL, следовательно, значение в этом регистре постепенно увеличивается от 0 до 255, после чего снова становится равным 0. Таким образом реализуется постоянное циклическое изменение цвета выводимых сообщений.

Перейдя ниже по коду программы, вы можете обнаружить, что процедура print_string немного похожа на процедуру, реализованную нами в статье месячной давности, но является более простой и не вычисляет длину строки. В этот раз мы используем процедуру BIOS под названием "Teletype text" с идентификатором 0Eh, которая позволяет вывести символ на экран и передвинуть курсор. Определенный символ передается посредством регистра AL (этот символ извлекается из строки с помощью инструкции lodsb), а значение цвета, установленное описанным выше образом - посредством регистра BL. Таким образом, в рамках данной подпрограммы осуществляется извлечение символов из строки и вывод их на экран средствами BIOS (с помощью инструкции int 10h) до того момента, пока не встречается нулевой символ, после чего используется инструкция ret для возврата в точку ее вызова.

Важным аспектом данного кода является использование меток с символами точек в начале, например:

.repeat:

Символ точки указывает на то, что это локальная метка, при этом ассемблер NASM раскрывает ее имя, добавляя в качестве префикса название ближайшей расположенной выше в коде полноценной (без начального символа точки в имени) метки. Таким образом, NASM преобразует имя рассматриваемой метки в имя "print_string.repeat" в процессе обработки кода. Вы можете задать вопрос: "Чем же полезны данные метки?". Ну, поддержка локальных меток позволяет использовать локальные метки с идентичными именами в рамках кода программы. Например, в файлах исходного кода большого объема для удобства нередко используется множество локальных меток с такими именами, как "loop", "repeat" или "finish". Благодаря поддержке локальных меток, в рамках каждой подпрограммы может однократно использоваться каждая из этих меток и вам не придется каждый раз изобретать уникальные имена.

Две последние строки кода содержат не инструкции, а директивы ассемблера NASM:

times 510-($-$$) db 0
dw 0AA55h ; Последовательность символов, указывающая на окончание кода загрузчика

Для того, чтобы прошивка BIOS смогла распознать и загрузить нашу программу, программа должна иметь размер, равный ровно 512 байтам и заканчиваться числовым значением AA55h. По этой причине в первой строке приведена директива, позволяющая дополнить бинарный код программы нулевыми байтами до 510 байт, а во второй - директива, позволяющая дописать "машинное слово" (16-битное двухбайтовое значение) 0AA55h в конец бинарного кода программы.

На этом разработка системного загрузчика может считаться оконченной! Мы не реализовали какого-либо сложного механизма в рамках данного системного загрузчика, но вы можете самостоятельно добавить в него любой код с тем условием, что объем его результирующего бинарного кода не будет превышать 512 байт. (Если объем результирующего бинарного кода становится слишком большим, NASM выведет сообщение об ошибке при попытке ассемблирования.) Ограничение объема бинарного кода системного загрузчика 512 байтами может показаться слишком жестким условием для реализации каких-либо сложных функций, но опытные разработчики умудряются реализовывать большой спектр функций даже в рамках такого ограниченного объема кода, примером чему может служить соревнование "512-byte OS contest", проходившее по адресу http://forum.osdev.org/viewtopic.php?f=2&t=21042. В рамках данного соревнования разработчикам предлагалось создать какое-либо впечатляющее приложение, объем бинарного кода которого не должен превышать 512 байт, и они, разумеется, не остались в долгу: один из разработчиков создал псевдо-трехмерный хранитель экрана с видом из кабины гоночной машины, другой - игру "Жизнь" ("Game of Life").

Другим проектом, который может показаться вам интересным и как источник фрагментов исходного кода для исследования, и как источник вдохновения, является проект Tetranglix, расположенный по адресу https://github.com/Shikhin/tetranglix. Это игра "Тетрис", реализованная в формате системного загрузчика, то есть, в рамках 512 байт и, хотя она не позволяет насладиться какими-либо визуальными эффектами, в ней реализованы все элементы геймплея нестареющей классической игры. Кроме того, существует проект BootChess, который с гордостью называют "наикомпактнейшей компьютерной реализацией шахмат для любой из платформ" объемом всего в 487 байт: www.pouet.net/prod.php?which=64962.

Если вам нужны дополнительные примеры реализации 16-битного системного загрузчика и простой операционной системы, вы можете перейти по адресу http://tinyurl.com/dossource (для ознакомления с исходным кодом MS-DOS 1.1 и 2.0)

Если вам нужны дополнительные примеры реализации 16-битного системного загрузчика и простой операционной системы, вы можете перейти по адресу http://tinyurl.com/dossource (для ознакомления с исходным кодом MS-DOS 1.1 и 2.0)

Исполнение кода

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

mkdosfs -C floppy.img 1440

Данная команда создает образ диска DOS с именем floppy.img размером 1.4 МБ. После этого следует осуществить ассемблирование кода:

nasm -f bin -o boot.bin boot.asm

Параметр -f bin является особо важным в данном случае, ведь мы желаем получить простой бинарный файл, а не сложный исполняемый файл для Linux со всеми дополнительными секциями и данными. Данная команда позволяет создать бинарный файл с именем boot.bin размером в 512 байт, после чего следует добавить его в начало ранее созданного образа флоппи-диска с помощью аналогичной команды:

dd conv=notrunc if=boot.bin of=floppy.img

Теперь следует установить эмулятор ПК, такой, как DOSBox или QEMU из репозитория программного обеспечения вашего дистрибутива и загрузить образ созданного виртуального флоппи-диска его средствами с помощью одной из следующих команд:

dosbox floppy.img
qemu-system-i386 floppy.img

Вуаля: на экране появляются разноцветные сообщения, генерируемые только что разработанным кодом для аппаратной платформы. Неплохо, не так ли? Если вы желаете протестировать созданный код на реальной аппаратной платформе, вы можете обратиться к следующему разделу, а в следующем месяце мы значительно усложним этот системный загрузчик таким образом, что он сможет исполнять программы с диска, в том числе разработанные вами. Да, мы превратим его в ограниченную в плане функций, но работоспособную операционную систему!

Запуск системного загрузчика на реальной аппаратной платформе

Если ваш компьютер имеет встроенный дисковод для флоппи-дисков, вы можете записать образ виртуального диска на реальный диск с помощью следующей команды:

dd if=floppy.img of=/dev/fd0 bs=1024

Вы должны использовать учетную запись пользователя root для выполнения этой команды, а в случае использования дисковода с интерфейсом USB, вам придется изменить имя файла устройства на /dev/sdb1 или аналогичное - воспользуйтесь командой dmesg сразу же после подключения дисковода для выяснения этого имени устройства. После записи образа диска вы сможете загрузить компьютер с подготовленного диска и убедиться в работоспособности кода на реальной аппаратной платформе.

Велика вероятность того, что вы не пользовались флоппи-дисками уже много лет, но в этом случае существует альтернативный вариант: флеш-накопитель с интерфейсом USB. Многие прошивки BIOS позволяют осуществлять чтение образа флоппи-диска с флеш-накопителя с интерфейсом USB и исполнять его таким образом, как будто он считан с обычного флоппи-диска. Обратите внимание на то, что в случае использования данного способа загрузки вы удалите все денные с накопителя с интерфейсом USB, причем впоследствии вам придется отформатировать этот накопитель! Подключите накопитель к компьютеру и выполните команду dmesg в терминале. В конце вывода вы должны обнаружить сообщения, аналогичные следующему:

sd 2:0:0:0: [sdc] 501760 512-byte logical blocks

На основе этого сообщения несложно сделать вывод о том, что подключенному накопителю соответствует файл устройства с именем sdc - в вашем случае имя файла устройства может отличаться. Отмонтируйте/извлектие накопитель с помощью вашего файлового менеджера (или команды umount в терминале) и запишите образ флоппи-диска на него с помощью следующей команды:

dd if=floppy.img of=/dev/sdc bs=1024

Убедитесь в том, что вы правильно ввели команду и замените имя файла устройства /dev/sdc на имя устройства, которое вы увидели в выводе команды dmesg. Вы можете задать вопрос на нашем форуме (http://forums.linuxvoice.com) в том случае, если столкнетесь с какими-либо трудностями.

Как только данные будут записаны на накопитель, вы увидите приглашение командной оболочки, после чего вы можете перезагрузить свой ПК и выбрать вариант загрузки с накопителя с интерфейсом USB в меню загрузки BIOS. В случае корректного выполнения приведенных выше рекомендаций вы должны снова увидеть разноцветные сообщения, но в этот раз код системного загрузчика будет исполняться непосредственно на аппаратной платформе. Насколько это круто? Ответ прост: это очень круто!


Предыдущие статьи из серии "Школа ассемблера":