A futilidade de programar para Windows diretamente em assembly…

Deixe-me mostrar como o código em C do “hello, world” (mais simples, sem menus ou ícone customizado) é codificado em assembly para ambas as plataformas i386 e x86-64. Vou me deter na função WinMain() aqui, sem mostrar o tratador de mensagens (que é bem mais simples). O código que mostrarei é esse:

#include <windows.h>

LRESULT CALLBACK WindowMessagesHandler(HWND, UINT, 
                                       WPARAM, LPARAM);

int CALLBACK WinMain(HINSTANCE hInstance, HINSTANCE hPrevInst,
                     LPSTR lpszCmdLine, int nCmdShow)
{
  HWND hWnd;
  MSG msg;
  static const char *className = "MyWinAppClass32";
  WNDCLASS wc = {
    .style = CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS,
    .lpfnWndProc = WindowMessagesHandler,
    .hInstance = hInstance,
    .hIcon = LoadIcon(NULL, IDI_APPLICATION),
    .hCursor = LoadCursor(NULL, IDC_ARROW),
    .hbrBackground = (HBRUSH)(COLOR_WINDOW+1),
    .lpszClassName = className
  };

  RegisterClass(&wc);

  if ((hWnd = CreateWindow(className,
                           "My Win32 App",
                           WS_OVERLAPPEDWINDOW,
                           CW_USEDEFAULT, CW_USEDEFAULT,
                           640, 480,
                           NULL,
                           NULL,
                           hInstance,
                           NULL)) == NULL)
  {
    MessageBox(NULL, "Error creating window!", "Error", 
               MB_OK | MB_ICONERROR);
    return 0;
  }

  UpdateWindow(hWnd);
  ShowWindow(hWnd, nCmdShow);

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

  return msg.wParam;
}

Em primeiro lugar, será interessante notar que todas as chamadas de funções da Win32 API são feitas de forma indireta. Não tem outro jeito, já que essas funções estão localizadas em DLLs: kernel32.dll, user32.dll e msvcrt.dll. O outro detalhe é a diferença entre o nome das funções nas arquiteturas i386 e x86-64: Na primeira o nome é sempre precedido de “__imp__” e sucedido do tamanho (em bytes) da lista de parâmetros que a função recebe. Por exemplo, A função RegisterClass() é importada como __imp__RegisterClass@4. No caso da plataforma x86-64 o prefixo “__imp__” continua lá e o acesso indireto também, mas esse sufixo não existe.

Eis o código aproximado, parcial, de WinMain() para i386 (NASM):

bits 32
; win32.inc não existe. temos que fazê-lo menualmente.
%include "win32.inc"

section .data

className: db "MyWinAppClass32",0
wintitle:  db "My Win32 App",0

wc: istruc WNDCLASS
    iend

msg: istruc MSG
     iend

section .bss

hWnd: resd 1

section .text

struc WinMainStack
  .oldebp:      resd 1
  .retaddr:     resd 1
  .hInstance:   resd 1
  .hPrevInst:   resd 1
  .lpszCmdLine: resd 1
  .nCmdShow:    resd 1
endstruc

; Definida em outro lugar
extern _WindowMessagesHandler@16

global _WinMain@16
_WinMain@16:
  push ebp
  mov  ebp,esp

  ; Preenche a estrutura em wc.
  xor eax,eax
  mov ecx,WNDCLASS.size
  lea edi,[wc]
  cld
  rep stosb
  mov  eax,[ebp+WinMainStack.hInstance]
  mov  [wc+WNDCLASS.hInstance],eax
  mov  dword [wc+WNDCLASS.style],(CS_VREDRAW | CS_HREDRAW | CS_DBLCLKS)
  mov  dword [wc+WNDCLASS.lpfnWndProc],_WindowMessagesHandler@16
  push dword IDI_APPLICATION
  push dword 0
  call dword [__imp__LoadIconA@8]
  mov  [wc+WNDCLASS.hIcon],eax
  push dword IDC_ARROW
  push dword 0
  call dword [__imp__LoadCursorA@8]
  mov  [wc+WNDCLASS.hCursor],eax
  mov  dword [wc+WNDCLASS.hbrBackground],(COLOR_WINDOW+1)
  mov  dword [wc+WNDCLASS.lpszClassName],className

  ; Registra a classe
  push wc
  call dword [__imp__RegisterClassA@4]

  ; Cria a janela.
  xor eax,eax
  push eax
  push dword [ebp+WinMainStack.hInstance]
  push eax
  push eax
  push dword 240
  push dword 320
  push dword CW_USEDEFAULT
  push dword CW_USEDEFAULT
  push dword WS_OVERLAPPEDWINDOW
  push wintitle
  push className
  push eax
  call dword [__imp__CreateWindowExA@48]

  ; Testa para ver se conseguiu...
  test eax,eax
  jnz  ok

  ; Não conseguiu...
  push dword (MB_OK | MB_ICONERROR)
  push caption_msg
  push title_msg
  push dword 0
  call dword [__imp__MessageBoxA@16]
  xor  eax,eax
  jmp  WinMainExit

  ; Conseguiu criar a janela...
ok:
  mov  [hWnd],eax

  ; Atualiza e mostra a janela.
  push eax
  call dword [__imp__UpdateWindow@4]
  push dword [ebp+WinMainStack.nCmdShow]
  push dword [hWnd]
  call dword [__imp__ShowWindow@8]

MessageLoop:
  xor eax,eax
  push eax
  push eax
  push eax
  push msg
  call dword [__imp__GetMessageA@16]
  test eax,eax
  jz   MessageLoopExit
  push msg
  call dword [__imp__TranslateMessage@4]
  push msg
  call dword [__imp__DispatchMessage@4]
  jmp  MessageLoop

MessageLoopExit:
  ; Saiu do loop, retorna msg.wParam.
  mov  eax,[msg+MSG.wParam]

WinMainExit:
  pop  ebp
  ret  16      ; Livra-se dos 16 bytes da pilha!

Que diabos é esse ‘A’ na frente do nome de algumas funções da API? Por exemplo: __imp__CreateWindowExA@48… Acontece que Windows faz distinção entre funções que aceitam strings no formato ANSI (ou seja, Windows-1252 — uma variação do charset ISO-8859-1) e UNICODE (Windows usa UTF-16, não UTF-8!) conhecida como WideChar. Assim, em C, a função CreateWindowEx é um apelido para CreateWindowExA (que espera strings ANSI), cujas strings serão convertidas, internamente, para UNICODE… Se quiser trabalhar diretamente com UNICODE você deve usar CreateWindowExW. Sempre que uma função tomar ponteiros para strings, direta ou indiretamente, ela deve ser seguida de A ou W, de acordo com o charset que você estiver usando.

Você deve estar achando estranho o fato do SDK da Microsoft usar, por padrão, apelidos para as varições ANSI das funções já que, internamente, ele sempre usa UNICODE… Essa é mais uma característica história… Antes da Win32 API ninguém usava UNICODE (nem existia!). Quando o Windows NT 3 surgiu, as versões WideChar das funções foram incorporadas, mas a Microsoft quis manter compatibilidade com o Win16. Daí os apelidos…

Além do ‘A’ e do ‘W’, note também que usei a função CreateWindow, no código em C, sem o Ex no final… Essa função é obsoleta, mas o apelido consta em winuser.h como CreateWindowExA, onde o primeiro argumento dessa nova função é uma constante para estilos extras de janela…

A listagem em assembly acima serve a dois propósitos: Mostra que codificar um programa para Windows diretamente em assembly é tarefa fútil, uma vez que o compilador C faz um trabalho melhor… Por exemplo: Diferente de meu código, o gcc mantém os ponteiros para as funções contidos em __imp__GetMessageA@16, __imp__TranslateMessage@4 e __imp__DispatchMessage@4 nos registradores ESI, EBP e EDI, respectivamente, daí ele realiza as chamadas via registradores, no loop de mensagens:

  lea esi,[__imp__GetMessageA@16]
  lea ebp,[__imp__TranslateMessage@4]
  lea edi,[__imp__DispatchMessage@4]
MessageLoop:
  xor  eax,eax
  push eax
  push eax
  push eax
  push msg
  call esi
  test eax,eax
  jz   MessageLoopExit
  push msg
  call ebp
  push msg
  call edi
  jmp  MessageLoop
MessageLoopExit:

Compare o código que mostrei lá em cima com o gerado via:

$ i586-mingw32msvc-gcc -O3 -S -masm=intel winapp.c

O outro propósito da listagem é mostrar que, no modo i386, Windows usa um padrão parecido com PASCAL para passagem de parâmetros, necessariamente usando a pilha e empilhando da direita para a esquerda… Isso difere muito da arquiteura x86-64 onde, por padrão, registradores são usados para passar parâmetros. Compare o código do WinMain, acima, com o gerado pelo x86_64-w64-mingw32-gcc (código aproximado, em NASM):

bits 64
; win32-64.inc não existe. temos que fazê-lo menualmente.
%include "win32-64.inc"

section .data

className: db "MyWinAppClass32",0
wintitle:  db "My Win32 App",0

wc: istruc WNDCLASS
    iend

msg: istruc MSG
     iend

section .bss

hWnd: resq 1

section .text

struc WinMainStack
  .oldrbp:      resq 1
  .retaddr:     resq 1
  .hInstance:   resq 1
  .hPrevInst:   resq 1
  .lpszCmdLine: resq 1
  .nCmdShow:    resd 1
endstruc

; Definida em outro lugar
extern WindowMessagesHandler

global WinMain
WinMain:
  push rbp
  mov  rbp,rsp

  ; Preenche a estrutura em wc.
  xor  eax,eax
  mov  ecx,WNDCLASS.size
  lea  rdi,[wc]
  cld
  rep  stosb
  mov  rax,[rbp+WinMainStack.hInstance]
  mov  [wc+WNDCLASS.hInstance],rax
  mov  dword [wc+WNDCLASS.style],(CS_VREDRAW | CS_HREDRAW | CS_DBLCLKS)
  mov  qword [wc+WNDCLASS.lpfnWndProc],WindowMessagesHandler

  ; ecx já contém 0.
  mov  edx,IDI_APPLICATION
  call qword [__imp__LoadIconA]
  mov  [wc+WNDCLASS.hIcon],rax

  xor  ecx,ecx
  mov  edx,IDC_ARROW
  call qword [__imp__LoadCursorA]
  mov  [wc+WNDCLASS.hCursor],rax

  mov  dword [wc+WNDCLASS.hbrBackground],(COLOR_WINDOW+1)
  mov  qword [wc+WNDCLASS.lpszClassName],className

  ; Registra a classe
  lea rcx,[wc]
  call qword [__imp__RegisterClassA]

  ; Cria a janela.
  xor ecx,ecx    ; exStyle = 0
  lea rdx,[className]
  lea r8,[wintitle]
  mov r9d,WS_OVERLAPPEDWINDOW
  push rcx
  push qword [rbp+WinMainStack.hInstance]
  push rcx
  push rcx
  push dword 240
  push dword 320
  push dword CW_USEDEFAULT
  push dword CW_USEDEFAULT
  call qword [__imp__CreateWindowExA]

  ; Testa para ver se conseguiu...
  test rax,rax
  jnz  ok

  ; Não conseguiu...
  xor  ecx,ecx
  lea  rdx,[title_msg]
  lea  r8,[caption_msg]
  mov  r9d,(MB_OK | MB_ICONERROR)
  call qword [__imp__MessageBoxA]
  xor  eax,eax
  jmp  WinMainExit

  ; Conseguiu criar a janela...
ok:
  mov  [hWnd],rax

  ; Atualiza e mostra a janela.
  mov  rcx,rax
  call qword [__imp__UpdateWindow]
  mov  rcx,[hWnd]
  mov  edx,[rbp+WinMainStack.nCmdShow]
  call qword [__imp__ShowWindow]

MessageLoop:
  lea  rcx,[msg]
  xor  edx,edx
  mov  r8d,edx
  mov  r9d,edx
  call qword [__imp__GetMessageA]
  test rax,rax
  jz   MessageLoopExit
  lea  rcx,[msg]
  call qword [__imp__TranslateMessage]
  lea  rcx,[msg]
  call qword [__imp__DispatchMessage]
  jmp  MessageLoop

MessageLoopExit:
  ; Saiu do loop, retorna msg.wParam.
  mov  eax,[msg+MSG.wParam]

WinMainExit:
  pop  rbp
  ret

A convenção de chamada x86-64, usada pelo Windows, usa os registradores RCX, RDX, R8 e R9 para os 4 primeiros parâmetros e os demais são empilhados no mesmo padrão PASCAL. A outra diferença está na nomenclatura das funções importadas. Os nomes dos símbolos são mais “simples” sem aquele “@n” no final. E, por fim, você não precisa mais prestar atenção para a quantidade de bytes usados nos argumentos (repare no RET final).

Mesmo com essas facilidades o GCC realiza tarefa melhor que o código acima.

Tenho que, agora, responder uma pergunta simples: Como é que o compilador sabe o ponteiro das funções contidas em DLLs?

Que bruxaria é essa?

Aparentemente os ponteiros, todos prefixados com __imp__ são conhecidos de antemão pela nossa aplicação e referem-se às funções da Win32 API. Nosso código não os inicializou em nenhum lugar! Como isso é possível?

DLLs são carregadas de duas maneiras: Ou na inicialização da aplicação (early binding que em tradução livre significa “ligação antecipada”) ou em tempo de execução (late binding, “ligação tardia”). No primeiro caso, ao linkar o nosso arquivo objeto, usamos bibliotecas estáticas contendo códigos de inicialização, inclusive da lista de DLLs informadas no linker. No segundo caso (late binding), temos que usar a função LoadLibrary() da API — que carregará a DLL no espaço de endereçamento da nossa aplicação — e GetProcAddress(), para obter o ponteiro para a função. É claro que essas funções também estão em DLLs, mas elas são, obrigatoriamente, early boundedkernel32.dll e user32.dll. Assim, os ponteiros para algumas funções da API estarão sempre disponíveis para nossa aplicação.

No caso do uso de DLLs early bounded o GCC tem um atalho: Ele permite a linkagem diretamente com a DLL, incorporando o código de carga e inicialização dos ponteiros diretamente, exatamente como é feito com um shared object no Linux… Basta informar o nome da dll na opção -l do linker (possivelmente informarndo o path na opção -L). Caso contrário, teríamos que criar uma biblioteca estática de importação (no Visual Studio seria um arquivo com extensão .lib, no Linux seria um archive, extensão .a). No caso do uso da biblioteca estática de importação, podemos criá-la através dos utilitários dlltool ou dllwrap, que acompanham o MinGW. Note que este é o comportamento default do Visual Studio: Toda DLL importada em tempo de linkagem tem que ter uma bibliocate de importação estática, criada pelo utilitário implib… Não é o caso do GCC.

Já no caso de usarmos late binding, temos que fazer algo assim, em código:

#include <windows.h>
#include <GL.h> /* Para usar GLenum. */

HMODULE hLib;

/* Ponteiro para uma função de opengl.dll. */
void CALLBACK (*glBegin)(GLenum);
...

/* Tenta carrgar opengl32.dll */
if (!(hLib = LoadLibrary("opengl32.dll"))
{ ... trata erro de carga aqui ... }

/* Obtem o ponteiro da função. */
glBegin = (void CALLBACK (*)(GLenum))GetProcAddress(hModule, "glBegin");
if (!glBegin)
{ ... trata erro de binding aqui ... }

...
/* Usa a função */
glBegin(GL_TRIANGLES);
...

/* Quando não precisar mais da bibliteca, livramo-nos dela: */
FreeLibrary(hLib);

É comum encontrarmos códigos de late binding, no Windows, que crie typedefs convenientes para o casting de ponteiros de funções. No exemplo acima você pode encontrar algo assim:

typedef void CALLBACK (*GLBEGINFARPROCPTR)(GLenum);

GLBEGINFARPROCPTR glBegin;
...
glBegin = (GLBEGINFARPROCPTR)GetProcAddress(hLib, "glBegin");
...

Fica mais “fácil” de ler, mas prefiro o casting explícito (já tive alguns problemas com typedefs)…

Outra coisa: Para descobrir os nomes reais das funções exportadas pela DLL, basta usar o utilitário objdump, que acompanha o MinGW:

C:\> objdump -x %windir%\system32\opengl32.dll | 
          sed -n '/Ordinal\/Name/,/^PE/p'
[Ordinal/Name Pointer] Table
        [   0] GlmfBeginGlsBlock
        [   1] GlmfCloseMetaFile
        [   2] GlmfEndGlsBlock
        [   3] GlmfEndPlayback
        [   4] GlmfInitPlayback
        [   5] GlmfPlayGlsRecord
        [   6] glAccum
        [   7] glAlphaFunc
        [   8] glAreTexturesResident
        [   9] glArrayElement
        [  10] glBegin
        ...
        [ 365] wglUseFontBitmapsW
        [ 366] wglUseFontOutlinesA
        [ 367] wglUseFontOutlinesW

PE File Base Relocations (interpreted .reloc section contents)
C:\>

(O ‘sed’ está em outra linha por motivos de legibilidade aqui. ‘\’ como continuação de linha parece não funcionar no Windows!)

Como falei lá em cima, no modo i386, provavelmente o ponteiro conterá o tamanho dos parâmetros quando usado como early binding e, portanto, será conhecido como __imp__glBegin@4 (e, de novo, o mesmo não acontece no modo x86-64 e com late binding).

Anúncios