Ассемблер NASM позволяет вызывать функции C/C++ в программе на ассемблере. Это упрощает создание приложений. Так, мы можем использовать уже готовые функции языка Си вместо того, чтобы тратить время и писать аналогичные функции. При вызове функций C/C++ применяется ряд условностей, которые еще называют ABI (Application Binary Interface или бинарный интерфейс приложения).
В Linux применяемый интерфейс ABI еще называется "System V ABI".
Согласно условностям первые 6 параметров передаются в функцию через следующие регистры (в порядке следования параметров):
rdi
rsi
rdx
rcx
r8
r9
Итак, если есть только один параметр, он передается в rdi. Если их два, первый параметр передается в rdi, а второй — в rsi. Если параметров более шести, то все дополнительные параметры помещаются в стек в виде 8-разрядных чисел (например, с помощью push). Последний параметр первым помещается в стек.
Возвращаемые значения помещаются в регистр rax. Спецификация ABI также позволяет использовать rdx, если есть второе возвращаемое значение, то есть значение возвращается через два регистра rdx:rax. Обычно, если требуется больше возвращаемых значений, либо rax будет содержать указатель на набор значений, либо входные параметры будут включать указатели на участки памяти, где должны храниться эти дополнительные возвращаемые значения.
Если функция в качестве параметров принимает числа с плавающей точкой, то они передаются через регистры xmm0, xmm1, xmm2, xmm3, xmm4, xmm5, xmm6, xmm7. Если функция принимает значения как с плавающей точкой, так и целые числа, то целые числа передаются обычным способом (с использованием rdi, rsi, rdx, rcx, r8 и r9), а числа с плавающей точкой передаются с использованием регистров XMM.
Кроме того, для функций (таких как printf или scanf), которые принимают переменное количество аргументов, количество используемых регистров XMM должно быть помещено в rax. Причина этого в том, что вызываемая функция знает, сколько регистров XMM ей необходимо сохранить.
Если возвращаемое значение является значением с плавающей запятой, оно возвращается в xmm0. Если возвращаются два значения, то через регистры xmm1:xmm0.
Также согласно условностям вызываемая функция должна сохранять содержимое регистров RSP, RBP, RBX, R12, R13, R14 и R15 (например, сохранять их в стек). Это так называемые неизменяемые (nonvolatile) регистр. Остальные регистры - RAX, RCX, RDX, RSI, RDI, R8, R9, R10 и R11 относят к изменяемым (volatile) регистрам - они могут быть сохраняться по мере необходимости. За их сохранение отвечает вызывающий код.
Например, пусть у нас есть функция с сигнатурой вызова
double myfunc(int a, double b, int c, double d)
В этом случае мы бы передали параметр a
в rdi
, параметр b
в xmm0
, параметр c
в rsi
и
параметр d
в xmm1.
. Возвращаемое значение будет в xmm0.
Если бы это была функция с переменным количеством аргументов, то мы бы установили rax равным 2, чтобы указать, что только два регистра XMM предназначены для параметров.
Также согласно System V ABI, стек должен быть выровнен по размеру, кратному 16 байтам, непосредственно перед каждым вызовом функции. "Выравнивание по 16 байтам" означает, что адрес указателя стека (rsp) должен быть кратен 16. Это не всегда обязательно, но вызовы некоторых функций приведут к сбою, если это выравнивание не будет соблюдаться. Поскольку все вызовы функций будут включать в себя сохранение адреса возврата, соответственно при вызове функции стек может быть выровнен по 8 байтам. Нередко в стек дополнительно сохраняют предыдущее значение регистра RBP (базового указателя фрейма стека). В итоге получается 16 байт. Это упрощает управление стеком, поскольку внутри функции достаточно следует выделять в стеке размер, кратный 16 байтам. Хотя опять же это не является строго необходимым, если функция не вызывает другие функции.
Кроме того, при вызове функций, которые принимают неопределенное количество аргументов (так называемые функции с переменным числом аргументов), рекомендуется установить rax равным нулю, если не нужно передавать какие-либо значения с плавающей запятой.
При использовании функций C/С++ в программе на ассемблере в качестве точки входа используют метку main (а не _start):
global main ; функция main - точка входа section .text main: ; инструкции функции main ret
Кроме того, программа теперь завершается не системным вызовом Linux, а инструкцией ret, как любая обычная функцияю
Код, который проецируется на метку _start, подключается из отдельной библиотеки, которая выполняет всю необходимую инициализацию библиотеки C. Затем этот код с помощью инструкции call вызывает функцию main, которая определена в нашем приложении. После завершения работы функция main с помощью инструкции ret возвращает управление коду на Си.
Для примера выведем на консоль строку с помощью функции printf:
global main ; функция main - точка входа extern printf ; подключаем функцию printf section .data message db "Hello METANIT.COM",10 ; строка для вывода section .text main: sub rsp, 8 ; выравнивание должно быть по 16 байтам mov rdi, message ; первый параметр функции printf - строка для вывода на консоль call printf add rsp, 8 ; восстанавливаем стек ret
Чтобы компилятор не ругался на стадии компиляции, с помощью директивы extern указываем, что функция printf определена вне приложения:
extern printf ; подключаем функцию printf
Перед вызовом функции printf установим выравнивание стека по 16-байтной границе. По умолчанию при вызове функции стек выровнен по 8-байтам, а регистр rsp указывает на адрес возврата. Поэтому вычитаем еще 8 байт от адреса в rsp:
sub rsp, 8
Поскольку не всегда мы можем угадать, как выровнен стек, по какой границе - по 8 или 16 байтам, то в этом случае нередко применяется следующий трюк:
and rsp, -16
То есть у значения в RSP убираются (обнуляются) последние 4 разряда, таким образом, RSP будет выровнен по 16 байтам.
Первый параметр функции printf
представляет строку форматирования, которая выводится на экран. В нашем случае это глобальная переменная message, адрес которой помещается
в регистр rdi:
mov rdi, message
Для компиляции на Linux, как и в общем случае, применяется следующая команда:
nasm -f elf64 hello.asm -o hello.o
Для компоновки объектного файла в исполняемое приложение применяется компилятор GCC:
gcc hello.o -static -o hello
Компилятору gcc передается объектный файл. Компилятор GCC автоматически компонует программу с библиотекой языка Си. Флаг -static
указывает компоновщику, что необходимо добавить в программу функции библиотеки. В этом случае какие бы функции из библиотеки мы ни использовали, они физически копируются в нашу окончательную программу.
-l
и имя дополнительной библиотеки, которую надо связать.
Полный пример компиляции и выполнения программы:
root@Eugene:~/asm# nasm -f elf64 hello.asm -o hello.o root@Eugene:~/asm# gcc hello.o -static -o hello root@Eugene:~/asm# ./hello Hello METANIT.COM root@Eugene:~/asm#
Выше был рассмотрен пример со статической компоновкой, когда функционал библиотеки Си включался в исполняемый файл приложения. Однако создаваемый исполныемый файл имеет очень большой размер. Вместо статической компоновки GCC также позволяет применять динамическую компоновку. В этом случае функции библиотек Си не включаются в исполняемый файл, а динамически подгружаются при запуске приложения. Но тут есть особенности. Так, применим динамическую компоновку. Для это выполним следующую команду:
gcc hello.o -o hello -no-pie
Компилятору gcc также передается тот же ранее собранный объектный файл hello.o. При этом также применяется флаг -no-pie. Этот флаг говорит о том, что функционал компилируемого приложения будет зависеть от позиции (pie - сокращение от Position Independent). То есть код приложения будет располагаться на определенных позициях. Построенное таким образом приложение в моем случае весит 16 КБ против 880 КБ и при этом работает также.
Однако это не рекомендуемый подход. Зависимый от позиции код гораздо более подвержен различным уязвимостям. И по умолчанию GCC как раз пытается построить приложение, которое не зависит от позиции. Однако если мы попытаемся построить приложение без флага -no-pie, мы столкнемся с ошибкой:
root@Eugene:~/asm# gcc hello.o -o hello /usr/bin/ld: hello.o: warning: relocation in read-only section `.text' /usr/bin/ld: hello.o: relocation R_X86_64_PC32 against symbol `printf@@GLIBC_2.2.5' can not be used when making a PIE object; recompile with -fPIE /usr/bin/ld: final link failed: bad value collect2: error: ld returned 1 exit status root@Eugene:~/asm#
Я не буду подробно вдаваться в детали, что такое PIE, и в подробности компоновки, так как это отдельная тема. Только представлю общее решение для программы на ассемблере NASM. Так, изменим код программы следующим образом:
global main ; функция main - точка входа extern printf ; подключаем функцию printf section .data message db "Hello METANIT.COM2",10 ; строка для вывода section .text main: sub rsp, 8 ; выравнивание должно быть по 16 байтам lea rdi, [rel message] ; первый параметр функции printf - строка для вывода на консоль call printf WRT ..plt add rsp, 8 ; восстанавливаем стек ret
Первое изменение - загрузка строки в регистр происзводится с помощью инструкции lea, а для получения адреса применяется оператор rel:
lea rdi, [rel message]
Это так называемая адресация относительно регистра RIP. И все переменнае, к которым мы хотим обратиться, следует получать, используя адресацию относительно RIP. Например, у нас в секции .data определена некоторая числовая переменная:
section .data num dq 23
Для получения ее значения применяется выражение [rel num]
:
mov rsi, [rel num]
И обращение к остальным переменным происходит подобным образом.
Другой важный момент в коде программы выше - вызов функции printf
call printf WRT ..plt
После названия функции идет слово WRT (сокращение от "with respect to"), а затем после двух точек plt. PLT - это специальная таблица, из которой извлекается реальный адрес функции.
Компиляция подобной программы с помощью NASM происходит как и в общем случае:
nasm -f elf64 hello.asm -o hello.o
А при построении исполняемого файла GCC достаточно указать имя объектного файла и генерируемого исполняемого файла:
gcc hello.o -o hello
Полный пример компиляции и консольного вывода программы:
root@Eugene:~/asm# nasm -f elf64 hello.asm -o hello.o root@Eugene:~/asm# gcc hello.o -o hello root@Eugene:~/asm# ./hello Hello METANIT.COM root@Eugene:~/asm#
Рассмотрим более сложный пример, когда в функция printf принимает несколько параметров. Для примера возьмем следующую программу на Linux:
global main ; функция main - точка входа extern printf ; подключаем функцию section .data message db "Name: %s Age: %u Company: %s Salary: %u",10,0 ; строка для вывода name db "Tom",0 age dq 39 company db "METANIT.COM",0 salary dq 1150 section .text main: sub rsp, 8 ; выравнивание должно быть по 16 байтам mov rdi, message ; первый параметр функции printf - строка форматирования mov rsi, name ; первый аргумент для строки форматирования mov rdx, [age] ; второй аргумент для строки форматирования mov rcx, company ; третий аргумент для строки форматирования mov r8, [salary] ; четвертый аргумент для строки форматирования mov rax, 0 ; функция принимает 0 аргументов с плавающей точкой call printf add rsp, 8 ; восстанавливаем стек ret
Теперь в функцию printf передается строка форматирования, которая принимает 4 аргумента. Соотвестветственно функция printf принимает 5 параметров, которые последовательно передаются с помощью регистров RDI, RSI, RDX, RCX, R8. Пример компиляции и вывода программы:
root@Eugene:~/asm# nasm -f elf64 hello.asm -o hello.o root@Eugene:~/asm# gcc hello.o -static -o hello root@Eugene:~/asm# ./hello Name: Tom Age: 39 Company: METANIT.COM Salary: 1150 root@Eugene:~/asm#