[Перевод] Как избегать типичных ошибок при встраивании ассемблерных вставок: подборка правил
Ассемблерные вставки, используемые компиляторами GCC и Clang, опосредуют взаимодействие высокоуровневых и низкоуровневых языков программирования. Это тонкая и коварная штука. Многие попадают в расставленные здесь капканы, зачастую совершенно неожиданно для себя. В сущности, ключевое слово asm
можно перевести на C и C++ как unsafe
. Почти в любых руководствах по встроенному ассемблеру, в том числе, и на ужасной странице ibilio, которая десятилетиями попадает в самый верх поисковой выдачи, неисправимо фигурируют фундаментальные серьёзные ошибки, а примеры в большинстве своём некорректны. Наиболее опасно, что эти примеры обычно приводят к ожидаемым результатам! Ситуация плачевная. Эта статья — не руководство, а подборка элементарных правил, которые помогут вам избежать самых распространённых ошибок либо отловить их при ревью кода.
Здесь мы сосредоточимся сугубо на расширенном, а не на базовом ассемблере, а правила в этих версиях отличаются. На первом пишут любые инструкции, относящиеся к встроенному ассемблеру, с ограничениями или затираемыми. То есть, имеем токен : в обрамлении asm
. Базовый ассемблер — тупой инструмент, который используется сравнительно нечасто, в основном в самом верху файла с кодом или в «голых» функциях. Поэтому злоупотребления базовым ассемблером на практике маловероятны.
(1) По возможности избегайте встроенного ассемблера
Поскольку он настолько коварен, первое правило — избегать им пользоваться, если это вообще возможно. Современные компиляторы как следует оснащены внутренними и встроенными функциями, заменяющими старый встроенный ассемблер практически во всех прикладных случаях. Через них удобно обращаться из высокоуровневого языка к низкоуровневым возможностям. Не приходится перекидывать мостик между высокоуровневым и низкоуровневым кодом, если на данный случай предусмотрена внутренняя функция компилятора.
В компиляторах нет встроенных функций, которые действовали бы как системные вызовы, а также то и дело не хватает какой-нибудь полезной внутренней функции. Бывает и так, что вам приходится заниматься возведением базовой инфраструктуры. Оставшиеся случаи касаются в основном взаимодействия с внешними интерфейсами, но не оптимизации и не производительности.
(2) Ассемблерный код почти всегда должен быть volatile
Весь тот встроенный ассемблер, который не подпадает под правило (1), почти всегда будет иметь побочные эффекты, не описываемые ограничениями, заданными для вывода. Это касается и обращений к памяти, и, конечно же, системных вызовов. Именно поэтому встраиваемый ассемблер обычно должен сопровождаться квалификатором volatile
.
asm volatile ( ... );
Так мы не позволим компилятору опускать или переупорядочивать ассемблерные инструкции. Действует особое правило: весь встроенный ассемблер, в котором отсутствуют выходные ограничения, по умолчанию считается volatile
(может иметь переменные значения). Тем не менее, пожалуйста, всё равно пользуйтесь ключевым словом volatile! Когда я не вижу volatile
, я сразу подозреваю дефект. Когда приходится останавливаться и размышлять, а не особый ли это случай, где так и задумано, код осмысляется медленнее, и в результате тормозится весь процесс ревью.
В руководствах часто применяется __volatile__
. Не делайте так. Это древнее ключевое слово-псевдоним, оно применяется для поддержки старых нестандартизированных компиляторов, в которых отсутствует ключевое слово volatile. Явно не ваш случай. Если мне попадается __volatile__
, то я считаю, что вы, скорее всего, тупо скопировали откуда-то встроенный ассемблер и вставили его, а сам код не понимаете. Это звоночек, после которого я начинаю рецензировать код ещё тщательнее.
Отмечу: __asm
или __asm__
— это нормально, а в некоторых случаях даже требуется (напр., -std=cXX
). Я обычно пишу asm
.
(3) Вероятно вам понадобится затираемый регистр memory
Затираемый регистр "memory"
функционально ортогонален volatile
, у обоих этих инструментов — свои цели. К нему приходится прибегать реже, чем к volatile
, но обычно он требуется в оставшихся случаях, когда имеешь дело с ассемблерными вставками. Если при исполнении ассемблера мы каким-либо образом обращаемся к памяти, то нам нужен затираемый регистр memory
. Это касается работы с большинством системных вызовов, и уж конечно — с универсальной обёрткой syscall
.
asm volatile (... : "memory");
Если при ревью кода вы не видите затираемого регистра "memory"
— уделите этому максимум внимания. Вероятно, этот регистр потерялся. Если он совершенно точно не нужен, рекомендую документировать это в виде комментария, чтобы ревьюеры знали, что вы целенаправленно опустили данный регистр.
При помощи такого ограничения мы не позволяем компиляторам переупорядочивать операции загрузки и сохранения в ассемблерном коде. Может случиться настоящая катастрофа, если, например, системный вызов write(2)
будет выполнен ещё до того, как программа успеет наполнить выходной буфер! В таком случае volatile
не позволит выкинуть последующий write(2)
в рамках оптимизации, тогда как "memory"
принудительно обеспечивает, чтобы операции сохранения в памяти предшествовали системному вызову.
(4) Никогда не изменяйте входные ограничения
Совершенно не сложно следовать этому правилу, так что нарушается оно обычно из-за невежества, но нарушается пугающе часто. Как правило, вам это сходит с рук, но только до тех пор, пока вам не встретится конфигурация, в которой присутствует гейзенбаг. Как правило, для исправления ситуации достаточно поменять входное ограничение на выходное ограничение чтения-записи, это делается знаком "+"
:
asm volatile ("..." :: "r"(x) : ...); // было
asm volatile ("..." : "+r"(x) : ...); // стало
Если вы не пользовались volatile
(нарушая правило 2), то сейчас оно вам внезапно понадобится, поскольку здесь есть выходное ограничение. Такое часто случается.
(5) Никогда не вызывайте функций из встраиваемого ассемблера
Многие вещи могут нарушиться из-за того, что определённую семантику не удаётся выразить на языке ограничений встроенного ассемблера. Стек может быть не выровнен, и вы затрёте информацию в красной зоне. Да, существует ограничение "redzone"
, но его недостаточно, чтобы выполнить вызов функции. Не делайте так. В руководствах любят приводить такой пример, поскольку он прост и нагляден, но обычно подобные примеры полны изъянов.
Системные вызовы допустимы. В базовом ассемблере вызовы функций уместны, если выполнять их вне неголых функций. При грамотном использовании квалификатор goto
позволяет безопасно сообщать компилятору о переходах. Просто не используйте call
в расширенном ассемблере.
(6) Не определяйте абсолютных ассемблерных меток
Я имею в виду, если вам требуется выполнять переходы внутри ассемблерного блока, например, в пределах цикла, не пишите именованных меток:
myloop:
...
jz myloop
Ассемблерная вставка входит в состав функции, а эта функция впоследствии может клонироваться или встраиваться. В таком случае в единице трансляции появится несколько копий вашего ассемблерного блока. Ассемблер заметит, что имена меток дублируются, и отвергнет такую программу. Но до того, как функция будет встроена (это может произойти при какой-то высокоуровневой оптимизации), такой код будет работать нормально. Несомненный плюс, что, если такой код работать не будет, то компилятор громко заявит о подобной ошибке.
Во встраиваемом ассемблере можно заставить компилятор сгенерировать уникальную метку командой %=
, но я предпочитаю пользоваться предусмотренными в ассемблере локальными метками:
0:
...
jz 0b
В таком случае ассемблер генерирует уникальные метки, а число 0 именем метки не является. 0b
(«backward») относится к предыдущей метке 0, а 0f
(«forward») будет относиться к следующей метке 0. Всё совершенно недвусмысленно.
На практике, естественно, случаются проблемы
Итак, раз вы почти дочитали статью, вот вам практическое задание. Найдите в Интернете руководство по встраиваемому ассемблеру и посчитайте, сколько в нём будет дефектов, если применять мои 6 правил. Вероятно, вы найдёте минимум по одному в любом документе кроме официальной документации по компиляторам. Кроме чтения руководств и ревью реальных программ можете попросить LLM сгенерировать встроенный ассемблер, поскольку уже в ходе обучения большие языковые модели привыкли допускать такие распространённые ошибки.