Функция

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

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

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

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

Application Binary Interface (ABI)

Описание ABI ver 1.0
взято здесь

Согласно описанию ABI, регистры %rbp, %rbx и с %r12 по $r15 рекомендуется резервировать под значения вызывающей функции, то есть вызываемая функция не должна трогать значения в этих регистрах. Оставшиеся регистры вызываемая функция может использовать по своему усмотрению.

Как работает функция

Функция состоит из нескольких частей

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

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

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

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

Глобальные переменные - это данные, которые нужны функции для обработки, но которые существуют вне функции.

Адрес возврата - это "невидимый" параметр, который напрямую не используется функцией. Адрес возврата это параметр, который говорит функции откуда продолжить выполнение программы после завершения функции. Это необходимо, так как функция может быть вызвана из множества различных мест программы и функции необходимо иметь возможность вернуться в том место, откуда она была вызвана. В большинстве языков программирования этот параметр передаётся автоматически при вызове функции. В Ассемблере, инструкция call заботится о передаче адреса возврата за вас, а инструкция ret использует этот адрес для возврата в то место, откуда была вызвана функция.

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

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

Ассемблер может использовать любые соглашения о вызове. Вы можете сделать даже своё собственное. Однако, если вы хотите взаимодествовать с функциями, написанными на других языках, вы должны учитывать их соглашения. Мы будем использовать соглашение о вызовах для языка Си, потому что оно наиболее часто используется и потому что оно является стандартом для Linux.

Ассемблер использует соглашение о вызовах для Си

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

Ваш компьютер также имеет стек. Компьютерный стек живёт в самых верхних адресах памяти. Вы можете поместить значение на вершину используя инструкцию pushl, которая поместит значение регистра или памяти на вершину стека. Мы говорим про вершину, но на самом деле "вершиной" стека является самый низ в памяти, отведённой под стек. Это немного путает, так как мы думая о стеке представляем себе стопку бумаги, в которую что-то добавляют кладя ето наверх. Однако, память стека начинается на самом верху памяти и "растёт" вниз из-за архитектурных соображений. Поэтому, когда коворят о вершине стека, то на самом деле имеется ввиду его самый низ. Вы также можете удалять значения с вершины стека используя инструкцию popl. Это удаляет значение с вершины стека и помещает в регистр или ячейку памяти, которую вы выбрали.

Когда мы добавляем значение в стек, вершина стека перемещается в соответствии с размером добавленного значения. Мы можем добавлять значения, а стек будет расти, пока не достигнет кода или данных. Как мы узнаем, где находится вершина стека? Регистр %esp всегда хранит указатель на вершину стека, где бы он не находился.

Каждый раз, когда мы добавляем что-нибудь в стек при помощи pushl, %esb уменьшается на 4, для того чтобы указывать на новую вершину (помните, что каждое слово состоит из 4 байт и что стек растёт вниз). Если мы хотим что-нибудь убрать из стека, мы просто используем инструкцию popl, которая добавляет 4 к %esp и помещает значение предудущей вершины в указанный регистр. Инструкции pushl и popl работают с одним операндом - регистром, значение которого помещается в стек при pushl или принимающем значение которое взято из стека при popl.

Если мы просто хотим получить значение вершины стека без его удаления, то мы можем просто использовать регистр %esp в режиме косвенной адресации. Для примера поместим значение вершины стека в регистр %eax

movl (%esp), %eax

Если мы просто укажем %esp

movl %esp, %eax

то %eax будет содержать просто указатель на вершину стека, а не значение вершины. Помещение %esp в скобки переводит процессор в режим косвенной адресации и поэтому мы получаем значение, на которое указывает %esp. Если мы хотим получить значение, находящееся под вершиной стека (на самом деле над, так как стек растёт вниз), то мы можем просто использовать такую инструкцию

movl 4(%esp), %eax

Эта инструкция использует режим адресации базового указателя, который просто добавляет 4 к значению %esp прежде чем получить значение, на которое %esp ссылается.

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

Перед выполнением функции, программа помещает в стек все параметры в обратном порядке который указан в их описании. Потом прорамма использует инструкцию call в которой указывает какую функцию хочет выполнить. Инструкция call делает две вещи. Во-первых оно помещает адрес следующей за ней инструкции, который является адресом возврата для функции. Во-вторых она изменяет регистр %eip для того чтобы тот стал ссылать на начало функции. К моменту запуска функции стек будет выглядеть так

Параметр N
...
Параметр 2
Параметр 1
Адрес возврата <- (%esp)

Каждый параметр функции помещён в стек и, наконец, туда помещён адрес возврата. Теперь сама функция должна проделать некоторую работу.

Первым делом она сохраняет значение регистра текущего базового указателя %ebp выполняя инструкцию

pushl %ebp

%ebp специальный регистриспользуемый для доступа функции к своим параметрам и локальным переменным. Далее она копирует указатель вершины стека в регистр базового указателя

movl %esp, %ebp

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

Копирование указателя стека в указатель базы в начале функции позволит вам всегда знать где находятся ваши параметры (и как мы увидим позже и локальные переменные тоже), даже если вы будете добавлять и удалать значения из стека. Регистр %ebp всегда будет знать где был указатель стека в начале выполнения функции, поэтому это более или менее постоянная ссылка на стековый фрейм (стековый фрейм состоит из всех переменных стека используемых в функции, включая параметры, локальные переменные и адрес возврата).

Теперь стек выглядит так

Параметр N <--- N*4+4(%ebp)
...
Параметр 2 <--- 12(%ebp)
Параметр 1 <--- 8(%ebp)
Адрес возврата <--- 4(%ebp)
Старый %ebp <--- (%esp) или (%ebp)

Как вы видите, каждый параметр может быть доступен через использование базового указателя из %ebp.

Далее функция резервирует пространство в стеке для любых локальных переменных, которые ей нужны. Это делается путём простого смещения указателя стека. Например, нам нужно два слова в памяти (8 байт) для выполнения функции. Мы можем просто просто переместить указатель стека на два слова, тем смым зарезервировав пространство. Делается это так:

subl $8, %esp

Это вычтет 8 из %esp. Таким образом мы можем использовать стек для хранения переменных без опасения, что вызовы других функций затрут наши данные. Также, так как это выделение места стекового фрейма для текущего вызова функции, переменные будут жить только пока выполняется данная функция. Когда мы вернёмся, стековый фрейм уйдёт, а с ним и эти переменные. Поэтому они и называются локальными - они существуют только пока выполняется функция.

Теперь у нас есть два слова для хранения локальных переменных. Наш стек теперь выглядит так:

Параметр N <--- N*4+4(%ebp)
...
Параметр 2 <--- 12(%ebp)
Параметр 1 <--- 8(%ebp)
Адрес возврата <--- 4(%ebp)
Старый %ebp <--- (%esp) или (%ebp)
Локальная переменная 1 <--- -4(%ebp)
Локальная переменная 2 <--- -8(%ebp)

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

Глобальные и статические переменные получают обычным способом. Единственная разница между глобальными и статическими переменными в том, что статическую переменную использует только одна функция, в то время как глобальную переменную могут использовать все функции. Для Ассемблера эти переменные являются одинаковыми, а некоторые языки различают их.

Когда функция завершает своё выполнение, она делает три вещи:

  1. Она сохраняет возвращаемое значение в регистр %eax.
  2. Она возвращает стек к тому состоянию, в котором он бл до вызова функции.
  3. Она возвращает управление в то место, откуда она была вызвана. Это делается с помощью инструкции ret, которая берёт значение с вершины стека и устанавливает это значение в указатель на инструкцию %eip.

Поэтому, прежде чем функция возвратит управление в ту часть программы, которая вызвала её, она должна восстановить состояние предыдущего стекового фрейма. Помните также, что без этого, ret не будет работать, потому что в текущем стековом фрейме, адрес возврата находится не на вершине стека. Поэтому, прежде чем мы вернёмся, мы должны вернуть в %esp и %ebp те значения, которые те имели перед вызовом функции.

Поэтому для возврата из функции мы должны сделать это:

movl %ebp, %esp
popl %ebp
ret

Реально работающий код


  1 .global _start
  2 .data
  3 hello: .string "Hello\n"
  4 .text
  5 _start:
  6     call output
  7     call exit
  8
  9 output:
 10     mov     $1, %rax
 11     mov     $1, %rdi
 12     mov     $hello, %rsi
 13     mov     $6, %rdx
 14     syscall
 15
 16 exit:
 17     mov     $60, %rax
 18     mov     $0, %rdi
 19     syscall
 20