Взаимодействие кода ассемблера и C/C++

Вызов функций C/C++ из ассемблера на Linux

Последнее обновление: 18.10.2023

Ассемблер NASM позволяет вызывать функции C/C++ в программе на ассемблере. Это упрощает создание приложений. Так, мы можем использовать уже готовые функции языка Си вместо того, чтобы тратить время и писать аналогичные функции. При вызове функций C/C++ применяется ряд условностей, которые еще называют ABI (Application Binary Interface или бинарный интерфейс приложения).

Условности при взаимодействии с С/С++ на Linux

В Linux применяемый интерфейс ABI еще называется "System V ABI".

Согласно условностям первые 6 параметров передаются в функцию через следующие регистры (в порядке следования параметров):

  1. rdi

  2. rsi

  3. rdx

  4. rcx

  5. r8

  6. 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 возвращает управление коду на Си.

Вызов функции C/C++ на Linux

Для примера выведем на консоль строку с помощью функции 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 указывает компоновщику, что необходимо добавить в программу функции библиотеки. В этом случае какие бы функции из библиотеки мы ни использовали, они физически копируются в нашу окончательную программу.

В итоге компоновщик gcc создаст исполняемый файл, в который будет добавлен код библиотеки Си. Минусом такого подхода является большой размер исполняемого файла. Например, в моем случае исполняемый файл занимает 880 КБ. При необходимости можно добавить дополнительные библиотеки, используя флаг -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#

Передача параметров в функцию C/C++

Рассмотрим более сложный пример, когда в функция 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#
Помощь сайту
Юмани:
410011174743222
Перевод на карту
Номер карты:
4048415020898850