Компиляторы GCC/Clang позволяют встраивать код ассемблера в различные части программы на Си. Для этого применяется выражение asm() - внутри скобок определяется код на ассемблере, который может напрямую обращаться к коду на языке Си. В общем случае инструкция asm() имеет следующий синтаксис:
asm asm-qualifiers ( AssemblerTemplate : OutputOperands [ : InputOperands] [ : Clobbers ] ] [ : GotoLabels])
Инструкция принимает следующие параметры:
AssemblerTemplate: собственно код ассемблера. Он может содержать подстановки, которые начинаются с символа % и вместо
которых компилятор языка С подставляет входные и выходные параметры.
OutputOperands:: выходные параметры - список переменных или регистров, через которые код ассемблера возвращает некоторый результат.
InputOperands: входные параметры - список переменных или регистров, через которые в код ассемблера передаются некоторые значения.
Clobbers: список регистров, которые будут использоваться программой и значения которых будут затерты в процессе выполнения программы.
GotoLabelsr: список меток в коде С, на которые можно совершать переход из кода ассемблера.
Код ассемблера представляет стандартные команды ассемблера, которые выполняют различные действия. Например, простейшее выражение:
asm(
"mov $12, %rax"
);
В данном случае помещаем в регистр RAX число 12. Здесь применяется синтаксис ассемблера GAS (систаксис AT&T, родной для компилятора GCC), в соответствии с которым все регистры должны быть указаны с префиксом % (например, %eax).
Также можно применять несколько операций:
asm(
"mov $12, %rax\n"
"mov $13, %rcx\n"
"add %rcx, %rax"
);
В данном случае помещаем в регистр RAX число 12, в RCX - число 13 и затем складываем их и результат помещаем в регистр RAX. Но обратите внимание, что все эти строки складываются в одну, и чтобы операции были отделены друг от друга, в конце каждой строки стоит перевод строки \n.
Возьмем простейший пример программы на Си для Linux:
#include <stdio.h>
int main (void)
{
puts("Hello METANIT.COM");
asm(
"movq $22, %rdi # в RDI код статуса результата\n"
"movq $60, %rax # в RAX номер системной функции\n"
"syscall # выполняем системную функцию"
);
}
Для понимания программы мы даже можем добавить в код комментарии - после символа #. Здесь вначале выводится на консоль произвольная строка, а затем идет ассемблерная вставка, которая вызывает системный вызов 60 в Linux, который означает завершение программы. Для этого в регистр RDI
помещается статусный код возврата, в регистр RAX - код системного вызова (число 60), и в конце вызывается оператор syscall. Таким образом, программа завершится со статусным кодом 22. Это все равно,
если бы явным образом возвратили это число (return 22;). В итоге после выполнения программы мы можем проверить ее статусный код возврата командой "echo $?":
eugene@Eugene:$ gcc app.c -o app && ./app Hello METANIT.COM eugene@Eugene:$ echo $? 22 eugene@Eugene:$
Выходные параметры - список переменных или регистров, через которые код ассемблера возвращает некоторый результат. Рассмотрим простейшую программу:
#include <stdio.h>
int main (void)
{
int x = 1;
printf("Initial X value: %d\n", x);
asm(
"mov $12, %%rax\n"
"mov $13, %%rdx\n"
"add %%rdx, %%rax"
: "=a" (x)
);
printf("Final X value: %d\n", x);
return 0;
}
В коде ассмеблера складываем значения регистров RAX и RDX. Причем поскольку здесь применяется выходной параметр, то в названиях регистров символ % экранируется и, как и в строке форматирования функции printf, %% означает один %.
В качестве выходного параметра применяется переменная x:
: "=a" (x)
в этом выражении "=a" означает выходной регистр (RAX), а x указывает, что результат этого регистра будет помещаться в переменную x.
Для проверки выполнения ассемблерного кода выводим два раза значение переменной x. В итоге мы получим следующий консольный вывод:
Initial X value: 1 Final X value: 25
В примере выше для получения результата операции в выходной параметр - переменную x мы использовали следующее выражение: "=a". Для архитектуры x86 у нас есть специальные обозначения-символы
"a" (RAX), "b" (RBX), "c" (RCX) и "d" (RDX), "D" (для RDI), "S" (для RSI). Для RIP и RFLAGS дескрипторы не доступны напрямую. Нет также дескрипторов для R8~R15. Но есть более "общие" дескрипторы.
Дескриптор "r" - это "регистр" (выберет компилятор), а дескриптор "m" - это ссылка на память (которая будет транслироваться в эффективный адрес).
Например, в примере выше изменим получение данных на регистр RDX:
asm(
"mov $12, %%rax\n"
"mov $13, %%rdx\n"
"add %%rdx, %%rax"
: "=d" (x)
);
В итоге в переменную x будет помещено число 13.
Кроме того, rлючевое слово asm с классом хранения (storage class) register может использоваться для того, чтобы сообщить компилятору, что определенный объект должен быть
выделен в определенном регистре. Например:
#include <stdio.h>
int main (void)
{
register int x asm ("r8");
x = 1;
printf("Initial X value: %d\n", x); // 1
asm(
"mov $15, %%r8\n"
: "=r" (x)
);
printf("Final X value: %d\n", x); // 15
return 0;
}
В данном случае переменная x сопоставляется с регистром r8. Соответственно в определении выходного параметра мы можем написать : "=r" (x), и этот общий дескритор "r" в данном случае будет представлять регистр
r8.
В реальности выходные параметры можно использовать как входные или точнее можно обновлять их значение. Для этого вместо символа "=" применяется символ "+" (плюс). Например:
#include <stdio.h>
int main (void)
{
int x = 11;
printf("Initial X value: %d\n", x);
asm(
"add %%rax, %%rax"
: "+a" (x)
);
printf("Final X value: %d\n", x);
return 0;
}
Здесь благодаря определению "+a" значение переменной x будет передаваться в регистр RAX и также оттуда загружаться обратно при завершении выполнения кода ассемблера. В итоге при выполнении программы мы получим следующий консольный вывод:
Initial X value: 11 Final X value: 22
Другой пример - используем несколько параметров:
#include <stdio.h>
int main (void)
{
int x = 11;
int y = 0;
printf("Initial X value: %d\n", x);
printf("Initial Y value: %d\n", y);
asm(
"mov $12, %%rdx\n"
"add %%rdx, %%rax"
: "+a" (x), "=d" (y)
);
printf("Final X value: %d\n", x);
printf("Final Y value: %d\n", y);
return 0;
}
Здесь используем два выходных параметра, представленных переменными x и y. Но если x загружается и обновляется через регистр RAX, то переменная y просто получает значение из регистра RDX.
Входные параметры - список переменных или регистров, через которые в код ассемблера передаются некоторые значения. Например:
#include <stdio.h>
int main (void)
{
long x = 11;
long y = 5;
long z = 0;
printf("Initial z value: %ld\n", z);
asm(
"add %%rdx, %%rax"
: "=a" (z)
: "a" (x), "d" (y)
);
printf("Final z value: %ld\n", z);
return 0;
}
Здесь входные параметры заданы следующим образом:
: "a" (x), "d" (y)
То есть значение переменной x помещается в регистр RAX, а значение переменной y - в регистр RDX. Таким образом, здесь два входных параметра.
Результат сложения ("add %%rdx, %%rax") передается в переменную z, которая выступает в качестве выходного параметра (: "=a" (z)). Консольный вывод:
Initial z value: 0 Final z value: 16
Мы также можем ссылаться на аргументы по их индексу из списка (выходные, так и входные все вместе), используя формат %N (где N находится между 0 и 9). Например, мы могли бы написать:
asm ( "add %0,%0" : "+a" (x) );
То есть в данном случае у нас только один параметр - x, соответственно его индекс - 0. Соответственно через выражение %0 мы можем ссылаться на этот параметр.
Другой пример:
#include <stdio.h>
int main (void)
{
long x = 11;
long y = 3;
long z = 0;
//printf("Initial z value: %ld\n", z);
asm(
"add %2, %1"
: "=a" (z)
: "a" (x), "d" (y)
);
//printf("Final z value: %ld\n", z);
return 0;
}
Здесь в общей сложности три параметра: выходной параметр z, который загружается в регистр RAX, и два входных - x загружается в регистр RAX, а y загружается в регистр RDX.
Соответственно мы можем ссылаться на эти параметры следующим образом:
%0 - x
%1 - y
%2 - z
И в данном случае мы складываем второй и третий параметр и результат помещаем в RAX, откуда его получает первый параметр.
В случае с параметром x, который по регистру совпадает с первым параметром z, мы могли бы вместо регистра использовать индекс:
asm(
"add %2, %1"
: "=a" (z)
: "0" (x), "d" (y)
);
То есть в данном случае x и z будут использовать один и тот же регистр.
И если мы посмотрим на ассемблерный код, сгенерированный по этой программе, то он будет выглядеть следующим образом:
main:
pushq %rbp
movq %rsp, %rbp
movq $11, -8(%rbp)
movq $3, -16(%rbp)
movq $0, -24(%rbp)
movq -8(%rbp), %rax
movq -16(%rbp), %rdx
add %rdx, %rax
movq %rax, -24(%rbp)
movl $0, %eax
popq %rbp
ret
Еще один пример - поиск длины строки:
#include <stdio.h>
int main (void)
{
char* text = "hello";
size_t len = 0;
asm (
"mov %%rdi, %%rsi\n"
"repne scasb\n"
"sub %%rsi, %%rdi\n"
"sub $1, %%rdi\n"
: "=D" (len)
: "a" (0), "D" (text), "c" (-1)
);
printf("len: %lu\n", len); // len: 5
return 0;
}
В данном случае в регистр RDI загружаем текст, длину которого мы хотим найти. В регистр RCX загружаем количество символов, который надо пройти - число -1 указывает на максимальное число (то есть всю строку). В регистр AL (RAX) загружается символ для поиска. Поскольку строка заканчивается на нулевой байт, то нам надо найти символ с кодом 0.
Для поиска нулевого байта применяется инструкция repne scasb, которая проходит по строке из RDI и в итоге в том же RDI окажется количество адрес за найденным символом. Соответственно
нам надо из этого адреса вычесть адрес начала строки (который перед этим загружаем в RCX) и еще 1 байт. И полученную длину загружаем в выходной параметр - переменную len.
Обычно к блоку asm добавляют ключевое слово volatile. Это ключевое слово изменяет класс хранения объекта, чтобы компилятор не «оптимизировал» операцию с его
использованием:
#include <stdio.h>
int main (void)
{
int x = 11;
asm volatile(
"add %%rax, %%rax"
: "+a" (x)
);
printf("Final X value: %d\n", x);
return 0;
}