Найти статью
Таблица символов
Таблица символов — это структура данных, используемая трансляторами языков программирования, такими как компиляторы и интерпретаторы, для хранения информации о каждом идентификаторе, символе, константе, процедуре и функции, встречающихся в исходном коде программы. Для каждого элемента исходного кода таблица символов связывает сведения, относящиеся к его объявлению или использованию[1].
Предпосылки
Таблица символов может существовать только в оперативной памяти во время процесса трансляции, либо быть встроенной в выходные данные трансляции, например, в объектный файл ABI — для последующего использования. Например, таблица символов может использоваться во время отладки или как источник сведений при формировании диагностического отчёта во время или после выполнения программы[2].
Описание
Минимальная информация, содержащаяся в таблице символов, используемой транслятором и промежуточным представлением, включает в себя имя символа и его местоположение или адрес. Для компиляторов, целевыми платформами которых являются системы с поддержкой перемещаемости кода, таблица также содержит соответствующие атрибуты (абсолютный, релокейтабельный(перемещаемый) и т. д.) и необходимые сведения для обработки таких символов. В таблицах символов для языков высокого уровня могут храниться сведения о типе (строка, целое число, число с плавающей запятой и др.), размере, измерениях и границах символа. Не вся эта информация включается в выходной файл, но может предоставляться для целей отладки. Во многих случаях к каждому символу хранится перекрёстная ссылка. Большинство компиляторов формируют итоговые отчёты, где перечисляют информацию из таблицы символов и перекрестных ссылок[1]. В современных системах для поддержки позиционно-независимого кода (PIC) и рандомизации размещения адресного пространства (ASLR) применяются атрибуты относительной адресации (например, PC-relative). Вместо жёсткой привязки к абсолютным адресам используется слой косвенности через глобальную таблицу смещений (GOT) и таблицу связывания процедур (PLT). GOT хранит фактические адреса внешних символов, которые обновляются динамическим загрузчиком во время выполнения, а PLT обеспечивает механизм «ленивого связывания» для вызова внешних функций[3]..
Реализация
Для реализации таблицы символов доступны различные структуры данных. Можно использовать деревья, линейные списки или самоупорядочивающиеся списки. Таблица символов используется на большинстве этапов работы компилятора, начиная с лексического анализа и заканчивая оптимизацией.
Компилятор может использовать единую большую таблицу символов для всех символов или раздельные (иерархические) таблицы для каждого блока видимости. Например, в языках с блочной структурой, таких как ALGOL или PL/I, символ «p» может быть объявлен неоднократно — в разных процедурах и с разными атрибутами. Область видимости каждого объявления — это участок программы, где использование «p» разрешается к этой конкретной декларации. Таблица символов должна обеспечивать различие между такими случаями.
Часто таблицы символов реализуются в виде хеш-таблиц. Время поиска в хеш-таблице не зависит от количества хранимых элементов, что делает этот подход эффективным при большом их числе. Кроме того, это упрощает классификацию литералов — необходимая информация включается в вычисление хеш-ключа[4].
Так как лексический анализатор значительную часть времени занят поиском в таблице символов, эффективность её организации существенно сказывается на скорости компиляции. В таблице символов элемент должен быть найден максимально быстро. Для этого она, как правило, реализуется как хеш-таблица, где по ключевому слову или идентификатору вычисляется индекс массива. Коллизии неизбежны, и часто для их разрешения используется помещение синонимов в ближайшую свободную ячейку таблицы.
Помимо классических хеш-таблиц, для задач автодополнения кода применяются префиксные деревья (Trie), так как они эффективно поддерживают поиск по префиксу[5][6].
Оптимизация памяти
В современных компиляторах для управления памятью в таблицах символов применяются методы, направленные на повышение производительности и эффективности использования кэша. Одним из таких методов является интернирование строк (англ. String Interning), при котором каждый уникальный идентификатор сохраняется в дедуплицированном пуле в единственном экземпляре. Вместо самих строк используются легковесные дескрипторы (указатели или целочисленные идентификаторы), что позволяет свести операцию сравнения строк к быстрому сравнению чисел и радикально улучшает локальность кэша[7].[8]
Кроме того, для управления памятью часто используется Arena-аллокация. При этом подходе объекты размещаются в памяти непрерывным блоком, что также повышает эффективность кэша процессора. Освобождение памяти происходит практически мгновенно за одну операцию, что оптимально для трансляторов, где множество объектов имеют одинаковое время жизни[9].[10]
Многопоточность и инкрементальная компиляция
Для современных режимов компиляции применяются специализированные структуры данных, обеспечивающие эффективную многопоточную и инкрементальную обработку. При реализации таблицы символов для многопоточной компиляции используются Lock-free структуры и Concurrent Hash Maps. Для синхронизации доступа могут применяться различные подходы: от сегментных блокировок (Fine-Grained Locking) до полностью неблокирующих хеш-таблиц, использующих атомарные операции Compare-And-Swap (CAS). Также применяется подход с lock-free чтением и точечной синхронизацией при коллизиях, а для навигации по иерархии узлов используются атомарные операции[11].
Для поддержки инкрементальной компиляции применяются персистентные и неизменяемые структуры данных, такие как Red-Green Trees (используемые, в частности, в компиляторе Roslyn). Этот механизм основан на разделении структуры на два дерева. «Зелёное» дерево строится «снизу вверх», его узлы не содержат контекста (ссылок на родителей и абсолютных позиций), что позволяет их переиспользовать. «Красное» дерево выступает в роли фасада, строится «сверху вниз» и добавляет необходимый контекст. При внесении правок используется подход Copy-on-Write: создаётся новая версия «зелёного» дерева, при этом перестраиваются только изменённые узлы и путь от них до корня, а остальные безопасно переиспользуются. Узлы нового «красного» дерева создаются лениво по мере обращения к ним, что позволяет экономить вычислительные ресурсы[12][13].
Применение
Объектный файл содержит таблицу символов используемых идентификаторов, видимых извне. При компоновке нескольких объектных файлов компоновщик идентифицирует и разрешает такие символы. Обычно все неопределённые внешние символы ищутся в одной или нескольких библиотеках объектных файлов. Если найден модуль, определяющий требуемый символ, он связывается с первым объектным файлом, а все новые неопределённые идентификаторы добавляются к списку для поиска. Этот процесс продолжается до тех пор, пока все внешние ссылки не будут разрешены. Если после завершения процесса остались неразрешённые ссылки, это считается ошибкой.
В отличие от классических объектных файлов, при оптимизации во время компоновки (Link Time Optimization, LTO) промежуточное представление сохраняет расширенные метаданные (например, граф вызовов и межпроцедурные ссылки) в специализированных секциях[14].
Во время реверс-инжиниринга выполняемых файлов многие инструменты используют таблицу символов для поиска адресов глобальных переменных и известных функций. Если перед созданием исполняемого файла таблица символов была удалена или очищена, становится существенно сложнее определить адреса или понять структуру программы.
Пример
Рассмотрим следующий фрагмент программы на языке C:
// Объявление внешней функции
extern double bar(double x);
// Определение публичной функции
double foo(int count)
{
double sum = 0.0;
// Суммирование значений bar(1) до bar(count)
for (int i = 1; i <= count; i++)
sum += bar((double) i);
return sum;
}
Компилятор C, который анализирует этот код, как правило, сформирует как минимум следующие записи таблицы символов:
| Имя символа | Тип | Область видимости |
|---|---|---|
bar |
функция, double | extern |
x |
double | параметр функции |
foo |
функция, double | глобальная |
count |
int | параметр функции |
sum |
double | локальная (блочная) |
i |
int | оператор цикла for |
Кроме перечисленных, компилятор может добавить в таблицу служебные записи, генерируемые для промежуточных выражений (например, преобразование i в double), возвратных значений функций, меток перехода и т. п.
SysV ABI
| Адрес | Тип | Имя |
|---|---|---|
| 00000020 | a | T_BIT |
| 00000040 | a | F_BIT |
| 00000080 | a | I_BIT |
| 20000004 | t | irqvec |
| 20000008 | t | fiqvec |
| 2000000c | t | InitReset |
| 20000018 | T | _main |
| 20000024 | t | End |
| 20000030 | T | AT91F_US3_CfgPIO_useB |
| 2000005c | t | AT91F_PIO_CfgPeriph |
| 200000b0 | T | main |
| 20000120 | T | AT91F_DBGU_Printk |
| 20000190 | t | AT91F_US_TxReady |
| 200001c0 | t | AT91F_US_PutChar |
| 200001f8 | T | AT91F_SpuriousHandler |
| 20000214 | T | AT91F_DataAbort |
| 20000230 | T | AT91F_FetchAbort |
| 2000024c | T | AT91F_Undef |
| 20000268 | T | AT91F_UndefHandler |
| 20000284 | T | AT91F_LowLevelInit |
| 200002e0 | t | AT91F_DBGU_CfgPIO |
| 2000030c | t | AT91F_PIO_CfgPeriph |
| 20000360 | t | AT91F_US_Configure |
| 200003dc | t | AT91F_US_SetBaudrate |
| 2000041c | t | AT91F_US_Baudrate |
| 200004ec | t | AT91F_US_SetTimeguard |
| 2000051c | t | AT91F_PDC_Open |
| 2000059c | t | AT91F_PDC_DisableRx |
| 200005c8 | t | AT91F_PDC_DisableTx |
| 200005f4 | t | AT91F_PDC_SetNextTx |
| 20000638 | t | AT91F_PDC_SetNextRx |
| 2000067c | t | AT91F_PDC_SetTx |
| 200006c0 | t | AT91F_PDC_SetRx |
| 20000704 | t | AT91F_PDC_EnableRx |
| 20000730 | t | AT91F_PDC_EnableTx |
| 2000075c | t | AT91F_US_EnableTx |
| 20000788 | T | __aeabi_uidiv |
| 20000788 | T | __udivsi3 |
| 20000884 | T | __aeabi_uidivmod |
| 2000089c | T | __aeabi_idiv0 |
| 2000089c | T | __aeabi_ldiv0 |
| 2000089c | T | __div0 |
| 200009a0 | D | _data |
| 200009a0 | A | _etext |
| 200009a4 | A | __bss_end__ |
| 200009a4 | A | __bss_start |
| 200009a4 | A | __bss_start__ |
| 200009a4 | A | _edata |
| 200009a4 | A | _end |
Пример таблицы символов можно найти в спецификации SysV ABI (англ. System V Application Binary Interface), где стандартизируется способ размещения символов в бинарных файлах. Это обеспечивает совместную работу различных компиляторов, компоновщиков и загрузчиков с символами в скомпилированных объектах.
SysV ABI реализован, например, в утилите англ. nm пакета GNU binutils. Формат таблицы включает отсортированное поле с адресом памяти, поле «тип символа» и идентификатор символа («имя»)[15].
В спецификации формата ELF секции .symtab и .dynsym служат для хранения таблиц символов, но имеют разные технические характеристики. Секция .dynsym используется для динамического связывания, содержит только необходимые для динамического компоновщика символы, загружается в память при выполнении программы и сохраняется после выполнения команды strip. В отличие от неё, .symtab представляет собой полную таблицу символов (включая локальные), используется для статической компоновки и отладки, не требует загрузки в память и может быть удалена..
В утилите GNU англ. nm тип символа обозначается одной буквой. По общему правилу заглавные буквы обозначают глобальные (внешние) символы, а строчные — локальные (исключениями являются строчные «u», «v» и «w», которые используются для специальных глобальных символов)[16]. Полный список кодов типов символов:
- A — абсолютное значение символа (не изменяется при дальнейшей линковке);
- B / b — неинициализированные данные (секция BSS);
- C / c — общий символ (common), представляющий неинициализированные данные;
- D / d — инициализированные данные;
- G / g — инициализированные данные для малых объектов;
- i — косвенная функция (для ELF) или символ реализации DLL (для PE);
- I — косвенная ссылка на другой символ;
- N — отладочный символ;
- n — данные только для чтения;
- p — информация для раскрутки стека;
- R / r — данные только для чтения;
- S / s — неинициализированные или нуль-инициализированные данные для малых объектов;
- T / t — исполняемый код (текстовая секция);
- U — неопределённый символ;
- u — уникальный глобальный символ (GNU-расширение);
- V / v — слабый объект (weak object);
- W / w — слабый символ (weak symbol), не помеченный как слабый объект;
- - — символ stabs (отладочная информация).
Таблица символов Python
Язык программирования Python содержит широкие возможности для создания и анализа таблиц символов[17]. Можно, например, проверять, является ли символ свободной или связанной переменной, к какому пространству имён относится, импортирован ли он и принадлежит ли к блочной или глобальной области видимости.
Построение таблицы символов в CPython выполняется в два прохода: первый проход по абстрактному синтаксическому дереву (AST) создаёт базовую структуру записей, а второй вычисляет области видимости и обрабатывает нелокальные переменные[18].
Динамические таблицы символов
В некоторых языках таблица символов может изменяться во время выполнения программы — позволяется создавать новые символы на лету. Примером такого языка является англ. Racket[19].
Языки LISP и Scheme позволяют ассоциировать с каждым символом произвольные свойства[20].
Язык Prolog во многом представляет собой язык обработки таблицы символов: символы называются атомами, а взаимосвязи между ними могут анализироваться логически. Похожая система реализована в OpenCog, где динамическая таблица символов («atomspace») служит для представления знаний.
Безопасность
В современных компиляторах и утилитах (например, GNU Binutils) для защиты от атак через манипуляцию таблицей символов применяются специализированные механизмы. Для предотвращения DoS-атак, связанных с переполнением стека из-за бесконечной рекурсивной вставки символов (например, уязвимость CVE-2018-10373), вводится жёсткое ограничение глубины рекурсии. Также используется арен-аллокация (arena-based allocation) для управления областями видимости, позволяющая выполнять массовую очистку памяти за константное время, и принципы Panic Mode для ограничения глубины восстановления при ошибках[21].
Для защиты исполняемых файлов от реверс-инжиниринга применяется удаление отладочных символов (stripping) и обфускация. При удалении символов имена внутренних функций заменяются адресами памяти, что существенно усложняет статический анализ кода. При этом символы, необходимые для динамического связывания, сохраняются для обеспечения работоспособности программы. Дополнительно могут применяться автоматизированная обфускация на уровне промежуточного представления, ручное запутывание критичных участков кода и шифрование строковых констант[22].
Механизмы защиты таблиц символов при динамическом связывании включают технологии RELRO (Relocation Read-Only). Partial RELRO делает часть таблицы глобальных смещений (GOT) доступной только для чтения после применения релокаций, оставляя секцию .got.plt доступной для записи для поддержки ленивого связывания. Механизм Full RELRO в сочетании с флагом BIND_NOW выполняет немедленное разрешение всех символов при запуске программы, делая всю таблицу GOT доступной только для чтения и полностью предотвращая атаки типа перезаписи GOT (GOT overwrite)[23][24].
Угрозу безопасности также представляет манипуляция поиском символов через переменные окружения (например, LD_PRELOAD в Linux или DYLD_INSERT_LIBRARIES в macOS), что позволяет загружать сторонние библиотеки с более высоким приоритетом и перехватывать вызовы функций. Для предотвращения подобных атак используются флаги безопасности: в Linux при запуске привилегированных программ ядро передаёт флаг AT_SECURE, который очищает опасные переменные окружения, а в macOS аналогичную защиту обеспечивают механизмы System Integrity Protection (SIP) и Hardened Runtime. Кроме того, применяются механизмы верификации цифровых подписей и хешей библиотек перед их загрузкой (например, подсистемы IMA и EVM в Linux)[25][26].
Примечания
Литература
- Copper, Keith D. Engineering a Compiler : [англ.] / Keith D. Copper, Linda Torczon. — 2. — Хьюстон, Техас : Elsevier, Rice University, 18 января 2011. — ISBN 978-0-12-088478-0. — doi:10.1016/C2009-0-27982-7.