Наверняка многие и не задумываются: а как на самом деле происходит возврат структур и других типов значений из функций? Что происходит под капотом, какие приемы задействует компилятор? В данной статье я постараюсь дать ответы на эти вопросы и сделать это просто и понятно.
Сразу обозначу уровень: предполагается базовое понимание 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
В итоге всё сводится к тому, что компилятор заранее выделяет память под результат и передаёт её адрес как скрытый аргумент.
Конечная
Подводя итог, в данной статье мы разобрали, как происходит возврат различных типов данных под капотом и какие приемы для этого использует компилятор.
Это моя первая статья, поэтому мне бы очень хотелось получить обратную связь. Буду рад любым советам, замечаниям и конструктивной критике.
Автор: klewy
