Введение

Техника в построении компиляторов

В построении компиляторов, искажение имён (также называемое декорированием имён) — это техника, используемая для решения различных проблем, вызванных необходимостью разрешения уникальных имён для программных сущностей во многих современных языках программирования. Она предоставляет средства для кодирования дополнительной информации в имени функции, структуры, класса или другого типа данных, чтобы передать больше семантической информации от компилятора к компоновщику. Необходимость в искажении имён возникает, когда язык позволяет различным сущностям иметь одно и то же имя, если они находятся в разных пространствах имён (обычно определяемых модулем, классом или явной директивой пространства имён) или имеют разные сигнатуры типов (например, при перегрузке функций). Это требуется, поскольку для каждой сигнатуры может потребоваться различная, специализированная соглашение о вызовах в машинном коде. Любой объектный код, созданный компиляторами, обычно компонуется с другими фрагментами объектного кода (созданными тем же или другим компилятором) с помощью программы, называемой компоновщиком. Компоновщику требуется большое количество информации о каждой программной сущности. Например, для правильной компоновки функции ему необходимо её имя, количество аргументов и их типы и так далее. Простые языки программирования 1970-х годов, такие как C, различали подпрограммы только по имени, игнорируя другую информацию, включая типы параметров и возвращаемые значения. Более поздние языки, такие как C++, определили более строгие требования к рутинам, чтобы они считались "равноценными", такие как типы параметров, тип возвращаемого значения и соглашение о вызовах функции. Эти требования позволяют перегружать методы и обнаруживать некоторые ошибки (например, использование разных определений функции при компиляции разных файлов исходного кода). Эти более строгие требования должны были работать с существующими инструментами и соглашениями программирования. Таким образом, дополнительные требования кодировались в имени символа, поскольку это была единственная информация, которой располагал традиционный компоновщик о символе. Другое применение искажения имён — обнаружение добавленных изменений, не связанных с сигнатурой, таких как чистота функции или возможность генерации исключения или вызова сборщика мусора. Язык D является примером языка, использующего эту возможность. Это скорее упрощённая проверка ошибок. Например, функции `f` и `g` могут быть скомпилированы в один объектный файл, но затем их сигнатуры изменены на `f'` и `g'` и использованы для компиляции другого исходного кода, вызывающего их. При компоновке компоновщик обнаружит отсутствие функции `f` и вернёт ошибку. Аналогично, компоновщик не сможет обнаружить, что тип возвращаемого значения `f` отличается, и вернёт ошибку. В противном случае будут использоваться несовместимые соглашения о вызовах, что, скорее всего, приведёт к неправильному результату или аварийному завершению программы. Искажение имён обычно не фиксирует каждую деталь процесса вызова. Например, оно не полностью предотвращает ошибки, такие как изменения членов данных структуры или класса. Например, `struct S { int x; }` может быть скомпилировано в один объектный файл, затем определение `struct S` изменено на `struct S { double x; }` и использовано при компиляции вызова в `S`. В таких случаях компилятор обычно использует другое соглашение о вызовах, но в обоих случаях `x` будет искажено до одного и того же имени, поэтому компоновщик не обнаружит эту проблему, и результатом обычно будет сбой, повреждение данных или памяти во время выполнения.

C++

Компиляторы C++ являются наиболее распространенными пользователями искажения имен. Первые компиляторы C++ были реализованы как трансляторы в исходный код C, который затем компилировался C-компилятором в объектный код; из-за этого имена символов должны были соответствовать правилам идентификаторов C. Даже впоследствии, с появлением компиляторов, генерирующих машинный код или ассемблер напрямую, системный компоновщик обычно не поддерживал символы C++, и искажение имен все еще требовалось. Язык C++ не определяет стандартную схему оформления, поэтому каждый компилятор использует свою собственную. C++ также обладает сложными языковыми возможностями, такими как классы, шаблоны, пространства имен и перегрузка операторов, которые изменяют смысл конкретных символов в зависимости от контекста или использования. Метаданные об этих возможностях могут быть уточнены путем искажения (декорирования) имени символа. Поскольку системы искажения имен для таких возможностей не стандартизированы между компиляторами, немногие компоновщики могут связать объектный код, сгенерированный разными компиляторами.

Стандартное искажение имени в C++

Похоже, что стандартизированное искажение имен в языке C++ могло бы привести к большей совместимости между реализациями компиляторов. Однако, одной такой стандартизации недостаточно для гарантии совместимости компиляторов C++, и она даже может создать ложное впечатление о возможности и безопасности взаимодействия, когда это не так. Искажение имен – это лишь одна из многих деталей интерфейса прикладной программы (ABI), которые должны быть определены и соблюдены реализацией C++. Другие аспекты ABI, такие как обработка исключений, структура виртуальных таблиц, структура данных и выравнивание стекового фрейма, также приводят к несовместимости различных реализаций C++. Более того, требование определенной схемы искажения имен создаст проблемы для систем, где ограничения реализации (например, длина символов) диктуют конкретную схему искажения. Стандартизированное требование к искажению имен также исключит возможность реализации, в которой искажение вообще не требуется – например, компоновщик, понимающий язык C++. Поэтому стандарт C++ не пытается стандартизировать искажение имен. Напротив, в Аннотированном справочном руководстве по C++ (также известном как ARM, раздел 7.2.1c) активно рекомендуется использовать различные схемы искажения имен, чтобы предотвратить компоновку, когда другие аспекты ABI несовместимы. Тем не менее, как подробно описано в предыдущем разделе, на некоторых платформах полный ABI C++ был стандартизирован, включая искажение имен.

Реальные последствия искажения имен на C++

Поскольку символы C++ обычно экспортируются из DLL и общих объектных файлов, схема искажения имен — это не просто внутренняя проблема компилятора. Разные компиляторы (или разные версии одного и того же компилятора, во многих случаях) создают такие двоичные файлы, используя различные схемы декорирования имен, что означает, что символы часто не разрешаются, если для создания библиотеки и программы, использующей её, были использованы разные схемы. Например, если в системе установлено несколько компиляторов C++ (например, GNU GCC и компилятор поставщика ОС) и она должна установить библиотеки Boost C++, то их потребуется компилировать несколько раз (один раз для GCC и один раз для компилятора поставщика). Для обеспечения безопасности полезно, чтобы компиляторы, генерирующие несовместимые объектные коды (основанные на разных ABI, например, в отношении классов и исключений), использовали различные схемы искажения имен. Это гарантирует, что эти несовместимости будут обнаружены на этапе компоновки, а не во время выполнения программы (что может привести к трудноуловимым ошибкам и серьезным проблемам со стабильностью). Поэтому декорирование имен является важным аспектом любого ABI, связанного с C++. Бывают случаи, особенно в больших и сложных кодовых базах, когда бывает сложно или непрактично сопоставить искаженное имя, выведенное в сообщении об ошибке компоновщика, с конкретным соответствующим именем токена/переменной в исходном коде. Эта проблема может сильно затруднить определение соответствующих исходных файлов для инженеров по сборке или тестированию, даже если используется только один компилятор и компоновщик. Демонстраторы (включая те, что входят в механизмы отчетов об ошибках компоновщика) иногда помогают, но сам механизм демонстрации может отбрасывать критически важную информацию, позволяющую устранить неоднозначность.

Ява

В Java сигнатура метода или класса включает в себя его имя, типы аргументов метода и тип возвращаемого значения, если таковое имеется. Формат сигнатур задокументирован, поскольку язык, компилятор и формат файла класса разрабатывались совместно (и изначально были ориентированы на объектно-ориентированное программирование и универсальную взаимодействие).

Интерфейс Java

Java Native Interface, поддержка нативных методов Java, позволяет программам, написанным на языке Java, вызывать программы, написанные на другом языке (обычно C или C++). Здесь возникает две проблемы, связанные с разрешением имен, ни одна из которых не имеет стандартизированной реализации:

Перевод имен из JVM в нативные имена – эта схема представляется более стабильной, поскольку Oracle публикует её. Стандартное искажение имен в C++ – см. выше.

Свободный Паскаль

Free Pascal поддерживает перегрузку функций и операторов, и поэтому использует искажение имен для обеспечения этой функциональности. С другой стороны, Free Pascal способен вызывать символы, определенные во внешних модулях, созданных на других языках, и экспортировать собственные символы для вызова из других языков. Более подробную информацию можно найти в главах 6.2 и 7.1 «Руководства программиста Free Pascal».

Ржавчина

Имена функций в Rust по умолчанию подвергаются искажению. Однако это можно отключить с помощью атрибута `function`. Этот атрибут можно использовать для экспорта функций в C, C++ или Objective-C. Более того, вместе с атрибутом `function` или атрибутом `crate`, он позволяет пользователю определить точку входа в стиле C для программы. Rust использовал множество версий схем искажения символов, которые можно выбрать во время компиляции с помощью опции. Определены следующие искажатели:

Искажение в стиле C++, основанное на Itanium IA 64 C++ ABI. Символы начинаются с `_`, а хеши имён файлов используются для устранения неоднозначности. Используется с Rust 1.9. Улучшенная версия устаревшей схемы, с изменениями для Rust. Символы начинаются с `_`. Полиморфизм может быть закодирован. Типы возвращаемых значений функций не кодируются (Rust не поддерживает перегрузку). В именах Unicode используется модифицированный punycode. Сжатие (обратные ссылки) использует адресацию на основе байтов. Используется с Rust 1.37. Примеры приведены в тестах Rust.

Стрелец

Swift хранит метаданные о функциях (и не только) в искажённых символах, ссылающихся на них. Эти метаданные включают имя функции, атрибуты, имя модуля, типы параметров, тип возвращаемого значения и многое другое. Например:

Искажённое имя метода класса в модуле для Swift 2014 года выглядит как . Компоненты и их значения следующие:

: Префикс для всех символов Swift. Все символы начинаются с него. : Не каррированная функция. : Функция класса, то есть метод. : Имя модуля, которому предшествует его длина. : Имя класса, которому принадлежит функция, которому предшествует его длина. : Имя функции, которому предшествует его длина. : Атрибут функции. В данном случае ‘f’, что означает обычную функцию. : Указывает тип первого параметра (в данном случае экземпляр класса) как первый в стеке типов (здесь он не вложен и, следовательно, имеет индекс 0). : Это начало списка типов для кортежа параметров функции. : Внешнее имя первого параметра функции. : Указывает встроенный тип Swift `Int` для первого параметра. : Тип возвращаемого значения: снова `Swift.Int`. Искажение имён для версий Swift, начиная с 4.0, официально документировано. Оно сохраняет некоторое сходство с Itanium.