Процесс сборки программ, предназначенных для другой процессорной архитектуры или операционной системы называется кросс-компиляцией.
Для этого необходимо специальная версия компилятора gcc
, предназначенного для другой платформы. Во многих дистрибутивах существуют отдельные пакеты компилятора для других платформ, включая различные варианты ARM. Готовую сборку компилятора для armv8 можно взять из проекта Linaro: http://releases.linaro.org/components/toolchain/binaries/7.5-2019.12/aarch64-linux-gnu/.
Полные названия команд gcc
имеют вид триплетов:
ARCH-OS[-VENDOR]-gcc
ARCH-OS[-VENDOR]-g++
ARCH-OS[-VENDOR]-gdb
и т. д.
где ARCH
- это имя архитектуры: i686
, x86_64
, arm
, aarch64
, ppc
и т.д.; OS
- целевая операционная система, например linux
, win32
или darwin
; а необязательный фрагмент триплета VENDOR
- соглашения по бинарному интерфейсу, если их для платформы существует несколько, например для ARM это может быть gnu
(стандартное соглашение Linux) или none
(без операционной системы, просто голое железо).
Выполнение программ, предназначенных для других архитектур, возможно только интерпретацией инородного набора команд. Для этого предназначены специальные программы - эмуляторы.
Архитектуры arm/aarch64, как и многие другие архитектуры, поддерживает эмулятор QEMU.
Эмулировать можно как компьютерную систему целиком, по аналогии с VirtualBox, так и только набор команд процессора, используя при этом окружение хост-системы Linux.
Команды QEMU имеют вид:
qemu-ARCH
qemu-system-ARCH
где ARCH - это имя эмулируемой архитектуры. Команды, в названии которых присутствует system, запускают эмуляцию компьютерной системы, и для их использования необходимо установить операционную систему.
Команды без system требуют в качестве обязательного аргумента имя выполняемого файла для ОС Linux, и эмулируют только набор команд процессора в пользовательском режиме, выполняя "инородный" исполняемых файл так, как будто это обычная программа.
Поскольку большинство программ, скомпилированных для Linux и другой процессорной архитектуры,, подразумевают использование стандартной библиотеки Си, необходимо использовать именно версию glibc для для нужной архитектуры. Минимальное окружение с необходимыми библиотеками можно взять из проекта Linaro (см. ссылку выше), и скормить его qemu с помощью опции -L ПУТЬ_К_SYSROOT.
Пример компиляции и запуска:
# в предположении, что компилятор распакован в /opt/aarch64-gcc,
# а sysroot - в /opt/aarch64-sysroot
# Компилируем
> /opt/aarch64-gcc/bin/aarch64-linux-gnu-gcc -o program hello.c
# На выходе получаем исполняемый файл, который не запустится
> ./program
bash: ./program: cannot execute binary file: Exec format error
# Но мы можем запустить его с помощью qemu-aarch64
> qemu-aarch64 -L /opt/aarch64-sysroot ./program
Hello, World!
У команд qemu-*
предусмотрена возможность запуска в режиме отладки, - в этом случае возможно взаимодействие с qemu
точно так же, как и с gdbserver
:
# Компилируем с отладочной информацией
> /opt/aarch64-gcc/bin/aarch64-linux-gnu-gcc -g -o program hello.c
# Запускаем под qemu в режиме отладки
# Обратите внимание на опцию -g ПОРТ
> qemu-aarch64 -L /opt/aarch64-sysroot -g 1234 ./program
# В другом терминале можно подключиться к программе из gdb
> /opt/aarch64-gcc/bin/aarch64-linux-gnu-gdb ./program
(gdb) target remote localhost 1234
Программы на языка ассемблера для компилятора GNU сохраняются в файле, имя которого оканчивается на .s
или .S
. Во втором случае (с заглавной буквой) подразумевается, что текст программы может быть обработан препроцессором, то есть можно использовать конструкции #define
или #include
.
Для компиляции используется одна из команд: aarch64-linux-gnu-as
или aarch64-linux-gnu-gcc
. В первом случае текст только компилируется в объектный файл, во втором - в выполняемую программу, скомпонованную со стандартной библиотекой Си, из которой можно использовать функции ввода-вывода.
Полная документация по архитектуре команд armv8 приведена в официальном источнике. Описание основных инструкций приведено в разделе C3: A64 Instruction Set Overview.
Разберем синтаксис ассемблера armv8 на примере реализации функции f
, которая доступна извне, и вычисляет выражение
A*x*x + B*x + C
, где A
, B
, C
и x
являются агументами функции f(A,B,C,x)
.
// Это комментарий, как в Си/С++
.text // начало секции .text с кодом программы
.global f // указание о том, что метка f будет доступна извне
// (аналог extern в языке Си)
f: // метка - имя функции или строки для перехода
// последовательность команд для вычисления
mul x0, x0, x3
mul x0, x0, x3
mul x1, x1, x3
add x0, x0, x1
add x0, x0, x2
// возвращаемся из функции f
ret
Процессор может выполнять операции только над регистрами - ячейками памяти в ядре процессора. У armv8 есть 31 регистр общего назначения, доступных программно: x0
, x1
, ... , x30
. Размер каждого регистра - 64 бит. Обращение по именам w0
, w1
, ..., w30
к этим регистрам означает использование младших 32-битных частей соответствующих регистров с префиксом x
.
Регистры x30
и sp
следует изменять с осторожностью, поскольку у них есть специальное назначения для всех платформ armv8, независимо от используемой операционной системы: x30
хранит адрес возврата из функции, sp
- указатель на вершину стека. Кроме того, в системе Linux предусмотрены следующие соглашения об использовании регистров:
Регистры | Назначение |
---|---|
x0 ... x7 |
аргументы функции и возвращаемое значение (x0 ) |
x8 ... x18 |
временные регистры, для которые не гарантируется сохранение результата, если вызывать какую-либо функцию |
x19 ... x28 |
регистры, для которых гарантируется, что вызываемая функция их не будет портить |
x29 |
указатель на границу фрейма функции, обычно используется отладчиком |
x30 |
адрес возврата из функции |
sp |
указатель на вершину стека |
Помимо регистров общего назначения предусмотрены еще два специальных регистра: xzr
- всегда хранит значение 0
, и pc
- Program Counter, который хранит адрес следующей инструкции, которая должна быть выполнена.
Выполнение команд может приводить к появлению некоторой дополнительной информации, которая хранится в регистре флагов. Флаги относятся к последней выполненной команде. Основные флаги, это:
C
: Carry - возникло беззнаковое переполнениеV
: oVerflow - возникло знаковое переполнениеN
: Negative - отрицательный результатZ
: Zero - обнуление результата.
Команды процессора выполняются определенные действия над регистрами. Некоторые команды, которые называются командами переходов, позволяют менять значение регистра pc
.
Для арифметических команд необходимо указывать в качестве первого аргумента регистр, в который нужно записать результат, а остальные аргументы - это аргументы операции. Если используются регистры с префиксом w
, то подразумевается 32-битная арифметика, если регистры с префиксом x
- 64-битная арифметика. Для того, чтобы арифметические команды изменяли флаги, необходимо указать им суффикс s
, - в противном случае значения флагов не изменятся.
Базовые арифметические команды:
add X𝕕, X𝕒, X𝕓
// X𝕕 ← X𝕒 + X𝕓sum X𝕕, X𝕒, X𝕓
// X𝕕 ← X𝕒 - X𝕓mul X𝕕, X𝕒, X𝕓
// X𝕕 ← X𝕒 ∙ X𝕓madd X𝕕, X𝕒, X𝕓, X𝕔
// X𝕕 ← X𝕒 ∙ X𝕓 + X𝕔umaddl X𝕕, W𝕒, W𝕓, X𝕔
// X𝕕 ← W𝕒 ∙ W𝕓 + X𝕔, где W𝕒 и W𝕓 - беззнаковые значенияsmaddl X𝕕, W𝕒, W𝕓, X𝕔
// X𝕕 ← W𝕒 ∙ W𝕓 + X𝕔, где W𝕒 и W𝕓 - знаковые значенияudiv X𝕕, X𝕒, X𝕓
// X𝕕 ← X𝕒 / X𝕓, где X𝕒 и X𝕓 - беззнаковые значенияsdiv X𝕕, X𝕒, X𝕓
// X𝕕 ← X𝕒 / X𝕓, где X𝕒 и X𝕓 - знаковые значения
Побитовые операции:
and X𝕕, X𝕒, X𝕓
// X𝕕 ← X𝕒 & X𝕓orr X𝕕, X𝕒, X𝕓
// X𝕕 ← X𝕒 | X𝕓eor X𝕕, X𝕒, X𝕓
// X𝕕 ← X𝕒 ^ X𝕓asr X𝕕, X𝕒, X𝕓
// X𝕕 ← X𝕒 >> X𝕓 (арифметический сдвиг, деление на степени 2)lsr X𝕕, X𝕒, X𝕓
// X𝕕 ← X𝕒 >> X𝕓 (логический сдвиг)lsl X𝕕, X𝕒, X𝕓
// X𝕕 ← X𝕒 << X𝕓 (логический сдвиг)
Копирование и приведения типов:
mov X𝕕, X𝕒
// X𝕕 ← X𝕒uxtb X𝕕, X𝕒
// X𝕕 ← X𝕒, где X𝕒 - это uint8_tuxth X𝕕, X𝕒
// X𝕕 ← X𝕒, где X𝕒 - это uint16_tuxtw X𝕕, X𝕒
// X𝕕 ← X𝕒, где X𝕒 - это uint32_tsxtb X𝕕, X𝕒
// X𝕕 ← X𝕒, где X𝕒 - это int8_tsxth X𝕕, X𝕒
// X𝕕 ← X𝕒, где X𝕒 - это int16_tsxtw X𝕕, X𝕒
// X𝕕 ← X𝕒, где X𝕒 - это int32_t
Внутри программы отдельные инструкции могут иметь метки, - именованные относительные адреса в программе, которые ассемблер при сборке программы заменяет на численные смещения относительно текущей инструкции. Управление ходом программы осуществляется инструкциями перехода на метки, которые могут быть как безусловными, так и выполняться при выполнении определенного условия.
Команды безусловного перехода:
b LABEL
- безусловный переход на меткуLABEL
, которая закодирована в команду как смещение относительно текущей выполняемой инструкции, эта инструкция обычно используется для переходов внутри функции;bl LABEL
- безусловный переход на меткуLABEL
, как в случае инструкцииb
, но при этом в регистрx30
сохраняется адрес следующей заbl
инструкции, чтобы к нему можно было вернуться инструкциейret
; обычно эта инструкция используется для вызова функций, метки которых синтаксически ничем не отличаются от меток внутри функций.
Метки должны быть отдалены от текущей инструкции не более чем на ±32Mb
. Этого обычно более чем достаточно для вызова функции из того же самого исполняемого файла, где используется вызов функции, но возможны и вызовы функций, которые в памяти располагаются значительно дальше, например функции библиотек. Для таких случаев предусмотрена возможность перехода на инструкцию по 64-битному адресу:
br X
- безусловный переход на адрес, который хранится в регистреX
;brl X
- безусловный переход на адрес, который хранится в регистреX
, с сохранением адреса возврата в регистреx30
, который может быть использован инструкциейret
.
Арифметические операции, у которых в названии указан суффикс s
, а также команда сравнения регистров cmp X𝕒, X𝕓
изменяют набор флагов процессора. Эти флаги могут использоваться в качестиве условий для операций условного перехода, которые кодируются в виде суффиксов инструкции b
:
EQ equal (Z)
NE not equal (!Z)
CS or HS carry set / unsigned higher or same (C)
CC or LO carry clear / unsigned lower (!C)
MI minus / negative (N)
PL plus / positive or zero (!N)
VS overflow set (V)
VC overflow clear (!V)
HI unsigned higher (C && !Z)
LS unsigned lower or same (!C || Z)
GE signed greater than or equal (N == V)
LT signed less than (N != V)
GT signed greater than (!Z && (N == V))
LE signed less than or equal (Z || (N != V))
Пример. Реализация цикла, который в Си нотации эквивалентен for (x0=0; x0<x1; x0++) {}
:
mov x0, 0 // x0 = 0
mov x1, 10 // x1 = какое-то значение, например 10
LoopBegin: // метка начала цикла
cmp x0, x1 // сравниваем
bge LoopEnd // переходим в конец цикла, если x0 >= x1
// какие-то инструкции внутри цикла
add x0, x0, 1 // инкремент переменной цикла
b LoopBegin // переходим к началу цикла, где будет проверка
LoopEnd:
// цикл закончился
В традиционной для RISC-архитектур модели адресации, процессоры умеют выполнять действия только над регистрами. Любое взаимодействие с памятью осуществляется отдельными командами загрузки и сохранения.
Для базового обращения к памяти используются инструкции:
ldr R𝕕, [X𝕒]
- прочитать из памяти содержимое по адресу, указанному в регистреX𝕒
и сохранить результат вR𝕕
str R𝕒, [X𝕕]
- сохранить содержимое регистраR𝕒
в памяти по адресу, указанному в регистреX𝕕
Эти инструкции оперируют 32 или 64-битными данными (размерность определяется названием регистра). Для чтения/записи операндов меньшего размера эти инструкции имеют дополнительные суффиксы:
ldrb
/strb
- дляuint8_t
ldrsb
/strsb
- дляint8_t
ldrh
/strh
- дляuint16_t
ldrsh
/strsh
- дляint16_t
ldrsw
/strsw
- дляint32_t
Обратите внимание на то, что для меньшей, чем размер регистра, разрядности используются разные инструкции для знаковых и беззнаковых типов данных. Это связано с тем, как должен обрабатываться старший (знаковый) бит целого числа, - либо оставаться на месте, либо становиться старшим битом регистра.
Для 64-битной архитектуры ARMv8 (но не для 32-битных архитектур ARM) возможно указание смещения относительно базового адреса. Этот синтаксис может быть использован для обращения к полям структур, если известен адрес начала структуры в памяти, либо для обращения к элементам массивов по индексу.
// загрузка значения по адресу из x1 со смещением 8 байт
ldr x0, [x1, 8] // x0 = *(x1 + 8)
// загрузка значения по адресу из x1 с индексом элемента x2,
// в предположении, что размер одного элемента равен 8 байтам
ldr x0, [x1, x2, lsl 3] // x0 = *(x1 + x2 * (1 << 3))