Что делать, если в вызываемом блоке кода в программах сборки нет оператора 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 (указатель инструкции) в стек. В конце концов, вы переполните стек.

Другие вопросы по тегам