Ассемблерные вставки в GCC (4 стр)
Автор: FordPerfect
Примеры
Примечание: больше примеров можно найти, например, в весьма подробном руководстве http://locklessinc.com/articles/gcc_asm/ .
Начнём с несложного примера:
#include <cstdio> #include <cstdint> int main() { uint32_t a,b=5; asm( "movl %[arg_b],%%eax\n\t" "incl %%eax\n\t" :"=a"( a) :[arg_b]"m"( b) :"cc"); printf( "a=%d\n",int( a)); return 0; }
Здесь используется расширенная форма ассемблерной вставки с одним выходным операндом (связывающим переменную a с регистром eax), и одним входным операндом, связывающим имя arg_b с адресом области памяти под переменную b. Ассемблерная вставка указывает в clobberlist, что изменяет регистр флагов (инструкция inc может изменить его).
Компиляция его (для 32-битного выходного файла) командной строкой
gcc -std=c++11 test_asm.cpp -S
приводит к следующему коду:
.file "test_asm.cpp" .section .text$printf,"x" .linkonce discard .globl _printf .def _printf; .scl 2; .type 32; .endef _printf: pushl %ebp movl %esp, %ebp pushl %ebx subl $36, %esp leal 12(%ebp), %eax movl %eax, -12(%ebp) movl -12(%ebp), %eax movl %eax, 4(%esp) movl 8(%ebp), %eax movl %eax, (%esp) call ___mingw_vprintf movl %eax, %ebx movl %ebx, %eax addl $36, %esp popl %ebx popl %ebp ret .def ___main; .scl 2; .type 32; .endef .section .rdata,"dr" LC0: .ascii "a=%d\12\0" .text .globl _main .def _main; .scl 2; .type 32; .endef _main: pushl %ebp movl %esp, %ebp andl $-16, %esp subl $32, %esp call ___main movl $5, 24(%esp) /APP # 12 "test_asm.cpp" 1 movl 24(%esp),%eax incl %eax # 0 "" 2 /NO_APP movl %eax, 28(%esp) movl 28(%esp), %eax movl %eax, 4(%esp) movl $LC0, (%esp) call _printf movl $0, %eax leave ret .ident "GCC: (tdm-1) 4.9.2" .def ___mingw_vprintf; .scl 2; .type 32; .endef
Видим, что компилятор заменил %[arg_b] на выражение 24(%esp), указывающее адрес переменной b (будучи локальной, она выделена на стеке, и выражение для адреса имеет соответствующий вид).
Запуск программы выводит ожидаемое
a=6
Изменив программу следующим образом
#include <cstdio> #include <cstdint> int main() { uint32_t a,b=5; asm( "movl %[arg_b],%[arg_a]\n\t" "incl %[arg_a]\n\t" :[arg_a]"=r"( a) :[arg_b]"m"( b) :"cc"); printf( "a=%d\n",int( a)); return 0; }
увидим, что текст ассемблерного вывода остался тем же: компилятор (которому была предоставлена свобода в выборе конкретного регистра общего назначения для %[arg_a]) выбрал регистр eax. Но теперь он дополнительно произвёл замену (макроподстановку) %[arg_a] -> %eax в тексте вставки.
Ради интереса превратим переменную-приёмник в глобальную:
#include <cstdio> #include <cstdint> uint32_t a; int main() { uint32_t b=5; asm( "movl %[arg_b],%[arg_a]\n\t" "incl %[arg_a]\n\t" :[arg_a]"=m"( a) :[arg_b]"m"( b) :"cc"); printf( "a=%d\n",int( a)); return 0; }
Результат:
.file "test_asm.cpp" .section .text$printf,"x" .linkonce discard .globl _printf .def _printf; .scl 2; .type 32; .endef _printf: pushl %ebp movl %esp, %ebp pushl %ebx subl $36, %esp leal 12(%ebp), %eax movl %eax, -12(%ebp) movl -12(%ebp), %eax movl %eax, 4(%esp) movl 8(%ebp), %eax movl %eax, (%esp) call ___mingw_vprintf movl %eax, %ebx movl %ebx, %eax addl $36, %esp popl %ebx popl %ebp ret .globl _a .bss .align 4 _a: .space 4 .def ___main; .scl 2; .type 32; .endef .section .rdata,"dr" LC0: .ascii "a=%d\12\0" .text .globl _main .def _main; .scl 2; .type 32; .endef _main: pushl %ebp movl %esp, %ebp andl $-16, %esp subl $32, %esp call ___main movl $5, 28(%esp) /APP # 14 "test_asm.cpp" 1 movl 28(%esp),_a incl _a # 0 "" 2 /NO_APP movl _a, %eax movl %eax, 4(%esp) movl $LC0, (%esp) call _printf movl $0, %eax leave ret .ident "GCC: (tdm-1) 4.9.2" .def ___mingw_vprintf; .scl 2; .type 32; .endef
Версия с [arg_a]"=r"(a) тоже компилируется. Компилятор сам позаботится о том, чтобы записать значение из регистра в _a:
/APP # 14 "test_asm.cpp" 1 movl 28(%esp),%eax incl %eax # 0 "" 2 /NO_APP movl %eax, _a movl _a, %eax movl %eax, 4(%esp) movl $LC0, (%esp) call _printf
Если добавить -O2, то перемещение оптимизируется:
/APP # 14 "test_asm.cpp" 1 movl 28(%esp),%eax incl %eax # 0 "" 2 /NO_APP movl %eax, 4(%esp) movl %eax, _a call _printf
В качестве следующего примера, рассмотрим функцию, получающую значение TSC (пример взят из документации).
32-битная версия:
unsigned long long rdtsc (void) { unsigned long long tick; __asm__ __volatile__("rdtsc":"=A"(tick)); return tick; }
Для 64-битной версии код будет несколько иным (т. к. иначе в этом случае tick целиком помещался бы в регистре и "=A" связал бы его либо с rax либо с rdx):
unsigned long long rdtsc (void) { unsigned int tickl, tickh; __asm__ __volatile__( "rdtsc":"=a"( tickl),"=d"( tickh)); return ( ( unsigned long long)tickh << 32)|tickl; }
Работа с FPU зачастую требует особой аккуратности, т. к. стековая модель работы FPU довольно неочевидным образом ложится на constraints.
Раздел 6.44.2.9 x86 Floating-Point asm Operands документации весьма рекомендуется к прочтению.
Содержание, тезисно:
1. Clobber используется для того, чтобы указать, что ассемблерная вставка вытолкнет (pop) входной (input) регистр самостоятельно (если только он не использует matching constraint с выходным (output) регистром).
2. Все входные регистры, которые ассемблерная вставка вытолкнет самостоятельно, должны находится выше по стеку, чем невытолкнутые (которые вытолкнет компилятор).
3. Если хотя бы один входной регистр использует constraint "f", то все выходные регистры должны быть помечены как early-clobber ("&").
4. Выходные регистры должны быть указаны точно, "f" не допускается.
5. Выходные операнды должны начинаться с вершины стека и идти подряд.
6. Для того чтобы указать, что вычисление требует промежуточных регистров, можно добавить в clobber дополнительные регистры.
Примеры:
asm ("fsincos" : "=t" ( cos), "=u" ( sin) : "0" ( inp));
(из документации)
asm ("fyl2xp1" : "=t" ( result) : "0" ( x), "u" ( y) : "st(1)");
(из документации)
(указание "st(1)" clobber даёт компилятору понять, что ассемблерная вставка выталкивает оба аргумента самостоятельно).
В разделе Диспетчеризация CPU приводятся примеры с использованием SIMD.
goto-форма ассемблерной вставки не имеет выходных операндов. Если есть необходимость в ней что-то изменить, то компилятор об этом следует уведомить, указав "memory" в clobbers.
Пример (несколько надуманный):
// Tests, whether the low byte of 'value' has even number of bits set. bool test_parity_of_low_byte(uint32_t value) { asm goto( "test %[value],%[value]\n\t" "jp %l[yes]\n\t" : :[value]"r"( value) :"cc" :yes); return false; yes: return true; }
В качестве примера жизненного бага, связанного с ассемблерной вставкой, читатель может ознакомиться с:
http://free-electrons.com/blog/how-we-found-that-the-linux-nios2-… on-had-a-bug/
Переходы и метки
За исключением goto-формы, переходы наружу (напр. jmp или ret) запрещены: программа может скомпилироваться, но GCC не знает об этих переходах и может сгенерировать некорректный код, т. к. будет исходить из неверных предположений.
Переходы внутри одной ассемблерной вставки вполне разрешены.
Возникает вопрос, как их реализовать.
Очевидной идеей кажется явное написание метки и переход на неё.
У прямолинейной реализации этой идеи есть существенный недостаток: как упоминалось выше, компилятор может продублировать код ассемблерной вставки (например при инлайнинге функции). Это приведёт к тому, что метка будет существовать в нескольких экземплярах, и код откажется компилироваться с ошибкой компиляции, связанной с повторным определением символа.
Более того наличие ошибки зависит от многих факторов (таких как опции компиляции и эвристики используемые оптимизатором), что может далее затруднить ситуацию.
Одним возможным решением является использование подстановки %= для генерации уникального целого числа, как части идентификатора.
Ещё одной альтернативой является использование локальных меток ассемблера (см. https://sourceware.org/binutils/docs/as/Symbol-Names.html и http://stackoverflow.com/questions/3898435/labels-in-gcc-inline-assembly ).
Локальные метки не обязаны иметь уникальные имена (точнее, ассемблер самостоятельно преобразует их имена в уникальные).
Объявление локальной метки представляет собой целое неотрицательное число, за которым следует двоеточие.
Переход выполняется на ближайшую локальную метку с указанным именем. Ближайшая может означать "ближайшая вперёд" или "ближайшая назад", что явно указывается суффиксами f или b.
Пример (32-битная версия):
uint32_t sum(const uint32_t *data,uint32_t n) { uint32_t ret; asm( "movl %[n],%%eax\n\t" "testl %%eax,%%eax\n\t" "je 1f\n\t" "movl %[data],%%edx\n\t" "leal (%%edx,%%eax,4),%%ecx\n\t" "xorl %%eax,%%eax\n\t" "0:\n\t" "addl (%%edx), %%eax\n\t" "addl $4,%%edx\n\t" "cmpl %%ecx,%%edx\n\t" "jne 0b\n\t" "1:\n\t" :"=&a"( ret) :[n]"m"( n),[data]"m"( data) :"ecx","edx","cc","memory"); return ret; }
Результат (-O2):
__Z3sumPKjj: pushl %ebx /APP # 52 "test_asm.cpp" 1 xorl %eax,%eax movl 8(%esp),%ebx movl 12(%esp),%ecx test %ecx,%ecx jz 1f 0: addl -4(%ebx,%ecx,$4),%eax decl %ecx jnz 0b 1: # 0 "" 2 /NO_APP popl %ebx ret
Та же функция для 64-битной версии выглядит:
uint32_t sum(const uint32_t *data,uint32_t n) { uint32_t ret; asm( "movl %[n],%%eax\n\t" "testl %%eax,%%eax\n\t" "je 1f\n\t" "movq %[data],%%rdx\n\t" "leaq (%%rdx,%%rax,4),%%rcx\n\t" "xorl %%eax,%%eax\n\t" "0:\n\t" "addl (%%rdx), %%eax\n\t" "addq $4,%%rdx\n\t" "cmpq %%rcx,%%rdx\n\t" "jne 0b\n\t" "1:\n\t" :"=&a"( ret) :[n]"m"( n),[data]"m"( data) :"rcx","rdx","cc","memory"); return ret; }
Примечание: отсутствие "memory" может привести к некорректному коду. Например
http://rextester.com/NAG83514
https://godbolt.org/g/eZXRK8
Компилятор счёл себя вправе перенести заинлайненый вызов sum прямо в середину инициализации buf, и таким образом функция получила на вход частично непроинициализированный участок массива.
Диспетчеризация CPU
Диспетчеризация CPU (CPU dispatch) - приём, состоящий в определении модели процессора в начале работы программы, и вызова в программе разных версий функций в зависимости от этой модели (например, оптимизированную AVX-версию каких-либо вычислений, если известно, что процессор поддерживает AVX, и обычную версию - во всех остальных случаях).
Intel C++ compiler предоставляет некоторую поддержку автоматической диспетчеризации.
GCC также предоставляет способ частично автоматизировать этот процесс:
https://gcc.gnu.org/wiki/FunctionMultiVersioning
https://gcc.gnu.org/onlinedocs/gcc/x86-Built-in-Functions.html (__builtin_cpu_init, __builtin_cpu_is, __builtin_cpu_supports)
http://nickclifton.livejournal.com/6612.html
http://www.airs.com/blog/archives/403
Этот подход не лишён проблем, см. напр.:
https://github.com/nothings/stb/issues/280
Данный раздел, однако, в основном посвящён ручной диспетчеризации.
Диспетчеризация в GCC сопряжена с определённой проблемой: GCC ожидает один и тот же набор инструкций как для собственного (генерируемого) кода, так и для пользовательского (ассемблерных вставок). Соответственно опция -mno-sse2 не разрешает использовать инструкции SSE2 в т. ч. в ассемблерных вставках (ассемблер откажется их принимать), а опция -msse2 - разрешает их использование самому компилятору, во всём коде единицы трансляции. Ни та, ни другая ситуация является подходящей для диспетчеризации CPU.
Рекомендуемое решение - выделить версии функций, требующие определённый набор инструкций, в отдельную единицу тренсляции, и компилировать эту единицу трансляции с опциями, включающими поддержку данного набора инструкций.
На практике у этого решения есть некоторые проблемы:
1. Это имеет тенденцию приводить к багам (как со стороны программиста, так и со стороны компилятора), связаным с нестыковкой соглашений о вызове / ABI, особенно если используется Link Time Optimization (Link-time Code Generation).
2. В некоторых системах сборки настройка индивидуальных опций компиляции для разных единиц трансляции вызывает неудобства.
Проблема особенно остро проявляется при работе с интринсиками:
http://www.virtualdub.org/blog/pivot/entry.php?id=363
https://gist.github.com/rygorous/f26f5f60284d9d9246f6
Однако для ассемблерных вставок, по наблюдениям автора данной статьи, использование атрибутов функций ( https://gcc.gnu.org/onlinedocs/gcc/x86-Function-Attributes.html ) практически не вызывает неудобств.
Это можно считать одним из преимуществ ассемблерных вставок над интринсиками в GCC.
Для работы с числами с плавающей точкой, кроме включения соответствующих инструкций (напр. __attribute__((target("sse4")))) обычно нужно также указать __attribute__((target("fpmath=sse"))) (если режим -mfpmath=sse не включен глобально).
Возможной альтернативой упомянутым выше подходам является непосредственное указание опкода соответствующей инструкции в ассемблерной вставке (напр. с помощю ".byte"). Но данный подход может быть неудобен, особенно если инструкция содержит операнды.
Возникает вопрос доступа к векторным типам данных. Компилятор поддерживает constraint "x" для SSE-регистров, но что указывать в качестве выражения для него?
Один из возможных подходов - не использовать эти типы, и аргументы передавать, например, через void*. Это может привести к потере производительности, но не во всех случаях она будет значительной.
Другая возможность - использовать типы данных указанных в заголовочных файлах интринсик. Однако, как отмечалось выше, их подключение может быть проблематичным в ситуации CPU dispatch.
Третий возможный подход - использование векторных типов данных GCC:
https://gcc.gnu.org/onlinedocs/gcc/Vector-Extensions.html
https://gcc.gnu.org/onlinedocs/gcc/Common-Variable-Attributes.html
Например
typedef float float4 __attribute__ (( __vector_size__ ( 16)));
определяет тип float4 размером 16 байт, состоящий из элементов типа float.
Собственно, GCC примерно таким образом и определяет векторные типы, используемые в интринсиках (отрывок из xmmintrin.h):
/* The Intel API is flexible enough that we must allow aliasing with other vector types, and their scalar components. */ typedef float __m128 __attribute__ (( __vector_size__ ( 16), __may_alias__)); /* Internal data types for implementing the intrinsics. */ typedef float __v4sf __attribute__ ( ( __vector_size__ ( 16)));
Эти типы предоставляют доступ к элементам по индексу и арифметические операторы.
Эти типы вполне можно использовать в обычном коде (не включая -msse2 и т. д.). Компилятор при этом развернёт работу с ними в последовательность скалярных операций (возможно - на FPU). Однако использование их в качестве аргументов или возвращаемого значения функции может вести к ошибкам, связанным с нарушением ABI.
В качестве примера рассмотрим функцию, вычисляющую 4 приближённых обратных квадратных корня.
Референсная версия:
void rsqrt_x4_ref(const float *src,float *dst) { for( size_t i=0;i<4;++i) dst[i]=1.0f/sqrtf( src[i]); }
Версия с использованием SSE2 и передачи аргументов по указателю:
void __attribute__(( target( "sse2,fpmath=sse"))) rsqrt_x4_sse2( const float *src,float *dst) { asm( "movups %[src],%%xmm0\n\t" "rsqrtps %%xmm0,%%xmm0\n\t" "movups %%xmm0,%[dst]\n\t" :[dst]"=m"( *dst) :[src]"m"( *src) :"xmm0","memory"); }
"memory" используется т. к. иначе компилятор может посчитать, что задействованы только src[0] и dst[0].
Версия с векторными типами:
typedef float float4 __attribute__ (( vector_size ( 16))); float4 __attribute__( ( target( "sse2,fpmath=sse"))) rsqrt_x4_sse2_vec( float4 src) { asm( "rsqrtps %[src],%[src]\n\t" :[src]"+x"( src) : :); return src; }
Иногда может возникнуть желание использовать выровненные константы.
Для этого можно воспользоваться аттрибутом aligned, например: