Ассемблерные вставки

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

Компиляторы 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

Обычно к блоку 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;
}
Помощь сайту
Юмани:
410011174743222
Номер карты:
4048415020898850