Когда 1.0 = 0.0

Столкнулся недавно с новой для себя областью - ловлей багов в компиляторе. Ловить баг в процессоре мне уже доводилось, а вот с компиляторами до недавнего времени как-то везло.

Началось все очень прозаично. В нашем репозитории есть своя реализация умного указателя. Ну, вы знаете, как это бывает. Обычная история на самом деле… Этот класс был написан в древние времена, тогда и С++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.

Чтобы найти проход, который приводит к ошибке, воспользовался таким алгоритмом поиска:

Через несколько часов получаем результат - ошибка в коде появляется после прохода 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. Сразу отвечу на очевидный вопрос. В силу специфических особенностей отрасли, нам даже завести репорт в публичном баг-трекере без юриста никак нельзя. Да и с юристом это еще тот геморрой.

comments powered by Disqus