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

Сразу обозначу уровень: предполагается базовое понимание x86-64, знакомство с assembly и общее представление о том, как устроены системы семейства Unix. Начинающим программистам возможно будет тяжеловато.

Вступление

Итак, насколько известно, для передачи аргументов используются регистры общего назначения, а именно следующие: rdi - первый аргумент, rsi - второй аргумент, rdx - третий аргумент, rcx - четвертый аргумент, r8 - пятый аргумент, r9 - шестой аргумент (на данный момент мы рассматриваем System V ABI, которому следуют все крупные ОС из семейства Unix). Все вполне логично, учитывая что все эти регистры не callee saved, поэтому не нужно заботиться о значениях, которые там были прежде.

Как для передачи аргументов используются регистры общего назначения, так и для возврата переменных используются те же самые регистры, а конкретно rax - первое возвращаемое значение, rdx - второе возвращаемое значение (по крайней мере, для значений типа INTEGER: об этом будет написано ниже).

Возврат фундаментальных типов

Рассмотрим следующий код, который просто возвращает int, который по совместительству является фундаментальным типом:

int one_plus_one() 
{	
    int temp = 1 + 1;	
    return temp;
}

В assembly функция выглядит так (без оптимизаций, естественно):

one_plus_one():	
  ; ...	
  mov DWORD PTR [rbp-4], 2 ; Это та самая переменная `temp`	
  mov eax, DWORD PTR [rbp-4] ; Записываем переменную в первый возвратный регистр - eax (32 бита от rax).	
  ; ...	
  ret ; Возврат из функции

Как мы видим, переменная возвращается с помощью регистра rax (eax).
Возврат фундаментальных типов из функции предельно ясен, а что дальше? Дальше у нас возврат небольших структур.

Возврат маленьких структур

Рассмотрим следующую безобидную структуру:

struct nums 
{	
    std::int64_t first{};	
    std::int64_t second{};
};

В ней содержатся два поля по 8 байт (наверное, на что-то это намекает?).
Также рассмотрим следующий код, который создает структуру на стэке и возвращает ее:

nums construct() 
{	
    nums ret{10, 120};	
    return ret;
}

Достаточно незамысловатый код, который просто возвращает ту самую безобидную структуру из функции.
Теперь рассмотрим assembly, который бесспорно чуть страшнее, нежели предыдущий:

construct():	
  ; ...	
  ; Создание структуры на стэке.	
  mov QWORD PTR [rbp-16], 10 ; значение для nums::first	
  mov QWORD PTR [rbp-8], 120 ; значение для nums::second	
  ; Можно заметить, что поля как бы хранятся наоборот, но если рассматривать память снизу вверх (а не сверху вниз, как растет стэк), то все ок.

  ; Помним, что два поля по 8 байт должны были на что-то намекать. Вот оно! Каждое из полей записано в отдельный регистр.	
  mov rax, QWORD PTR [rbp-16] ; Пишем nums::first в возвратный регистр	
  mov rdx, QWORD PTR [rbp-8] ; Пишем nums::second в возвратный регистр	
  ; ...	
  ret

Как мы видим, компилятор достаточно умный - и возвращает структуру, используя все доступные данные ему ресурсы.
Интересно, а как происходит возврат крупных структур, который никак не влезут в эти два регистра по 8 байт? К этому мы и переходим.

Возврат больших структур

Рассмотрим такую достаточно устрашающую (или не очень) структуру:

struct many_nums 
{	
    std::int64_t first{};	
    std::int64_t second{};	
    std::int64_t third{};	
    std::int64_t fourth{};
};

Она точно в возвратные регистры не влезет.
Также, рассмотрим С++ код:

many_nums construct_scary() 
{	
    many_nums temp{10, 20, 30, 40};	
    return temp;
}

Опять же незамысловатый код.
А теперь посмотрим на такой же незамысловатый (или же нет) assembly код.

construct_scary():
	; ...
	mov QWORD PTR [rbp-8], rdi ; Сохраняем на стэк адрес из rdi, который хранит первый аргумент функции.
	mov rax, QWORD PTR [rbp-8] ; Переносим адрес функции из стэка в rax - тот самый первый возвратный регистр.
	
	; Начинается создание структуры
	mov QWORD PTR [rax], 10 ; Переходим по адресу в rax, где хранится many_nums::first и пишем туда.
	mov rax, QWORD PTR [rbp-8] ; Переносим адрес функции из стэка в rax
	mov QWORD PTR [rax+8], 20 ; Переходим по адресу (rax + 8), где хранится many_nums::second и пишем туда.
	mov rax, QWORD PTR [rbp-8] ; Переносим адрес функции из стэка в rax
	mov QWORD PTR [rax+16], 30 ; Переходим по адресу (rax + 16), где хранится many_nums::third и пишем туда.
	mov rax, QWORD PTR [rbp-8] ; Переносим адрес функции из стэка в rax
	mov QWORD PTR [rax+24], 40 ; Переходим по адресу (rax + 24), где хранится many_nums::fourth и пишем туда.
	mov rax, QWORD PTR [rbp-8] ; Переносим адрес функции из стэка в rax
	; ...
	ret

(Хочу отметить, что повторение mov rax, QWORD PTR [rbp-8] никакой смысловой нагрузки не несет, поскольку повторения есть только потому, что код скомпилирован под флагом -O0).
Интересно, что это вообще за адрес? Мы же ничего не передавали в качестве аргументов.

Тут уже можно рассказать про различные классы возвращаемых значений.
При возврате фундаментальных типов или маленьких структур они представлялись как класс INTEGER, поэтому их можно былов вернуть через rax и rdx.
У структур большого размера класс типа MEMORY. Вот что пишется об этом в System V ABI: If the type has class MEMORY, then the caller provides space for the return value and passes the address of this storage in %rdi as if it were the first argument to the function. In effect, this address becomes a “hidden” first argument".
Наша структура many_nums таковой и является.

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

int main() 
{	
    many_nums s2 = construct_scary();	
    // ...
}

Рассмотрим как это дело выглядит в assembly:

main:
	; ...
	sub rsp, 32 ; Двигаем stack pointer на 32 байта вниз (размер нашей структуры), тем самым выделяя память для переменной `s2`.
	
	lea rax, [rbp-32] ; Сохраняем адрес [rbp - 32] в rax.
	mov rdi, rax ; Передаем значение из rax как первый аргумент.
	call construct_scary()
	
	; ...
	ret

В итоге всё сводится к тому, что компилятор заранее выделяет память под результат и передаёт её адрес как скрытый аргумент.

Конечная

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

Это моя первая статья, поэтому мне бы очень хотелось получить обратную связь. Буду рад любым советам, замечаниям и конструктивной критике.

Комментарии (6)


  1. MasterMentor
    29.03.2026 09:04

    Статья норм. Карма,закладки,статья +/+/+.

    В начале нужно было обязательно:

    – рассмотреть rsp и rbp (если с первым - всё просто, то со вторым - всё не просто)

    – calling convention

    Иначе текст выглядит “магией”.

    PS Буду писать виртуалки для скриптовых языков. Приглашаю поучаствовать.


    1. klewy Автор
      29.03.2026 09:04

      Спасибо за обратную связь! Соглашусь, что в начале стоило про calling convention подробнее написать - в следующий раз учту. Статью несколько раз переписывал, поскольку то слишком, казалось бы, много информации уточняющей писал, то слишком мало. Пока пытаюсь пристреляться, насколько подробно стоит все разъяснять)

      PS Буду писать виртуалки для скриптовых языков. Приглашаю поучаствовать.

      Спасибо за приглашение, но, к сожалению, не моя специализация :(


  1. Melirius
    29.03.2026 09:04

    Что меня всегда интересовало, это почему параметров входных через регистры можно передать больше, чем выходного размера?


    1. klewy Автор
      29.03.2026 09:04

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


    1. morgot
      29.03.2026 09:04

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


  1. Siemargl
    29.03.2026 09:04

    Не затронута тема с плавающей точкой