29 июля 2011 г.

Ответ на задачку №11

Ответ на задачку №11.

По какой-то причине эта задачка получила множество откликов и споров, хотя она относительно проста.

Пожалуй, я начну подводить к решению разбором оставленных намёков:
  1. Для начала замечу, что задача заключалась в объяснении, почему показания отладчика не соответствуют реальному положению дел. Мне показалось, что не все это уловили.
  2. Несколько первых подсказок ("не менял значение ZF", нет антивируса/антиотладочного кода и т.п.) говорят нам о том, что ситуация не сфабрикована специально (я не рисовал снимок экрана в фотошопе! :) ), а естественно получилась в программе.
  3. Вторая большая подсказка ("это проявляется в пустом VCL приложении") решительно намекает на то, что код в вопросе тут не при чём. Если код не причём, внешнего кода нет (других потоков, антивируса и т.п.), то это в явном виде оставляет нам только саму сессию отладки.
  4. Позже добавленный намёк "Это поведение, которое вы используете постоянно по время отладки" ещё больше указывает на что-то, связанное с отладчиком. Причём - абсолютно типичное.
Мне кажется, что только этой информации уже может быть достаточно для ответа, но, тем не менее, я специально добавил несколько дополнительных намёков, чтобы сделать ситуацию ещё более очевидной:
  1. Снимок экрана указывает, что байт в вопросе находится в секции кода ($004C3B8F принадлежит .exe-файлу).
  2. А код в вопросе указывает на то, что производится проверка кода на соответствие шаблону машинной инструкции CALL.
В итоге я сказал, что если я дам хоть ещё одну подсказку, скажу хоть ещё одно слово, то этим я озвучу ответ.

Что же это за слово?

Подумайте, это ваш последний шанс. Что может делать с кодом программы отладчик, да ещё почти в каждой сессии отладки?































И слово это... breakpoint

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

Как типичный отладчик пользовательского режима работает с точками останова? Ну, он изменяет код в нужной точке, запоминая то, что там было раньше, а взамен записывая байт $CC, что на языке x86 означает int 3 - программная точка останова.

Когда процессор выполняет код и встречает инструкцию int 3 - он генерирует прерывание, которое ловится отладчиком. Отладчик видит, что это произошло прерывание "STATUS_BREAKPOINT", видит, что сейчас произошло выполнение инструкции вот по этому адресу, где он поставил точку останова, так что отладчик может восстановить код и запустить выполнение с этого же места.

Конечно же, когда отладчик так вмешивается в программу, он компенсирует это, показывая вам те значения, которые были бы в случае реального выполнения программы без отладчика. Т.е. хотя в памяти на самом деле записана программная точка останова ($CC), отладчик это учитывает и показывает вам исходные значения - вот почему все evaluate покажут вам то, что произошло бы при выполнении программы без отладчика, но не текущее положение дел.

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

Итак, это был очередной пример того, как программа может менять своё поведение под отладкой.

Осталось только пояснить вот это: "Об этом не знают многие новички, а те, кто знает - часто забывают (даже опытные программисты). Кажется, сложность решения этой задачи как раз и происходит от этого факта". Что же это такое, о чём часто забывают?

Многие забывают о том, что отладчик Delphi - инвазивный (invasive). Что означает, что отладчик активно вмешивается в программу, он не просто пассивно следит за ней. В дополнение к таким очевидностям как пошаговое выполнение, в голову приходят и более яркие примеры - скажем, изменение значение переменных. И хотя это на первый взгляд может показаться не очень волнующим (к примеру, изменение Integer-переменной) - но подумайте о случае, когда вы меняете значение строковой переменной. Ведь изменение значения строки будет означать не только изменение байтов содержимого строки в памяти, но и вызов функций менеджера памяти для освобождения старого значения и выделения памяти под новое значение - вызов функций в то время, пока программа стоит на паузе!

Что ж, теперь, когда ответ озвучен, у вас есть шансы высказать своё мнение, что ещё можно было сказать, чтобы не раскрыть ответ. Замечу, что с задачкой вполне справились некоторые люди - ответ был дан в комментариях к посту о задачке (поздравления khan.malign). Однако его (ответ) опознали не все, что говорит о том, что дело не столько в сложности задачки как таковой, сколько в том, что область знаний отвечающих не покрывает те, которые нужны для решения задачи. В частности, как мне кажется, не все разбираются в принципах и типичных подходах в работе прикладных отладчиков в Windows. Эй, только не надо винить в этом меня!

Кстати, было высказано множество версий, но все они, так или иначе, не принимали в расчёт какой-то факт из указанной картины, а некоторые были и вовсе ошибочными. Я быстренько пробегусь по ним и кратко укажу на несостыковки (поскольку я не обладаю глубокими знаниями ассемблера, я допускаю, что я мог что-то неверно понять или наврать):
  • "Сравнение с регистром идет словом, а не байтом" - на снимке явно указан префикс байта.
  • "Либо ошибка выравнивания проца" - на x86 ошибки выравнивания исправляются автоматически. На других архитектурах - возбуждается исключение. Оба случая не соответствуют картине.
  • "Либо некогерентность кеша процессора и работы оборудования в режиме DMA mastering" - не уверен, насколько это реально, но чтобы сильно не думать (я ленив), я просто дополнительно добавил примечание, что ситуация стабильна на длительном участке.
  • "Либо кривая компиляция именно этого блока" - в CPU отладчике мы видим уже скомпилированный код.
  • "Либо включён режим процессора прерываний по не выравненным переменным и эта переменная в стеке неправильно размещена и стек изменился" - кажется, явно упущен момент, что адрес принадлежит секции кода.
  • "$B2 = 13 = перенос строки" - здесь вижу отсутствие понимания, что для машинного уровня нет понятия "тип" и "имя", а лишь "адрес", "размер", да атрибуты страницы памяти.
  • "Команда CMP сравнивает два значения, и в случае их равенства ставит ZF=0" - явная ошибка. Наоборот.
  • "Оптимизация условия AND" - во-первых, идёт выполнение первого куска (первого условия). Во-вторых, это никак не объясняет видимую разницу в поведении кода и показаниях отладчика.
  • "Кажется понял, отладчик имеет права доступа по чтению на сегмент кода, а реальная программа может и не иметь, особенно если включены средства защиты от вирусов или переполнения буферов и т.д." - сегмент кода по-умолчанию имеет атрибуты чтение+выполнение, но даже если что-то сбросит "чтение", оставив только "выполнение", то попытка выполнить CMP возбудит Access Violation, а не даст неверный результат, как на снимке экрана.

P.S. Замечание по исходному проекту, откуда это взялось. Собственно, я производил отладку совершенно другого вопроса, но неожиданно при запуске программы под отладчиком стал заходить в другую ветку кода. После пошагового выполнения программы до точки ветвления и наблюдения ровно той же картины, что была показана в вопросе, я и обнаружил причину, которая заключалась в том, что мне для отладки нужно было установить точку останова ровно на строчку кода, содержащую вызов функции (инструкцию CALL), которая и проверялась кодом в вопросе.

Установка точки останова была необходима потому, что проверочный код вызывается зиллионы раз, а меня интересовал только вызов по конкретной ветке - поэтому было установлено две точки: первая - в интересующем меня месте, но неактивная, а вторая - в точке инструкции вызова, как идентифицирующая ветку кода. Точка была non-stop, а просто активировавшая вторую (неактивную) точку. Таким образом, они должны были работать парой, останавливая сессию отладки именно в нужный момент, на нужной ветке.

Соответственно, не поставив точку останова, я не получал неверного поведения, но я и не мог остановиться в нужном месте. А остановка в нужном месте означала установку точки останова. Таким образом, со стороны казалось, что это вообще баг отладчика: если выполнять программу без него или под ним, но простым прогоном без остановки - всё OK, но если выполнять по шагам конкретный участок (что требовало установки точки останова) - то программа начинает вести себя неверно.

Проблему с $CC я обошёл просто вставив NOP перед CALL и установив бряк на NOP. Мне было важно идентифицировать лишь ветку кода точкой останова - не имеет значения, на какой инструкции ветки кода она будет установлена. Как я уже заметил выше, здесь нет никакой проблемы при реальном выполнении кода (вне отладчика) - мы просто получаем странное поведение во время отладочной сессии. Разумеется, после решения исходной проблемы (не имеющей отношения к вопросу задачки), NOP был удалён.

5 комментариев :

  1. Ещё раз замечу, что секция "Задачки" не является каким-либо конкурсом, соревнованием или попытками что-то кому-то доказать. Я просто делюсь увиденным и интересным. Здесь нет никаких призов, выигравших или проигравших - комментарии с (попытками) ответов к посту вы пишете добровольно.

    Возможно, мне стоит запрещать комментарии к постам-задачкам, чтобы это было более очевидно?

    ОтветитьУдалить
  2. Анонимный30 июля 2011 г., 1:36

    > Я быстренько пробегусь по ним и кратко укажу на несостыковки (поскольку я не обладаю глубокими знаниями ассемблера, я допускаю, что я мог что-то неверно понять или наврать):
    ...

    Всё понял верно и не наврал. В целом. Я Qraizer, если чё :)

    > Возможно, мне стоит запрещать комментарии к постам-задачкам, чтобы это было более очевидно?
    Не надо. Читать забавно. А троллей везде хватает, относись к этому философски.

    ОтветитьУдалить
  3. После подсказки "Это поведение, которое вы используете постоянно по время отладки", ответ был уже очевиден.
    А вообще задачки у вас интересные, буду ждать следующую.

    ОтветитьУдалить

Можно использовать некоторые HTML-теги, например:

<b>Жирный</b>
<i>Курсив</i>
<a href="http://www.example.com/">Ссылка</a>

Вам необязательно регистрироваться для комментирования - для этого просто выберите из списка "Анонимный" (для анонимного комментария) или "Имя/URL" (для указания вашего имени и (опционально) ссылки на сайт). Все прочие варианты потребуют от вас входа в вашу учётку (поддерживается OpenID).

Пожалуйста, по возможности используйте "Имя/URL" вместо "Анонимный". URL можно просто не указывать.

Ваше сообщение может быть помечено как спам спам-фильтром - не волнуйтесь, оно появится после проверки администратором.