undefined behavior
Nov. 29th, 2022 05:25 pmИнтересная заметка о багах в коде на C, вызванных оптимизациями компилятора, GCC в данном случае. В последние годы наметился тренд в сторону того, что компиляторы агрессивно оптимизируют код, опираясь на то, что undefined behavior по стандарту "не может случиться", и если случается, то "все дозволено". Я привык уже к этому, но такой прямой и бесхитростный случай, как по ссылке, меня шокировал: прямо написано
if (i >= 0 && i < sizeof(tab)) {
...
}
И при том, что i типа signed int, компилятор заводит выполение внутрь этого блока с отрицательным значением i.
То, что происходит, как объясняется подробно по ссылке, это что i получает отрицательное значение вследствие переполнения: большое положительное i умножают на другое положительное число. При этом компилятор при анализе кода позволил себе предположить, что "undefined behavior никогда не происходит" - что буквально говоря ложь - и "доказал" себе, что i остается положительным после умножения; поэтому проверку i>=0 просто удалил, в скомпилированном коде ее нет.
Я понимаю причины, по которым компиляторам нужны такие оптимизации, но это заходит слишком далеко все же, на мой взгляд. Если есть эксплицитная проверка границ, она не должна быть нарушена из-за ошибки с переполнением перед этим; такие проверки как раз нужны программисту, чтобы не сделать чего-то слишком ужасного. Здесь это всего лишь доступ к невыделенной памяти, но можно представить ситуацию, при которой, скажем, есть какое-то окно "безопасных операций" в виртуальной таблице функций, и операция вызывается по индексу, доступом к этой таблице, после того, как проверено, что индекс попал в "безопасное окно". В общем, много чего можно натворить.
В обсуждении в HN поясняют, что любой из двух флагов -fwrapv и -fno-strict-overflow решает проблему; первый заставляет компилятор считать, что переполнение переменной возвращается циклически с другой стороны, как это обычно и происходит; второй не дает компилятору предположить это, но также не позволяет ему пользоваться undefined behavior в таких случаях. Мне понравилось замечание 10-летней давности в какой-то линкуксовской рассылке: "The kernel currently uses -fno-strict-overflow because -fwrapv was buggy in some versions of gcc. Unfortunately -fno-strict-overflow is also buggy in some other versions of gcc."
Да, насчет того, зачем все это компиляторам нужно - это хорошо объясняется в другой заметке другого автора. Если вкратце, то 32-битные int-переменные со знаком требуют особого ухода на 64-битных платформах, и когда они служат переменной цикла, который должен запускаться как можно быстрее, их труднее соптимизировать. Если компилятор может незаметно для программиста предположить, что переменная цикла на самом деле 64-битная, код выходит быстрее. А для этого нужно, чтобы никогда не происходило (32-битного) переполнения, или хотя бы чтобы компилятор мог это предположить.
Эти проблемы в свою очередь восходят к решению, принятому четверть века назад, использовать 32 бита для int на 64-битных платформах. Вот обсуждение этого вопроса и этого решения, написанные в то время. Они тогда считали, что 64-битные системы редки и "в обозримом будущем 32-битные компьютеры будут доминировать в мире", а кроме того для переменных циклов, индексов массивов итп. 32 бита во всех нормальных ситуациях хватает. Оба аргумента сомнительны, а то, что int должен быть самым естественным типом на данной платформе - очевидный контраргумент; впрочем, я не возьмусь с уверенностью утверждать, что поступили тогда неверно. Так или иначе, этот фарш не прокрутить назад, и плоды этого решения в виде 32-битных интов мы пожинаем и будем продолжать пожинать.
if (i >= 0 && i < sizeof(tab)) {
...
}
И при том, что i типа signed int, компилятор заводит выполение внутрь этого блока с отрицательным значением i.
То, что происходит, как объясняется подробно по ссылке, это что i получает отрицательное значение вследствие переполнения: большое положительное i умножают на другое положительное число. При этом компилятор при анализе кода позволил себе предположить, что "undefined behavior никогда не происходит" - что буквально говоря ложь - и "доказал" себе, что i остается положительным после умножения; поэтому проверку i>=0 просто удалил, в скомпилированном коде ее нет.
Я понимаю причины, по которым компиляторам нужны такие оптимизации, но это заходит слишком далеко все же, на мой взгляд. Если есть эксплицитная проверка границ, она не должна быть нарушена из-за ошибки с переполнением перед этим; такие проверки как раз нужны программисту, чтобы не сделать чего-то слишком ужасного. Здесь это всего лишь доступ к невыделенной памяти, но можно представить ситуацию, при которой, скажем, есть какое-то окно "безопасных операций" в виртуальной таблице функций, и операция вызывается по индексу, доступом к этой таблице, после того, как проверено, что индекс попал в "безопасное окно". В общем, много чего можно натворить.
В обсуждении в HN поясняют, что любой из двух флагов -fwrapv и -fno-strict-overflow решает проблему; первый заставляет компилятор считать, что переполнение переменной возвращается циклически с другой стороны, как это обычно и происходит; второй не дает компилятору предположить это, но также не позволяет ему пользоваться undefined behavior в таких случаях. Мне понравилось замечание 10-летней давности в какой-то линкуксовской рассылке: "The kernel currently uses -fno-strict-overflow because -fwrapv was buggy in some versions of gcc. Unfortunately -fno-strict-overflow is also buggy in some other versions of gcc."
Да, насчет того, зачем все это компиляторам нужно - это хорошо объясняется в другой заметке другого автора. Если вкратце, то 32-битные int-переменные со знаком требуют особого ухода на 64-битных платформах, и когда они служат переменной цикла, который должен запускаться как можно быстрее, их труднее соптимизировать. Если компилятор может незаметно для программиста предположить, что переменная цикла на самом деле 64-битная, код выходит быстрее. А для этого нужно, чтобы никогда не происходило (32-битного) переполнения, или хотя бы чтобы компилятор мог это предположить.
Эти проблемы в свою очередь восходят к решению, принятому четверть века назад, использовать 32 бита для int на 64-битных платформах. Вот обсуждение этого вопроса и этого решения, написанные в то время. Они тогда считали, что 64-битные системы редки и "в обозримом будущем 32-битные компьютеры будут доминировать в мире", а кроме того для переменных циклов, индексов массивов итп. 32 бита во всех нормальных ситуациях хватает. Оба аргумента сомнительны, а то, что int должен быть самым естественным типом на данной платформе - очевидный контраргумент; впрочем, я не возьмусь с уверенностью утверждать, что поступили тогда неверно. Так или иначе, этот фарш не прокрутить назад, и плоды этого решения в виде 32-битных интов мы пожинаем и будем продолжать пожинать.
no subject
Date: 2022-11-29 05:55 pm (UTC)It's not that UB never happens. It happens. But when it happens, everything is allowed. No requirements on behaviour, even on behaviour before the UB. It is written in the standard.
I have seen this today:
This compiles to an infinite loop.
no subject
Date: 2022-11-29 06:14 pm (UTC)the standard is shitty, they must have ruled any UB to be a fatal compile error, period
no subject
Date: 2022-11-30 11:13 am (UTC)