Войти
ПрограммированиеСтатьиОбщее

Введение в программирование под Windows.

Автор:

Как ни хотелось, а избежать этой темы не удалось.

Немного терминологии. Называть эту статью введением в Win32 было бы неверно — консольное приложение под Windows такое же полноправное Win32 приложение. Что такое консольное приложение? Ах да - да ты ведь регулярно сталкиваешься с такими программами - например мой любимый файл-менеджер FAR является полноценным консольным Win32 приложением. Точка входа для консольных приложений та же, что и в дос программах на Си - это функция main(). Однозначно разделять оконные приложения и консольные тоже, как мне кажется, было бы не правильно - программа, использующая как точку входа в программу WinMain(), как это ни странно, может и не создавать окно, а даже организовать свою консоль (!) вызовом AllocConsole(), а консольное приложение без проблем может создать окно. Возникает лишь один резонный вопрос: зачем это нужно? ;) Консольные приложения хороши своей самодостаточностью — они идеально подходят для утилит командной строки. О достоинствах оконных приложений я говорить не буду - чуть ниже мы перейдем напрямую к оконным приложениям под Windows.

Архитектурно, эти обе категории различаются стабом (stub) который прилинковывается к твоему откомпилированному коду - в данном контексте под стабом подразумевается некий программный код, выполняемый до (и по выходу) передачи управления в main() или WinMain() (в зависимости от типа приложения). Стабы различны для каждых фирм и часто для разных версий одного и того же продукта - что вполне логично. Например, стаб Борланда не совпадает со стабом от MS, хотя обычно они выполняют одни и те же цели - инициализацию каких-либо внутренних обработчиков и т.п. Исходные тексты стаба (кстати говоря, обычно открыты и исходные тексты рантайм функций - например, вполне реально найти исходный текст знакомых тебе fopen() или printf()) открыты. Например, при инсталяции Visual C++ ты можешь установить и исходные тексты стаба и рантайма (по умолчанию в \CRT\SRC).

Ну что - перейдем к созданию оконных приложений? Как я уже и сказал точка входа в оконные приложения WinMain(). Да - чуть не забыл - пример, и исходные тексты лежат ЗДЕСЬ. Приложение максимально упрощено - не жди от него чего-либо необычного. Наша цель понять общие идеи.

Итак, WinMain(). Давай разберемся с его определением. Ну, то что функция возвращает int, это понятно (кстати она возвращает его всегда - переопределить возвращаемое значение скажем на void не выйдет - компилятор выдаст ошибку) - но вот что такое WINAPI? Опытный читатель уже успел (если сталкивался с подобным) перерыть заголовочные *.h файлы Windows ("хедеры") в поисках макроопределения WINAPI. Определение находится в windef.h. Реально то, что подставляется вместо WINAPI, зависит от используемой платформы (ОС) и версии компилятора. Так что же это такое? Это СОГЛАШЕНИЯ о вызове функции. Что это такое? Дело в том, что любая программа высокого уровня (конечно же речь идет о компиляторах, а не интерпретаторах) транслируется при компиляции в маш-коды понятные процессору. Сейчас тебе понадобится минимальные знания процессоров Intel архитектуры IA-32 (достаточно даже более ранней). В ассемблере (язык в котором одна команда сопоставима одной комманде машинных кодов - в общем-то, те же машинные коды, но в понятной для человека форме) нет вызова функции с параметрами, как и возврата результата - зато есть стек, регистры, безусловный переход JMP и вызов подпрограммы инструкцией CALL. Какой код сгенерирует компилятор - зависит от него самого - я не буду затрагивать эту обширную тему - скажу лишь, что существует некоторое количество соглашений, благодаря которым компилятор генерирует код для вызова функций - вот некоторые из них (Кстати говоря - программа, написанная на ассемблере вручную не обязательно придерживается каких-то соглашений. А зачем? Программист легко может использовать необходимые ему регистры - потому, что он пишет свои функции сам. Другое дело, когда он использует какие-то стандартные библиотеки - теперь он ограничен спецификацией используемого им API):

  • __stdcall - параметры передаются через стек - справа налево (в обратном порядке - первый параметр ложится в стек последним). Вызываемая процедура "возвращает" указатель стека ESP "на место". Большинство Win32 функций используют эти соглашения.
  • __cdecl - параметры передаются через стек, справа налево. Вызывающая функция (та, из которой был инициирован вызов) "правит" стек. Классический вызов в Си.
  • __pascal - параметры передаются через стек, слева направо. Стек "правит" вызываемая процедура. Типичные соглашения о вызовах для "Паскаля".
  • thiscall - параметры передаются через стек справа налево, плюс к этому в регистре ECX передается указатель на C++ this. Вызывающая функция "правит" стек. Вызов C++.
  • __fastcall - первый и второй параметры передаются в паре регистров ECX и EDX, остальные - через стек справа налево, вызываемая функция "правит" стек. Соглашения Борланда (который уже давно Inprise ;) для всяческих Inprise Delphi и C++ Builder'ов.
  • Для полного понимания приведу пример того, как бы скорее всего ассемблировался вызов следующей функции используя соглашения __cdecl:

    ParseString("Это просто строка !",0x12);

    А теперь ассемблерные текст:

    push    0x00000012     ; Первый DWORD в стек второй параметер функции
    push    0x00040600     ; Адрес придуманный мной - указатель на строку
    call    Какой-то-адрес ; Наша функция - адрес ParseString
    add     esp,08         ; 2 DWORD параметра = на 8 байта нужно сдвинуть
                           ; указатель стека ESP (соглашения cdecl -
                           ; вызывающая процедура правит стек).

    Это что касается соглашений - WINAPI - определяется одним из этих типов (не из всех, конечно же, - из пары-тройки) в зависимости, как я уже говорил, от платформы или используемого компилятора. Параметры у WinMain():

    int WINAPI WinMain( HINSTANCE hInstance, HINSTANCE hPrevInstance,
                        LPSTR lpCmdLine, int nCmdShow);

  • hInstance - это идентификатор экземпляра приложения. Не пугайся типа HINSTANCE. Это наверняка какой-нибудь unsigned int. Каждому приложению присваивается уникальный номер, который стаб читает вызовом GetModuleHandle(), а потом передает его тебе. Если ты в приложении прочитаешь этой функцией идентификатор приложения - ты увидишь, что он совпадает с тем, что тебе передали в WinMain(). Этот идентификатор пригодится нам для очень многих целей.
  • hPrevInstance - это идентификатор предыдущего экземпляра приложения. Под Win32 он всегда равен NULL. Тут уж тебе решать - работать дальше или нет - в своем примере фактически я отказываюсь работать, если второй параметр отличен от NULL... Кстати - дизассемблируй Quake1,Quake2 или Quake3 - увидишь то же самое.
  • lpCmdLine - просто указатель на командную строку - без разделения на параметры - одна монолитная строка.
  • nCmdShow - это параметр описывает флажки для окна - то, как оно будет показано на экране при создании. Обычно этот параметр передают в вызов ShowWindow(). В своем приложении я его не использую, а просто передаю в ShowWindow() флажок SW_SHOW.
  • Ты не поверишь, осталось самое простое - зарегистрировать класс приложения, создать окно и запустить цикл обработки сообщений. Ты уже знаешь что Windows "событие-ориентированная" ОС (в оригинале это звучит более лаконично - Event-driven). Это означает, что каждому приложению (а точнее его окну) посылаются некоторые коды-сообщения на которые приложение может реагировать. Ну, например, приложению посылается сообщение WM_CREATE после создания окна, но до его отображения на экране. Или WM_KEYDOWN когда нажата клавиша(ы) и окно имеет фокус. Однако это не значит, что все сообщения ты должен обрабатывать - нисколько. Теперь давай вернемся к регистрации класса приложения:

      // более подробную информацию о полях структуры ты сможешь найти в документации
    
      // структура описывающая класс
      WNDCLASSEX wc;
    
      // размер самой структуры - для совместимости в случае будущих изменений
      wc.cbSize    = sizeof(wc);
    
      // стили класса - в 90% случаев ты будешь пользоваться этими двумя флажками
      wc.style    = CS_HREDRAW | CS_VREDRAW;
    
      // "Оконная процедура" - функция, которая будет получать "сообщения" от Windows
      wc.lpfnWndProc  = WndProc;
    
      // Расширенные параметры - я их не рассматриваю
      wc.cbClsExtra  = 0;
      wc.cbWndExtra  = 0;
    
      // Сюда мы положим идентификатор нашего приложения
      // На заметку - в Win16 - если второй параметр
      // WinMain() был не равен 0 - класс не нужно было регистрировать.
      // Именно поэтому регистрация класса во многих примерах
      // выделена в отдельную функцию.
    
      wc.hInstance  = hInst;
    
      // Параллельно загружаю стандартную иконку (идентификатор ресурса IDI_APPLICATION).
      wc.hIcon    = LoadIcon(NULL,IDI_APPLICATION);
    
      // Стандартный курсор (идентификатор ресурса IDC_ARROW).
      wc.hCursor    = LoadCursor(NULL,IDC_ARROW);
    
      // Фон. Запрашиваю цвет стандартного белого браша. Мало ли
      // какая у нас цветовая схема.
      wc.hbrBackground= (HBRUSH)GetStockObject(WHITE_BRUSH);
    
      // Меню - у меня его нет. Да не больно то и хотелось.
      wc.lpszMenuName  = NULL;
    
      // Строка - имя класса - поле для фантазии разработчика. Визуально нигде не
      // отображается.
      wc.lpszClassName= TITLE_STRING;
    
      // Этот параметр - маленькая иконка которая будет отображаться
      // для приложения в task bar'e. Если он не указан - Windows
      // уменьшит hIcon до требуемых размеров. Это единственный параметр,
      // в котором различаются два аналогичных по смыслу вызова
      // RegisterClass и RegisterClassEx. Соответственно в первом случае
      // используется структура WNDCLASS а во втором WNDCLASSEX - ну это на любителя.
      wc.hIconSm    = NULL;
    
      // пытаюсь зарегистрировать класс.
      if (!RegisterClassEx(&wc))
      {
        return FALSE;
      }

    Этот кусочек кода отвечает за регистрацию класса окна - да, именно - скорее это даже класс окна.

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

     
    // создаю окно
    // для создания окна также можно использовать аналогичный по смыслу
    // вызов CreateWindowEx - у него немного больше возможностей.
    
    // первый параметр - это имя класса зарегистрированного RegisterClass или
    // RegisterClassEx. 
    // второй - имя окна в заголовке - пользуюсь одной и той же строкой в обоих
    // случаях.
    // третий параметр - стиль окна - флагов много - я не буду останавливаться
    // на них - дело за тобой.
    // следующие 4 параметра это координаты X, Y; длина по X и длина по Y окна.
    // Параметр CW_USEDEFAULT нужен в случае если я хочу получить эти координаты от
    // Windows. Этот флаг достаточно указать один раз для каждой пары
    // параметров - именно поэтому два последних в каждой паре - нули.
    
    // восьмой параметр - идентификатор окна-родителя. В моем случае окно одно -
    // сирота - параметр NULL.
    
    // девятый - идентификатор окна ребенка или меню в зависимости от стилей. Хм, ни меню,
    // ни потомков у нас нет...
    
    // угу - идентификатор экземпляра приложения ;)
    
    // дополнительный параметр при создании окна - обычно используется
    // как указатель на дополнительную структуру. Нас сейчас не интересует.
    // Потому и NULL.
    
    g_hWnd = CreateWindow(
      TITLE_STRING,
      TITLE_STRING,
      WS_OVERLAPPEDWINDOW,
      CW_USEDEFAULT,0,
      CW_USEDEFAULT,0,
      NULL,NULL,hInst,NULL);
    
    // получили ли мы идентификатор ?
    
    if (g_hWnd == NULL)
    {
      return FALSE;
    }
    
    // показать рамку и заголовок окна 
    // (если они есть - я их запросил - указав соответствующие стили)
    
    ShowWindow(g_hWnd,SW_SHOW);
    
    // послать окну сообщение WM_PAINT - фактически просьба перерисовать
    // содержимое клиентской части окна - той, что доступна пользователю.
    // Если поставить до этого задержку, можно некоторое время лицезреть
    // "дырявое" окно ;)
    
    UpdateWindow(g_hWnd);

    На заметку - именно вызовом CreateWindow(Ex) в Windows создаются ВСЕ стандартные элементы управления - начиная от кнопок, заканчивая ListBox'ами. Для них существуют заранее определенные имена классов, некоторые дополнительные сообщения для их оконных процедур, стили и т.п. Каково? Нам осталось запустить цикл обработки сообщений:

     
    while(GetMessage(&msg,NULL,0,0))
    {
      TranslateMessage(&msg);
      DispatchMessage(&msg);
    }

    Вызов GetMessage() вернет 0 (то есть "ложь") только когда в получит сообщение WM_QUIT - запрос на завершение работы. Первый параметр - это указатель на структуру, которая будет заполнена данными о сообщении - его номером и некоторой другой полезной информацией.

    Второй параметр идентификатор окна (HWND) - передав NULL я прошу обрабатывать сообщения для ВСЕХ окон приложения - их ведь могло бы быть много. Последние два нуля - это фильтр обрабатываемых сообщений - минимальное и максимальное значения сообщения. Я не использую фильтр - поэтому и передаю нули. GetMessage() - блокированная функция - это значит, что пока сообщение, удовлетворяющее фильтру или WM_QUIT не будет получено - выполнение не будет продолжено. Для игровых и некоторых других приложений это неприемлемо - в этом случае лучше использовать другую структуру цикла обработки сообщений - например, такого, который я использовал в примерах по Direct3D. После того как сообщение будет принято, оно будет оттранслированно (TranslateMessage) и преданно в оконную процедуру (DispatchMessage). Где же она, виновница торжества?

    // надеюсь ты уже понял что такое WINAPI - и для тебя не составит
    // труда найти определение CALLBACK ;)
    
    // параметры, передаваемые в оконную процедуру:
    // hwnd - идентификатор окна, которому направленно сообщение
    // uMsg - собственно сообщение
    // wParam и lParam - дополнительные данные - в зависимости от сообщения
    // в них хранится другая полезная информация, зависящая от сообщения.
    
    LRESULT CALLBACK WndProc(HWND hWnd,UINT uMsg,WPARAM wParam,LPARAM lParam)
    {
      // А не обработать ли сообщения?
      switch(uMsg)
      {
    
      // А тут мы обрабатываем нажатие клавиш
    
        case WM_KEYDOWN:
        
          switch(wParam)
          {
            // wParam сигнализирует что нажата клавиша Escape ?
            case VK_ESCAPE:
              // Отлично - "сезам" закройся. Если пользователь нажал Escape,
              // я просто ставлю в очередь сообщений WM_CLOSE - сообщение
              // которое инициирует завершение. Два последних нолика -
              // это wParam и lParam для моего сообщения.
              SendMessage(g_hWnd,WM_CLOSE,0,0);
              // почти все сообщения, обработанные в оконной процедуре (это оговоренно
              //  в документации, описывающей конкретное сообщение) должны
              // возвращать 0 по выходу из оконной процедуры
              break;
          }
    
          // ну, тут небольшая неувязка - большого преступления впрочем нет,
          // если нажата другая клавиша, а я, не обработав ее, не отдаю управление
          // ядру вызовом DefWindowProc() (см. ниже). Но это только частный случай!!!
          break;
    
        // сообщение WM_DESTORY посылается, когда окно уже закрыто и
        // убрано с рабочего стола. Учти, если ты не вызовешь функцию 
        // PostQuitMessage() (параметр для нее - это код возврата
        // ложится в wParam который возвращается из WinMain), окно будет
        // закрыто, но приложение все еще будет "вертеться" в фоне.
        // PostQuitMessage() выполняет только одно действие - оно ставит
        // в очередь сообщений сообщение WM_QUIT, а ты уже знаешь, что
        // это единственное сообщение, после обработки которого GetMessage()
        // вернет 0...
    
        case WM_DESTROY:
          PostQuitMessage(0);
          // почти все сообщения, обработанные в оконной процедуре (это оговоренно
          // в документации, описывающей конкретное сообщение) должны
          // возвращать 0 по выходу из оконной процедуры
          break;
    
        default:
          // не обработанные сообщения обязательно должны быть отданы ядру
          // нижеследующим вызовом. Тогда система сможет корректно отработать
          // например, WM_SIZE(изменение размера окна).
          return DefWindowProc(hWnd,uMsg,wParam,lParam);
      }
    
      // почти все сообщения, обработанные в оконной процедуре (это оговоренно
      // в документации, описывающей конкретное сообщение) должны
      // возвращать 0 по выходу из оконной процедуры
    
      return 0;
    }

    Учти, что эту программку я писал, отталкиваясь исключительно от MS Visual C, то есть не пользовался другими компиляторами. Хотя, думаю, проблем не должно возникнуть. Наверное, этого достаточно для начала - теперь дело только за тобой. Удачной «охоты»!

    5 июня 2002 (Обновление: 26 авг 2010)

    Комментарии [8]