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

Урок объясняет, как процессор вызывает функцию и возвращается обратно через стек.

call сохраняет на стеке адрес следующей команды и прыгает в функцию; ret забирает этот адрес и возвращается.

Проблема возврата

Когда мы прыгаем в функцию через jmp, мы не помним, откуда пришли. А функцию вызывают из разных мест, и вернуться нужно именно туда, откуда позвали. Решение — сохранять адрес возврата на стеке.

Что делает call

Команда call func — это два действия в одной: push адреса следующей команды и jmp в функцию:

; call func эквивалентен:
    push rip_next   ; сохранить адрес возврата на стек
    jmp func        ; прыгнуть в функцию

; ret эквивалентен:
    pop rip         ; забрать адрес возврата и прыгнуть на него

Таким образом стек запоминает «хлебные крошки» — цепочку адресов, куда возвращаться. Это и есть стек вызовов, который вы видите в отладчике.

Простой пример

_start:
    call dvoin      ; вызвать функцию, rax удвоится
    ; ... сюда вернёт ret

dvoin:
    add rax, rax    ; rax = rax * 2
    ret             ; вернуться по адресу со стека

Как работает под капотом

Промоделируем call/ret на Python, используя список как стек адресов возврата:

call_stack = []

def call(return_addr):
    call_stack.append(return_addr)   # push адрес возврата
    print("вошли в функцию, на стеке:", call_stack)

def ret():
    addr = call_stack.pop()          # pop адрес возврата
    print("вернулись по адресу:", addr)

call(0x401050)   # call func
ret()            # ret

Вывод:

вошли в функцию, на стеке: [4198480]
вернулись по адресу: 4198480

Адрес 0x401050 равен 4198480 в десятичном виде — это и есть точка, куда вернётся ret. Когда функция вызывает другую функцию, на стеке копится несколько таких адресов: получается стек вызовов, по которому отладчик строит «трассировку».

Частые ошибки

  • Менять rsp внутри функции и не восстановить перед ret. Тогда ret заберёт «не тот» адрес и прыгнет в никуда — крах.
  • Делать push без парного pop до ret. Адрес возврата окажется погребён под лишними данными.
  • Вызывать функцию через jmp вместо call. Тогда адрес возврата не сохранится, и ret уведёт неизвестно куда.

Итог

  • call = сохранить адрес возврата на стек + прыгнуть в функцию.
  • ret = забрать адрес возврата со стека и прыгнуть на него.
  • Стек хранит цепочку адресов возврата — это стек вызовов.
  • Баланс стека критичен: лишний push без pop ломает возврат.
Проверьте себя
1. Что сохраняет команда call перед прыжком в функцию?
AЗначение rax
BАдрес возврата (следующей команды) на стеке
CВсе регистры
DНомер строки исходника
2. Что делает ret?
AОбнуляет rax
BЗабирает адрес возврата со стека и прыгает на него
CЗавершает программу
DОчищает стек целиком
3. Что случится при лишнем push без pop перед ret?
AНичего, push безопасен
Bret заберёт не адрес возврата, а лишние данные — крах
CПрограмма ускорится
Drax обнулится