Новые оптимизации с использованием неопределенного поведения в gcc 4.9.0

    Отличные новости ждут пользователей gcc при переходе на версию 4.9.0 – новые оптимизации с использованием неопределенного поведения могут «сломать» (на самом деле — доломать) существующий код, который, например, сравнивает с нулем указатели, ранее переданные в memmove() и ряд других функций стандартной библиотеки.

    Например, утверждается, что в таком коде:
    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, т.е. относится к «строковым функциям», в ее описании ничего не сказано о допустимости нулевых указателей на входе.

    TL;DR; Смотрим в 7.1.4, там сказано «Если аргумент функции имеет недопустимое значение (такое как <…>, нулевой указатель) <…>, то поведение не определено».

    Таким образом, передача нулевых указателей в 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 для индексирования массивов недостаточно.

    Дмитрий Мещеряков,
    департамент продуктов для разработчиков
    ABBYY
    114.91
    Решения для интеллектуальной обработки информации
    Share post

    Comments 27

      +4
      Интересно, это действительно кому то ускорит выполнение программ? Лучше бы они не оптимизацию сделали, а warning.
        +1
        Например, во многих проектах есть правило использовать safe_free (это может быть макрос или inline-функция) для проверки указателя на NULL перед освобождением с помощью free (опционально — занулить указатель). Т. е. даже если программисту очевидно, что указатель ненулевой, он всё-равно будет использовать safe_free, потому что так закреплено в правилах этого проекта. Остаётся надеяться, что компилятор выкинет эти проверки. Один из старейших способов сообщить GCC о ненулевом указателе — использовать __attribute__((nonnull (...)). Насколько я понимаю, теперь этот атрибут действует не только вглубь функций, но и наружу.
          +6
          Именно для free() эта проверка не нужна, так как он по стандарту должен игнорировать нулевое значение. Если падает — то это уже проблема реализации, которая положила на стандарты, так что приходится так костылить.

          А safe_free скорее всего определяют так:
          #define safe_free(ptr) do { free(ptr); (ptr) = NULL; } while (0)
          
          что не лишено смысла.
            +1
            Совершенно верно.
            Я раз работал в проекте, где на уровне собственного препроцессора (на тикле) определялись многие макросы, в том числе и safe_free. Принцип такой: make собирает сначала исполняемый бинарник, который определяет поведение компилятора/платформы, а затем генерит header для тиклевого препроцессора. Затем прогон на тикле, который делает из headers.tcl.h -> headers.h, и дальше компилит уже проект (использующий headers.h)…

            Например (на память):
            <% if $std(free_checks_null) { %>
            #define safe_free(ptr) { free(ptr); (ptr) = NULL; }
            <% } else { %>
            #define safe_free(ptr) if ( !(ptr) ) { free(ptr); (ptr) = NULL; } else {}
            <% } %>

            Вплоть до определения нужен ли такой «цикл» do ... while (0), т.е. ваш пример может выглядеть:
            #define safe_free(ptr) <%= [ $need_dummy_do { free(ptr); (ptr) = NULL; } ] %>
            

            Работать было удобно, хоть и компиляторов и платформ был целый зоопарк.
            Ну а мы его пользовали не всегда по прямому назначению (например, заместо некоторых макросов или тех же сишных templates, было гораздо удобнее). До сих пор иногда беру для удобства, если пользую c/c++, т.к. много чего делать можно, например пути (сегменты их) — под виндой поворачивает слэш:
            char * pdd_path = "<%= [file nativename {conf/data/idx/pdd}] %>";

            Если вдруг кому надо — препро-парсер выложу на гитхаб.
              0
              естественно очепятался, при !(ptr) тело ложится в else:
              #define safe_free(ptr) if ( !(ptr) ) {} else { free(ptr); (ptr) = NULL; }
              ну или
              #define safe_free(ptr) if ( (ptr) != NULL ) { free(ptr); (ptr) = NULL; } else {}
          +7
          Так можно сказать практически про любую оптимизацию.

          Как я уже писал ранее, по отдельности они могут быть незначительными, но тем не менее, любая оптимизация может открыть дорогу другим, более серьезным трансформациям кода, которые уже повлияют.

          Особенно заметно это при каскадном инлайнинге кода через несколько функций.
          –8
          И вы так реально пишете программный код, как привели в примерах? А потом видимо еще не понимаете, почему же всё падает. Очень всё же интересует, почему сначала делается memove, а только потом проверяется указатель. Надеяться на какое-то определенное поведение сторонней функции изначально глупая затея.

          int wtf( int* to, int* from, size_t count ) {
          if (from == 0) return 0;
          memmove( to, from, count );
          return *from;
          }

          Чем такой код не устраивает?
            +6
            И вы так реально пишете программный код, как привели в примерах? А потом видимо еще не понимаете, почему же всё падает.
            Вы это серьезно? Новая оптимизация может доломать кучу «успешно работавшего» десятилетиями кода в проектах размером в сотни тысяч и миллионы строк.
            +3
            Описанная выше ситуация может произойти вследствие трансформаций. Все то, что компилятор имеет право делать, он так или иначе будет делать.

            В какой-то конкретной ситуации, компилятор может посчитать, что для снижения register pressure имеет смысл поменять порядок блоков кода с A, B, C на A, C, B, если это не повлияет на результат. А влияние на результат он рассчитает исходя из всех доступных соображений, включая и описанное в статье.

            В итоге — различное поведение.
            +2
            Плохо конечно, что легаси ломается, но пользователи undefined behaviour сами себе злобные буратины. В языке явно сказано, что так делать нельзя.
                +25
                PVS-Studio говорит: V595 The 'from' pointer was utilized before it was verified against nullptr. Check lines: 16, 17. test.cpp 16.

                Я просто оставлю это здесь: Примеры V595. Здравствуй новый глючный мир. :)
                  0
                  Выдается для всех примеров в публикации или только для первого?
                    +2
                    Для всех (wtf, magic1, magic3).

                    Анализатор правда молчит про подозрительный magic2. Впрочем, этот код может ведь быть и правильным :). Вдруг printf( «null\n» ); кидает исключение и до memmove() мы не доберёмся.
                      0
                      Выдается на magic3() с «велосипедом» или с memcpy()?
                        +2
                        На mymemcpy(), к сожалению, не ругается. Быть может, когда научится.
                          0
                          А что не так в вызове magic3(0, 0, 0) с «велосипедом» внутри?
                            +3
                            Мы (пока) не знаем, будет разыменовываться в mymemcpy() указатель или нет.
                        0
                        По стандарту extern «C» функции не могут кидать исключения (чем кстати некоторые компиляторы на некоторых платформах пользуются, отключая генерацию unwind info, после чего отваливается пробрасывание исключений через С-шный код из С++-ных коллбеков, увы)
                          0
                          Пробрасывание исключений через код на C — изначально плохая идея. Даже если предположить, что исключение «просто пролетит», очевидно, что код на C никак не сможет на него отреагировать — в результате код на C, который собирался изменить состояние программы после возврата управления из callback-функции, сделать это не сможет, данные программы могут оказаться в рассогласованном состоянии.
                            0
                            Да, разумеется, в общем случае оно так.
                            В частном случае это был libpng, где предполагается делать error handling с помощью setjmp, но исключения тоже работают… на некоторых платформах :)
                      +6
                      Непонятно, что я сказал такого неугодного, что лепят минусы. Видимо кто-то подумал, что я не уважаю его любимый компилятор. Отнюдь. Виноват кривой код. И его, как я показал, весьма много.
                      –1
                      А с -Wall -Wextra никаких варнингов не выдаёт? (мне самому сейчас лень gcc-4.9.0 ставить)
                        0
                        Наслышан о чрезмерно агрессивной оптимизации GCC 4.9. Но в этом конкретном примере, если memmove(0, 0, 0) это неопределённое поведение, то почему вы решили, что программа после такого вызова функции вообще продолжит нормально выполняться и даст корректный результат?
                        Понятно, что в каких-то конкретных реализациях компилятора это работает (или работало до этого дня), и такими реализациями могут оказаться даже все существующие компиляторы, но по стандарту это стрельба в ногу, хоть и холостыми патронами. GCC 4.9 решил помочь и дал вам боевые.
                          +1
                          Нужно добавить, что gcc 4.9.0 смотрит не на «memmove», а на __attribute__((nonnull)).
                          Мы на этом погорели разок, долго на ассемблерный код пришлось смотреть. Диагностика тут у 4.9.0 просто никакая.
                          –1
                          Проверка указателя на ноль вообще неясно, зачем нужна. Если, конечно, нулевое значение не является функциональным и что-то значит само по себе. Ничто не мешает размепить нулевую страницу и ловить такие пойнтеры на page fault.

                          Only users with full accounts can post comments. Log in, please.