Что делать, если в вызываемом блоке кода в программах сборки нет оператора return
Что произойдет, если я скажу "позвони" вместо прыжка? Поскольку оператор return не написан, управление просто переходит к следующей строке ниже или оно все еще возвращается в строку после вызова?
start:
mov $0, %eax
jmp two
one:
mov $1, %eax
two:
cmp %eax, $1
call one
mov $10, %eax
2 ответа
Процессор всегда выполняет следующую инструкцию в памяти, если только инструкция перехода не отправляет выполнение куда-либо еще.
Метки не имеют ширины и не влияют на исполнение. Они просто позволяют сделать ссылку на этот адрес из других мест. Выполнение просто проваливается через метки или выходит из конца функций.
Если вы пропустите ret
в конце функции выполнение продолжает выполнять и декодирует все, что будет дальше, в виде инструкций. (Часто что случится, если система выполнит часть файла с нулевым заполнением? Если это была последняя функция в исходном файле asm)
Вы можете (и, возможно, должны) попробовать это сами в отладчике. Один шаг по этому коду и смотреть изменения RSP и RIP. Хорошая вещь в asm заключается в том, что общее состояние процессора (исключая содержимое памяти) не очень велико, поэтому можно наблюдать все архитектурное состояние в окне отладчика. (Ну, по крайней мере, интересная часть, которая относится к целочисленному коду в пользовательском пространстве, поэтому исключая регистры, специфичные для модели, которые может настраивать только ОС, и исключая FPU и векторные регистры.)
call
а также ret
не являются "специальными" (то есть процессор не "запоминает", что он находится внутри "функции").
Они просто делают именно то, что написано в руководстве, и вы должны правильно их использовать для реализации вызовов функций и возвратов. (например, убедитесь, что указатель стека указывает на адрес возврата, когда ret
работает.) Это также зависит от вас, чтобы правильно определить соглашение о вызовах, и все такое. (Смотрите вики-тег x86.)
Там также нет ничего особенного в лейбле, который вы jmp
против ярлыка, который вы call
, Ассемблер просто собирает байты в выходной файл и запоминает, куда вы поместили маркеры меток. Он не "знает" о функциях так, как это делает компилятор Си. Вы можете размещать метки где угодно, и это не влияет на байты машинного кода.
С использованием .globl one
директива скажет ассемблеру поместить запись в таблицу символов, чтобы компоновщик мог ее увидеть. Это позволит вам определить метку, которую можно использовать из других файлов или даже вызывать из C. Но это всего лишь метаданные в объектном файле и все равно ничего не помещает между инструкциями.
Ваш код будет работать точно так же, если бы вы эмулировали call
с эквивалентным push
обратного адреса, а затем jmp
,
one:
mov $1, %eax
# missing ret so we fall through
two:
cmp %eax, $1
# call one # emulate it instead with push+jmp
pushl $.Lreturn_address
jmp one
.Lreturn_address:
mov $10, %eax
# fall off into whatever comes next, if it ever reaches here.
Обратите внимание, что эта последовательность работает только в коде, отличном от PIC, поскольку абсолютный адрес возврата кодируется в push imm32
инструкция. В 64-битном коде с доступным запасным регистром вы можете использовать RIP-относительный lea
чтобы получить адрес возврата в регистр и нажмите его перед прыжком.
Также обратите внимание, что, хотя архитектурно ЦП не "запоминает" прошлые инструкции CALL, реальные реализации выполняются быстрее, предполагая, что пары call / ret будут совпадать, и используют предиктор обратного адреса, чтобы избежать ошибочных прогнозов в ret.
Почему RET трудно предсказать? Потому что это косвенный переход к адресу, хранящемуся в памяти! Это эквивалентно pop %internal_tmp
/ jmp *%internal_tmp
Таким образом, вы можете эмулировать его таким образом, если у вас есть запасной регистр для клоббера (например, rcx не сохраняется в большинстве соглашений о вызовах и не используется для возвращаемых значений). Или, если у вас есть красная зона, так что значения ниже указателя стека все еще безопасны от асинхронного замыкания (обработчиками сигнала или чем-то еще), вы можете add $8, %rsp
/ jmp *-8(%rsp)
,
Очевидно, что для реального использования вы должны просто использовать ret
потому что это самый эффективный способ сделать это. Я просто хотел указать, что он делает, используя несколько более простых инструкций. Ни больше ни меньше.
Обратите внимание, что функции могут заканчиваться хвостовым вызовом вместо ret
:
int ext_func(int a); // something that the optimizer can't inline
int foo(int a) {
return ext_func(a+a);
}
# asm output from clang:
foo:
add edi, edi
jmp ext_func # TAILCALL
ret
в конце ext_func
вернусь к foo
Звонящий foo
Можно использовать эту оптимизацию, потому что не нужно вносить какие-либо изменения в возвращаемое значение или выполнять другие очистки.
В соглашении о вызовах SystemV x86-64 первый целочисленный аргумент edi
, Так что эта функция заменяет это на +, а затем переходит к началу ext_func
, При входе в ext_func
, все в правильном состоянии, как если бы что-то было запущено call ext_func
, Указатель стека указывает на адрес возврата, а аргументы находятся там, где они должны быть.
Оптимизацию вызовов в хвосте можно проводить чаще в соглашении о вызовах register-args, чем в 32-разрядном соглашении о вызовах, которое передает аргументы в стек. Вы часто сталкиваетесь с ситуациями, когда у вас возникают проблемы, потому что функция, которую вы хотите вызвать с помощью хвоста, принимает больше аргументов, чем текущая функция, поэтому нет места, чтобы переписать наши собственные аргументы в аргументы для функции. (И компиляторы не склонны создавать код, который изменяет свои собственные аргументы, даже если ABI очень ясно, что функции владеют пространством стека, содержащим их аргументы, и могут захлопнуть его, если захотят.)
В соглашении о вызовах, где вызываемый абонент очищает стек (с ret 8
или что-то еще, чтобы вытолкнуть еще 8 байтов после адреса возврата), вы можете вызывать только хвостовую функцию, которая занимает точно такое же количество байтов arg.
Ваша интуиция верна: управление просто переходит к следующей строке ниже после возврата функции.
В вашем случае после call one
Ваша функция перейдет к mov $1, %eax
а затем продолжить до cmp %eax, $1
и в конечном итоге в бесконечном цикле, как вы будете call one
снова.
Помимо бесконечного цикла, ваша функция в конечном итоге выйдет за пределы своих ограничений памяти, так как call
команда записывает текущий rip
(указатель инструкции) в стек. В конце концов, вы переполните стек.