вторник, 9 декабря 2008 г.

Неинициализированный мусор на ia64 может быть смертелен

Это перевод Uninitialized garbage on ia64 can be deadly. Автор: Реймонд Чен.

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

Функция CreateThread принимает параметр типа LPTHREAD_START_ROUTINE, который имеет сигнатуру:
function ThreadProc(lpParameter: Pointer): DWord; stdcall;
Похоже часто люди делают такую вещь: они берут процедуру (не функцию) и приводят её к функции типа LPTHREAD_START_ROUTINE. По их теории это означает: "я никак не использую возвращаемое значение, поэтому я могу использовать здесь процедуру. Вызывающий получит мусор, но это нормально; мусор меня вполне устроит". Вот код с одной web-странички, который содержит в точности эту ошибку:
procedure MyCritSectProc(P: Integer);  
begin
  // ...
end;

hMyThread := CreateThread(nil, 0, @MyCritSectProc, nil, 0, MyThreadID);
Это далеко не единственная web страница с бажным примером. Вот исходники с Old Dominion University, которые сделали ту же ошибку, а вот код с Long Island University. Это как стрельба рыбы в бочке: просто погуглите с "CreateThread LPTHREAD_START_ROUTINE" и вы увидите как много людей используют CreateThread неправильно. Даже пример в MSDN делает это неверно. А вот и whitepaper, в которой неверно объявлены как возвращаемое значение, так и входной параметр, причём так, что этот пример будет вылетать на Win64.

Пока это кажется забавным - но только пока вас самого не ударит по лбу.

На ia64 каждый 64-х разрядный регистр на самом деле является 65 битовым. Дополнительный бит называется "NaT", что расшифровывается как "Not a Thing" ("не имеет смысла"). Этот бит устанавливается, когда регистр не содержит корректного значения. Вы можете считать это целочисленным аналогом NaN в числах с плавающей запятой ("Not a Number" - "не число").

Бит NaT обычно устанавливается при следующем типе выполнения. На ia64 есть специальная форма инструкции загрузки, которая пытается загрузить значение из памяти, но если загрузка будет неудачной (например, потому что память была выгружена или адрес оказался неверным), то вместо возбуждения исключения устанавливается бит NaT и выполнение продолжается.

Все математические операции с NaT значением просто получают в результате снова NaT.

Загрузка называется "предполагаемой" потому что она предназначается для предполагаемого выполнения. Например, расмотрим такую воображаемую функцию:

procedure TSomeClass.Sample(P: PInteger);
begin
  if FReady then
    DoSomething(P^);
end;
Сгенерированный код для такого метода на ia64 мог бы выглядеть так:
TSomeClass.Sample
      alloc r35=ar.pfs, 2, 2, 1 // 2 входных, 2 локальных, 1 выходной
      mov r34, rp               // сохранить адрес возврата
      ld4 r32=[r32]             // загрузить FReady
      ld4.s r36=[r33];;         // предполагаемая загрузка P^
      cmp.eq p14, p15=r0, r32   // FReady = 0?
(p15) chk.s r36=[r33]           // если нет, проверить r36
(p15) br.call rp=DoSomething    //         вызов
      mov rp=r34;;              // восстановить адрес возврата
      mov.i ar.pfs=r35          // очистить регистры
      br.ret rp;;               // выход
Я подозреваю, что большинство из вас никогда ранее не видели ассемблера ia64. Поскольку это не статья о ассемблере ia64, то я пропущу детали, не имеющие отношения к идее сегодняшнего поста.

После установки регистрового фрейма и сохранения адреса возврата мы загружаем значение FReady, а также делаем предполагаемую загрузку P^ в регистр r36. Заметьте, что мы начинаем выполнять истинную ветку оператора if прежде, чем мы проверим истинно ли само утверждение! Вот поэтому это и называется предполагаемой загрузкой.

(Почему так делают? Потому что доступ к памяти является медленным. Лучше всего выполнять обращения к памяти как можно дальше от использования этих данных, потому что тогда в нужный момент вам не придётся ждать окончания обращения к памяти).

Затем мы проверяем значение FReady, и если оно не равно нулю, то мы выполняем две строки, помеченные (p15). Первая инструкция "chk.s" говорит: "если регистр r36 содержит NaT, тогда выполнить обычную (непредполагаемую) загрузку из [r33]; иначе - ничего не делать".

Итак, если предполагаемая загрузка P^ была неудачной, то команда chk.s попробует загрузить его ещё раз, вызывая ошибку страницы, что позволит менеджеру памяти подгрузить в память выгруженную на диск страницу (или возбудить исключение типа STATUS_ACCESS_VIOLATION).

Когда значение регистра r36 наконец-то окончательно устанавливается, мы вызываем DoSomething (поскольку мы имеем два входных регистра [r32, r33] и два локальных регистра [r34, r35], выходным регистром будет регистр r36).

После возвращения управления мы производим очистку и возвращаем управление нашему собственному вызывающему.

Заметим, что если FReady окажется ложным и первая загрузка P^ была неудачной по какой-либо причине, то тогда регистр r36 будет оставлен в состоянии NaT.

И вот здесь и кроется опасность.

Видите ли, если вы имеете регистр со значением NaT и хотя бы подышите на него в неверном направлении (например, попробуете сохранить его значение в память), то процессор немедленно возбудит исключение с кодом STATUS_REG_NAT_CONSUMPTION.

(Существуют и инструкции, которые не возбуждают исключения при оперировании с NaT значениями. Например, все арифметические операции поддерживают NaT - они просто возвращают ещё один NaT как результат таких операций. И есть даже специальная инструкция "сохранить в память, даже если это NaT", которая очень полезна в функциях с переменным числом аргументов).

Окей, наверное вы уже видите куда я клоню (да уж, это заняло немало времени).

Представьте себе, что вы один из тех людей, которые берут процедуру (не функцию) и приводят её тип к LPTHREAD_START_ROUTINE. Также предположим, что эта функция потока возвращает регистр r8 как NaT, потому что в функции потока была неудачная предполагаемая загрузка, которая не стала использоваться. Теперь вы возвращаете управление обратно в менеджер потоков kernel32 с NaT в результате. Kernel32 пытается сохранить это значение как код выхода потока и получает исключение STATUS_REG_NAT_CONSUMPTION.

Ваша программа умирает где-то в глубинах вызова функции ядра, и вы понятия не имеете почему. Удачи вам отладить это!

Есть и аналогичная проблема с передачей слишком малого числа параметров в функцию. Если вы передадите слишком мало параметров в функцию, то может оказаться, что недостающие параметры будут содержать NaT. И даже если функция не будет трогать эти параметры, пока не увидит, что другие параметры образуют условие, которое говорит о безопасности использования "недостающих" параметров, то всё равно сам компилятор может решить, что было бы неплохо скинуть эти параметры в стек (чтобы освободить регистры), что и вызовет очередное STATUS_REG_NAT_CONSUMPTION.

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

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

Комментариев нет:

Отправить комментарий

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

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

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

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

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