Опции компилятора: тонкие подводные камни сочетания флагов

Архивный материал Mozilla (январь 2024 г.). Указанные версии, результаты и планы относятся к публикации источника. Первоисточник.

Опции компилятора: тонкие подводные камни сочетания флагов

Разработка Firefox обнаруживает множество кроссплатформенных отличий и уникальных особенностей комбинации его зависимостей. Инженеры, работающие над Firefox, регулярно преодолевают эти сложности, и хотя мы не можем подробно описать их все, думаем, вам будет интересно узнать о некоторых — вот пример недавнего технического расследования.

Во время бета‑цикла Firefox 120 на наших радарах появилась новая сигнатура краха с заметным объёмом. Распределение по операционным системам показало, что более 50% объёма крашей приходится на пользователей Ubuntu 18.04 LTS.

Главный процесс падает в потоке CanvasRenderer со следующим стеком вызовов: На первый взгляд мы хотели обвинить WebGL. Функции стандартной библиотеки C++ не могут быть виноваты, верно? Однако при изучении кода WebGL стало видно, что крах происходит в полностью корректных строках C++, обобщённо показанных ниже: Этот код никогда не должен падать, и тем не менее он падает. Взгляд на стек даёт первый ключ к расследованию:

Хотя мы падаем в функции, принадлежащие стандартной библиотеке C++, эти функции, по‑видимому, находятся в бинарнике firefox. Это нетипичная ситуация, которая никогда не происходит в официальных сборках Firefox. Однако для дистрибутивов типично изменять настройки сборки и применять свои патчы к исходникам, и в этом нет ничего удивительного.

Более того, только одна сборка Firefox Beta вызывает этот крах. Мы знаем это благодаря уникальному идентификатору, связанному с любым ELF‑бинарником. Если выбрать конкретную версию Firefox 120 Beta (например, 120b9), все крахи содержат один и тот же уникальный идентификатор firefox.

Как угадать, какая сборка даёт такой странный бинарник? Полезный комментарий пользователя отметил, что он регулярно получает этот крах после обновления до 120.0~b2+build1-0ubuntu0.18.04.1. Поиск по этому идентификатору быстро привёл к Firefox Beta PPA. Затем мы смогли воспроизвести крах, установив пакет в виртуальной машине Ubuntu 18.04 LTS: он возникает при загрузке любой WebGL‑страницы!

Сейчас, когда бинарник у нас в руках, запуск nm -D ./firefox подтвердил наличие нескольких символов, связанных с libstdc++, которые находятся в текстовом сегменте (маркер T). Шаблонные и inline‑символы из libstdc++ обычно помечаются как weak (маркер W), так что остаётся только одно объяснение: firefox был статически слинкован с libstdc++, вероятно через флаг -static-libstdc++.

К счастью, логи сборки доступны для всех пакетов Ubuntu. После некоторого копания мы нашли логи для сборки 120b9, которые действительно содержат ссылки на -static-libstdc++. Всё задокументировано, и благодаря навыкам поиска мы находим баг‑репорт с интересными подробностями.

Firefox требует современного компилятора C++, а значит современной libstdc++, которой нет в старых системах вроде Ubuntu 18.04 LTS. Сборка использует -static-libstdc++, чтобы закрыть этот пробел. Это лишь объясняет странный сетап.

Поскольку мы можем воспроизвести крах, запустим Firefox в отладчике и продолжим расследование. При осмотре места падения кажется, что std::locale::classic() инициализирована неправильно. Взглянём на реализацию.

_S_initialize() отвечает за то, чтобы c_locale был корректно инициализирован, прежде чем мы вернём ссылку на него. Для этого _S_initialize() вызывает другую функцию, _S_initialize_once(). В _S_initialize() мы сначала проходим через обёртку pthread_once(): первый поток, доходящий до этого кода, потребляет _S_once и вызывает _S_initialize_once(), тогда как другие потоки (если они есть) ждут завершения _S_initialize_once(). Это выглядит достаточно надёжно, правда? Есть даже дополнительный прямой вызов _S_initialize_once(), если _S_classic всё ещё не инициализирован после этого.

_S_initialize_once() сама по себе довольно проста: она выделяет _S_classic и помещает его в c_locale. Крах выглядит так, будто мы никогда не заходили в _S_initialize_once(), поэтому давайте поставим там точку останова и посмотрим, что произойдёт. И уже один только этот шаг выявляет нечто подозрительное.

Мы доходим до _S_initialize_once(), но не в бинарнике firefox: вместо этого мы всегда попадаем в версию, экспортируемую liblgpllibs.so. На самом деле liblgpllibs.so тоже статически слинкована с libstdc++, так что и firefox, и liblgpllibs.so встраивают и экспортируют собственную функцию _S_initialize_once(). По умолчанию применяется интерпозиция символов, и _S_initialize_once() должна вызываться через процедуру PLT, так чтобы каждый модуль вызывал одну и ту же версию функции. Если бы интерпозиция символов работала как обычно, мы ожидали бы, что liblgpllibs.so вызовет версию _S_initialize_once(), экспортируемую firefox, а не свою собственную, потому что firefox загружён первым. Значит, возможно, интерпозиции символов здесь вообще нет.

Это может происходить при использовании -fno‑semantic‑interposition. Тогда каждая версия стандартной библиотеки живёт отдельно, независимо от других версий. Ни система сборки Firefox, ни мейнтейнер Ubuntu, похоже, не передают этот флаг компилятору. Однако, посмотрев дизассемблированный код _S_initialize() и _S_initialize_once(), мы видим, что экспортируемые глобальные переменные (_S_once, _S_classic, _S_global) подвержены интерпозиции символов: доступы идут через глобальную таблицу смещений (GOT), так что каждый модуль в итоге обращается к одной и той же версии переменной. Это странно в свете того, что мы сказали ранее о _S_initialize_once(). Непэкспортируемые глобальные переменные (c_locale, c_locale_impl), однако, доступны напрямую без интерпозиции, как и ожидалось.

У нас теперь достаточно информации, чтобы объяснить крах. Когда мы попадаем в _S_initialize() в liblgpllibs.so, мы на самом деле потребляем _S_once, который живёт в firefox, и инициализируем _S_classic и _S_global, которые также живут в firefox. Но мы инициализируем их указателями на корректно инициализированные переменные c_locale_impl и c_locale, которые живут в liblgpllibs.so! Переменные c_locale_impl и c_locale, которые живут в firefox, тем временем остаются неинициализированными. Если позже мы достигнем _S_initialize() в firefox, всё будет выглядеть так, будто инициализация уже произошла. Но затем мы вернём ссылку на версию c_locale, которая живёт в firefox — и эта версия никогда не была инициализирована.

Главный вопрос: почему мы видим интерпозицию для _S_once, но не для _S_initialize_once()? Если отойти на шаг, существует фундаментальное различие между этими символами: один — символ функции, другой — символ переменной. И действительно, система сборки Firefox использует флаг -Bsymbolic‑function! В man‑странице ld это описано так: Когда создаётся разделяемая библиотека, связывайте ссылки на глобальные символьные функции с определением внутри этой библиотеки, если оно есть. Этот параметр имеет смысл только на ELF‑платформах, которые поддерживают разделяемые библиотеки.

(Аналогично флаг -Bsymbolic связывает ссылки на глобальные символы с определением внутри библиотеки.)

Крах происходит потому, что этот флаг заставляет использовать странный вариант интерпозиции символов, при котором интерпозиция происходит для символов‑переменных, таких как _S_once и _S_classic, но не для символов‑функций, таких как _S_initialize_once(). Это приводит к несоответствию в доступе к глобальным переменным: экспортируемые глобальные переменные оказываются едиными благодаря интерпозиции, тогда как каждая неинтерпозированная функция будет обращаться к своей собственной версии любой неэкспортируемой глобальной переменной.

Имея всё это знание, легко написать воспроизводящий пример, который не использует никакой код Firefox.

Понимание бага — один шаг, а исправление — совсем другой. Следует ли считать багом libstdc++, что код локалей несовместим с комбинацией -static-libstdc++ и -Bsymbolic‑functions? Такое сочетание флагов похоже на очень надёжный способ самому себе выкопать яму, и похоже, что именно таково мнение мейнтейнеров libstdc++.

Возможно, самое странное в этой истории то, что эта комбинация до сих пор не вызывала проблем. Мы предложили мейнтейнеру пакета прекратить использовать -static-libstdc++. Есть и другие способы использовать отличную от системной libstdc++, например динамическая линковка с установкой RPATH для связывания с комплектной версией. Это позволило им успешно развернуть исправленную версию пакета.

Через несколько дней после официального релиза Firefox 120 мы заметили очень значительный всплеск объёма для той же сигнатуры краха. Не снова! На этот раз объём исходил исключительно от пользователей NixOS 23.05, и он был огромен! После того как мы поделились выводами из нашего бета‑расследования, мейнтейнеры NixOS смогли быстро связать крах с проблемой, которая не была ещё зафиксирована для 23.05 и вызывала поведение компилятора, похожее на -static-libstdc++.

Чтобы избежать таких проблем в будущем, мы добавили детекцию этой конкретной конфигурации в configure Firefox. Мы благодарны людям, которые помогли исправить эту проблему, в частности:

Rico Tzschichholz (ricotz), который быстро исправил пакет для Ubuntu 18.04 LTS, и Amin Bandali (bandali), которые помогали в ходе работы; Martin Weinelt (hexa) и Artturin за оперативные исправления для пакета NixOS 23.05; Nicolas B. Pierron (nbp) за помощь в знакомстве с NixOS, что позволило нам быстро поделиться полезной информацией с мейнтейнерами NixOS.

Спасибо! Проверьте ваш почтовый ящик, чтобы подтвердить подписку. Если вы ранее не подтверждали подписку на рассылку, связанную с Mozilla, возможно, вам придётся это сделать. Проверьте входящие или папку спам на наличие письма от нас.