Программирование для Linux. Профессиональный подход.

Глава 9. Встроенный ассемблерный код.

Сегодня лишь немногие программисты используют в своей практике язык ассемблера. Языки высокого уровня, такие как С и C++, поддерживаются практически на всех архитектурах и обеспечивают достаточно высокую производительность программ. Для тех редких случаев, когда требуется встроить в программу ассемблерные инструкции, в коллекции GNU-компиляторов (GCC) предусмотрены специальные средства, учитывающие особенности конкретной архитектуры.

Встроенными ассемблерными инструкциями следует пользоваться осторожно, так как они являются системно-зависимыми. Например, программу с инструкциями архитектуры x86 не удастся скомпилировать на компьютерах PowerPC. В то же время такие инструкции позволяют напрямую обращаться к аппаратным устройствам, вследствие чего программный код выполняется чуть быстрее.

В программы, написанные на языках С и C++, ассемблерные инструкции встраиваются с помощью функции asm(). Например, на платформе x86 команда.

Asm("fsin" : "=t" (answer) : "0" (angle));

Является эквивалентом следующей инструкции языка C:[30].

Answer = sin(angle);

Обратите внимание на то, что, в отличие от обычных ассемблерных инструкций, функция asm() позволяет указывать входные и выходные операнды, используя синтаксис языка С.

Подробнее узнать об инструкциях архитектуры x86, используемых в настоящей главе, можно по следующим адресам: http://developer.intel.com/design/pentiumii/manuals и http://www.x86-64.org/documentation.

9.1. Когда необходим ассемблерный код.

Инструкции, указываемые в функции asm(), позволяют программам напрямую обращаться к аппаратным устройствам, поэтому полученные программы выполняются быстрее. Ассемблерные инструкции используются при написании кода операционных систем. Например, файл /usr/include/asm/io.h содержит объявления команд, осуществляющих прямой доступ к портам ввода-вывода. Можно также назвать один из исходных файлов ОС Linux — /usr/src/linux/arch/i386/kernel/process.s; в нем с помощью инструкции hlt реализуется пустой цикл ожидания.

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

Иногда одна или две ассемблерные команды способны заменить целую группу высокоуровневых инструкций. Например, чтобы определить позицию самого старшего значащего бита целого числа в языке С, требуется написать цикл, тогда как во многих ассемблерных языках для этой цели существует операция bsrl. Ее использование будет продемонстрировано в разделе 9.4, "Пример".

9.2. Простая ассемблерная вставка.

Вот как с помощью функции asm() осуществляется сдвиг числа на 8 битов вправо:

Asm("shrl $8, %0" : "=r" (answer) : "r" (operand) : "cc");

Выражение в скобках состоит из секций, разделенных двоеточиями. В первой секции указана ассемблерная инструкция и ее операнды. Команда shrl осуществляет сдвиг первого операнда на указанное число битов вправо. Первый операнд представлен выражением %0. Второй операнд — это константа $8.

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

В третьей секции перечислены входные операнды. Переменная operand содержит значение, подвергаемое битовому сдвигу. Выражение "r" означает, что значение переменной записывается в регистр.

Выражение "cc" в четвертой секции говорит о том. что инструкция меняет значение регистра cc (содержит код завершения).

9.2.1. Преобразование функции asm() в ассемблерные инструкции.

Компилятор gcc интерпретирует функцию asm() очень просто: он генерирует ассемблерные инструкции, обрабатывающие указанные входные и выходные операнды, после чего заменяет вызов функции заданной инструкцией. Никакой дополнительный анализ не выполняется.

Например, следующий фрагмент программы:

Double foo, bar;

Asm("mycool_asm %1, %0" : "=r" (bar) : "r" (foo));

Будет преобразован в такую последовательность команд x86:

 movl -8(%ebp),%edx.

 movl -4(%ebp),%ecx.

#APP.

 mycool_asm %edx, %edx.

#NO_APP.

 movl %edx,-16(%ebp).

 movl %ecx,-12(%ebp).

Переменные foo и bar занимают по два слова в стеке в 32-разрядной архитектуре x86. Регистр ebp ссылается на данные, находящиеся в стеке.

Первые две команды копируют переменную foo в регистры edx и ecx, с которыми работает инструкция mycool_asm. Компилятор решил поместить результат в те же самые регистры. Последние две команды копируют результат в переменную bar. Выбор нужных регистров и копирование операндов осуществляются автоматически.

9.3. Расширенный синтаксис ассемблерных вставок.

В следующих подразделах будет описан синтаксис правил, по которым строятся выражения в функции asm(). Секции выражения отделяются друг от друга двоеточиями. Мы будем ссылаться на следующую инструкцию, которая вычисляет результат булевого выражения x > y:

Asm("fucomip %%st(1), %%st; seta %%al" :

 "=a" (result) : "u" (y), "t" (x) : "cc", "st");

Сначала инструкция fucomip сравнивает два операнда, x и y, и помещает значение, обозначающее результат, в регистр cc, после чего инструкция seta преобразует это значение в 0 или 1.

9.3.1. Ассемблерные инструкции.

Первая секция содержит ассемблерные инструкции, заключенные в кавычки. В рассматриваемом примере таких инструкций две: fucomip и seta. Они разделены точкой с запятой. Если текущий вариант языка ассемблера не допускает такого способа разделения инструкций, воспользуйтесь символом новой строки ( \n).

Компилятор игнорирует содержимое первого раздела, разве что один уровень символов процента удаляется, т.е. вместо %% будет %. Смысл выражения %%st(1) и ему подобных зависит от архитектуры компьютера.

Если при компиляции программы, содержащей функцию asm(), указать опцию -traditional или -ansi, компилятор gcc выдаст предупреждение. Чтобы этого избежать, используйте альтернативное имя __asm__.

9.3.2. Выходные операнды.

Во второй секции указаны выходные операнды инструкции. Каждому операнду соответствует строка адресации и выражение языка С, записанное в скобках. В случае выходных операндов (все они должны быть левосторонними значениями) строка адресации должна начинаться со знака равенства. Компилятор проверяет, действительно ли каждый выходной операнд является левосторонним значением (т.е может стоять в левой части оператора присваивания).

Список обозначений регистров для конкретной архитектуры можно найти в исходных текстах компилятора gcc (конкретнее — в определении макроса REG_CLASS_FROM_LETTER). Например, в файле gcc/config/i386/i386.h содержатся обозначения, соответствующие архитектуре x86 (табл. 9.1).

Таблица 9.1. Обозначения регистров в архитектуре Intel x86.

Символ регистра Регистры, которые могут использоваться компилятором gcc
R Регистры общего назначения (EAX, EBX, ECX, EDX, ESI, EDI, EBP, ESP)
q Общие регистры хранения данных (EAX, ЕВХ, ECX, EDX)
f Регистр для чисел с плавающей запятой
t Верхний стековый регистр для чисел с плавающей запятой
u Второй после верхнего стековый регистр для чисел с плавающей запятой
a Регистр EAX
b Регистр EBX
с Регистр ECX
d Регистр EDX
x Регистр SSE (регистр потокового расширения SIMD)
y Мультимедийные регистры MMX
A Восьмибайтовое значение, формируемое из регистров EAX и EDX
D Указатель приемной строки в строковых операциях (EDI)
S Указатель исходной строки в строковых операциях (ESI)

Если есть несколько однотипных операндов, то они разделяются запятыми, как показано в секции входных операндов. Всего можно задавать до десяти операндов, адресуемых как %0, %1, … %9. Если выходные операнды отсутствуют, но есть входные операнды или модифицируемые регистры, то вторую секцию следует оставить пустой или пометить ее комментарием наподобие /* нет выходных данных */.

9.3.3. Входные операнды.

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

Если требуется указать, что в одной инструкции осуществляется как чтение регистра, так и запись в него, необходимо в строке адресации входного операнда поставить номер выходного операнда. Например, если входной регистр должен быть тем же, что и регистр первого выходного операнда, назначьте ему номер 0. Выходные операнды нумеруются слева направо, начиная с нуля. Если просто указать одинаковое C-выражение для входного и выходного операндов, то это еще не означает, что оба значения будут помещены в один и тот же регистр.

Данную секцию можно пропустить, если входные операнды отсутствуют и следующая секция модифицируемых регистров пуста.

9.3.4. Модифицируемые регистры.

Если в качестве побочного эффекта инструкция модифицирует значение одного или нескольких регистров, в функции asm() должна присутствовать четвертая секция. Например, инструкция fucomip меняет регистр кода завершения, обозначаемый как cc. Строки, представляющие затираемые регистры, разделяются запятыми. Если инструкция способна изменить произвольную ячейку памяти, в этой секции должно стоять ключевое слово memory. На основании этой информации компилятор определяет, какие значения должны быть загружены повторно после завершения функции asm(). При отсутствии данной секции компилятор может сделать неверное предположение о том, что регистры содержат прежние значения, и это скажется на работе программы.

9.4. Пример.

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

Инструкция bsrl вычисляет местоположение старшего значащего бита в первом операнде и записывает результат (номер позиции начиная с нуля) во второй операнд. Например, следующая команда анализирует переменную number и помещает результат в переменную position:

Asm("bsrl %1, %0" : "=r" (position) : "r" (number)};

Ей соответствует такой фрагмент на языке С:

Long i;

For (i = (number >> 1), position = 0; i != 0; ++position).

 i >>= 1;

Чтобы сравнить скорость выполнения двух фрагментов, мы поместили их в цикл, где перебирается большое количество чисел. В листинге 9.1 приведена реализация на языке С. Программа перебирает значения от единицы до числа, указанного в командной строке. Для каждого значения переменной number вычисляется позиция старшего значащего бита. В листинге 9.2 показано, как сделать то же самое с помощью ассемблерной вставки. Обратите внимание на то, что в обоих случаях результат вычислений заносится в переменную result, объявленную со спецификатором volatile. Это необходимо для подавления оптимизации со стороны компилятора, который удалит весь блок вычислений, если их результаты не используются или не заносятся в память.

Листинг 9.1. (bit-pos-loop.c) Нахождение позиции старшего значащего бита в цикле.

#include <stdio.h>

#include <stdlib.h>

Int main(int argc, char* argv[]) {

 long max = atoi(argv[1]);

 long number;

 long i;

 unsigned position;

 volatile unsigned result;

 /* Повторяем вычисления для большого количества чисел. */

 for (number = 1; number <= max; ++number) {

  /* Сдвигаем число вправо, пока результат не станет.

     равным нулю.

     Запоминаем количество операций сдвига. */

 for (i = (number >> 1), position = 0; i != 0; ++position).

  i >>= 1;

  /* Позиция старшего значащего бита — это общее число.

     операций сдвига, кроме первой. */

  result = position;

 }

 return 0;

}

Листинг 9.2. (bit-pos-asm.c) Нахождение позиции старшего значащего бита с помощью инструкции bsrl.

#include <stdio.h>

#include <stdlib.h>

Int main(int argc, char* argv[]) {

 long max = atoi(argv[1]);

 long number;

 unsigned position;

 volatile unsigned result;

 /* Повторяем вычисления для большого количества чисел. */

 for (number = 1; number <= max; ++number) {

  /* Вычисляем позицию старшего значащего бита с помощью.

     ассемблерной инструкции bsrl. */

  asm("bsrl %1, %0" : "=r" (position) : "r" (number));

  result = position;

 }

 return 0;

}

Скомпилируем обе версии программы в режиме полной оптимизации:

% cc -O2 -о bit-pos-loop bit-pos-loop.c.

% cc -O2 -о bit-pos-asm bit-pos-asm.c.

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

% time ./bit-pos-loop 250000000.

19.51user 0.00system 0:20.40elapsed 95%CPU (0avgtext+0avgdata.

0maxresident)k0inputs+0outputs (73major+11minor)pagefaults 0swaps.

% time ./bit-pos-asm 250000000.

3.19user 0.00system 0:03.32elapsed 95%CPU (0avgtext+0avgdata.

0maxresident)k0inputs+0outputs (73major+11minor)pagefaults 0swaps.

Приведенные результаты могут немного меняться в зависимости от загруженности системы, но хорошо видно, что ассемблерная версия выполняется гораздо быстрее.

9.5. Вопросы оптимизации.

Даже при наличии в программе ассемблерных вставок модуль оптимизации компилятора пытается переупорядочить и переписать код программы, чтобы минимизировать время ее выполнения. Когда оптимизатор обнаруживает, что выходные данные функции asm() не используются, он удаляет ее, если только ему не встречается ключевое слово volatile. Любой вызов функции asm() может быть перемещен самым непредсказуемым образом. Единственный способ гарантировать конкретный порядок ассемблерных инструкций — включить все нужные инструкции в одну функцию asm().

Применение функции asm() ограничивает эффективность оптимизации, поскольку компилятор не понимает семантику используемых в ней ассемблерных выражений. Помните об этом!

9.6. Вопросы сопровождения и переносимости.

Если вы решили включить в программу архитектурно-зависимые ассемблерные вставки. поместите их в отдельные макросы или функции, что облегчит сопровождение программы. Когда все макросы находятся в одном файле и задокументированы, программу легче будет перенести в другую систему, так как придется переписать один-единственный файл. Например, большинство вызовов asm() в исходных текстах Linux сгруппировано в файлах /usr/src/linux/include/asm и /usr/src/linux/include/asm-i386.