ВПЕРЁД

НАЗАД



Вызовы функций

Функция без аргументов и возвратного значения

Код

С++

void func() {
	return;
}
 
int main() {
	func();
}

ASM (x86-64 clang)

func():
	push rbp
	mov rbp, rsp
	pop rbp
	ret
	
main:
	push rbp
	mov rbp, rsp
	call func()
	xor eax, eax
	pop rbp
	ret

ONLINE COMPILER

Godbolt

Разбор инструкций

func() - возвращаемый тип void

func():
    push rbp
    mov rbp, rsp
    pop rbp
    ret

Это практически пустая функция из первого примера, но без xor eax, eax. Причина проста: тип возврата void - функция ничего не возвращает, поэтому трогать eax/rax не нужно. Вызывающая сторона не будет читать этот регистр.

main - инструкция call func()

Эта инструкция делает две вещи атомарно:

  1. Кладёт на стек адрес следующей инструкции (xor eax, eax) - это будет адрес возврата
  2. Передаёт управление на метку func()
До call:

Верхние адреса памяти
---------------------------------------------------------------
                       < ret addr в _start >
    rsp → (rbp main) → <     rbp _start    >
---------------------------------------------------------------
Нижние адреса памяти

###############################################################

После call (уже внутри func):

Верхние адреса памяти
---------------------------------------------------------------
                       < ret addr в _start >
          (rbp main) → <     rbp _start    >
    rsp → (        ) → < addr of xor eax   > ← push был сделан 
                                               инструкцией call
---------------------------------------------------------------
Нижние адреса памяти

Затем func() начинает подготавливать свой собственный стек-фрейм:

Верхние адреса памяти
--------------------------------------------
                       < ret addr в _start >
          (rbp main) → <     rbp _start    >
                       < addr of xor eax   >
    rsp → (rbp func) → <     rbp main      >
--------------------------------------------
Нижние адреса памяти

ret внутри func()

Снимает с вершины стека адрес возврата (addr of xor eax) и прыгает туда. Стек возвращается в состояние как до call.

Полная картина выполнения

_start → call main 
                 | → push rbp             (сохранить rbp _start)
     пролог main | → mov rbp, rsp         (новый фрейм main)
                   → call func()          (push addr xor; jmp func)
                          → push rbp      (сохранить rbp main)
                          → mov rbp, rsp  (новый фрейм func)
                          → pop rbp       (восстановить rbp main)
                          → ret           (прыгнуть на xor eax)
     эпилог main | → xor eax, eax         (return 0)
                 | → pop rbp              (восстановить rbp _start)
                 | → ret                  (вернуться в _start)
_start → exit()

Почему xor eax, eax стоит после call, а не перед?

call не трогает eax, но func() теоретически могла бы его изменить (если бы возвращала значение). Компилятор ставит подготовку возвращаемого значения после вызова, чтобы не перезаписывать eax зря - на случай если бы func() возвращала что-то в eax. В данном случае это не принципиально, но компилятор следует единой схеме.

Функция без аргументов и c возвратным значением

Код

С++

int func() {
	return 1234;
}
 
int main() {
	int var{func()};
}

ASM (x86-64 clang)

func():
	push rbp
	mov rbp, rsp
	mov eax, 1234
	pop rbp
	ret
 
main:
	push rbp
	mov rbp, rsp
	sub rsp, 16
	call func()
	mov dword ptr [rbp - 4], eax
	xor eax, eax
	add rsp, 16
	pop rbp
	ret

ONLINE COMPILER

Godbolt

Разбор инструкций

func() - возврат int

func():
    push rbp
    mov rbp, rsp
    mov eax, 1234
    pop rbp
    ret

mov eax, 1234

Вместо записи на стек значение кладётся прямо в регистр eax. По соглашению System V AMD64 ABI, целочисленный результат функции всегда возвращается через rax (для 32-битных значений - через eax). Никакой промежуточной переменной на стеке нет, func() просто заряжает регистр и возвращает управление вызвавшей функции.

main - новые инструкции

sub rsp, 16

Впервые появляется явное выделение места на стеке. Компилятор заранее резервирует 16 байт, сдвигая rsp вниз.

Формально здесь нужен только 1 слот в 4 байта (под var), но компилятор выделяет 16 - это требование выравнивания стека. По ABI, перед любым call стек должен быть выровнен по 16 байт. После push rbp и mov rbp, rsp выравнивание уже соблюдено, и sub rsp, 16 сохраняет его перед вызовом func().

Верхние адреса памяти
-------------------------------------------------------------------
                            < ret addr в _start > ← 8 байт
          (rbp main     ) → <     rbp _start    > ← 8 байт
          (rbp_main - 4 ) → <        ???        > ← 4 байта под var
          (rbp_main - 8 ) → <        ???        > ← 4 байта
          (rbp_main - 12) → <        ???        > ← 4 байта
    rsp → (rbp_main - 16) → <        ???        > ← 4 байта
-------------------------------------------------------------------
Нижние адреса памяти
call func()

Работает так же, как в предыдущем примере - кладёт адрес возврата на стек и прыгает в func(). Внутри func() вычисляется результат и помещается в eax.

Стек после входа в func()

Верхние адреса памяти
-------------------------------------------------------------------
                            < ret addr в _start > ← 8 байт
          (rbp main     ) → <     rbp _start    > ← 8 байт
          (rbp_main - 4 ) → <        ???        > ← 4 байта под var
          (rbp_main - 8 ) → <        ???        > ← 4 байта
          (rbp_main - 12) → <        ???        > ← 4 байта
          (rbp_main - 16) → <        ???        > ← 4 байта
          (rbp_main - 24) → <  ret addr в main  > ← 8 байт
    rsp → (rbp func     ) → <      rbp main     > ← 8 байт
-------------------------------------------------------------------
Нижние адреса памяти
mov dword ptr [rbp - 4], eax

Ключевое новшество этого примера - значение путешествует через регистрfunc() кладёт его в eaxmain забирает из eax в память. Стек используется для хранения локальных переменных функций, регистр - для передачи возвратного значения из вызываемой функции в вызывающую.

После возврата из func() значение 1234 находится в eax. Инструкция mov dword ptr [rbp - 4], eax инициализирует var на стеке путём чтения значения из регистра и записи этого значения в ячейку памяти, расположенную в стек-фрейме выполняющейся функции. Это прямое воплощение int var{func()}.

Стек после выхода из func()

Верхние адреса памяти
------------------------------------------------------------------------
                            < ret addr в _start > ← 8 байт
          (rbp main     ) → <     rbp _start    > ← 8 байт
          (rbp_main - 4 ) → <     0x000004D2    > ← 4 байта (var = 1234)
          (rbp_main - 8 ) → <        ???        > ← 4 байта
          (rbp_main - 12) → <        ???        > ← 4 байта
    rsp → (rbp_main - 16) → <        ???        > ← 4 байта
------------------------------------------------------------------------
Нижние адреса памяти
xor eax, eax

Инструкция xor eax, eax выполняет подготовку возврата (return 0) из main. Обратите внимание: та же самая инструкция затирает eax, в котором только что лежал результат func(). Это нормально - значение уже сохранено в [rbp - 4].

add rsp, 16

Симметрично инструкции sub rsp, 16 из пролога функции main, инструкция add rsp, 16 в эпилоге функции main освобождает зарезервированные 16 байт, возвращая rsp к rbp.

Полная картина выполнения

_start → call main 
                 | → push rbp              (сохранить rbp _start)
                 | → mov rbp, rsp          (новый фрейм main)
     пролог main | → sub rsp, 16           (резерв под var + выравнивание)
                   → call func()           (push addr mov; jmp func)
                          → push rbp       (сохранить rbp main)
                          → mov rbp, rsp   (новый фрейм func)
                          → mov eax, 1234  (подготовить возврат)
                          → pop rbp        (восстановить rbp main)
                          → ret            (прыгнуть на mov [rbp-4], eax)
                   → mov [rbp-4], eax      (сохранить 1234 в var)
     эпилог main | → xor eax, eax          (return 0)
                 | → add rsp, 16           (освободить стек)
                 | → pop rbp               (восстановить rbp _start)
                 | → ret                   (вернуться в _start)
_start → exit()

Обратите внимание на то, как много “лишних” инструкций приходится выполнять только для того, чтобы просто вернуть 1234 из func в main. Добавление опции компиляции -O2 (оптимизации 2-го уровня) позволяет значительно сократить объём сгенерированных ассемблерных инструкций:

int func() {
	return 1234;
}
 
int main() {
	int var{func()};
}
func():
	mov eax, 1234
	ret
 
main:
	xor eax, eax
	ret

Godbolt

В данном случае, поскольку результат вызова функции func() нигде не используется в вызывающей функции main(), компилятор не добавляет (в ассемблерную программу) избыточную инструкцию вызова call func(). Создание стек-фрейма также не выполняется.

Функция с аргументом и без возвратного значения

Код

С++

void func(int arg) {
	return;
}
 
int main() {
	int var{1234};
	func(var);
}

ASM (x86-64 clang)

func(int):
	push rbp
	mov rbp, rsp
	mov dword ptr [rbp - 4], edi
	pop rbp
	ret
 
main:
	push rbp
	mov rbp, rsp
	sub rsp, 16
	mov dword ptr [rbp - 4], 1234
	mov edi, dword ptr [rbp - 4]
	call func(int)
	xor eax, eax
	add rsp, 16
	pop rbp
	ret

ONLINE COMPILER

Godbolt

Разбор инструкций

func(int) - приём аргумента через регистр

func(int):
    push rbp
    mov rbp, rsp
    mov dword ptr [rbp - 4], edi
    pop rbp
    ret

mov dword ptr [rbp - 4], edi

Это сохранение аргумента на стек. По соглашению System V AMD64 ABI, первые 6 целочисленных аргументов передаются через регистры в таком порядке:

  1. rdi (64 бита) / edi (32 бита) - Destination Index Register
  2. rsi (64 бита) / esi (32 бита) - Source Index Register
  3. rdx (64 бита) / edx (32 бита) - Data Register
  4. rcx (64 бита) / ecx (32 бита) - Counter Register
  5. r8 (64 бита) / r8d (32 бита) - дополнительный регистр x64-архитектуры R8
  6. r9 (64 бита) / r9d (32 бита) - дополнительный регистр x64-архитектуры R9

У func() один аргумент типа int (4 байта), поэтому он приходит в edi. Компилятор копирует его на стек по адресу [rbp - 4] - это локальная копия параметра arg, как если бы это была обычная локальная переменная.

Стек после входа в func()

Верхние адреса памяти
--------------------------------------------------------------------------
                           < ret addr в _start >
                                   ...
                           < ret addr в main   >
    rsp → (rbp func    ) → <     rbp main      >
          (rbp_func - 4) → <   0x000004D2      > ← arg (скопирован из edi)
--------------------------------------------------------------------------
Нижние адреса памяти

Поскольку func() ничего не делает с arg, инструкция mov dword ptr [rbp - 4], edi - просто формальность. При -O2 она исчезнет.

void func(int arg) {
	return;
}
 
int main() {
	int var{1234};
	func(var);
}
func():
	ret
 
main:
	xor eax, eax
	ret

Godbolt

main - подготовка и передача аргумента

sub rsp, 16

Выделение 16 байт на стеке. Формально нужно только 4 байта под var, но выравнивание требует кратности 16 перед call.

mov dword ptr [rbp - 4], 1234

Инициализация локальной переменной var значением 1234.

Верхние адреса памяти (внутри main после sub)
---------------------------------------------------------------
                            < ret addr в _start >
          (rbp main     ) → <     rbp _start    >
          (rbp_main - 4 ) → <     0x000004D2    >  ← var (1234)
          (rbp_main - 8 ) → <        ???        >
          (rbp_main - 12) → <        ???        >
    rsp → (rbp_main - 16) → <        ???        >
---------------------------------------------------------------
Нижние адреса памяти
mov edi, dword ptr [rbp - 4]

Ключевая инструкция передачи аргумента. Читает значение var из стека и помещает его в регистр edi. Это подготовка к вызову - по соглашению ABI, первый аргумент должен лежать именно там.

Обратите внимание на направление: edi, [rbp - 4] - это передача из памяти в регистр (направление передачи справа налево по синтаксису Intel).

call func(int)

Инструкция кладёт адрес возврата (xor eax, eax) на стек и прыгает в func(). В момент входа в func() регистр edi содержит значение 1234.

Верхние адреса памяти (сразу после call, до пролога func)
---------------------------------------------------------------
                            < ret addr в _start >
          (rbp main     ) → <     rbp _start    >
          (rbp_main - 4 ) → <     0x000004D2    >  ← var (1234)
          (rbp_main - 8 ) → <         ???       >
          (rbp_main - 12) → <         ???       >
          (rbp_main - 16) → <         ???       >
    rsp → (             ) → <  addr of xor eax  >
---------------------------------------------------------------
Нижние адреса памяти

edi = 0x000004D2  ← аргумент готов к использованию

Затем func() выполняет свой пролог (push rbp; mov rbp, rsp) и копирует edi на свой стек.

Верхние адреса памяти (внутри func после пролога)
---------------------------------------------------------------
                            < ret addr в _start >
          (rbp main     ) → <     rbp _start    >
          (rbp_main - 4 ) → <     0x000004D2    >  ← var (1234)
          (rbp_main - 8 ) → <         ???       >
          (rbp_main - 12) → <         ???       >
          (rbp_main - 16) → <         ???       >
          (rbp_main - 24) → <  addr of xor eax  >
    rsp → (rbp func     ) → <     rbp main      >
          (rbp_func - 4 ) → <   0x000004D2      > ← arg
---------------------------------------------------------------
Нижние адреса памяти

Полная картина выполнения

_start → call main 
            |   → push rbp                      (сохранить rbp _start)
            |   → mov rbp, rsp                  (новый фрейм main)
пролог main |   → sub rsp, 16                   (резерв под var)
                → mov [rbp-4], 1234             (инициализировать var)
                → mov edi, [rbp-4]              (загрузить var в edi)
                → call func(int)                (push addr xor; jmp func)
                    |   → push rbp              (сохранить rbp main)
        пролог func |   → mov rbp, rsp          (новый фрейм func)
                        → mov [rbp-4], edi      (сохранить arg на стек)
        эпилог func |   → pop rbp               (восстановить rbp main)
                    |   → ret                   (прыгнуть на xor eax)
эпилог main |   → xor eax, eax                  (return 0)
            |   → add rsp, 16                   (освободить стек)
            |   → pop rbp                       (восстановить rbp _start)
            |   → ret                           (вернуться в _start)
_start → exit()

Маршрут данных

1234         →  [main_rbp-4] (var в main)
[main_rbp-4] →  edi (подготовка к вызову)
edi          →  [func_rbp-4] (arg в func)

Значение трижды копируется:

  1. Константа → память (инициализация var)
  2. Память → регистр (подготовка аргумента для func)
  3. Регистр → память (сохранение подготовленного аргумента во входном параметре arg/в локальной переменной arg функции func )

Сравнение с предыдущим примером

int var{func()}func(var)
Направление передачиfuncmainmainfunc
Регистрeax (возврат)edi (первый аргумент)
Инструкция в mainmov [rbp-4], eaxmov edi, [rbp-4]
Операциячтение регистра eax и запись в [rbp-4]чтение регистра [rbp-4] и запись в edi


ВПЕРЁД

НАЗАД