Компилятор GNU для языка C позволяет компилировать программы, в которых взаимодействует код на ассемблере и код на языке Си.
Вместо того, чтобы с нуля определять весь функционал программы на ассемблере, мы можем использовать уже готовый функционал языка C, который также обладает высокой производительностью. Например, какие-то определенные функции, для написания которых на ассемблере потребовалось бы много времени и для которых не нужна высокая производительность ассемблера. Для вызова функций языка C из кода на ассемблере необходимо добавить в программу среду выполнения языка C. Но стоит отметить, что различные функции среды выполнения С могут опираться на возможности конкретной операционной системы, например, Linux. И если программа создается под Linux (в том числе Android), то для компиляции приложения с помощью компилятора GCC на Windows среди цепочек компиляции на https://developer.arm.com/downloads/-/arm-gnu-toolchain-downloads лучше выбрать пакет aarch64-none-linux-gnu.
(Если в силу географической принадлежности доступ к сайту блокируется, то необходимый пакет инструментов для Windows можно загрузить отсюда - arm-gnu-toolchain-13.2.rel1-mingw-w64-i686-aarch64-none-linux-gnu.exe)
На Linux x86-64 можно установить аналогичный пакет gcc-aarch64-linux-gnu:
sudo apt-get install gcc-aarch64-linux-gnu
На Linux ARM64 можно установить пакет gcc:
sudo apt-get install gcc
Следует отметить, что aarch64-none-linux-gnu
доступен только для разработки на Linux и Windows, а для MacOS не доступен.
Самый простой способ добавить в исполняемый файл среду выполнения C состоит в компиляции программы с помощью компилятора GNU для языка C, то есть gcc
,
который также включает и ассемблер as и который при установки располагается в том же каталоге:
GCC при компиляции автоматически добавит среду выполнения языка C. В зависимости от платформы для компиляции применяется команда
aarch64-none-linux-gnu-gcc -o app app.s // На Windows aarch64-linux-gnu-gcc -o app app.s // На Linux x86-64 gcc -o app app.s // На Linux ARM64
Эта команда вызовет ассемблер as для файла app.s
и затем автоматически вызовет
программу компоновщика ld, которая добавит среду выполнения языка C.
Следует отметить, что для запуска на некоторых системах (например, для запуска на Android) может потребоваться скомпилировать приложение с флагом -static
для статической линковки функционала:
aarch64-none-linux-gnu-gcc -o app app.s -static // На Windows aarch64-linux-gnu-gcc -o app app.s -static // На Linux x86-64 gcc -o app app.s -static // На Linux ARM64
Рассмотрим простейший пример - выведем на консоль строку с помошью стандартной функции С - printf. Для этого определим файл app.s со следующим кодом:
.global main main: // функция main STR LR,[SP,#-16]! // сохраняем в стеке текущий адрес из регистра LR LDR X0, =message // загружаем выводимую строку BL printf // вызываем стандартную функцию printf языка С MOV X0, #0 // код возврата LDR LR, [SP], #16 // извлекаем из стека адрес в регистр LR RET // выходим из функции .data message: .asciz "Hello METANIT.COM!\n"
Прежде всего стоит отметить, что метка, по которой располагается код программы, называется main, а не _start.
Среда выполнения языка C уже имеет метку _start
. Соответственно ожидается, что сначала будет загружаться среда выполнения C, а затем вызывается собственно код программы. И если мы оставим в программе на
ассемблере метку _start
, то мы получим ошибку, что такая метка определена более одного раза.
После загрузки срежа выполнения будет обращаться к функции main. И в данном случае код ассемблера по сути представляет функцию main
В функции main
сначала сохраняем в стек текущий адрес из регистра LR. Далее в регистр X0 загружаем строку message. Эта строка определяется с помошью директивы .asciz, которая определяет строку с концевым
нулевым байтом. Собственно в языке С строки как раз и представляют набор символов, который заканчивается нулевым байтом.
Далее инструкцией BL printf
вызываем стандартную функцию языка С - printf
, которая выводит строку на консоль.
Затем в регистр X0 помещаем код возврата - число 0. Восстанавливаем значение регистра LR и выходим из функции инструкцией RET
.
Скомпилируем этот код с помощью команды:
aarch64-none-linux-gnu-gcc -o app app.s -static // На Windows aarch64-linux-gnu-gcc -o app app.s -static // На Linux x86-64 gcc -o app app.s -static // На Linux ARM64
В итоге у нас получится файл app
, который содержит среду выполнения C. Минусом такого подхода является более раздутый код файла.
Пример выше, возможно, не очень показательный, поскольку без вызова функции printf
нам потребовалось лишь на пару инструкций больше, чтобы вывести строку на консоль.
Но посмотрим на другую задачу - вывод содержимого регистра на консоль. На ассемблере нам для этого потребовалось бы написать порядочное число инструкций, а использование функции
printf
позволяет быстро решить данную задачу:
.global main main: // функция main STR LR,[SP,#-16]! // сохраняем в стеке текущий адрес из регистра LR LDR X0, =str // загружаем строку форматирования str MOV X1, #15 // для спецификатора %d MOV X2, #15 // для спецификатора %x BL printf // вызываем стандартную функцию printf языка С MOV X0, #0 // код возврата LDR LR, [SP], #16 // извлекаем из стека адрес в регистр LR RET // выходим из функции .data str: .asciz "X1 = %ld \tX2 = 0x%016lx\n"
Здесь выводимая на консоль строка представляет строку форматирования "X1 = %ld \tX2 = 0x%016lx\n". Здесь используются два спецификатора. Вместо первого спецификатора
%ld
вставляется десятичное число, которое в языке представляет тип long. Вместо второго спецификатора 0x%016lx
вставляется шестнадцатеричное число типа long,
для вывода которого используются 16 символов. Если число занимает меньше 16 символов, то оставшиеся позиции заполняются нулями. Фактически в данном случае мы будем выводить значение регистра
X1 в виде десятичного числа и значение регистра X2 в виде шестнадцатеричного числа.
В итоге функция printf
принимает три аргумента - сама выводимая строка и два значения для ее
спецификаторов. Параметры передаются функциям в порядке следования через регистры X0, X1, X2. То есть первый параметр функции printf - строка помещается в регистр X0, второй параметр -
значение для спецификатора %ld
помещается в регистр X1, значение для второго спецификатора помещается в регистр X2. То есть, если бы мы вызывали функцию
printf
в Си, то условно ее вызов бы выглядел следующим образом:
printf("X1 = %ld \tX2 = 0x%016lx\n", X1, X2);
Причем в обоих случае выводим одно и то же число - 15, только в разных системах исчисления. В итоге консольный вывод будет выглядеть следующим образом:
X1 = 15 X2 = 0x000000000000000f
Пойдем дальше и абстрагируемся от конкретного регистра и вынесем код печати регистра в отдельный макрос. Для этого определим файл printRegister.s со следующим кодом:
.macro printRegister reg STP X0, X1, [SP, #-16]! STP X2, X3, [SP, #-16]! STP X4, X5, [SP, #-16]! STP X6, X7, [SP, #-16]! STP X8, X9, [SP, #-16]! STP X10, X11, [SP, #-16]! STP X12, X13, [SP, #-16]! STP X14, X15, [SP, #-16]! STP X16, X17, [SP, #-16]! STP X18, LR, [SP, #-16]! MOV X2, X\reg // для спецификатора %d MOV X3, X\reg // для спецификатора %x MOV X1, #\reg // устанавливаем название регистра ADD X1, X1, #'0' // для установки символа для спецификатора %c LDR X0, =str // строка форматирования BL printf // вызываем функцию printf LDP X18, LR, [SP], #16 LDP X16, X17, [SP], #16 LDP X14, X15, [SP], #16 LDP X12, X13, [SP], #16 LDP X10, X11, [SP], #16 LDP X8, X9, [SP], #16 LDP X6, X7, [SP], #16 LDP X4, X5, [SP], #16 LDP X2, X3, [SP], #16 LDP X0, X1, [SP], #16 .endm .data str: .asciz "X%c = %ld, 0x%016lx\n" .align 4 .text
Вначале идет 10 строк для сохранения состояния регистров в стек, чтобы внешний код, в котором используется данный макрос, получил те же значения в регистрах, которые были до вызова макроса. Поскольку часть регистров может использовать сам макрос, некоторые регистры могут использовать вызываемые функции типа printf, и хорошей практикой считается сохранение вначале и восстановление в конце регистров.
Сам макрос имеет один параметр - reg
, через который макрос получае номер регистра, например, число 1 будет представлять регистр X1.
На консоль здесь выводится строка, которая имеет следующее форматирование: "X%c = %ld, 0x%016lx\n"
. Через спецификатор %c
передается символ - номер регистра.
Через спецификатор %ld
передается десятичное значение регистра. А через спецификатор %016lx
передается шестнадцатеричное значение регистра.
Важный момент - инструкция .align 4
. Она позволяет выравнить строку по 4 байтам, что позвляет более быстро загружать данные, выравненные по 4 байтам. И далее идет объявление секции
.text
Таким образом,
в функцию printf
передается четыре параметра, Соответственно, чтобы вывести значение одного регистра нам потребуется 4 регистра. В регистры X2 и X3 помещаются значения
для второго и третьего спецификаторов:
MOV X2, X\reg // для спецификатора %d MOV X3, X\reg // для спецификатора %x
В регистр X1 помещается числовой код номера регистра. Чтобы получить числовой код ASCII, прибавляем к номеру числовой код символа "0"
MOV X1, #\reg // устанавливаем название регистра ADD X1, X1, #'0' // для установки символа для спецификатора %c
В главном файле программы, который будет называться app.s, подключим макрос:
.include "printRegister.s" // подключем макрос printRegister .global main main: // функция main STR LR,[SP,#-16]! // сохраняем в стеке текущий адрес из регистра LR MOV X1, #0x0000000000000005 MOV X2, #0xFFFFFFFFFFFFFFFF MOV X3, #0x000000000000000E MOV X4, #0x0000000000000009 printRegister 1 // обращаемся к макросу, передавая номера регистров - X1 printRegister 2 // X2 printRegister 3 // X3 printRegister 4 // X4 MOV X0, #0 // код возврата LDR LR, [SP], #16 // извлекаем из стека адрес в регистр LR RET // выходим из функции
Здесь последовательно вызываем макрос printRegister значения регистров X1, X2, X3, X4 и выводим их на консоль. Консольный вывод:
X1 = 5, 0x0000000000000005 X2 = -1, 0xffffffffffffffff X3 = 14, 0x000000000000000e X4 = 9, 0x0000000000000009