Отличные новости ждут пользователей gcc при переходе на версию 4.9.0 – новые оптимизации с использованием неопределенного поведения могут «сломать» (на самом деле — доломать) существующий код, который, например, сравнивает с нулем указатели, ранее переданные в memmove() и ряд других функций стандартной библиотеки.
Например, утверждается, что в таком коде:
новый gcc может удалить сравнение указателя с нулем и в результате вызов wtf( 0, 0, 0 ) будет приводить к разыменованию нулевого указателя (и аварийному завершению программы).
На первый взгляд, выглядит так, как будто компилятор целенаправленно сломал программу. Отдельные читатели уже полны возмущения (особенно «невразумительным» примером кода) и спешат в комментарии, чтобы его высказать. Пока рано. Сначала стоит посмотреть, что сказано по этому поводу в Стандарте C99.
В разделе 7.21 описаны «строковые функции», объявляемые в заголовке string.h В 7.21.1/2 сказано следующее: «если в описании конкретной функции в данном подразделе не сказано иное, то указатели, передаваемые в качестве аргументов при вызове функции, должны иметь допустимые значения, соответствующие требованиям 7.1.4». Функция memmove() описана в 7.21.2.2, т.е. относится к «строковым функциям», в ее описании ничего не сказано о допустимости нулевых указателей на входе.
TL;DR; Смотрим в 7.1.4, там сказано «Если аргумент функции имеет недопустимое значение (такое как <…>, нулевой указатель) <…>, то поведение не определено».
Таким образом, передача нулевых указателей в memmove() приводит к неопределенному поведению, даже если значение третьего параметра (число байт) равно нулю. Компилятор делает из этого следующий вывод: если указатель передается в memmove(), можно считать, что он ненулевой, и оптимизировать остальной код соответствующим образом. Эта идея подробно и с примерами объяснена вот в этой замечательной публикации.
Попробуем это воспроизвести на MinGW с gcc 4.9.0
Компилируем:
Запускаем полученный исполняемый файл – получаем в выдаче «not null».
Для сравнения, если вызов memmove() перенести ниже:
то выдача будет ожидаемая: «null» — с новой оптимизацией работа программы может меняться в зависимости от того, стоит вызов memmove() выше или ниже сравнения указателя с нулем.
Это еще не все. Работа программы может измениться при замене библиотечной функции на «велосипед» или наоборот:
При вызове magic3( 0, 0, 0 ) программа выдает «null». В случае использования библиотечной memcpy() выдается «not null».
В описании настроек оптимизации описанная выше в явном виде не упоминается. Самой похожей выглядит -fdelete-null-pointer-checks, и действительно с настройкой -fno-delete-null-pointer-checks эта оптимизация отключается вместе с рядом других оптимизаций, полагающих, что ранее разыменованный указатель нет смысла сравнивать с нулем. Заметим, что в описанной выше оптимизации речь не идет о разыменовании указателя, а только о передаче указателя в качестве параметра строковых функций.
Вопреки распространенному мнению, по-настоящему переносимый код писать не так легко, как хотелось бы. Использовать size_t для индексирования массивов недостаточно.
Дмитрий Мещеряков,
департамент продуктов для разработчиков
Например, утверждается, что в таком коде:
int wtf( int* to, int* from, size_t count ) {
memmove( to, from, count );
if( from != 0 )
return *from;
return 0;
}
новый gcc может удалить сравнение указателя с нулем и в результате вызов wtf( 0, 0, 0 ) будет приводить к разыменованию нулевого указателя (и аварийному завершению программы).
На первый взгляд, выглядит так, как будто компилятор целенаправленно сломал программу. Отдельные читатели уже полны возмущения (особенно «невразумительным» примером кода) и спешат в комментарии, чтобы его высказать. Пока рано. Сначала стоит посмотреть, что сказано по этому поводу в Стандарте C99.
В разделе 7.21 описаны «строковые функции», объявляемые в заголовке string.h В 7.21.1/2 сказано следующее: «если в описании конкретной функции в данном подразделе не сказано иное, то указатели, передаваемые в качестве аргументов при вызове функции, должны иметь допустимые значения, соответствующие требованиям 7.1.4». Функция memmove() описана в 7.21.2.2, т.е. относится к «строковым функциям», в ее описании ничего не сказано о допустимости нулевых указателей на входе.
Таким образом, передача нулевых указателей в memmove() приводит к неопределенному поведению, даже если значение третьего параметра (число байт) равно нулю. Компилятор делает из этого следующий вывод: если указатель передается в memmove(), можно считать, что он ненулевой, и оптимизировать остальной код соответствующим образом. Эта идея подробно и с примерами объяснена вот в этой замечательной публикации.
Попробуем это воспроизвести на MinGW с gcc 4.9.0
#include <stdio.h>
#include <string.h>
void magic1( char* to, char* from, size_t count )
{
memmove( to, from, count );
if( from == 0 ) {
printf( "null\n" );
} else {
printf( "not null\n" );
}
}
int main()
{
magic1( 0, 0, 0 );
return 0;
}
Компилируем:
gcc magic.c -O2 -o magic.exe
Запускаем полученный исполняемый файл – получаем в выдаче «not null».
Для сравнения, если вызов memmove() перенести ниже:
void magic2( char* to, char* from, size_t count )
{
if( from == 0 ) {
printf( "null\n" );
} else {
printf( "not null\n" );
}
memmove( to, from, count );
}
то выдача будет ожидаемая: «null» — с новой оптимизацией работа программы может меняться в зависимости от того, стоит вызов memmove() выше или ниже сравнения указателя с нулем.
Это еще не все. Работа программы может измениться при замене библиотечной функции на «велосипед» или наоборот:
void mymemcpy( char* to, char* from, size_t count )
{
while( count > 0 )
{
*to++ = *from++;
count--;
}
}
void magic3( char* to, char* from, size_t count )
{
mymemcpy( to, from, count );
if( from == 0 ) {
printf( "null\n" );
} else {
printf( "not null\n" );
}
}
При вызове magic3( 0, 0, 0 ) программа выдает «null». В случае использования библиотечной memcpy() выдается «not null».
В описании настроек оптимизации описанная выше в явном виде не упоминается. Самой похожей выглядит -fdelete-null-pointer-checks, и действительно с настройкой -fno-delete-null-pointer-checks эта оптимизация отключается вместе с рядом других оптимизаций, полагающих, что ранее разыменованный указатель нет смысла сравнивать с нулем. Заметим, что в описанной выше оптимизации речь не идет о разыменовании указателя, а только о передаче указателя в качестве параметра строковых функций.
Вопреки распространенному мнению, по-настоящему переносимый код писать не так легко, как хотелось бы. Использовать size_t для индексирования массивов недостаточно.
Дмитрий Мещеряков,
департамент продуктов для разработчиков