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

Что может пойти не так, если я напутаю с моделями вызова?

Это перевод What can go wrong when you mismatch the calling convention? Автор: Реймонд Чен.

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

И тогда Windows обречена вечно поддерживать ваш бажный код.

Оконная процедура
Оконные процедуры неправильно составляет так много людей (обычно объявляя их как cdecl вместо stdcall (*)), что системная функция, которая передаёт сообщения оконным процедурам, содержит дополнительную защиту, чтобы опознавать неверно написанные оконные процедуры и выполнять соответствующие исправления. Именно это является источником загадочного значения $dcbaabcd в стеке. Функция, которая передаёт сообщения оконной процедуре проверяет, находится ли это значение в правильном месте стека. Если нет, то она проверяет что произошло: либо оконная процедура вытащила из стека одно лишнее двойное слово (если так, то она исправляет стек; я не имею ни малейшего представления, как вообще могут существовать такие оконные процедуры) или же оконная процедура была ошибочно объявлена как cdecl вместо stdcall (а если так, то функция вычищает параметры со стека - работа, которую должна была сделать сама оконная процедура).

Функции обратного вызова DirectX
Многие функции DirectX используют callback-и и люди снова умудряются объявлять свои callback-и как cdecl вместо stdcall, так что перечислители (enumerators) DirectX вынуждены производить аналогичные проверки для таких плохих функций.

IShellFolder::CreateViewObject
Я помню одну программу, которая неверно объявляла свою функцию CreateViewWindow и при этом как-то сумела уломать компилятор на пропуск такого кода!
type
  TBuggyFolder = class(..., IShellFolder)
    ...
    // неверная сигнартура функции!
    function CreateViewObject(hwnd: HWND): HRESULT; stdcall; // (**)
  end;

function TBuggyFolder.CreateViewObject(hwnd: HWND): HRESULT;
begin
  Result := S_OK;
end;
Проблема здесь не только в сигнатуре: они возвращали S_OK, хотя они совершенно ничего не сделали! Мне пришлось добавить дополнительный код для очистки стека после такой функции и проверить, что возвращаемое значение не врёт.

Точки входа для Rundll32.exe
Сигнатура функции для функций, вызываемых rundll32.exe, документирована в статье Knowledge Base. Но это не останавливает людей от попыток использования rundll32 для вызова каких-угодно произвольных функций, совершенно не предназначенных для вызова через rundll32, как, например, LockWorkStation или ExitWindowsEx из user32.

Давайте посмотрим, что происходит, когда вы пытаетесь использовать rundll32.exe для такой функции как ExitWindowsEx:

Программа rundll32.exe разбирает переданную ей командную строчку и вызывает функцию ExitWindowsEx в предположении, что она имеет такой прототип:
procedure ExitWindowsEx(hwnd: HWND; hinst: HINSTANCE; pszCmdLine: PChar; nCmdShow: Cardinal); stdcall;
Но это не так. На самом деле ExitWindowsEx имеет такую сигнатуру:
function ExitWindowsEx(uFlags: UINT; dwReserved: DWORD): BOOL; stdcall;
Что же произойдёт? Ну, при входе в ExitWindowsEx стек будет выглядеть вот так:
.. другие данные ..
nCmdShow
pszCmdLine
hinst
hwnd
адрес возврата       <- ESP
Однако сама функция ожидает увидеть:
.. другие данные ..
dwReserved
uFlags
адрес возврата       <- ESP
Что при этом произойдёт? Параметр hwnd, передаваемый rundll32.exe интерпретируется как uFlags, а значение hinst становится dwReserved. Поскольку оконные дескрипторы случайны, то может оказаться, что вы передаёте случайное число в ExitWindowsEx. Может быть сегодня это будет EWX_LOGOFF, завтра - EWX_FORCE, а послезавтра это станет EWX_POWEROFF.

Теперь предположим, что функция сумела отработать и возвращает управление (например, выход неудачен). Функция ExitWindowsEx очищает два параметра со стека - ведь ей неизвестно, что на самом деле их было четыре. Получается такой стек:
.. другие данные ..
nCmdShow             (мусор)
pszCmdLine           <- ESP (мусор)
Теперь стек оказывается повреждён и начинают происходить всякие забавные вещи. Например, предположим, что в ".. другие данные .." записан адрес возврата. Ну, код собирается выполнить инструкцию возврата ("return" instruction) для выхода из процедуры по этому адресу возврата, но с нашим повреждённым стеком, инструкция возврата переведёт управление по адресу pszCmdLine - т.е. на командную строчку и попытается выполнить её так, как если бы это был код.

Другие свои функции
Анонимный комментатор экспортировал функцию как cdecl, но обращался с ней как со stdcall. На первый взгляд код кажется рабочим, но при выходе из функции происходит повреждение стека (потому что вызывающий ожидает, что stdcall-функция очистит стек, но вызываемая cdecl-функция этого не делает, т.к. ожидает, что это сделает вызывающий) - в результате получаются Плохие Вещи.

Окей, на сегодня примеров хватит; я думаю вы уже поняли. Я уверен, что кто-то из вас уже спрашивал:

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

Вот первый попавшийся мне пример (***):
LRESULT CALLBACK DlgProc(HWND hWnd, UINT Msg, WPARAM wParam, LPARAM lParam);
Это неверная сигнатура функции для диалоговой процедуры. Верная сигнатура:
INT_PTR CALLBACK DialogProc(HWND hwndDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
Вы пишете:
DialogBox(hInst, MAKEINTRESOURCE(IDD_CONTROLS_DLG), hWnd, DlgProc);
но компилятор совершенно справедливо грозит вам сообщением об ошибке:
error C2664: 'DialogBoxParamA' : cannot convert parameter 4 from 'LRESULT (HWND,UINT,WPARAM,LPARAM)' to 'DLGPROC'
поэтому вы "исправляете" это добавлением явного преобразования типов, чтобы заткнуть компилятор (****):
DialogBox(hInst, MAKEINTRESOURCE(IDD_CONTROLS_DLG), hWnd, reinterpret_cast<DLGPROC>(DlgProc));

"Да ну, перестань. Кто же будет таким тупым, чтобы вставлять преобразования для скрытия ошибки вместо её исправления?".

По-видимому, все.

Я наткнулся на эту страницу, которая делает в точности это же, и на эту на немецком, которая не только неверно возвращает результат, но также неверно определяет третий и четвёртый параметры, а вот одна на японском. И это также легко исправить (неверно), как 1-2-3 (прим. переводчика: к сожалению, эта ссылка мертва и я не знаю, что имел ввиду Реймонд).

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

Я отвечу на этот вопрос завтра.

Примечания переводчика:
(*) cdecl - потому что это соглашение по-умолчанию во многих сишных компиляторах. В Delphi это был бы register, но поскольку эта модель существенно отличается от stdcall, то вероятность, что это вообще будет работать, здесь ниже.
(**) В оригинальном примере здесь стояло "HRESULT CreateViewObject(HWND hwnd)" - т.е. по-видимому имелась ввиду модель вызова по-умолчанию, т.е. cdecl. Из-за предыдущего замечания пример на Delphi будет более правдоподобен с stdcall, чем вообще без спецификатора (т.е. с моделью register) или с явным cdecl. Корректная запись метода (модуль ShlObj.pas): "function CreateViewObject(hwndOwner: HWND; const riid: TIID; out ppvOut): HResult; stdcall;".
(***) Насколько я понимаю в сях, бинарно это одно и то же (LRESULT и INT_PTR). Видимо, речь идёт о том, что сишный компилятор считает это разными типами, и люди привыкли затыкать его не исправлением прототипа, а приведением типа - что потенциально ведёт к серьёзным ошибкам, если сигнатуры в действительности не совпадают. Я специально оставил здесь оригинальный код, потому что это специфика языка. В Delphi здесь ошибки не будет - лишь бы бинарно сигнатуры совпадали, тогда компилятор не будет ругаться, несмотря на то, что сигнатуры записаны по-разному.
(****) В случае с Delphi вместо преобразования типа люди часто используют оператор взятия адреса - @. Для передачи функции в другую функцию нужно просто написать её имя в параметрах. При этом, если будет несовпадение типов, то компилятор будет ругаться. Но часто к этому добавляют @. Взятие @ от функции даёт нетипизированный указатель на код функции. Разумеется, такой указатель будет совместим с чем хочешь.

3 комментария:

  1. Вот здесь ещё неплохое описание моделей вызова с точки зрения ассемблера:
    http://www.nynaeve.net/?p=66

    ОтветитьУдалить
  2. > Насколько я понимаю в сях, бинарно это одно и то же (LRESULT и INT_PTR).
    > Видимо, речь идёт о том, что сишный компилятор считает это разными типами (...)

    Посмотрел сейчас в C++ Builder 6:

    typedef _W64 long LONG_PTR, *PLONG_PTR;
    typedef LONG_PTR LRESULT;

    typedef _W64 int INT_PTR, *PINT_PTR;


    Стало быть, в сях LRESULT это лонгинт, а INT_PTR это интеджер. Вот и ругается.
    А в Delphi (у меня D7):

    LRESULT = Longint;

    а INT_PTR я вообще не нашел. Упоминается, вроде, в фреймворке каком-то. Еще тут упоминается:
    https://helloacm.com/integer-data-types-in-delphi/
    объявлен как

    INT_PTR = System.IntPtr;

    но у меня его тоже нет...

    ОтветитьУдалить
  3. Longint и Integer - и то и другое - знаковые 4-байтовые числа. Т.е. это один и тот же тип, но с разными именами. Delphi не делает между ними разницы (если только не указан дополнительный квалификатор type, а он не указан), позволяя приводить одно к другому (даже через псевдонимы), а C++ - делает, там это разные типы.

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

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

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

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

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

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

Примечание. Отправлять комментарии могут только участники этого блога.