Расскажу вам историю о том, как я далеко не в первый раз столкнулась с багом компилятора. Дело в том, что я уже довольно хорошо научилась распознавать случаи, когда проблема заключается совсем не в моём коде.

Большинство таких багов, которые мне попадались, связаны с генерацией кода. Иногда компилятор может сам допустить ошибку сегментации. Конечно, если такая ошибка происходит раз за разом, то её довольно легко вычленить. Бывает, что компилятор допускает ошибку сегментации эпизодически. Прежде, чем сообщать о такой ошибке, нужно прогнать на своей машине тест аппаратного обеспечения. Компиляторы очень любят использовать много оперативной памяти, а также ведут историю, в которой могут содержаться ранние сигналы об аппаратных проблемах. Мне доводилось работать с машиной, на которой ошибка сегментации могла возникать в процессе сборки ядра.

Особенно запомнился случай, когда машина перещёлкнула один разряд в патче, так, что тема изменилась с “keep track of page owners” на “meep track of page owners”. Иногда эпизодические отказы – это действительно баги компилятора, но для начала всегда полезно исключить все проблемы, которые могли произойти по вашей вине.

Развивая тему «исключить проблемы, виной которым вы», отмечу: один из самых верных признаков, что вы, возможно, имеете дело с багом компилятора — это когда компилятор делает что-то необъяснимое. Возьмём, к примеру, этот фрагмент кода на C:

int a = 4;
int b = 5;
int *c = (int *)0x1000;
*c = (a*b) << 24;

Попытаемся объяснить, что делает этот код. Кажется, что ситуация такова: “4 сохраняется в a, 5 сохраняется в b, 0x1000 сохраняется в ca и b перемножаются, сдвигаются влево на 24 разряда и сохраняются в c”. Правда, если работу кода можно объяснить, это ещё не значит, что он работает корректно. Значения ab или c могут быть неверными, сдвиг может требоваться на 16, а не на 24 (вы могли забыть, что в u32… содержится 4 байта). Найти такие баги бывает невероятно сложно, но важное свойство багов, содержащихся в вашем собственном коде — в том, что для их отладки вам требуется просто переосмыслить происходящее. С багом компилятора такая модель совершенно не работает.

Ваш высокоуровневый код компилируется в ассемблер. При помощи команды objdump можно просмотреть ассемблерный код исполняемого файла, либо можно воспользоваться gcc -S, чтобы gcc прекратил работу с этапе сборки. (Если вы отлаживаете ядро Linux, то можете выполнить make path/to/kernel/file.s) Язык ассемблера — это вещь в себе, и, чтобы научиться его читать, нужна практика (но научиться этому определённо можно). Если выполнить вышеприведённый фрагмент на платформе ARM, то в результате компиляции может получиться следующий код:

 mov r0, #4 // Переместить 4 в регистр 0
 mov r1, #5 // Переместить 5 в регистр 1
 mov r2, #4096 // Переместить 0x1000 в регистр 2
 mul r0, r0, r1 // Перемножить r0 и r1 и сохранить результат в r0
 lsl r0, r0, #24 // Сдвинуть r0 влево на 24 разряда
 str r0, [r2]  // Сохранить значение r0 по адресу, который содержится в r2

Немного сложно читается, но этот код очень точно соответствует тем операциям, которые пытается выполнить код на C. Теперь представьте, что компилятор выдал не вышеприведённый, а вот такой код:

 mov r0, #4 // Переместить 4 в регистр 0
 mov r1, #5 // Переместить 5 в регистр 1
 mov r2, #4096 // Переместить 0x1000 в регистр 2
 mul r0, r0, r1 // Перемножить r0 и r1 и сохранить результат в r0
 str r0, [r2]  // Сохранить значение r0 по адресу, который содержится в r2
 lsl r0, r0, #24 // Сдвинуть r0 влево на 24 разряда

Здесь компилятор (неверно) поставил операцию сохранения раньше операции сдвига. В таком случае вы удивитесь, насколько изменится результат. Пытаясь отлаживать проблемы такого характера, приходится проверять ваши допущения на каждом шаге, вооружившись отладчиком или printf (и надеяться, что ни один из этих инструментов не повлияет на состояние кода). Что, согласно вашим ожиданиям, происходит в каждой строке кода, и соответствует ли ассемблер вашим допущениям? Именно такие вопросы следует себе задавать, занимаясь отладкой подобных проблем.

Поэтому, если вы не можете объяснить, почему компилятор выдал именно такой ассемблерный код, значит ли это, что вы нашли в компиляторе баг? Не обязательно. Тот фрагмент кода, который я привела выше, определённо содержит ошибки, но в ассемблере не всегда удаётся найти точные соответствия коду на высокоуровневых языках. В частности, в языке C есть богатая история неопределённых поведений. Полагаясь на неопределённое или на недостаточно чётко описанное поведение, вы наверняка будете удивляться выдаче компилятора. Чтобы разобраться, нашли ли вы баг или просто неожиданное поведение, нужно обратиться за помощью к специалистам по компилятору, глубоко понимающим язык. Можно отлаживать компилятор самостоятельно, но в современных компиляторах применяются многоходовые оптимизации, и бывает сложно определить, какой шаг оптимизации за что отвечает. Когда собираетесь сообщить о проблеме с компилятором, помните один ключевой момент: команда по поддержке компилятора не знает вашей базы кода, поэтому, если вы можете подобрать максимально узкий тестовый кейс, не охватывающий ничего лишнего кроме самой проблемы, то лучше всего так и сделать. При работе с gcc можно передать компилятору -E, чтобы он остановился на этапе предобработки и выдал вам самодостаточное завершении файла в .i. (При работе с ядром Linux можно выполнить make path/to/kernel/file.i).

Такова история последнего бага, вдохновившего меня на этот пост. Я отлаживала одну проблему (на Rust, а не на C!) и заметила, что переменная изменяется после системного вызова, который, казалось бы, вообще не должен был затрагивать переменные. Потребовалось хорошо покопаться в дампе ассемблера, чтобы понять, что для считывания переменной после системного вызова использовался регистр, содержимое которого не сохраняется. В частности, код использовал указатель кадра в качестве регистра общего назначения, и компилятор обрабатывал эту ситуацию неверно. Хорошее напоминание о том, что в компиляторах, как и в любых других программах, часто возникают проблемы с обработкой пограничных случаев. Кроме того, в компиляторах требуется очень дисциплинированно предусматривать тесты на все случаи, чтобы впоследствии не приходилось откатываться. Особенно много проблем возникает с ассемблерными вставками, поскольку при их программировании нужно на самом фундаментальном уровне разбираться в механизме выделения регистров. Опять же, снимаю шляпу перед разработчиками компиляторов, благодаря которым большинству из нас (почти) никогда не приходится беспокоиться о том, что же делает компилятор.

Я написала этот пост на тему багов компилятора, но он также касается и аппаратных багов. Вы ожидаете от аппаратной части определённого поведения и, если она проявляет себя не так, как следует, то отлаживать такие случаи бывает очень сложно. Поэтому определитесь с тем, из каких допущений вы исходите (и даже какие подразумеваете) и выясните, где эти допущения не соблюдаются. Это важнейший навык при отладке.


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud  в нашем Telegram-канале 

Перейти ↩

? Читайте также:

Комментарии (2)


  1. Jijiki
    19.02.2025 11:22

    спасибо, интересно, подскажите а какой ответ у этих строк кода?

    поидее надо просто это не на лету делать, в калькуляторе проверил ответ вроде сходится


  1. unreal_undead2
    19.02.2025 11:22

    Хотя бы volatile написали что ли...