Опубликован: 01.03.2016 | Доступ: свободный | Студентов: 586 / 62 | Длительность: 03:55:00
Лекция 5:

Команды ассемблера

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

Термином "подпрограмма " будем называть и функции, которые возвращают значение, и функции, не возвращающие значение (void proc(…)). Подпрограммы нужны для достижения одной простой цели - избежать дублирования кода. В ассемблере есть две команды для организации работы подпрограмм.

call  метка

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

  • Поместить в стек адрес следующей за call команды. Этот адрес называется адресом возврата.
  • Передать управление на метку.

Для возврата из подпрограммы используется команда ret.

ret
ret   число

Принцип работы:

  • Извлечь из стека новое значение регистра %eip (то есть передать управление на команду, расположенную по адресу из стека).
  • Если команде передан операнд число, %esp увеличивается на это число. Это необходимо для того, чтобы подпрограмма могла убрать из стека свои параметры.

Существует несколько способов передачи аргументов в подпрограмму.

  • При помощи регистров. Перед вызовом подпрограммы вызывающий код помещает необходимые данные в регистры. У этого способа есть явный недостаток: число регистров ограничено, соответственно, ограничено и максимальное число передаваемых параметров. Также, если передать параметры почти во всех регистрах, подпрограмма будет вынуждена сохранять их в стек или память, так как ей может не хватить регистров для собственной работы. Несомненно, у этого способа есть и преимущество: доступ к регистрам очень быстрый.
  • При помощи общей области памяти. Это похоже на глобальные переменные в Си. Современные рекомендации написания кода (а часто и стандарты написания кода в больших проектах) запрещают этот метод. Он не поддерживает многопоточное выполнение кода. Он использует глобальные переменные неявным образом - смотря на определение функции типа void func(void) невозможно сказать, какие глобальные переменные она изменяет и где ожидает свои параметры. Вряд ли у этого метода есть преимущества. Не используйте его без крайней необходимости.
  • При помощи стека. Это самый популярный способ. Вызывающий код помещает аргументы в стек, а затем вызывает подпрограмму.

Рассмотрим передачу аргументов через стек подробнее. Предположим, нам нужно написать подпрограмму, принимающую три аргумента типа long (4 байта). Код:

sub:
        pushl %ebp              /* запоминаем текущее значение 
                                   регистра %ebp, при этом %esp -= 4 */
        movl  %esp, %ebp        /* записываем текущее положение 
                                   вершины стека в %ebp              */
 
        /* пролог закончен, можно начинать работу */
 
        subl  $8, %esp          /* зарезервировать место для локальных 
                                   переменных                        */
 
 
        movl  8(%ebp),  %eax    /* что-то cделать с параметрами      */
        movl  12(%ebp), %eax
        movl  16(%ebp), %eax
 
 
        /* эпилог */
 
        movl  %ebp, %esp        /* возвращем вершину стека в исходное 
                                   положение                         */
        popl  %ebp              /* восстанавливаем старое значение 
                                   %ebp, при этом %esp += 4          */
        ret
 
main:
        pushl $0x00000010       /* поместить параметры в стек        */
        pushl $0x00000020
        pushl $0x00000030
        call  sub               /* вызвать подпрограмму              */
        addl  $12, %esp

С вызовом всё ясно: помещаем аргументы в стек и даём команду call. А вот как в подпрограмме удобно достать параметры из стека? Вспомним про регистр %ebp.

Мы сохраняем предыдущее значение регистра %ebp, а затем записываем в него указатель на текущую вершину стека. Теперь у нас есть указатель на стек в известном состоянии. Сверху в стек можно помещать сколько угодно данных, %esp поменяется, но у нас останется доступ к параметрам через %ebp. Часто эта последовательность команд в начале подпрограммы называется "прологом ".

.                      .
.                      .
.                      .
+----------------------+ 0x0000F040  <-- новое значение %ebp
| старое значение %ebp |
+----------------------+ 0x0000F044  <-- %ebp + 4
|    адрес возврата    |
+----------------------+ 0x0000F048  <-- %ebp + 8
|      0x00000030      |
+----------------------+ 0x0000F04C  <-- %ebp + 12
|      0x00000020      |
+----------------------+ 0x0000F050  <-- %ebp + 16
|      0x00000010      |
+----------------------+ 0x0000F054
.                      .
.                      .
.                      .

Используя адрес из %ebp, мы можем ссылаться на параметры:

 8(%ebp) = 0x00000030
12(%ebp) = 0x00000020
16(%ebp) = 0x00000010

Как видите, если идти от вершины стека в сторону аргументов, то мы будем встречать аргументы в обратном порядке по отношению к тому, как их туда поместили. Нужно сделать одно из двух: или помещать аргументы в обратном порядке (чтобы доставать их в прямом порядке), или учитывать обратный порядок аргументов в подпрограмме. В Си принято при вызове помещать аргументы в обратном порядке. Так как операционная система Linux и большинство библиотек для неё написаны именно на Си, для обеспечения переносимости и совместимости лучше использовать "сишный " способ передачи аргументов и в ваших ассемблерных программах.

Подпрограмме могут понадобиться собственные локальные переменные. Их принято держать в стеке, так как в этом случае легко обеспечить необходимое время жизни локальных переменных: достаточно в конце подпрограммы вытолкнуть их из стека. Для того, чтобы зарезервировать для них место, мы просто уменьшим содержимое регистра %esp на размер наших переменных. Это действие эквивалентно использованию соответствующего количества команд push, только быстрее, так как не требует записи в память. Предположим, что нам нужно 2 переменные типа long (4 байта), итого 2 * 4 = 8 байт. Таким образом, регистр %esp нужно уменьшить на 8. Теперь стек выглядит так:

.                        .
.                        .
.                        .
+------------------------+ 0x0000F038  <-- %ebp - 8
| локальная переменная 2 |
+------------------------+ 0x0000F03C  <-- %ebp - 4
| локальная переменная 1 |
+------------------------+ 0x0000F040  <-- %ebp
|  старое значение %ebp  |
+------------------------+ 0x0000F044  <-- %ebp + 4
|     адрес возврата     |
+------------------------+ 0x0000F048  <-- %ebp + 8
|       0x00000030       |
+------------------------+ 0x0000F04C  <-- %ebp + 12
|       0x00000020       |
+------------------------+ 0x0000F050  <-- %ebp + 16
|       0x00000010       |
+------------------------+ 0x0000F054
.                        .
.                        .
.                        .

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

При возврате из процедуры мы восстанавливаем старое значение %ebp из стека, потому что после возврата вызывающая функция вряд ли будет рада найти в регистре %ebp неизвестно что (а если серьёзно, этого требует ABI). Для этого необходимо, чтобы старое значение %ebp было на вершине стека. Если подпрограмма что-то поместила в стек после старого %ebp, она должна это убрать. К счастью, мы не должны считать, сколько байт мы поместили, сколько достали и сколько ещё осталось. Мы можем просто поместить значение регистра %ebp в регистр %esp, и стек станет точно таким же, как и после сохранения старого %ebp в начале подпрограммы. После этого команда ret возвращает управление вызывающему коду. Эта последовательность команд часто называется "эпилогом " подпрограммы.


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

Остаётся одна маленькая проблема: в стеке всё ещё находятся аргументы для подпрограммы. Это можно решить одним из следующих способов:

  • использовать команду ret с аргументом;
  • использовать необходимое число раз команду pop и выбросить результат;
  • увеличить %esp на размер всех помещенных в стек параметров.

В Си используется последний способ. Так как мы поместили в стек 3 значения типа long по 4 байта каждый, мы должны увеличить %esp на 12, что и делает команда addl сразу после call.

Заметьте, что не всегда обязательно выравнивать стек. Если вы вызываете несколько подпрограмм подряд (но не в цикле!), то можно разрешить аргументам "накопиться " в стеке, а потом убрать их всех одной командой. Если ваша подпрограмма не содержит вызовов других подпрограмм в цикле и вы уверены, что оставшиеся аргументы в стеке не вызовут проблем переполнения стека, то аргументы можно не убирать вообще. Всё равно это сделает команда эпилога, которая восстанавливает %esp из %ebp. С другой стороны, если не уверены - лучше уберите аргументы, от одной лишней команды программа медленнее не станет.

Строго говоря, все эти действия с %ebp не требуются. Вы можете использовать %ebp для хранения своих значений, никак не связанных со стеком, но тогда вам придётся обращаться к аргументам и локальным переменным через %esp или другие регистры, в которые вы поместите указатели. Трюк состоит в том, чтобы не изменять %esp после резервирования места для локальных переменных и до конца функции: так вы сможете использовать %esp на манер %ebp, как было показано выше. Не изменять %esp значит, что вы не сможете использовать push и pop (иначе все смещения переменных в стеке относительно %esp "поплывут "); вам понадобится создать необходимое число локальных переменных для хранения этих временных значений. С одной стороны, этот способ доступа к переменным немного сложнее, так как вы должны заранее просчитать, сколько места в стеке вам понадобится. С другой стороны, у вас появляется еще один свободный регистр %ebp. Так что если вы решите пойти этой дорогой, вы должны заранее продумать, сколько места для локальных переменных вам понадобится, и дальше обращаться к ним через смещения относительно %esp.

И последнее: если вы хотите использовать вашу подпрограмму за пределами данного файла, не забудьте сделать её глобальной с помощью директивы .globl.

Посмотрим на код, который выводил содержимое регистра %eax на экран, вызывая функцию стандартной библиотеки Си printf(3). Вы его уже видели в предыдущих программах, но там он был приведен без объяснений. Для справки привожу цитату из man:

PRINTF(3)            Linux Programmer 's Manual            PRINTF(3)
 
NAME
       printf - formatted output conversion
 
SYNOPSIS
       #include  <stdio.h >
 
       int printf(const char *format, ...);
.data
printf_format:
        .string  "%d\n "
 
.text
        /* printf(printf_format, %eax); */
        pushl %eax              /* аргумент, подлежащий печати       */
        pushl $printf_format    /* аргумент format                   */
        call  printf            /* вызов printf()                    */
        addl  $8, %esp          /* выровнять стек                    */

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


Внимание! Значения регистров глобальны, вызывающая и вызываемая подпрограммы видят одни и те же регистры. Конечно же, подпрограмма может изменять значения любых пользовательских регистров, но она обязана при возврате восстановить значения регистров %ebp, %ebx, %esi, %edi и %esp. Сохранение остальных регистров перед вызовом подпрограммы - задача программиста. Даже если вы заметили, что подпрограмма не изменяет какой-то регистр, это не повод его не сохранять. Ведь неизвестно, как будут обстоять дела в следующей версии подпрограммы. Вы не должны делать каких-либо предположений о состоянии регистров на момент выхода из подпрограммы. Можете считать, что они содержат случайные значения.

Также внимания требует флаг df. При вызове подпрограмм флаг должен быть равен 0. Подпрограмма при возврате также должна установить флаг в 0. Коротко: если вам вдруг нужно установить этот флаг для какой-то операции, сбросьте его сразу, как только надобность в нём исчезнет.

До этого момента мы обходились общим термином "подпрограмма ". Но если подпрограмма - функция, она должна как-то передать возвращаемое значение. Это принято делать при помощи регистра %eax. Перед началом эпилога функция должна поместить в %eax возвращаемое значение.

Программа: печать таблицы умножения

Рассмотрим программу посложнее. Итак, программа для печати таблицы умножения. Размер таблицы умножения вводит пользователь. Нам понадобится вызвать функцию scanf(3) для ввода, printf(3) для вывода и организовать два вложенных цикла для вычислений.

.data
input_prompt:
        .string  "enter size (1-255):  "
 
scanf_format:
        .string  "%d "
 
printf_format:
        .string  "%5d  "
 
printf_newline:
        .string  "\n "
 
size:
        .long 0
 
.text
.globl main
main:
        /* запросить у пользователя размер таблицы */
        pushl $input_prompt     /* format                            */
        call  printf            /* вызов printf                      */
 
        /* считать размер таблицы в переменную size */
        pushl $size             /* указатель на переменную size      */
        pushl $scanf_format     /* format                            */
        call  scanf             /* вызов scanf                       */
 
        addl  $12, %esp         /* выровнять стек одной командой сразу 
                                   после двух функций                */
 
        movl  $0, %eax          /* в регистре %ax команда mulb будет 
                                   выдавать результат, но мы печатаем 
                                   всё содержимое %eax, поэтому два 
                                   старших байта %eax должны быть 
                                   нулевыми                          */
 
        movl  $0, %ebx          /* номер строки                      */
 
print_line:
        incl  %ebx              /* увеличить номер строки на 1       */
        cmpl  size, %ebx
        ja    print_line_end    /* если номер строки больше 
                                   запрошенного размера, завершить цикл
                                                                     */
 
        movl  $0, %ecx          /* номер колонки                     */
 
print_num:
        incl  %ecx              /* увеличить номер колонки на 1      */
        cmpl  size, %ecx
        ja    print_num_end     /* если номер колонки больше 
                                   запрошенного размера, завершить цикл
                                                                     */
 
        movb  %bl, %al          /* команда mulb ожидает второй 
                                   операнд в %al                     */
        mulb  %cl               /* вычислить %ax = %cl * %al         */
 
        pushl %ebx              /* сохранить используемые регистры 
                                   перед вызовом printf              */
        pushl %ecx
 
        pushl %eax              /* данные для печати                 */
        pushl $printf_format    /* format                            */
        call  printf            /* вызов printf                      */
        addl  $8, %esp          /* выровнять стек                    */
 
        popl  %ecx              /* восстановить регистры             */
        popl  %ebx
 
        jmp   print_num         /* перейти в начало цикла            */
print_num_end:
 
        pushl %ebx              /* сохранить регистр                 */
 
        pushl $printf_newline   /* напечатать символ новой строки    */
        call  printf
        addl  $4, %esp
 
        popl  %ebx              /* восстановить регистр              */
 
        jmp   print_line        /* перейти в начало цикла            */
print_line_end:
 

        movl  $0, %eax          /* завершить программу               */
        ret