ВПЕРЁД

НАЗАД



Пустая функция main()

Код

C++

int main() {}

ASM (x86-64 clang)

main:
	push rbp
	mov rbp, rsp
	xor eax, eax
	pop rbp
	ret

ONLINE COMPILER

Godbolt

Что происходит при запуске main()?

Общий контекст

В сгенерированном ассемблерном коде представлены стандартные пролог (заголовок) и эпилог (концевик) функции для x86-64 (System V AMD64 ABI - соглашение о вызовах в Linux/macOS). Компилятор без включённых опций оптимизации всегда генерирует пролог и эпилог, даже для пустой функции.

_start → call main 
                | → push rbp     (сохранить rbp _start; начало пролога)
    пролог main | → mov rbp, rsp (новый фрейм main)

    эпилог main | → xor eax, eax (return 0; можно считать началом эпилога)
                | → pop rbp      (восстановить rbp _start)
                | → ret          (вернуться в _start)
_start → exit()

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

push rbp

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

Вариант №1 (Стек до push rbp):

Верхние адреса памяти
--------------------------------------------------
    rsp → < адресс возврата из подпрограммы main >
--------------------------------------------------
Нижние адреса памяти

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

Вариант №2 (Стек после push rbp):

Верхние адреса памяти
--------------------------------------------------
          < адресс возврата из подпрограммы main >
    rsp → <             старый rbp               >
--------------------------------------------------
Нижние адреса памяти

mov rbp, rsp

Устанавливает rbp равным текущему значению rsp (указателя стека, stack pointer, SP). Теперь rbp - это указатель фрейма (frame pointer, FP): фиксированная точка отсчёта для доступа к локальным переменным и аргументам через [rbp - N] и [rbp + N]. SP во время выполнения инструкций функции всё время будет инкрементироваться, а FP будет оставаться неизменным и указывать на стек-фрейм, в котором хранятся локальные переменные текущей выполняемой функции. В пустой функции FP никак не используется, но компилятор создаёт стек-фрейм всегда (если не передан флаг -fomit-frame-pointer).

xor eax, eax

Обнуляет регистр eax - это возвращаемое значение функции. По ABI, целочисленный результат передаётся через rax. Инструкция xor eax, eax предпочтительнее mov eax, 0 потому что:

  • занимает меньше байт (2 байта против 5),
  • не требует константы из памяти,
  • процессор может выполнить её с нулевой задержкой.

Важное замечание

Обнуление eax автоматически обнуляет и старшие 32 бита rax - это гарантия x86-64.

Именно эта инструкция соответствует return 0. Хотя в C/C++ стандарт позволяет опустить return в main, компилятор всё равно генерирует возврат нуля.

pop rbp

Восстанавливает сохранённый ранее rbp вызывающей функции. Стек возвращается в то состояние, в котором был до push rbp.

ret

Снимает с вершины стека адрес возврата (который туда положила вызывающая сторона командой call main) и передаёт туда управление. В данном случае это код инициализации libc (__libc_start_main), который после этого вызовет exit().

Схема работы стека

Вызов:   call main     →  кладёт "ret addr" на стек
         push rbp      →  кладёт старый rbp
         mov rbp, rsp  →  фиксирует начало фрейма
         ...тело...
         pop rbp       →  восстанавливает старый rbp
         ret           →  снимает адресс "ret addr" и переходит по нему

Почему не просто xor eax, eax + ret?

Указатель фрейма (FP) нужен для отладки. Без него отладчик (gdb, lldb) и инструменты профилирования не смогут восстановить стек вызовов (call stack / backtrace). С флагом -O2 или -fomit-frame-pointer компилятор действительно убирает push rbp / mov rbp, rsp / pop rbp, оставляя только:

main:
    xor eax, eax
    ret

Godbolt



ВПЕРЁД

НАЗАД