Нечего на зеркало пенять, коли рожа кривая

http://www.jgc.org/blog/2010/02/bad-workman-blames-his-tools.html
  • Перевод
Одной из самый угнетающих вещей для каждого программиста является осознание того, что все ваше время тратится не на создание чего нибудь полезного, а на устранение проблем, которые мы же сами и создаем.

Этот процесс называется отладка. Каждый день, каждый программист предстает перед тем фактом, что когда он пишет код — он создает и ошибки в коде. И как только он понимает, что его программа не работает, он должен искать проблемы, которые сам же и создал.

Для решения таких проблем, компьютерная индустрия создала огромное количество инструментов, которые помогают вам убедится, что программа работает правильно. Программисты, для поиска ошибок, используют методы непрерывной интеграции, unit-тестирование, утверждения, отладчики, и т.д. Но ошибки все равно остаются, и должны быть устранены с помощью человеческого мышления.

Некоторые языки программирования, такие например как С, чрезвычайно восприимчивы к таким видам ошибок, которые появляются и исчезают случайным образом, и как только вы начинаете разбираться в причине их появления они сразу пропадают. Такие ошибки часто называют Гейзенбагами, потому что как только вы начинаете их искать, они исчезают.

Такие ошибки могут возникать в любом языке программирования, особенно при написании многозадачного кода, где малейшие задержки во времени могут вызвать состояние гонки. Но в C есть другая проблема — утечка памяти.

Однако, что бы не вызвало ошибку, ключевые шаги при поиске проблемы всегда следующие:
  • Найти наименьшую закономерность, при которой ошибка полностью воспроизводится. С гейзенбагами это может оказаться сложным, но даже небольшой процент испытаний, при которых ошибка воспроизводится является значимым.
  • Автоматизировать процесс испытаний. Намного лучше, когда можно запускать тест снова и снова. Можно даже сделать его частью программы, когда ошибка будет устранена — это не допустит появления ошибки вновь.
  • Искать причину, пока не будет найдена её основа. До тех пор, пока вы не найдете истинную причину возникновения ошибки, вы не можете с уверенностью говорить, что вы её исправили. С гейзенбагами можно очень просто сбиться с толку, полагая, что вы исправили ошибку, после того как она вдруг исчезнет после того что вы сделаете в процессе поиска.
  • Исправить причину и проверить с помощью шага 2.

Недавно, в Hacker News появилась стаьтя — Если у вас гейзенбаг в С — значит проблемма в вашем оптимизаторе компилятора. Это очень неверное суждение.

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

В самом деле, признаком неопытности программиста является тот факт, что первое, что они делают при поиске ошибки — сваливают вину на кого-то другого. Очень заманчиво винить компьютер, операционную систему, библиотеку, которую вы используете. Однако настоящий программист тот, кто может контролировать своё «Я» и осознать что ошибка вероятнее всего его.

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

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

В статье выше есть один определенно не завершенный пример:

«Отключите оптимизатор и проверьте программу снова. Если она работает — значит проблема в оптимизаторе. Поиграйтесь с уровнями оптимизации, поднимая уровень до тех пор, пока ошибка не начнет воспроизводится.»

Все что вы знаете когда меняете уровни оптимизации — это то, что уровни меняются независимо от того, появляется ошибка или нет. Это не говорит вам о том, что оптимизатор работает неверно. Вы не нашли искомую причину ошибки.

Так как оптимизаторы производят манипуляции с кодом для ускорения их работы, вполне вероятно, что в зависимости от уровня оптимизации гейзенбаги могут как появляться так и исчезать. Это ещё не значит что оптимизатор работает неправильно. Это все ещё, вероятнее всего, ваша ошибка.

Вот конкретный пример программы на C которая содержит ошибку, появляющуюся при изменении уровня оптимизации компилятора, и показывает странное поведение программы.

#include <stdlib.h>

int a()
{
    int ar[16];
    ar[20] = (getpid() % 19 == 0);
}

int main(int argc, char * argv[])
{
    int rc[16];
    rc[0] = 0;
    a();
    return rc[0];
}


Скомпилируйте эту программу с помощью gcc под Mac OS X с помощью следующего Makefile (я сохранил код в файле odd.c).

CC=gcc
CFLAGS=

odd: odd.o


И вот пример скрипта, который запускает программу 20 раз и выводит результат:

#!/bin/bash
for i in {0..20}
do
    ./odd ; echo -n "$? "
done
echo


Если вы запустите этот скрипт, вы будете ожидать строку нулей, поскольку rc[0] никогда не получает значений отличных от нуля. Однако вот пример работы программы:

$ ./test
0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0


Если вы опытный программист C, вы увидите как я сделал чтобы появлялась единица, и почему она появляется в разных местах. Но давайте теперь попробует отладить программу с помощью printf:

[...]
rc[0] = 0;
printf( "[%d]", rc[0] );
a();
[...]


Теперь, когда вы запустите программу, ошибка исчезнет.

$ ./test
[0]0 [0]0 [0]0 [0]0 [0]0 [0]0 [0]0 [0]0 [0]0 [0]0 [0]0
[0]0 [0]0 [0]0 [0]0 [0]0 [0]0 [0]0 [0]0 [0]0 [0]0


Выглядит странно, поэтому вы перемещаете printf в другое место:

[...]
rc[0] = 0;
a();
printf( "[%d]", rc[0] );
[...]


и получаете тот же странный результат с исчезновением ошибки. И то же самое произойдет если вы отключите оптимизатор и даже без printf ошибка не будет появляться:

$ make CFLAGS=-O3
gcc -O3 -c -o odd.o odd.c
gcc odd.o -o odd

$ ./test
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0


Это все происходит, потому что ф-я a() выделяет память для 16-ти Integer элементов. И тут же записывает после конца массива либо 1 либо 0 в зависимости от того, делится ли PID процесса на 19 или нет. В конечном итогде она записывается в rc[0] из-за расположения в стеке.

Добавление printf или изменение уровня оптимизации меняет расположение кода и исключает неверное обращение к rc[0]. Но, будьте осторожны, ошибка не ушла. Единица просто записалась в другую ячейку памяти.

Т.к. C очень восприимчив к этому типу ошибок, важно использовать хорошие инструменты для проверки таких проблем. Например статический анализатор кода splint и анализатор памяти valgrind помогают устранить массу мерзких ошибок. И вы должны разрабатывать свои приложения с максимальным уровнем ошибок и устранять их все.

Только если вы сделаете все что нужно, вы можете начать подозревать чужой код. Но даже если вы начали это делать — проверьте ещё раз все шаги, чтобы установить истинную причину ошибки. К сожалению, в большинстве случаев, большая часть ошибок — ваша.
Поделиться публикацией
Комментарии 42
    +1
    В борландовском турбо-паскале, кстати, очень удобно было: хочешь включаешь выдачу сообщений об index out of bounds, хочешь — отключаешь. При отладке такую ошибку поймает, а в релизной версии будет работать без оверхеда.
      0
      в си такое не прокатит
      +1
      ЗЫ перенесите в тематический блог
        0
        Статья интересная — спасибо — но остаётся не понятным, как вы нашли ошибку в вашем конкретном примере. Какими средствами? Следуя какой логике? Или это know how?
          0
          Если вы опытный программист C, вы увидите как я сделал чтобы появлялась единица, и почему она появляется в разных местах.

          в данном случае силой мысли
            +2
            Я не пишу на Си, но ошибку тоже сразу увидел.
            Если такие игры с памятью встречаются в коде, того кто это писал «нужно долго бить головой об бэкспейс» (с) bash.org.ru
              0
              в реальном коде подобная ошибка скорее всего не явной.
                –1
                Тогда нужно делать проверки на «out of bounds». Раз уж крутости хватило на прямую работу с памятью, то на проверки диапозона и подавно должно хватить.
                  0
                  *диапазона
                    +2
                    Что такое «прямая работа с памятью»? Данный пример демонстрирует как раз кривую =)
                      +1
                      В языке C практически повсеместно идет работа с памятью, поэтому эта «крутость» вынужденна.
                        0
                        В таком случае в каждом учебнике языка «С» на первой же странице должна быть надпись огромными буквами:«Не забывай делать проверки диапазона или сгори в аду!»
                          0
                          дак это итак все знают, однако от таких ошибок не застрахован никто. если писать уж очень параноидально, то возможно это сократит кол-во ошибок до минимума, но замедлит работу программы. есть местах где это очень критично.
                            +1
                            просто подобна ошибка может быть вызвана огромным кол-вом стандартных функций. да и дело не ограничивается только переполнением.
                            допустим у функция strcpy которая копирует набор байт из одной области памяти в другую, пока не наткнется на null-байт, но иногда бывает что выделенной памяти не хватает, допустим потомучто в конец исходной строки забыли поставить null-байт.
                            у неё есть «безопасный» аналог strncpy, за исключением того, что последним параметром принимает максимальное кол-во байт. рядо ошибок это конечно срезает, зато оставляет ещё челую кучу. например программист может ошибиться в максимальном количестве байт на единицу, это вызовет «однобайтовое переполнение», которое может не выявиться вообще никогда, а может и при первом запуске.
                            также вероятно раскрытие данных когда злоумышленник может передать исходную строку без null-байта намеренно и тогда если массив под строку назначения достаточно большой и она отображается у злоумышленника то он может увидеть некоторое кол-во данных из стека. иногда бывает пароль, иногда путь какой-нибудь. и это только вершина айсберга. ну вобщем вы поняли.
                              0
                              очепятка «аналог strncpy, которая делает тоже самое, за исключением того, „
                0
                Согласен конечно, но лично я, в свои студенческие будни, сталкивался со случаем, когда был неправ компилятор. Например, код
                a = c << i;
                Будет работать по-разному в зависимости от значения i. Компилятор — Borland C++ 3.1. Причем если ввести данную строку (c << i) в отладчике, то значение в отладчике будет правильное.
                  0
                  Типы a, c, i целочисленные, правда не помню, знаковые ли.
                    +2
                    а вы хотели, чтобы он одинаково работал не зависимо от i??
                      0
                      Я хотел бы, чтобы он правильно (т.е. ожидаемо) работал от значения i.
                      Причем, как я уже сказал, в пред просмотре (если в watch ввести то выражение) работает все правильно.
                        0
                        а можно конкретнее? что не так работало?
                          0
                          Если значение i >= 32 то вместо ожидаемого 0 я получал следующее:
                          a = c << (i % 32);
                            +2
                            Выдержка из стандарта языка С:

                            If the value of the right operand is negative or is
                            greater than or equal to the width of the promoted left operand, the behavior is undefined.
                              0
                              Хм, интересное замечание.
                              Получается нечего на компилятор пенять, коли рожа кривая ;).

                              К сожалению, в конкретном данном случае я все равно не понимаю, почему было именно такое поведение, при условии того, что в watch все отображалось правильно (конечно, я понимаю, что среда (где watch и был) — это не компилятор).

                              Но против стандарта не попрешь, хотя и не понятно зачем обнулять все остальное, кроме первых 5 бит…
                                0
                                Среда отладки, в которой Вы смотрели watch, мог быть запросто собран другим компилятором. Или тем же компилятором, но с другими настройками.

                                А вообще стандарты выглядят странными до тех пор, пока не познакомишься с архитектурами, отличными от х86. Например, для процессора, который умеет сдвигать только на 1 разряд за раз, обнуление старших бит вполне логично выглядит.
                                  +1
                                  Судя по всему дело не в оптимизации под процессор, а оптимизации самого процессора с последующим сохранением поведения последующими процессорами.
                                    +1
                                    Вообще-то да, Вы правы:

                                    shl, shr — Shift Left, Shift Right

                                    These instructions shift the bits in their first operand's contents left and right, padding the resulting empty bit positions with zeros. The shifted operand can be shifted up to 31 places. The number of bits to shift is specified by the second operand, which can be either an 8-bit constant or the register CL. In either case, shifts counts of greater then 31 are performed modulo 32.
                              +1
                              А размерность «с» случайно не 32 бита? Почитайте про операцию сдвига, например в яве, сдвиг именно так и делается, на (i % 32)
                                0
                                Интересно, почему все-таки так делается.
                                Пока нашел здесь замечание:
                                Почему Java сокращает правый операнд оператора сдвига или грустная история о заснувшем процессоре
                                Одной из главной причин введения сокращения было то, что процессоры сами сокращают подобным образом правый операнд оператора сдвига. Почему?

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

                                Основным применением данных процессоров был контроль систем реального времени. В данных системах самый быстрый ответ на внешнее событие должно занимать не более задержки на прерывание (interrupt latency). Отдельные инскрукции таких процессоров были неделимы. Поэтому выполнение длинных операций (сдвига на несколько бит и ротации) нарушало эффективную работу процессора.

                                Следующая версия процессора имплементировала эти операции уже по-другому: размер правого операнда сократился. Задержка на прерывание восстанавилась. И многие процессоры переняли данную практику.
                                Интересна история данного вопроса, ни кто не поделится более детальными ссылками?
                                  +1
                                  Вообще-то многоразрядные сдвиги уже давно делаюстя за 1 такт независимо от числа разрядов. Когда-то очень давно (во времена 286 процессора) сдвиги действительно делались поразрядно, а потом кривизна была сохранена ради совместимости.
                      0
                      Признаюсь честно — я любил валить на других. Но, то что я это хотя бы понял, уже маленькое достижение.
                        0
                        Собственно то что вы написали относится к любой диагностике — разделяй и властвуй.
                        • НЛО прилетело и опубликовало эту надпись здесь
                            0
                            Я кстати когда переводил тоже очень долго думал. Название взято с википедии, если кто-то скажет более приятное нашему уху слово — поменяю в статье.
                              +2
                              Не надо переводить! Сегодня впервые услышал (увидел) это слово от вас и понял его значение без всякой википедии, на ассоциативном уровне — принцип неопределённости Гайзенберга… неопределяемая ошибка… когда смотрим — тогда не видим…
                            0
                            Скажите, а Вам известен стандартный способ проверки корректности работы с массивами и/или с локальными переменными в С? Чтобы описанный в топике баг отловился автоматически…
                              0
                              Практически любые статические анализаторы кода это выявят. Даже gcc:

                              a.c: In function ‘a’:
                              a.c:8: warning: array subscript is above array bounds

                                +1
                                Я имел в виду средства типа паскалевского index out of bounds, работающего во время выполнения программы. В реальных ситуациях речь идёт не об ar[20], а об ar[i], что компилятор не выявит.
                              0
                              Если честно, на ошибку в компиляторе (Borland Builder) я нарывался! Я уже не помню, что это было — но смог разрешить её, перенеся пару переменных в static.
                                0
                                А откуда Вам известно, что это именно ошибка компилятора? Или код был тривиальный, как в рассмотренном примере?
                                  0
                                  борланд на то и называют багленд, что в его компиляторах неоднократно были ошибки
                                    0
                                    Блок работал сам по себе, но отказывал, будучи вставленным в программу.
                                      +1
                                      Как-то раз (давно уже) мне случилось переносить математическую библиотеку из фортрана в си. Что характерно, я тоже пользовался Борланом, и симптомы были очень похожи на Ваши — тесты отдельных функций библиотеки проходили на ура, а при сборке библиотеки целиком наступал трындец.

                                      Я тогда тоже на компилятор всё валил… А потом баги постепенно отловились :)

                                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                Самое читаемое