Трюки от КрысаКрис Касперски Хакер, номер #101, стр. 132 Сегодняшний выпуск посвящен проблемам удаленной диагностики ошибок. Это когда у пользователя падает программа, а воспроизвести ситуацию на месте у нас не получается. Удаленную отладку (по модему и/или интернету) не предлагать, поскольку далеко не всякий пользователь на это согласится. Все, что нам остается, — внедрить в программу дополнительный проверочный код. Совет 1: никогда не удаляй проверки из releaseБольшинство программистов, напичкивающих отладочную версию программы всевозможными проверками корректности всех значений, словно лемминги, подчиняющихся законам всеобщей традиции, удаляют их из финального релиза. А зачем?! Отладочную информацию (генерируемую компилятором) удалять, естественно, нужно, поскольку она не только в разы увеличивает размер исполняемого файла, но и облегчает его взлом, а также в большинстве случаев вырубает многие опции компиляции. Удаление избыточных проверок практически не сказывается на размере и слабо влияет на производительность (за исключением, быть может, многочисленных проверок в глубоко вложенных циклах). Так зачем же их удалять?! И каким образом выполнять диагностику, если на машине конечного пользователя программа внезапно откажет в работе?! Если программист предполагал, что ошибка может проявиться в отладочной версии, и добавил специальную проверку, то почему он считает, что она заведомо не появится в релизе? Где гарантия, что в процессе отладки были протестированы все возможные состояния программы? Где гарантия, что мы не имеем дело с «наведенной» ошибкой, зависящей от других частей программы, на первый взгляд, не имеющей к ней никакого отношения? Чем больше проверок останется в финальной версии, тем легче будет найти источник ошибки при ее возникновении. Конечно, проверка проверке рознь. Одно дело проверить указатель на нуль, и совсем другое - корректность форматированной строки данных. В этом случае (если программист озабочен производительностью) можно ввести специальный флаг или ключ командной строки, включающий все тяжеловесные проверки. Совет 2: активно используй самодиагностикуСамотестирование — великая вещь, и все сложные электронные устройства (в том числе и процессоры) обязательно включают компоненты, выполняющие самодиагностику. Тот же самый подход может (и должен) применяться в программном коде. Каждая мало-мальски сложная процедура должна поддерживать функцию самотестирования — подавать на свой вход контрольный набор данных (жестко прописанный в файле) и сравнивать полученный результат с эталоном (также хранящимся в файле). Обычно таких наборов бывает несколько (один не обеспечивает полного покрытия всех ветвей процедуры). На стадии отладки польза самодиагностики очевидна, но вот зачем она в финальной версии?! А затем, что мы не можем доверять ни аппаратуре, ни системным библиотекам, ни самой оси, установленной у пользователя. Личный пример из жизни — машинные команды PUSH reg16 в 32-битном режиме у Intel и AMD реализованы неодинаково. Обе они забрасывают на вершину стека двойное слово (как и положено по спецификации), но одна очищает старшие разряды, а другая оставляет их без изменений (со всем мусором, что в них есть). В моей программе была досадная ошибка, при определенных обстоятельствах приводящая к потере нуля в конце ASCIIZ-строки. Но поскольку за ней следовало двойное слово, заброшенное на стек командой PUSH reg16, и я отлаживал программу на процессоре, очищающем старший разряд, то все работало более или менее нормально (два байта мусора, появляющихся в конце строки, никому не мешали). Но вот при запуске на другом процессоре, где завершающего нуля не оказывалось, возникала критическая ситуация, завершающаяся исключением. Или вот: незначительные различия в реализации «плавающих» команд на различных процессорах могут привести к странному поведению программы, которое будет невозможно воспроизвести на любом другом процессоре! Про разгон, дефекты памяти и т.д. я вообще молчу! Никогда нельзя быть уверенным в том, что после выполнения «a=6; a=a+3;» в переменной а окажется именно 9, а не 83737382. И виноват тут может быть не только процессор, но и «удар по памяти», когда совершенно посторонняя функция, обратившись по неинициализированному указателю, запишет что-то в чужую область данных. Естественно, самотестирование занимает некоторое время, и для достижения максимальной производительности я использую его в том случае, если предыдущий запуск программы завершился в аварийном режиме. Кроме того, на всякий непредвиденный случай присутствует недокументированный флаг, форсирующий самотестирование, даже если предыдущий запуск был завершен нормально. Функции самотестирования не раз выручали меня и помогли мне сэкономить колоссальное количество времени, поскольку ряд ошибок был связан с особенностями конкретного оборудования, то есть причина крылась вне исходного кода программы. Совет 3: секреты отладочной печатиОтладочная печать — великолепное изобретение, появившееся еще в те времена, когда интерактивных отладчиков не существовало и в помине, а отлаживать было надо. Большинство программистов использует тривиальную запись в текстовый log-файл или API-функцию OutputDebugString. Первый метод, естественно, лучше, поскольку он не требует наличия отладчика или специальной утилиты для перехвата отладочной печати, которую конечному пользователю нужно устанавливать на свой компьютер. Мы же ведь не собираемся исключать отладочную печать из финальной версии, верно? Естественно, не собираемся! Достаточно добавить специальный ключ командой строки, секретную комбинацию клавиш или вполне честную опцию в настройках программы. Лог лучше всего вести в текстовой форме. Так пользователю будет проще пересылать его нам по почте, и он сможет убедиться, что там нет ничего такого, чего бы он не хотел разглашать. Вот только… при возникновении критической ошибки система завершает работу приложения еще до того, как будут сброшены дисковые буферы. Даже использование функции fflush ничего не решает (а вот скорость программы замедляет весьма существенно). Как же быть?! Да очень просто: создать в shared-memory кольцевой буфер заданного размера и весь отладочный вывод направлять туда, читая его с помощью дочернего процесса. Тогда, при аварийном завершении материнского процесса, shared-memory не будет освобождена системой и дочерний процесс успеет принять последнее отладочное сообщение, отправленное упавшей программой. К тому же этот метод работает намного быстрее прямой записи на диск. А почему буфер должен быть именно кольцевым?! В общем, это не требование, а так, простое пожелание. Обычно нас интересует не весь отладочный вывод целиком, а события, непосредственно предшествующие падению. Но при интенсивном отладочном выводе полный размер лога может достигать десятков мегабайт, большая часть из которых не несет никакой полезной нагрузки, так что лучше заранее исключить ее, замкнув буфер в кольцо. Совет 4: автоматический трассировщик - это простоВ самых ответственных случаях программу, поставляемую заказчику, имеет смысл снабдить простейшим автоматическим трассировщиком, на создание которого уйдет не больше одного вечера. Просто взводим флаг трассировки (TF) и отлавливаем отладочные исключения штатными средствами операционной системы (через SEH), записывая: а) адрес машинной команды; б) содержимое регистров; в) адрес ячейки памяти, к которой она обращается. Когда трассировка из прикладного уровня дойдет до ядра, процессор самостоятельно опустит флаг трассировки на время прохождения нулевого кольца и потом поднимет его при возвращении на прикладной уровень, так что предусматривать специальную обработку для исключения системных вызов из списка трассируемых функций не надо. Естественно, трассировка на несколько порядков (!) замедляет скорость работы программы и потому должна включаться специальной комбинацией клавиш, которую пользователь нажимает в тот момент, когда программа приближается к месту сбоя на максимально близкое расстояние. А для этого пользователю придется воспроизвести ситуацию, при которой возникает ошибка. Если же ему это сделать не удастся, что ж! Включаем трассировщик при старте программы специальным ключом командой строки и пишет результат трассировки в кольцевой буфер, который внимательно изучаем. Располагая информацией о ходе выполнения программы, содержимом регистров и ячеек памяти, мы сможем поймать любую ошибку, какой бы заковыристой она ни была, ведь фактически мы отлаживаем программу на клиентской стороне в не интерактивном режиме. При желании (если жаба душит) трассировщик можно реализовать в виде отдельной динамической библиотеки, высылаемой клиенту только при возникновении серьезных проблем. То же самое, кстати, относится к функциям самодиагностики. Понятное дело, программа, защищенная протекторами, содержащими антиотладочные приемы, с автоматическим трассировщиком работать не будет. Так что придется отказаться или от протекторов, или от трассировки, либо же надо писать «умный» трассировщик, обходящий анти-отладочные приемы, но это уже серьезная задача, решение которой может затянуться не на одну неделю. |