# Встроенная сборка

# Основная информация

Идея встроенной сборки заключается в том, чтобы встроить инструкции ассемблера в код C/C++, используя ключевое слово asm, когда нет другого выбора, кроме как использовать язык ассемблера.

# Обзор

Иногда, даже несмотря на то, что C/C++ является вашим основным языком, вам необходимо использовать некоторый код ассемблера в вашей операционной системе. Будь то из-за экстремальных потребностей в оптимизации или из-за того, что код, который вы реализуете, сильно зависит от оборудования (например, например, вывод данных через порт), результат один и тот же: обойти его невозможно. Вы должны использовать ассемблер.

Один из вариантов, который у вас есть, - это написать функцию asm и вызвать ее, однако могут быть случаи, когда даже накладные расходы на "вызов" слишком велики для вас. В этом случае вам нужна встроенная сборка, что означает вставку произвольных фрагментов asm в середине кода с использованием ключевого слова asm(). Способ работы этого ключевого слова зависит от компилятора. В этой статье описывается, как он работает в GCC, поскольку это, безусловно, самый используемый компилятор в мире ОС.

# Синтаксис

Это синтаксис для использования ключевого слова asm() в коде C/C++:

asm ( assembler template
    : output operands                   (optional)
    : input operands                    (optional)
    : clobbered registers list          (optional)
    );

Шаблон ассемблера - это в основном код, совместимый с GAS, за исключением случаев, когда у вас есть ограничения, и в этом случае имена регистров должны начинаться с %% вместо %. Это означает, что следующие две строки кода будут перемещать содержимое регистра eax в ebx:

asm ("movl %eax, %ebx");
asm ("movl %%eax, %%ebx" : );

Теперь вы можете задаться вопросом, почему появляется этот %%. Именно здесь появляется интересная особенность встроенной сборки: вы можете использовать некоторые из ваших переменных C в своем ассемблерном коде. И поскольку, чтобы упростить реализацию этого механизма, GCC называет эти переменные %0, %1 и так Далее в вашем ассемблерном коде, начиная с первой переменной, упомянутой в разделах операндов ввода/вывода. Вы должны использовать этот синтаксис %%, чтобы помочь GCC различать регистры и параметры.

Как именно работают операнды, будет более подробно объяснено в последующих разделах. На данный момент достаточно сказать, что если вы напишете что-то подобное:

int a = 10, b;
asm ("movl %1, %%eax; 
      movl %%eax, %0;"
     :"=r"(b)        /* вывод */
     :"r"(a)         /* ввод */
     :"%eax"         /* регистр */
     );

то вам удалось скопировать значение a в b с помощью ассемблерного кода, эффективно используя некоторые переменные C в вашем ассемблерном коде. Поздравляю!

Последний раздел "clobbered register" используется для того, чтобы сообщить GCC, что ваш код использует некоторые регистры процессора и что он должен переместить любые активные данные из запущенной программы из этого регистра перед выполнением фрагмента asm. В приведенном выше примере мы перемещаем a в eax в первой инструкции, эффективно стирая ее содержимое, поэтому нам нужно попросить GCC очистить этот регистр от несохраненных данных перед операцией.

# Шаблон ассемблера

Шаблон ассемблера определяет встроенные инструкции ассемблера. По умолчанию здесь используется синтаксис AT&T. Если вы хотите использовать синтаксис Intel, -masm=intel следует указать в качестве параметра командной строки.

Например, чтобы остановить процессор, вам просто нужно использовать следующую команду:

asm("hlt");

# Выходные операнды

Выходные операнды используется для того, чтобы указать компилятору/ассемблеру, как он должен обрабатывать переменные C, используемые для хранения некоторых выходных данных из кода ASM. Выходные операнды представляют собой список пар, каждый из которых состоит из строкового литерала, известного как "ограничение", указывающего, где должна быть отображена переменная C (регистры обычно используются для оптимальной производительности), и переменной C для отображения (в скобках).

В ограничении "a" относится к EAX, "b" - к EBX, "c" - к ECX, "d" - к EDX, "S" - к ESI и "D" - к EDI (полный список см. в руководстве GCC), предполагая, что вы разрабатываете для архитектуры IA32. Знак уравнения указывает на то, что ваш ассемблерный код не заботится о начальном значении сопоставленной переменной (что позволяет произвести некоторую оптимизацию). Учитывая все это, теперь совершенно ясно, что следующий код устанавливает EAX = 0.

int EAX;
asm( "movl $0, %0" : "=a" (EAX));

Обратите внимание, что компилятор перечисляет операнд, начинающийся с %0, и что вам не нужно добавлять регистр в список заблокированных регистров, если он используется для хранения выходного операнда. GCC достаточно умен, чтобы самостоятельно решить, что делать.

Начиная с GCC 3.1, вы можете использовать более читаемые метки вместо перечисления, подверженного ошибкам:

int current_task;
asm( "str %[output]" : [output] "=r" (current_task));

Эти метки находятся в собственном пространстве имен и не будут сталкиваться с какими-либо идентификаторами C. То же самое можно сделать и для входных операндов.

# Входные операнды

В то время как выходные операнды обычно используются для... хорошо... на выходе входные операнды позволяют параметризовать код ASM, т.е. передавать параметры только для чтения из кода C в блок ASM. Опять же, строковые литералы используются для указания деталей.

Если вы хотите переместить какое-то значение в EAX, вы можете сделать это следующим образом (хотя, конечно, было бы довольно бесполезно делать это вместо прямого сопоставления значения с EAX):

int randomness = 4;
asm( "movl %0, %%eax"
   :
   : "b" (randomness)
   : "eax"
    );

Обратите внимание, что GCC всегда будет предполагать, что входные операнды доступны только для чтения (без изменений). Правильная вещь, которую нужно сделать, когда входные операнды записываются, - это перечислить их в качестве выходных данных, но без использования знака уравнения, потому что на этот раз их исходное значение имеет значение. Вот простой пример:

asm("mov %%eax,%%ebx": : "a" (amount)); // бесполезно, но идея крутая

# Список сбитых регистров

Важно помнить одну вещь: компилятор C/C++ ничего не знает об ассемблере. Для компилятора оператор asm непрозрачен, и если вы не указали никаких выходных данных, он может даже прийти к выводу, что это не операция, и оптимизировать его. Некоторые сторонние документы указывают, что использование asm volatile приведет к тому, что ключевое слово не будет перемещено. Однако, согласно документации GCC, ключевое слово volatile указывает на то, что инструкция имеет важные побочные эффекты. GCC не удалит изменчивый asm, если он доступен, что указывает только на то, что он не будет удален (т.е. вопрос о том, может ли он все еще быть перемещен, остается без ответа). Подход, который должен работать, состоит в том, чтобы использовать asm (volatile) и помещать память в регистры clobber, например:

__asm__("cli": : :"memory"); // Это приведет к тому, что оператор не будет перемещен, но он может быть оптимизирован.
__asm__ __volatile__("cli": : :"memory"); // Это приведет к тому, что оператор не будет ни перемещен, ни оптимизирован.

Поскольку компилятор использует регистры процессора для внутренней оптимизации ваших переменных C/C++ и не знает об опкодах ASM, вы должны предупредить его о любых регистрах, которые могут быть заблокированы в качестве побочного эффекта, чтобы компилятор мог сохранить их содержимое перед вызовом ASM.

Список сбитых регистров представляет собой разделенный запятыми список имен регистров в виде строковых литералов.

# Wildcards: как вы можете позволить компилятору выбирать

Вам не нужно указывать компилятору, какой конкретный регистр он должен использовать в каждой операции, и в целом, за исключением случаев, когда у вас есть веские причины предпочесть один конкретный регистр, вам лучше позволить компилятору решать за вас.

Например, принудительное использование EAX над любым другим регистром может вынудить компилятор выдать код, который сохранит то, что ранее было в eax, в каком-либо другом регистре или может ввести нежелательные зависимости между операциями (нарушена оптимизация).

Ограничения "wildcards" позволяют предоставить больше свободы GCC, когда дело доходит до сопоставления ввода/вывода:

Ограничение "g":

"movl $0, %0" : "=g" (x) // x может быть тем, что предпочитает компилятор: регистром, ссылкой на память. Это может быть даже буквальная константа в другом контексте.

Ограничение "r":

"movl %%es, %0" : "=r" (x) // вы хотите, чтобы x прошел через реестр. Если x не был оптимизирован как регистр, компилятор переместит его в нужное место. Это означает, что "movl %0, %%es" : : "r" (0x38) достаточно для загрузки регистра сегмента.

Ограничение "N":

"outl %0, %1" : : "a" (0xFE), "N" (0x21) // указывает, что значение "0x21" может использоваться в качестве константы в результате или в работе, если оно находится в диапазоне от 0 до 255

Конечно, существует гораздо больше ограничений, которые вы можете наложить на выбор операнда, зависящего от машины или нет, которые перечислены в руководстве GCC (см. 1 (opens new window), 2 (opens new window), 3 (opens new window), и 4 (opens new window)).

# Использование C99

asm не является ключевым словом при использовании gcc-std=c99. Просто используйте gcc -std=gnu99, чтобы использовать C99 с расширениями GNU. Кроме того, вы можете использовать _asm_ в качестве альтернативного ключевого слова, которое работает даже тогда, когда компилятор строго придерживается стандарта.

# Назначение меток

Можно назначить так называемые метки ASM ключевым словам C/C++. Это можно сделать с помощью команды asm для определений переменных, как показано в этом примере:

int some_obscure_name asm("param") = 5; // "param" будет доступен во встроенной сборке
 
void foo() {
   asm("mov param, %%eax");
}

Вот пример того, как вы можете получить доступ к этим переменным, если вы явно не указываете имя:

int some_obscure_name = 5;
 
void foo() {
   asm("mov some_obscure_name, %%eax");
}

Обратите внимание, что вам также может потребоваться использовать _some_obscure_name (с начальным подчеркиванием), в зависимости от ваших параметров компоновки.

# asm goto

До GCC 4.5 переход через встроенные asm не поддерживался. Компилятор не может отслеживать, что происходит, поэтому почти гарантированно будет сгенерирован неправильный код.

Возможно, вам сказали, что "гото-это зло". Если вы верите, что это так, то asm goto-это ваш худший кошмар, который сбывается. Тем не менее, он предлагает некоторые интересные варианты оптимизации кода.

asm goto не очень хорошо документирован, но его синтаксис выглядит следующим образом:

asm goto( "jmp %l[labelname]" : /* нет вывода */ : /* ввод */ : "memory" : labelname /* любые метки */ );

Одним из примеров, где это может быть полезно, является инструкция CMPXCHG (см. Сравнение c Обменом (opens new window)), которую исходный код ядра Linux определяет следующим образом:

#include <stdint.h>
#define cmpxchg( ptr, _old, _new ) { \
  volatile uint32_t *__ptr = (volatile uint32_t *)(ptr);   \
  uint32_t __ret;                                     \
  asm volatile( "lock; cmpxchgl %2,%1"           \
    : "=a" (__ret), "+m" (*__ptr)                \
    : "r" (_new), "0" (_old)                     \
    : "memory");				 \
  );                                             \
  __ret;                                         \
}

В дополнение к возвращению текущего значения в EAX, CMPXCHG устанавливает нулевой флаг (Z) при успешном выполнении. Без asm goto ваш код должен будет проверить возвращаемое значение; этой инструкции CMP можно избежать следующим образом:

// Работает на x86 и x86-64
#include <stdint.h>
#define cmpxchg( ptr, _old, _new, fail_label ) { \
  volatile uint32_t *__ptr = (volatile uint32_t *)(ptr);   \
  asm goto( "lock; cmpxchg %1,%0 \t\n"           \
    "jnz %l[" #fail_label "] \t\n"               \
    : /* ничего */                                \
    : "m" (*__ptr), "r" (_new), "a" (_old)       \
    : "memory", "cc"                             \
    : fail_label );                              \
}

Затем этот новый макрос можно использовать следующим образом:

struct Item {
  volatile struct Item * next;
};
 
volatile struct Item * head;
 
void addItem(struct Item * i) {
  volatile struct Item * oldHead;
again:
  oldHead = head;
  i->next = oldHead;
  cmpxchg(&head, oldHead, i, again);
}

# Синтаксис Intel

Вы можете разрешить GCC использовать синтаксис intel, включив его во встроенной сборке, например:

asm(".intel_syntax noprefix");
asm("mov eax, ebx");

Аналогично, вы можете вернуться к синтаксису AT&T, используя следующий фрагмент кода:

asm(".att_syntax prefix");
asm("mov %ebx, %eax");

Важно

Таким образом, вы можете объединить синтаксис Intel и встроенную сборку синтаксиса AT&T. Обратите внимание, что как только вы запустите один из этих типов синтаксиса, все, что ниже команды в исходном файле, будет собрано с использованием этого синтаксиса, поэтому не забудьте переключиться обратно, когда это необходимо, или вы можете получить много ошибок компиляции!

Существует также опция командной строки -masm=intel для глобального запуска синтаксиса Intel.

# Внешние ссылки