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