От C к Ассемблеру

Статья знакомит с азами создания программ с использованием синтаксиса GNU Assembler Syntax (GAS), как совместно с программами на языке C, так и без них. Рассматриваются принципы создания подпрограмм на ассемблере с учётом соглашений, принятых в языке C и многое другое. Рекомендуется тем, кто не избавился от детской привычки все разбирать, чтобы понять ответы на такие важные вопросы как "почему самолёты тяжелее воздуха и не падают".

[Hiran Ramankutty. Перевод: Андрей Киселев]

От C к Ассемблеру


Автор: Hiran Ramankutty
Перевод: Андрей Киселев

1. Краткий обзор

Что входит в состав микрокомпьютерных систем? Типичный ответ на этот вопрос: "микропроцессор, шина, подсистема памяти, подсистема ввода-вывода и интерфейсная часть, объединяющая все компоненты воедино".

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

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

К числу системного ПО можно отнести трансляторы с языков программирования высокого уровня, ассемблеры, текстовые редакторы и разного рода программы, которые несут вспомогательные функции при создании других программ. Нам уже известны три уровня языков программирования -- это машинный язык, Ассемблер и высокоуровневые языки программирования.

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

Компиляторы высокоуровневых языков, подобных C/C++, имеют возможность трансляции программы с языка высокого уровня на язык ассемблера. GNU C/C++ Compiler, вызванный с ключом -S, сгенерирует ассемблерный код, эквивалентный тексту исходной программы на языке C/C++. Знакомство с тем, как элементарные конструкции, такие как циклы, вызовы функций, объявления переменных выглядят на языке ассемблера позволит вам достичь высочайших вершин мастерства. Прежде чем я продолжу, хочу заметить, что знание архитектуры Intel x86, позволит вам лучше понять материал, излагаемый ниже.

2. Начало

Для начала напишите небольшую программу на языке C, которая выводит на экран сообщение hello world и скомпилируйте ее с ключом -S. В результате вы получите файл с ассемблерным кодом, соответствующим исходной программе. По-умолчанию GCC создает файл с ассемблерным кодом с тем же именем, что и исходный файл, заменяя расширение `.c' на `.s'. Попробуйте интерпретировать несколько строк в полученном файле.

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

Как правило, инструкция на языке ассемблера включает в себя метку, мнемонику и операнды. Способ записи операндов однозначно определяет способ их адресации. Мнемоника определяет операцию, выполняемую над операндами. Фактически все ассемблерные инструкции оперируют с регистрами и ячейками памяти. Процессоры Intel 80386 и выше имеют ряд (32 битных) регистров общего назначения: eax, ebx, ecx и пр.. Два регистра для работы со стеком: ebp и esp. Типичная инструкция, записанная с использованием синтаксиса GNU Assembler Syntax (GAS), выглядит так:

movl $10, %eax

Эта инструкция записывает число 10 в регистр eax. Префиксы `%', перед именем регистра, и `$', перед числом, обязательны ибо того требует синтаксис ассемблера. Следует отметить, что не все ассемблеры придерживаются одинакового синтаксиса.

Напишем нашу первую программу на языке ассемблера и сохраним ее в файл first.s. Текст программы приводится ниже.

#Листинг 1
.globl main
main:
  movl $20, %eax
  ret

Эту программу можно ассемблировать и слинковать в исполняемый модуль a.out, если дать команду cc first.s. Расширение имени файла `.s' помогает компилятору идентифицировать язык, на котором написана программа, в результате cc вызывает ассемблер и линковщик, пропуская стадию компиляции.

Первая строка программы -- это комментарий. .globl -- это директива ассемблера, она объявляет имя main глобальным и делает его доступным для линковщика. Это совершенно необходимо, поскольку ваша ассемблерная программа будет линковаться с библиотекой языка C, которая в свою очередь вызывает функцию main. Если эту строку убрать, то линковщик выразит свое неудовольствие сообщением 'undefined reference to symbol main' (ссылка на неопределенный символ main). Эта программа просто записывает число 20 в регистр eax и возвращает управление операционной системе.

3. Арифметические операции, операции сравнения и циклы

Следующая наша программа вычисляет факториал числа, находящегося в регистре eax. Результат вычислений сохраняется в регистре ebx.

#Листинг 2
.globl main
main: 
        movl $5, %eax
        movl $1, %ebx
L1:     cmpl $0, %eax           // сравнить содержимое регистра eax с 0
        je L2                   // переход на L2 если 0==eax (je - jump if equal, перейти если равно)
        imull %eax, %ebx        // ebx = ebx*eax
        decl %eax               // decrement eax (уменьшить на 1)
        jmp L1                  // безусловный переход на L1
L2:     ret

Здесь L1 и L2 -- это метки. Когда программа приходит в точку L2, ebx содержит факториал числа, находящегося в регистре eax.

4. Подпрограммы

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

#Листинг 3
.globl main
main:
        movl $10, %eax
        call foo
        ret
foo:
        addl $5, %eax
        ret

Здесь инструкция call передает управление подпрограмме foo. Инструкция ret, в подпрограмме foo, передает управление обратно, следующей инструкции, расположенной за инструкцией call.

Как правило, функции определяют свои наборы локальных переменных и входных аргументов, используемые при каждом вызове. Для этих переменных необходимо выделять области памяти, обычно для этих целей используется стек. Очень важно понимать основные принципы выделения места на стеке под локальные переменные и аргументы, как эти переменные инициализируются и как они используются при повторном, рекурсивном или ином способе вызова функции, в ходе выполнения программы. Работа с регистрами esp/ebp и использование инструкций работы со стеком push/pop, является центральным аспектом, знание которого обязательно для понимания механизма вызова подпрограмм и возврата в точку вызова.

5. Работа со стеком

Часть памяти, выделяемой программе системой, резервируется под стек. Процессоры Intel 80386 и выше имеют в своем распоряжении регистр esp (от англ. stack pointer -- указатель вершины стека), в котором хранится адрес текущей вершины стека. На рисунке 1 показано содержимое стека, на который положено три целых числа 49,30 и 72 (каждое целое число занимает 4 байта), и значение регистра esp, который хранит адрес текущей вершины стека.

Рисунок 1

Один необычный момент, связанный со стеком для архитектуры Intel, заключается в том, что стек растет "вниз", т.е. от старших адресов -- к младшим. На рисунке 2 показан стек после выполнения инструкции pushl $15.

Рисунок 2

Содержимое регистра esp уменьшилось на 4, а на вершину стека было помещено число 15, которое разместилось по четырем ячейкам памяти с адресами 1988, 1989, 1990 и 1991.

Инструкция popl %eax скопирует число с вершины стека (четыре байта) в регистр eax и увеличит содержимое регистра esp на четыре. А что если вам нужно просто "выбросить" число с вершины стека, никуда его не копируя? Для этого можно просто выполнить инструкцию addl $4, %esp, которая просто увеличит содержимое регистра указателя стека на четыре.

В Листинге 3, инструкция call foo помещает адрес возврата из подпрограммы на стек и передает управление на метку foo. Подпрограмма завершается инструкцией ret, которая снимает с вершины стека адрес возврата и передает управление по этому адресу. Совершенно очевидно, что на вершине стека должен лежать корректный адрес возврата.

6. Выделение места на стеке под локальные переменные

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

Количества регистров в процессоре не так много и они не могут быть использованы для хранения всех переменных, имеющихся в программе. Локальные переменные размещаются на стеке. Листинг 4 демонстрирует -- как это делается.

#Листинг 4
.globl main
main:
        call foo
        ret
foo:
        pushl %ebp
        movl %esp, %ebp
        subl $4, %esp
        movl $10, -4(%ebp)
        movl %ebp, %esp
        popl %ebp
        ret

Прежде всего содержимое указателя стека копируется в регистр ebp, ( от англ. base pointer -- указатель основания, или базы). Этот регистр служит своего рода реперной точкой, относительно которой вычисляются адреса локальных переменных, размещенных на стеке. Регистр ebp может использоваться вызывающей программой, поэтому функция foo прежде всего сохраняет его содержимое на стеке. Инструкция subl $4, %esp резервирует место на стеке (четыре байта) для хранения целого числа. В следующей строке выполняется копирование числа 10 (четыре байта) по адресу на 4 меньшем, чем содержимое базового регистра ebp. Инструкция movl %ebp, %esp восстанавливает содержимое указателя стека, которое имелось после выполнения первой инструкции подпрограммы foo и инструкцией popl %ebp восстанавливает содержимое регистра ebp. Теперь указатель стека имеет то же значение, что и перед исполнением первой инструкции подпрограммы foo. В таблице ниже показано как изменялось содержимое стека и регистров ebp и esp, начиная с точки входа в функцию main и после выполнения каждой инструкции в программе из Листинга 4 (исключая момент выхода из функции main). Из таблицы видно, что в точке входа в функцию main, в регистрах ebp и esp находились значения 7000 и 4000, а на стеке, по адресам с 3988 по 3999, находились некоторые значения -- 219986, 1265789 и 86. Так же видно, что инструкция call foo положила на стек адрес возврата из подпрограммы -- 30000.

Таблица 1

6. Входные аргументы и возвращаемые значения

Стек может использоваться для передачи в подпрограмму значений входных аргументов. Будем придерживаться соглашений, принятых в языке C, при передаче входных параметров в подпрограмму. В соответствии с которыми, регистр eax служит для возврата результата в вызывающую программу, а входные аргументы передаются вызываемой подпрограмме через стек. Листинг 5 демонстрирует вызов простой функции sqr, которая принимает один входной аргумент.

#Listing 5
.globl main
main:
        movl $12, %ebx
        pushl %ebx
        call sqr
        addl $4, %esp       // вернуть содержимое esp в состояние, предшествующее выполнению инструкции push
        ret
sqr:
        movl 4(%esp), %eax
        imull %eax, %eax    // найти произведение eax * eax, результат остается в eax 
        ret

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

8. Комбинирование программ на языке C и ассемблере

В Листинге 6 представлены программа на языке C и модуль функции, написанной на языке ассемблера. Программа на языке C находится в файле main.c, а ассемблерный модуль с текстом функции -- в файле sqr.s. Чтобы скомпилировать и слинковать такую программу дайте команду cc main.c sqr.s.

#Listing 6
//main.c
main()
{
        int i = sqr(11);
        printf("%d\n",i);
}

//sqr.s
.globl sqr
sqr:
        movl 4(%esp), %eax
        imull %eax, %eax
        ret

Обратный вариант (вызов C-шной функции из ассемблера) не менее прост и понятен. В Листинге 7 демонстрируется возможность вызова функции, написанной на языке C, из программы, написанной на языке ассемблера.

#Listing 7
//print.c
print(int i)
{
        printf("%d\n",i);
}

//main.s
.globl main
main:
        movl $123, %eax
        pushl %eax
        call print
        addl $4, %esp
        ret

9. Ассемблерный код, генерируемый компилятором GNU C

Я полагаю, что прочитанного вами в этой статье уже достаточно для понимания содержимого ассемблерных листингов, которые создает gcc. В Листинге 8 приведено содержимое файла add.s, полученного в результате выполнения команды gcc -S add.c. Обратите внимание: в демонстрационных целях я удалил из файла add.s некоторые директивы ассемблера.

#Листинг 8
//add.c
int add(int i,int j)
{
        int p = i + j;
        return p;
}

//add.s
.globl add
add:
        pushl %ebp
        movl %esp, %ebp
        subl $4, %esp           // выделяется место для переменной p
        movl 8(%ebp),%edx       // 8(%ebp) -- ссылка на переменную i
        addl 12(%ebp), %edx     // 12(%ebp) -- ссылка на переменную j
        movl %edx, -4(%ebp)     // -4(%ebp) -- ссылка на переменную p
        movl -4(%ebp), %eax     // возвращаемое значение помещается в регистр eax
        leave                   // аналог комбинации инструкций movl %ebp, %esp; popl %ebp 
        ret

Вызов функции add с аргументами 10 и 20, будет оттранслирован в следующий ассемблерный код:

pushl $20
pushl $10
call add

Обратите внимание: последний аргумент помещается на стек первым.

10. Глобальные переменные

Мы уже знаем, что пространство под локальные переменные выделяется на стеке простым уменьшением содержимого регистра esp. А как выделяется пространство под глобальные переменные? Ответ на этот вопрос вы найдете в Листинге 9.

#Listing 9
//glob.c
int foo = 10;
main()
{
        int p = foo;
}

//glob.s
.globl foo
foo:
        .long 10
.globl main
main:
        pushl %ebp
        movl %esp,%ebp
        subl $4,%esp
        movl foo,%eax
        movl %eax,-4(%ebp)
        leave
        ret

Строка foo: .long 10 определяет блок памяти, под именем foo, размером в 4 байта и инициализирует его начальным значением. Директива .globl foo объявляет имя foo глобальным, что позволяет ссылаться на него из других модулей. Теперь попробуйте заменить объявление int foo на static int foo. Посмотрите -- как теперь будет выглядеть ассемблерный код. Вы наверняка заметите, что исчезла директива .globl. Попробуйте изменить тип переменной (double, long, short, const и т.п.).

11. Системные вызовы

Если программа не просто производит какие-либо арифметические вычисления на языке ассемблера, а так же организует ввод/вывод данных и т.п., то ей не обойтись без вызова служб операционной системы. В действительности, если не касаться системных вызовов, то программирование на ассемблере практически не зависит от типа операционной системы.

Существует два общепринятых способа выполнения системных вызовов в Linux: через библиотеку libc и напрямую.

Libc выполняет роль защитной прослойки, предохраняя приложение от возможных ошибок на тот случай, если в ядре изменится синтаксис того или иного системного вызова и предоставляет POSIX-совместимый интерфейс с ядром. Однако, ядро Linux само по себе является более или менее POSIX-совместимым, это означает, что синтаксис вызова библиотечных функций-оберток из libc в точности совпадает с синтаксисом реальных системных вызовов ядра (и наоборот).

Системные вызовы в Linux выполняются через прерывание int 0x80. Соглашение о системных вызовах в Linux отличается от общепринятого в Unix и соответствует соглашению "fastcall". Согласно ему, программа помещает в регистр eax номер системного вызова, входные аргументы размещаются в других регистрах процессора (таким образом, системному вызову может быть передано до 6 аргументов через регистры ebx, ecx, edx, esi, edi и ebp), после чего вызывается инструкция int 0x80. Если системному вызову необходимо передать большее количество аргументов, то они размещаются в структуре, адрес на которую передается в качестве первого аргумента. Результат возвращается в регистре eax, а стек вообще не используется.

Рассмотрим листинг, представленный ниже.

#Листинг 10
#fork.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>

int main()
{
        fork();
        printf("Hello\n");
        return 0;
}

Скомпилируйте программу командой cc -g fork.c -static. Запустите gdb и дайте команды
file fork
disassemble fork.

(Прим.ред. -- во-первых, для cc необходимо добавить ключ -o и в этом случае команда сборки fork.c будет выглядеть следующим образом: cc -o fork -g fork.c -static. Либо, запустив gdb, придётся указывать не file fork, а file a.out, т.к. при отсутсвии ключа -o линкуется программа с именем a.out. Это относится и к остальным примерам.)

Перед вами появится ассемблерный листинг программы, откуда вы увидите как выполняется системный вызов fork. Ключ -static -- это ключ статической линковки GCC (см. страницы справочного руководства man gcc). Попробуйте проделать то же самое с другими системными вызовами.

11. Программирование на встроенном ассемблере

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

Для вставки ассемблерного кода используется инструкция asm, например:

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

что для процессоров семейства x86 соответствует выражению на языке C:

answer = sin(angle);

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

  • Инструкция asm позволит программе получить прямой доступ к аппаратуре компьютера. Это может повысить скорость выполнения программ. Ее можно использовать для написания кода, который войдет в состав операционной системы и который будет взаимодействовать с аппаратурой компьютера. Например, /usr/include/asm/io.h содержит ассемблерный код для прямого доступа к портам ввода/вывода.
  • Ассемблерные вставки помогут значительно поднять скорость прохождения глубоких вложенных циклов в программе. Например, sine и cosine для одного и того же значения угла можно заменить одной ассемблерной инструкцией fsincos. Возможно два листинга, приведенных ниже, помогут вам лучше понять важность фактора времени.
#Листинг 11
#Name : bit-pos-loop.c 
#Description : Отыскивает позицию бита в цикле

#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;
}
#Listing 12
#Name : bit-pos-asm.c
#Description : Отыскивает позицию бита с помощью 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) {
                asm("bsrl %1, %0" : "=r" (position) : "r" (number));
                result = position;
        }
        return 0;
}

Скомпилируйте эти два примера с ключами оптимизации, как это показано ниже:

$ cc -O2 -o bit-pos-loop bit-pos-loop.c
$ cc -O2 -o bit-pos-asm bit-pos-asm.c

Для измерения скорости исполнения каждой версии воспользуйтесь командой time и задайте в командной строке достаточно большое число, чтобы каждая версия отработала по крайней мере несколько секунд..

$ time ./bit-pos-loop 250000000

и

$ time ./bit-pos-asm 250000000

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

(Прим.ред. -- наверное компьютеру (P4-1.7ГГц), где я проверял примеры, предложенные автором нужно что-то посолиднее -- результаты тестов одинаковые.)

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

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

12. Упражнения

  1. Разберитесь с ассемблерным кодом для программы на языке C из Листинга 6. Измените его таким образом, чтобы больше не появлялись предупреждающие сообщения, возникающие при генерации ассемблерного кода с ключом -Wall. Сравните два полученных ассемблерных листинга. Какие изменения вы заметили?
  2. Скомпилируйте какую нибудь небольшую программку на языке C с оптимизацией (например -O2) и без нее. Посмотрите получившийся ассемблерный код и найдите основные отличия, выполненные компилятором.
  3. Разберите ассемблерный код, который генерируется для оператора выбора switch.
  4. Скомпилируйте небольшую программку на языке C, которая имеет ассемблерную вставку. Какие различия вы заметили в ассемблерном коде такой программы?
  5. Вложенные функции -- это такие функции, которые определяются в теле другой ("объемлющей") функции, таким образом:
    • вложенная функция имеет возможность доступа к локальным переменным "объемлющей" функции и
    • вложенные функции являются локальными по отношению к "объемлющим" функциям и не могут быть вызваны за пределами "объемлющих" функций если они не передадут вам указатель на вложенную функцию.

    Разберите программу, приведенную ниже:


    #Listing 13 /* myprint.c */ #include <stdio.h> #include <stdlib.h> int main() { int i; void my_print(int k) { printf("%d\n",k); } scanf("%d",&i); my_print(i); return 0; }

    Скомпилируйте эту программу командой cc -S myprint.c и просмотрите ассемблерный код. Попробуйте скомпилировать эту программу командой cc -pedantic myprint.c. Что вы заметили?

Я только что сдал экзамены за последний курс Правительственного Колледжа Компьютерных Наук в городе Трикур (Trichur), Индия, штат Kerala.



Copyright (C) 2003, Hiran Ramankutty. Copying license http://www.linuxgazette.com/copying.html
Published in Issue 94 of Linux Gazette, September 2003

Перевод можно найти по адресу: http://gazette.linux.ru.net/lg94/ramankutty.html

[ опубликовано 18/08/2004 ]

Hiran Ramankutty. Перевод: Андрей Киселев - От C к Ассемблеру   Версия для печати