Каждый язык программирования имеет свои особенности. Язык ассемблера — самый низкоуровневый язык программирования, он ближе любых других приближен к архитектуре ЭВМ и ее аппаратным возможностям, позволяя получить к ним более полный доступ, нежели в языках высокого уровня, наподобие C, Java, Python и пр. Действия, которые может выполнять ассемблерная программа, делятся на две категории: инструкции центральному процессору и обращения к операционной системе (системные вызовы).
Инструкции являются словесным аналогом машинных команд и указывают, какие действия должны выполнять блоки процессора: загрузку данных из оперативной памяти, арифметические операции, сравнение чисел, пересылку управляющих байтов микроконтроллерам периферийных устройств и т. д. Каждое семейство процессоров имеет свою собственную систему команд, поэтому перенос ассемблерной программы на другую процессорную архитектуру фактически равноценен переписыванию с нуля.
Системные вызовы, т. е. обращения напрямую к ядру ОС, позволяют ассемблерной программе работать с файлами, динамически выделять память выполнять консольный ввод-вывод и совершать другие высокоуровневые (с точки зрения машинной архитектуры) действия. Подпрограммы ядра ОС, выполняющие все эти действия — обычно смесь кода на языке высокого уровня с фрагментами на ассемблере. В качестве языка высокого уровня в подавляющем большинстве операционных систем используется язык С. На ассемблере обычно программируются:
- архитектурно-зависимые части системы, т. е. такие, которые имеют принципиальные различия для разных процессорных архитектур;
- драйвера устройств, или по крайней мере те их части, которые отвечают за непосредственное взаимодействие с микроконтроллером соответствующего устройства;
- фрагменты кода, от которых требуется очень высокая скорость работы.
Наиболее простые микропроцессорные устройства обходятся вовсе без операционной системы, и в этом случае машинные команды — единственное, что доступно программисту. В более сложных устройствах работу программисту облегчает ОС, специально созданная или адаптированная для встраиваемой электроники. На текущий момент из универсальных ОС наиболее распространена в сегменте встраиваемой электроники система GNU/Linux — благодаря открытым исходным кодам, высокой масштабируемости и поддержке широкого спектра самых различных архитектур.
Ниже мы будем рассматривать программы для семейства микропроцессоров ARM, рассчитанные как на самостоятельную работу в микропроцессорном устройстве, так и на работу под управлением ОС GNU/Linux.
Как уже говорилось, без участия ОС ассемблерная программа обычно выполняет простые действия над данными, используя для этого инструкции, задающие команды блокам центрального процессора. Рассмотрим в качестве примера для архитектуры ARM такую программу, складывающую 2 операнда:
.text
start: @ Необязательная строка, обозначающая начало программы
mov r0, #5 @ Загрузка в регистр r0 значения «5»
mov r1, #4 @ Загрузка в регистр r1 значения 4
add r2, r1, r0 @ Складываем r0 и r1, ответ записываем в r2
stop: b stop @ Строка завершения программы
Прежде чем разбираться в действиях программы, рассмотрим на ее примере особенности синтаксиса ассемблера. В отличие от типичных высокоуровневых языков программирования, в ассемблерной программе каждая команда располагается на отдельной строке. Нельзя разместить несколько команд на одной строке, и не принято разбивать одну команду на несколько строк.
Синтаксис ассемблера GNU, который мы будем использовать далее, не является регистро-чувствительным, т. е. ассемблер не различает прописные и строчные буквы английского алфавита в именах команд и элементов данных. Команда может быть директивой – указанием транслятору, превращающему текст программы в машинный код. Многие директивы начинаются с точки. Кроме директив в программе еще бывают инструкции, т. к. команды процессору. Именно они и будут составлять машинный код программы.
Нужно отметить, что понятие «машинного кода» очень условно. Часто оно обозначает просто содержимое выполняемого файла, хранящего кроме собственно машинных команд еще и данные.
Каждая программа на ассемблере строится из инструкций, описанных следующим образом:
{метка} {инструкция|операнды} {@ комментарий}
Метка — необязательный параметр. Это символическое имя, указывающее на расположение инструкции или элемента данных в памяти. При трансляции имена меток заменяются на соответствующие им адреса. Имя метки должно состоять из букв, цифр, знаков
_
и $
.
Инструкция — непосредственно мнемоника (символьное имя) команды процессора.
Операнды — константы, адреса регистров, адреса в оперативной памяти.
Комментарий — необязательная часть, которая отбрасывается при трансляции и не влияет на исполнение программы. Обычно это текст, в котором программист поясняет назначение той или иной строки в программе.
Поскольку обращения к оперативной памяти — достаточно медленная операция, в качестве аргументов команд процессора для выполнения различных вычислений и других действий используют обычно внутренние ячейки памяти процессора — регистры.
Процессор ARM имеет несколько наборов регистров, из которых в каждый момент времени доступны программисту 16. Размер каждого регистра равен машинному слову, т. е. 4 байта. В отличие от ячеек оперативной памяти, регистры имеют имена — от r0
до r15
.
Большинство регистров могут использоваться программистом по своему усмотрению — это так называемые регистры общего назначения. Однако несколько регистров имеют специальное назначение:
r15
— это указатель на следующую исполняемую команду, известный также как PC (англ. Program Counter). Над его содержимым можно выполнять разные арифметические и логические операции, и тем самым будет осуществляться переход по новому адресу, т.е. исполнение программы будет переходить на другие ветви алгоритма. В противном случае, по выполнении команды значение этого регистра будет автоматически увеличено на 4 байта, что означает переход к следующей команде в программе.r14
содержит адрес возврата из подпрограммы и имеет также название LR, или Link Register.r13
является указателем стека и также известен как SP (Stack Pointer).r12
— IP или Intra-Procedure-call scratch register — используется компиляторами языка C особым образом для доступа к параметрам в стеке.
Помимо своего специального назначения, перечисленные 4 регистра могут быть аргументами инструкций процессора, участвуя тем самым в вычислительном процессе в точности так же, как регистры общего назначения.
Как уже упоминалось, процессор ARM имеет несколько наборов регистров. Это связано с тем, что существует несколько режимов работы процессора, и в зависимости от текущего режима работы доступен тот или иной банк регистров. Предполагается, что это избавляет программиста от необходимости сохранения значений регистров в оперативной памяти перед сменой режима.
Следующие режимы работы возможны для всех процессоров семейства ARM:
- режим приложения (USR, user mode);
- режим супервизора или режим операционной системы (SVC, supervisor mode);
- режим обработки прерывания (IRQ, interrupt mode);
- режим обработки «срочного прерывания» (FIRQ, fast interrupt mode).
Например, при возникновении прерывания процессор сам переходит к адресу программы обработчика прерываний и сам автоматически «переключает» банки регистров.
Процессоры ARM более продвинутых версий имеют еще дополнительные режимы:
- Abort (используется для обработки исключений доступа к памяти);
- Undefined (используется для реализации сопроцессора программным способом);
- System (режим привелигированных задач операционной системы).
Как переключаются банки регистров в зависимости от режима, видно из следующей таблицы:
Можно заметить, что регистры r0
— r7
одни и те же для всех режимов, а регистры r8
— r12
общие только для режимов USR, SVC, IRQ. Видно также, что режим FIRQ самый обособленный, у него больше всего собственных регистров. Это позволяет вне очереди обработать какое-то крайне срочное прерывание, не теряя времени на сохранение регистров в стек.
В младших моделях процессоров флаги хранятся в нескольких битах регистра r15
вместе с адресом инструкции. В более продвинутых процессорах семейства ARM все флаги и служебные биты расположены в отдельных регистрах, известных как Current Program Status Register (cpsr
) и Saved Program Status Register (spsr
). Для доступа к этим регистрам существуют отдельные команды. Это сделано, что бы расширить доступное адресное пространство программ.
Как уже упоминалось, разным семействам процессоров соответствуют разные наборы машинных команд. Кроме того, в некоторые команды могут различаться в пределах одного семейства (например, отсутствовать у одних процессоров и присутствовать у других). Приведем в таблице несколько часто встречающихся команд процессоров ARM:
Название | Действие | Пример | Действие |
add |
сложение | add r0, r1, r2 |
r0 = r1 + r2 |
sub |
вычитание | sub r0, r1, r2 |
r0 = r1 – r2 |
mul |
умножение | mul r0, r1, r2 |
r0 = r1 * r2 |
mov |
копирование данных | mov r0, r1 |
r0 = r1 |
ldr |
Загрузка данных из оперативной памяти | ldr r4, [r5] |
r4 = *(r5) |
Как видно, некоторые инструкции легко узнаваемы. Более подробно необходимые детали будут рассмотрены в следующих работах.
В отличие от языков высокого уровня (ЯВУ) ассемблерная программа содержит только тот код, который ввел программист. Никаких дополнительных «оберток». Вся ответственность за «логичность» кода полностью лежит на плечах программиста.
Простой пример. Обычно подпрограммы заканчиваются командой возврата. Если в ЯВУ ее не задать явно, транслятор все равно добавит ее в конец подпрограммы. Ассемблерная подпрограмма без команды возврата не вернется в точку вызова, а будет выполнять код, следующий за подпрограммой, как будто он является ее продолжением. Еще пример. Можно попробовать «выполнить» данные вместо кода. Часто это лишено смысла. Но если программист это сделает, транслятор не выдаст никаких сообщений об ошибке, просто байты данных будут интерпретированы как коды каких-то машинных команд.
Из-за специфики программирования, а также по традиции, для создания программ на языке ассемблера обычно пользуются утилитами командной строки (хотя поддержка ассемблера и есть в некоторых универсальных интегрированных средах).
Весь процесс технического создания ассемблерной программы можно разбить на 4 шага (исключены этапы создания алгоритма, выбора структур данных и т.д.).
- Набор программы в текстовом редакторе и сохранение ее в отдельном файле. Каждый файл имеет имя и тип, называемый иногда расширением. Например, программа на C имеет расширение C, на Pascal – PAS, на языке ассемблера есть несколько вариантов, но мы будем использовать в работе расширение S.
- Обработка текста программы транслятором. На этом этапе текст превращается в машинный код, называемый объектным. Кроме того есть возможность получить листинг программы, содержащий кроме текста программы различную дополнительную информацию и таблицы, созданные транслятором. Тип объектного файла – O, файла листинга – LST. Этот этап называется трансляцией.
- Обработка полученного объектного кода компоновщиком. Тут программа «привязывается» к конкретным условиям выполнения на микропроцессорной системе. Полученный машинный код называется выполняемым. Выполняемый файл обычно не имеет расширения. Этот этап называется компоновкой или линковкой.
- Запуск программы. Если программа работает не совсем корректно, перед этим может присутствовать этап отладки программы при помощи специальной программы – отладчика. При нахождении ошибки приходится проводить коррекцию программы, возвращаясь к шагу 1.
Процесс создания ассемблерной программы можно изобразить в виде следующей схемы:
Конечной целью, напомним, является работоспособный файл в формате ELF (например, «add.elf»), выполняющий сложения чисел.
В отличие от обычных компьютерных программ, программы для встраиваемой электроники разрабатывают (редактируют исходный код, выполняют трансляцию и т. д.) на архитектуре, отличной от той, на которой программа должна выполняться. Трансляция текста программы в исполняемый код для другой платформы (не той, на которой исполняется транслятор), называется кросс-компиляцией, а сами программы, выполняющие эту трансляцию, известны как кросс-компиляторы (англ. cross compiler).
Кросс-компиляция широко используется, когда нужно получить код для платформы, экземпляров которой нет в наличии, когда компиляция на целевой платформе невозможна или нецелесообразна. Например, даже если к встраиваемой микропроцессорной системе удастся подключить дисплей и клавиатуру, установить на нее ОС и все необходимые программы — использование ее в качестве рабочей станции наверняка оставит у программиста негативные впечатления из-за невысокой вычислительной мощности и ограниченных объемов памяти.
Поэтому программы для встраиваемых систем создают и компилируют на персональном компьютере. Более того, даже проверка работоспособности программы может проходить без использования реального микропроцессорного устройства – особенно на ранних этапах. Для простоты, вместо конечного устройства можно использовать программу-эмулятор, которая будет имитировать процессор нужной архитектуры и все необходимые для его работы периферийные устройства. Поскольку имеющаяся в распоряжении программиста рабочая станция, как правило, обладает существенно большими ресурсами, программная эмуляция конечного устройства на ней и запуск разрабатываемой программы на этом виртуальном устройстве достаточно удобны.
В качестве эмулятора системы на процессоре ARM мы будем пользоваться программой QEMU. Это мощная и универсальная система эмуляции, поддерживающая широкий спектр архитектур, доступная на многих платформах, и вместе с тем не слишком сложная в использовании. Для компиляции же программы воспользуемся ассемблером и линковщиком из набора инструментов GNU binutils, входящих в состав многих Unix-подобных операционных систем и также доступных для платформы Windows.
Обратите внимание, что в отличие от основного компилятора и соответствующих ему инструментов, набор для кросс-компиляции не ставится по-умолчанию даже в Unix-подобных системах. Например, в дистрибутивах Linux для его установки требуется доустановить определенный пакет, в имени которого будет присутствовать слово binutils (или toolchain) и название архитектуры, для которой должна выполняться кросс-компиляция: в нашем случае, arm. Пакет qemu более стандартен; однако из-за того, что он поддерживает несколько архитектур, в некоторых дистрибутивах его разделяют на несколько пакетов: для эмуляции ARM-совместимых устройств, интелловской архитектуры и т. д.
Например, в Ubuntu Linux нужные нам кросскомпилятор и эмулятор QEMU можно установить следующей командой (используйте её, например, чтобы установить их на собственный ноутбук):
sudo apt-get install binutils-arm-linux-gnueabi qemu
GNU AS превращает текст программы в объектный код. Чтобы пакеты кросс-компиляции под разные архитектуры могли легко совмещаться на одной файловой системе, к имена утилит из одного пакета обычно имеют одинаковую приставку, поясняющую их принадлежность. Так, например, ассемблер as может быть доступен для вызова по имени arm-none-linux-gnueabi-as
. Чтобы выяснить, какая приставка у кросс-компилятора, установленного в системе, можно набрать в командной arm
и нажать клавишу табуляции, чтобы посмотреть варианты автодополнения для набранной части команды.
Имя файла с текстом программы для ассемблирования задается в командной строке. В простейшем случае это выглядит так:
arm-none-linux-gnueabi-as -o add.o add.s
Опция -о
определяет имя выходного файла. Текст программы из файла add.s
преобразуется в объектный код, который запишется в файл add.o
.
При успешной трансляции вы не увидите никаких сообщений; они появляются только как информация об ошибках или предупреждения. При наличии ошибок объектный файл не создается. Когда транслятор находит что-то нетипичным, он выдает предупреждения. Однако предупреждение не всегда следствие допущенной ошибки.
Полностью формат командной строки AS можно увидеть, набрав as --help
. Для получения более подробной информации см. man as
.
Для того, чтобы получить исполняемую программу, ее необходимо передать на обработку компоновщику или, как его еще называют, линковщику:
arm-none-linux-gnueabi-ld -o add.elf add.o
Ключ -о
с последующим значением задает в данном случае имя создаваемого исполняемого файла.
Формат командной строки LD можно увидеть набрав ld --help
. Для получения более подробной информации см. man ld
.
Сохраним программу в файле add.s
. Для сборки файла требуется сначала вызвать ассемблер GNU — программу «as»:
arm-none-linux-gnueabi-as -o add.o add.s
Далее, чтобы создать исполняемый файл, вызовем компоновщик «ld», как показано в следующей команде:
arm-none-linux-gnueabi-ld -Ttext=0x0 -o add.elf add.o
Здесь опция -Ttext = 0x0
определяет, что адреса должны быть присвоены меткам таким образом, чтобы инструкции начинались с нулевого адреса. Это нужно, поскольку мы собираемся запускать программу без использования операционной системы.
Для просмотра адресов, присвоенных различным меткам, можно использовать команду «nm»:
arm-none-linux-gnueabi-nm add.elf
Результатом выполнения команды будет список:
...
00000000 t start
0000000c t stop
Адрес метки start это 0x0
, так как это метка первой команды. Метка stop находится через 3 инструкции. Каждая инструкция составляет 4 байта. Таким образом метке stop присваивается адрес 12 (0xс
в шестнадцатиричной нотации).
Выходной файл, созданный ld, имеет формат ELF. Доступны различные форматы хранения исполняемого кода. Формат ELF отлично работает в окружении операционной системы, но так как мы собираемся запустить программу на «голом железе», мы должны преобразовать его в более простой формат, называемый двоичным форматом.
В отличие от формата ELF, файл в двоичном формате содержит последовательные байты с определенного адреса в памяти, и больше никакой другой дополнительной информации.
Для преобразования различных форматов объектных файлов может использоваться команда «objcopy» из набора инструментов GNU. Общий вид команды:
objcopy -O <выходной формат> <файл-источник> <файл-приемник>
Для преобразования файла add.elf в двоичный формат выполним следующую команду:
arm-none-linux-gnueabi-objcopy -O binary add.elf add.bin
Можно проверить размер созданного бинарного файла: он должен быть ровно 16 байт, поскольку в листинге 4 команды, и каждая занимает 4 байта. Проверку можно выполнить, например, командой «ls»:
ls -al add.bin
Когда процессор ARM сброшен в нулевое состояние, он начинает выполнение команд с адреса 0×0. На эмулируемой QEMU плате будет 16Мб флеш-памяти, расположенной по адресу 0×0. Поэтому, чтобы протестировать программу на эмулируемой плате, мы должны создать файл, представляющий собой образ flash-памяти объемом 16Мб. Формат файла образа предельно прост: чтобы получить байт из адреса X во flash-памяти, QEMU читает байт со смещением X в файле.
Воспользуемся командой «dd», чтобы скопировать 16Мб нулей из виртуального устройства «генератора нулей» /dev/zero в файл flash.bin . Данные копируются как блоки по 4К.
dd if=/dev/zero of=flash.bin bs=4096 count=4096
Затем скопируем файл add.bin в начало образа flash-памяти с помощью следующей команды:
dd if=add.bin of=flash.bin bs=4096 conv=notrunc
Это эквивалентно помещению bin-файла во flash-память платы.
Команда для вызова QEMU с нужными нам параметрами следующая:
qemu-system-arm -M connex -pflash flash.bin -nographic -serial /dev/null
Параметр -М connex
указывает, что эмулируется плата connex. Опция -pflash
указывает, что файл flash.bin представляет собой flash-память, -Nographic
указывает на необязательность моделирования графического дисплея, а -serial /dev/null
указывает, что последовательный порт платы connex подключается к /dev/null, т. е. не используется.
Запустив программу, мы можем использовать системную консоль QEMU для просмотра содержимого регистров. Так как на данном этапе QEMU не будет иметь интерфейса, так что управлять эмулируемой системой и просматривать её состояние будем при помощи терминала, из которого она была запущена.
Для просмотра содержимого регистров используется команда QEMU info registers
, которая вводится сразу после вызова qemu-system-arm
.
(qemu) info registers
R00=00000005 R01=00000004 R02=00000009 R03=00000000
R04=00000000 R05=00000000 R06=00000000 R07=00000000
R08=00000000 R09=00000000 R10=00000000 R11=00000000
R12=00000000 R13=00000000 R14=00000000 R15=0000000c PSR=400001d3 -Z-- A svc32
- Создайте в своем домашнем каталоге новый подкаталог с именем asm_01. Создайте в нем текстовый файл add.s, и введите в него текст программы из первого раздела, пользуясь правилами оформления ассемблерных программ.
- Скомпилируйте программу и проверьте ее в эмуляторе:
- оттранслируйте полученный текст программы в объектный файл;
- выполните линковку объектного файла;
- преобразуйте отлинкованный файл в бинарный формат;
- разместите бинарный файл в файле образа flash-памяти;
- запустите получившийся образ виртуальной машины на выполнение в эмуляторе QEMU;
- проверьте результат выполнения через монитор QEMU.
- Измените в тексте программы что-либо. Повторите все подпункты пункта 2.
- Какие основные отличия ассемблерных программ от ЯВУ?
- В чем отличие инструкции от директивы?
- Каковы правила оформления программ на языке ассемблера?
- Расскажите о регистрах процессора ARM.
- Расскажите о режимах работы процессора ARM.
- Каковы этапы получения выполняемого файла?
- Каково назначение этапа трансляции?
- Каково назначение этапа компоновки?
- Как выполнить бинарный файл на ARM-устройстве без использования операционной системы?
- Какие аргументы эмулятора QEMU позволяют запускать программы для процессора ARM в виртуальной машине?