Когда 1.0 = 0.0
Dec 16, 2019 · CommentsПрограммированиеОтладка
Столкнулся недавно с новой для себя областью - ловлей багов в компиляторе. Ловить баг в процессоре мне уже доводилось, а вот с компиляторами до недавнего времени как-то везло.
Началось все очень прозаично. В нашем репозитории есть своя реализация умного указателя. Ну, вы знаете, как это бывает. Обычная история на самом деле… Этот класс был написан в древние времена, тогда и С++03 еще на свете не было. С тех пор уже и С++17 появился и С++20 не за горами, но класс по-прежнему активно используется. Мы его давно хотели заменить на что-нибудь более стандартное и современное, но все руки не доходили. “Работает - не трожь”.
В один прекрасный день в этом классе нашлось неопределенное поведение, что
стало последней каплей, переломившей верблюду соломинку. Указатель был
переписан на корню и стал тонкой оберткой вокруг std::shared_ptr<>
.
На следующий же день сломался один неприметный юнит тест на одной из платформ.
Причем на первый взгляд никакого отношения к переписанному указателю этот тест
не имел. Но от фактов было трудно отвертеться. С новым указателем тест падал, а
со старым - нет.
Проблему получилось свести к коду, структура которого выглядела примерно так:
bool func1(..., const double foo, ...)
{
...
printf(“func1: foo == %f\n, foo);
func2(..., foo, ...);
}
void func2(..., const double foo, ...)
{
printf(“func2: foo == %f\n, foo);
...
}
Этот код печатал следующее:
func1: foo == 1.0
func2: foo == 0.0
Т.е. буквально до вызова функции константа foo принимала одно значение, а в вызываемой функции - другое.
Реальный код был сложнее, конечно. Например было важно, чтобы func1()
была
встроена (inlined) в вызывающий код, а func2()
- нет. Без соблюдения этого
условия ошибка не воспроизводилась. Более того, добавление еще одного
printf(“func1: foo == %f\n, foo);
после вызова func2()
также убирало
проблему. К этому моменту стало довольно очевидно, что компилятор генерирует
что-то не то.
Следующим шагом стал детальный разбор ассемблерного кода, сгенерированного
компилятором. Самым сложным в этом деле оказалось найти правильный справочник
по инструкциям процессора и документ, описывающий соглашение о вызовах на
целевой платформе. Разбор показал, что константа 1.0
правильно загружается в
регистр процессора, но далее значение переписывается другими операциями до
вызова func2()
.
Поиски в баг-трекере и истории изменений в репозитории GCC не принесли успеха. Ничего похожего на эту ошибку не находилось. Пришлось отлаживать компилятор самостоятельно.
В понимании как работает компилятор очень помогла страница GCC Important
Passes. Если в двух словах, то логика компилятора сгруппирована по
“проходам” (passes). Проходы организованы в две группы: “tree” и “rtl”. Они
используют разные формы представления компилируемого кода: GIMPLE и
Register Transfer Language (RTL). Каждый проход выполняет определенное
преобразование. Например pass_remove_useless_stmts
удаляет явно бесполезный
код, а pass_loop2
выполняет оптимизацию циклов.
Результат работы каждого прохода можно сохранить в файл указав ключи
-fdump-rtl-all-all
и -fdump-tree-all-all
. Для каждого прохода создается
отдельный файл, куда пишется весь отладочный вывод вместе с полным описанием
компилируемого модуля на языке GIMPLE или RTL.
Чтобы найти проход, который приводит к ошибке, воспользовался таким алгоритмом поиска:
- Помечаем проблемный участок для того, чтобы его было легче найти. В данном
случае достаточно было сделать уникальной строку, которая передается в
printf()
. - Компилируем программу с
-fdump-rtl-all-all
и-fdump-tree-all-all
и получаем несколько сотен файлов с результатами работы каждого прохода. - Далее ищем проблемный проход методом половинного деления. Все выполненные проходы пронумерованы по порядку выполнения.
Через несколько часов получаем результат - ошибка в коде появляется после
прохода reload
. Какую роль выполняет проход reload
? Для начала, согласно
GCC wiki:
Reload is the GCC equivalent of Satan.
Reload - это эквивалент Сатаны в GCC.
С чем я полностью согласен, кстати. В коде reload
есть условие, которое
занимает 55 строк. Код reload
обильно припорошен макросами и
полиморфными структурами. Никогда не знаешь какой член объединения (union
member) имеет значение в данном месте. Полное ощущение, что читаешь код на
питоне. Только написанный на чистом C.
Разобраться в этом месиве было бы нереально, но в таких случаях на помощь
приходит rr. Скажем вам нужно найти место, где создается структура с
заранее известным содержимым. Ставите условную точку останова в теле free()
,
которая срабатывает по известному содержимому структуры (например четыре байта
по смещению 0x88
равны 0x12345678
). Когда точка останова срабатывает -
проверяете что удаляется искомая структура. Теперь ставите новую условную точку
останова, которая срабатывает по записи в память по адресу, где хранится
0x12345678
и запускаете обратное выполнение. Бац, и отладчик остановится в
момент создания структуры (в момент записи в нее 0x12345678
).
reload
принимает на вход код, инструкции в котором используют виртуальные
регистры, каждый из которых загружается только один раз. Таких регистров
может быть тысячи и десятки тысяч. Задача reload
преобразовать его в код,
который использует реальные регистры процессора, число которых ограничено
несколькими десятками. Уникальные номера виртуальных регистров позволяют легко
использовать вышеописанную технику, чтобы найти структуры, соответствующие
инструкциям, выполняющим передачу константы 1.0
в func2()
. А по ним, - найти
места в коде reload
, которые приводят в генерации некорректного кода на выходе.
Результатом всех этих усилий стала заплатка для GCC, которая выдает
предупреждение компилятора, каждый раз когда reload
пытается сгенерировать
ошибочный код. Тем самым, у нас появился способ автоматической проверки того,
что подобный код не просочится в итоговую сборку.
Умный указатель, кстати, к ошибке никакого отношения таки не имел. Просто со старой версией генерировался другой код, который приводил к другой последовательности использования регистров, с который проблема себя не проявляла.
P.S. Сразу отвечу на очевидный вопрос. В силу специфических особенностей отрасли, нам даже завести репорт в публичном баг-трекере без юриста никак нельзя. Да и с юристом это еще тот геморрой.