Mais um exemplo: Nem sempre assembly é a resposta!

Já escrevi sobre isso aqui: Criar suas próprias rotinas em assembly não é uma panaceia para a obtenção de alta performance. É possível acabar com uma rotina pior do que a gerada pela linguagem de alto nível. Especialmente se estivermos falando de compiladores que oferecem recursos de otimização.

Para demonstrar, consideremos a simples rotina de selection sort, abaixo:

#define swap(a,b) \
    { int _t; _t = (a); (a) = (b); (b) = _t; }

// Para ser justo, não vou permitir inlining...
__attribute__((noinline))
static void selection_sort(int *p, unsigned int size)
{
  unsigned int i, j;

  for (i = 0; i < size - 1; i++)
    for (j = i+1; j < size; j++)
      if (p[i] > p[j])
        swap(p[i], p[j]);
}

É legítimo pensar que essa rotina seja menos que ótima, já que usa o macro swap(), que lança mão de uma variável temporária que talvez seja alocada em memória. Assim, o programador pode criar uma rotina equivalente, em assembly, assim:

bits 64

section .text

; void selection_sort_asm(int *, unsigned int);
; -- Para ser justo, alinho em DWORD o ponto de entrada
;    da rotina!
  global selection_sort_asm
  align 4
selection_sort_asm:
  lea r8d,[rsi-1] ; r8d == size - 1;

  xor ecx,ecx     ; i = 0;

.loop2:
  lea edx,[rcx+1] ; j = i + 1;

.loop1:
  mov eax,[rdi+rcx*4]   ; if (ptr[i] > ptr[j]) ...
  cmp eax,[rdi+rdx*4]
  jle .skip

  xchg eax,[rdi+rdx*4]  ; ... swap(p[i], p[j]);
  mov [rdi+rcx*4],eax

.skip:
  add edx,1     ; j++
  cmp edx,esi   ; if (j < size) goto loop1;
  jb  .loop1

  add ecx,1     ; i++;
  cmp ecx,r8d   ; if (i < size - 1) goto loop2;
  jb  .loop2

  ret

Claro que essa rotina pode ser melhorada, especialmente entre os labels .loop1 e .skip, mas, em arquiteturas mais modernas (Ivy Bridge, SandyBridge e Haswell, sem contar com as mais recentes Broadwell e Skylake) os cálculo de endereços efetivos podem ser cacheados, numa espécie de common subexpression elimination, ou seja, isso não tem grande influência… Além do que, quero mostrar como uma abordagem “ingênua” se parece…

Bem… a rotina acima não é tão “ingênua” assim porque levei em conta algumas possíveis otimizações: Note que usei a instrução XCHG, usada para aproveitar o conteúdo de EAX na “troca” e evitar o uso de um outro registrador temporário. Repare também que não uso a instrução INC para evitar a penalidade de 1 ciclo de clock pelo fato dessa instrução não afetar o flag de carry (e precisar preservá-lo). É preferível usar ADD, mesmo tendo tamanho maior, em seu microcódigo… Ainda, os saltos condicionais são feitos “para trás”, para aproveitar o algoritmo estático de branch prediction (exceto para o label .skip). Uso também as instruções LEA para adições rápidas, carryless

Mas, se você medir a performance de ambas as rotinas contra um array de 10 elementos aleatórios obterá (num i5-3570 @ 3.4 GHz, arquitetura Ivy Bridge) algo em torno de 1800 ciclos para a rotina em C e 2200 ciclos para a rotina em assembly! Ou seja, a rotina em C é 18% mais rápida!!! Num i7, com arquitetura Haswell poderá obter menos ciclos…

A rotina em Assembly equivalente para o selection sort, gerada pelo GCC com as opções de compilação “-O2 -mtune=native“, é esta:

bits 64

section .text

  global selection_sort
  align 4
selection_sort:
  cmp esi, 1
  jbe .exit
  lea r11d, [rsi-1]
  mov r9, rdi
  xor r10d, r10d

  align 4
.loop2:
  add r10d, 1
  mov eax, r10d
  cmp esi, r10d
  jbe .skip2

  align 4
.loop1:
  mov edx, eax
  mov ecx, [r9]
  lea rdx, [rdi+rdx*4]
  mov r8d, [rdx]
  cmp ecx, r8d
  jle .skip1
  mov [r9], r8d
  mov [rdx], ecx

.skip1:
  add eax, 1
  cmp esi, eax
  jne .loop1

.skip2:
  add r9, 4
  cmp r10d, r11d
  jne .loop2
  ret

.exit:
  ret

Essencialmente, ela é a mesma coisa da rotina em assembly que mostrei acima. A diferença está na organização das instruções e no aproveitamento do paralelismo das unidades de execução do processador (não confundir com os “cores” ou “núcleos”!).

O GCC também evita instruções problemáticas como XCHG (que, quando faz acesso à memória, “trava” [lock] o barramento de dados). O compilador também entende que pontos de retorno de loops podem precisar estar alinhados para que eles não cruzem a fronteira de uma linha do cache L1I e, por isso, coloca aquele “align 4” lá… Ele também tenta evitar problemas de “interlocking” de registradores (quando modificamos um registrador e tentamos usá-lo, na instrução seguinte, como operando fonte. Isso evita que duas instruções sejam executadas em paralelo).

Ou seja, o compilador tenta levar em conta a grande quantidade de regras de otimização (que podem ser estudadas no manual de otimização de software da Intel [baixe aqui] – prepare-se: são quase 700 páginas!) que você, ou eu, poderíamos deixar “passar batido”. Isso não significa que não seja possível para você ou eu elaborarmos uma rotina que seja melhor da gerada pelo compilador. Isso significa, porém, que na maioria das vezes, o compilador faz um trabalho melhor do que faríamos!

Anúncios

strlen_sse42() usando função “intrínseca”.

Pode parecer que a instrução PCMPISTRI seja meio complicada de usar em C, já que ela oferece dois resultados diferentes: ECX, contendo um índice de acordo com a comparação, e os flags. Mas, felizmente, o valor de ECX será 16 se InRes2 estiver totalmente zerado! Assim, a função anterior, escrita em assembly, pode ser reescrita em C assim:

/* test.c

  Compilar com:
    gcc -Ofast -msse4.2 -o test test.c
*/
#include <stddef.h>
#include <x86intrin.h>

size_t strlen_sse42_c(const char *s)
{
  unsigned int index;
  size_t result;
  static const char ranges[16] = { 1, 255 };

  result = 0;
  do
  {
    index = _mm_cmpistri(*(__m128i *)ranges, 
                         *(__m128i *)s,
                         _SIDD_UBYTE_OPS         | 
                         _SIDD_CMP_RANGES        | 
                         _SIDD_NEGATIVE_POLARITY |
                         _SIDD_LEAST_SIGNIFICANT);

    result += index;
    s += sizeof(__m128i);
  } while (count == 16);

  return result;
}

O código final ficará semelhante, mas menos performático, ao anterior:

bits 64

section .rodata

  align 16
_ranges:  db 1, 255
          times 14 db 0

section .text

global strlen_sse42:
  align 16
strlen_sse42:
  movdqa xmm0,[_ranges]
  xor    eax,eax

.loop:
  pcmpistri xmm0, [rdi], 0x0_01_01_0_0  
  mov    edx,ecx
  add    rdi,16
  add    rax,rdx
  cmp    ecx,16
  jz     .loop
  
  ret

Algumas diferenças óbvias: a instrução MOVDQA é mais rápida que MOVDQU e exige que o array _ranges esteja alinhado. Eu deveria ter previsto isso no código em assembly no artigo anterior… O compilador escolheu fazer DUAS comparações, como instruído. Como não temos como verificar o flag ZF à partir da função intrínseca _mm_cmpistri, só nos restava comparar o valor retornado com 16.

Agora… é evidente que PCMPISTRI só está disponível se seu processador suportar SSE 4.2. Um método bem simples de usar essa função OU a função padrão do compilador é este:

#include <stddef.h>
#include <string.h>
#include <x86intrin.h>

// Daqui para frente, strlen será chamada por esse ponteiro!
size_t (*__strlen)(const char *);

static size_t strlen_sse42_c(const char *s)
{ ... }

// Esse atributo faz com que a função seja executada
// ANTES de main(). É interessante ter apenas uma dessas
// funções em seu programa, embora o atributo permita definir
// a ordem de execução...
static __attribute__((constructor)) void ctor(void)
{
  if (__builtin_cpu_supports("sse4.2"))
    __strlen = strlen_sse42_c;
  else
    __strlen = strlen;
}

As chamadas a __stlen, evidentemente, serão sempre indiretas, mas assim você garante a compatibilidade entre processadores ao usar a rotina. Além do mais, a quase totalidade das funções da libc são chamadas de forma indireta, já que localizam-se em libc6.so.

strlen() usando SSE 4.2

Há uns meses atrás (ou será que já faz um ano?) topei com uma dica do amigo Cooler, em seu blog, mostrando como comparar duas strings usando SSE 4.2. A rotina é realmente rápida porque compara 16 bytes de cada vez, ao invés de byte por byte ou, até mesmo, dword por dword (ou qword, se otimizarmos para o modo x86-64). Confesso que não tinha nenhuma intimidade com a instrução PCMPISTRI, disponibilizada pelo SSE 4.2 e fiquei maravilhado com o achado.

A rotina que ele publicou foi essa (publico aqui apenas a versão x86-64):

; Original code by Cooler_  c00f3r[at]gmail[dot]com
bits 64
section .text

global strcmp_sse42

; int strcmp_sse42(char *ptr1, char *ptr2);
strcmp_sse42:
  mov    rax,rdi
  sub    rax,rsi
  sub    rsi,16
   
strloop:
  add    rsi,16
  movdqu xmm0,[rsi]
  pcmpistri xmm0,[rsi+rax],0b0011000
  ja     strloop
  jc     blockmov
  xor    eax,eax
  ret
  
blockmov:
  add    rax,rsi    
  movzx  rax,byte [rax+rcx]
  movzx  rsi,byte [rsi+rcx]
  sub    rax,rsi    
  ret

O detalhe, é claro, está na instrução PCMPISTRI e no byte de controle… Ele torna a instrução bastante flexível e, ao mesmo tempo, difícil de entender… Eis uma outra rotina da libc que pode ser acelerada (de novo, para x86-64, mas, agora, com meus comentários):

bits 64

section .rodata
; Pares de bytes contendo faixas (ranges).
  align 16
_ranges:
  db 1, 0xff      ; Faixa entre (char)1 e (char)0xff.
  times 14 db 0   ; As demais "faixas" não são "usadas".

section .text

global strlen_sse42

; size_t strlen_sse42(const char* s1)
  align 16
strlen_sse42:
  xor ecx,ecx           ; Necessário. Garante que os bits
                        ; superiores, [63:5], de RCX 
                        ; estejam zerados.

  lea rax,[rdi-16]
  movdqu xmm0,[_ranges]

.loop:
  add rax,16

  ; Compara os 16 bytes do buffer apontado por RAX
  ; Com os pares de faixas em XMM0...
  ;
  ; PCMPISTRI é "Packed Compare Implicit-ending String 
  ; returning Index" ou algo assim... "implícito" 
  ; significa string terminada com '\0'.
  ;
  ; O byte de controle da instrução representa:
  ;
  ; +-+-+---+---+-+-+
  ; |0|0|0 1|1 0|0|0|-----------> 0 = char
  ; +-+-+---+---+---+             1 = short
  ;    |  |   |  |
  ;    |  |   |  +--------------> 0 = unsigned
  ;    |  |   |                   1 = signed
  ;    |  |   |
  ;    |  |   |                   00 = Equal any
  ;    |  |   |                   01 = "Ranges"
  ;    |  |   +-----------------> 10 = Equal each (string compare)
  ;    |  |                       11 = Equal ordered
  ;    |  |
  ;    |  |                       00 = Positive Polarity
  ;    |  +---------------------> 01 = Negative Polarity
  ;    |                          10 = Masked (+)
  ;    |                          11 = Masked (-)
  ;    |
  ;    +------------------------> 0 = Least significant index
  ;                               1 = Most significant index

  pcmpistri xmm0,[rax],0b0_01_01_0_0  ; LSB InRes2 offset; 
                                      ; InRes2=~InRes1; 
                                      ; Range compare; 
                                      ; unsigned char.

  jnz .loop   ; ZF=1 só se um dos bytes de [rax] for 0 
              ; (o 'i' do mnemônico pcmp(i)stri garante isso).
              ; ECX é índice (dos 16 bytes) onde achou o '\0'.
              ; (o 'i' de pcmpistr(i) garante isso).

  add rax,rcx

  lea rdx,[rdi]
  sub rax,rdx
  ret

Bem… Como é que PCMPISTRI funciona? Ela toma dois valores de 16 bytes (sim! bytes!) e os comparara de acordo com os bits de 0 a 3 do valor imediato, segundo o comentário que você pode ver acima. No caso de usarmos “unsigned char”, cada byte comparado setará ou zerará um bit de um registrador interno chamado InRes1. Depois que a comparação for feita, podemos inverter ou não o valor contido em InRes1 e colocá-lo em InRes2 – a isso é dado o nome de “polarização” (informada nos bits 4 e 5).

No caso da instrução PCMPISTRI, o bit 6 diz como o valor de ECX será calculado. Isso é feito fazendo uma varredura em InRes2 e, o primeiro bit setado, encontrado (do primeiro ao último ou vice-versa) é colocado em ECX. Se todos os bits estiverem zerados, ECX retornará 16 (será?), mas o flag ZF também estará zerado por causa do I no final do nome do mnemônico (ou seja, a instrução não achou um fim-de-string, um char ‘\0’).

Na rotina acima eu peço para PCMPISTRI comparar cada byte da string com uma faixa de bytes variando de 1 a 255. Os valores zerados na faixa nao contam porque PCMPISTRI irá parar a busca ao encontrar um ‘\0’, de qualquer maneira…

Só um aviso… existem instruções similares, como PCMPESTRI, onde o tamanho da string tem que ser informado no par EDX:EAX. Existem também PCMPISTRM e PCMPESTRM que retornam uma “máscara”, isto é, o próprio InRes2.

Outra dica importante: Essas instruções são feitas para lidar com strings e, por isso, a Intel não impõe a necessidade dos dados estarem alinhados em 16 bytes, como é comum com as instruções SSE. Alinhei _range acima só por vício mesmo… :)

Surpresa! Contar ciclos no ring0 não é tão bom assim!

Estive aqui testando um módulo para o kernel para medir os ciclos de clock gastos por uma função qualquer e comparando isso com a minha rotina para fazer o mesmo, no userspace. A rotina sob teste é simples:

/* test.c */

// Não vamos usar esse array fora daqui,
// por enquanto...
int a[100] = { 0 };

void f(void)
{
  for (int i = 1; i < 100; i++)
    a[i] = a[i-1] + 1;
}

No userspace basta usar as funções inline begin_tsc e end_tsc, como mostradas abaixo:

/* cycle_counting.h */

#ifdef __x86_64__
# define REGS1 "rbx", "rcx"
# define REGS2 "rax, "rbx", "rcx", "rdx"
#else
# ifdef __i386__
# define REGS1 "ebx", "ecx"
# define REGS2 "eax, "ebx", "ecx", "edx"
# else
#  error "Need x86-64 or i386 platforms to use cycle counting!"
# endif
#endif

unsigned long long __local_tsc;

inline void begin_tsc(void)
{
  unsigned int a, d;

  __asm__ __volatile__ ("xorl %%eax,%%eax\n"
                        "cpuid\n"
                        "rdtsc"
                        : "=a" (a), "=d" (d)
                        :: REGS1);

  __local_tsc = ((unsigned long long)d << 32) | a;
}

inline unsigned long long end_tsc(void)
{
  unsigned int a, d;

  __asm__ __volatile__ ("rdtscp\n"
                        "movl %%eax,%0\n"
                        "movl %%edx,%1\n"
                        "xorl %%eax,%%eax\n"
                        "cpuid"
                        : "=m" (a), "=m" (d)
                        :: REGS2);

  // Retorna a contagem de ciclos.
  return ((unsigned long long)d << 32) + a - __local_tsc;
}

Já para usá-las no kernelspace, temos que criar um módulo:

/* cyclemod.c */
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/hardirq.h>
#include <linux/preempt.h>
#include "cycle_counting.h"

//extern int a[];
extern void f(void);

static int __init cyclecnt_start ( void )
{
  int i;
  unsigned long flags;
  unsigned long long c;

  printk ( KERN_INFO "Loading Cycle-Counting module...\n" );

  preempt_disable();
  raw_local_irq_save(flags);

  begin_tsc();
  f();
  c = end_tsc();

  raw_local_irq_restore(flags);
  preempt_enable();

  printk ( KERN_INFO "\tCycles: %llu\n", c);

  return 0;
}

static void __exit cyclecnt_end ( void )
{
  printk ( KERN_INFO "Goodbye Cycle-Counter.\n" );
}

module_init ( cyclecnt_start );
module_exit ( cyclecnt_end );

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Frederico L. Pissarra");
MODULE_DESCRIPTION("Cycle Counting Module");

Para compilar esse bicho, crie o makefile abaixo e simplesmente execute make. Você verá diversos arquivos, mas o módulo é o com extensão .ko (kernel object).

ccflags-y := -Ofast
obj-m := cyclescount.o
cyclescounter-objs := cyclemod.o test.o

KVERSION = $(shell uname -r)

all:
  make -C /lib/modules/$(KVERSION)/build M=$(PWD) modules

clean:
  make -C /lib/modules/$(KVERSION)/build M=$(PWD) clean

A diferença na rotina acima é que desabilitamos as interrupções para o processador local (provavelmente o kernel usa apenas a instrução CLI) e a preempção (não haverá chaveamento de tarefa para essa thread)… Isso deveria eliminar algum custo que existe no userspace e nos dar uma contagem mais “precisa”, mas veja só… Não é isso o que acontece:

$ ./cnttest    # userspace app
Cycles: 552
$ sudo insmod cyclecounter.ko
$ sudo rmmod cyclecounter
$ dmesg | sed -n '/Load.\+Cycle-Count/,+2p'
[17630.739459] Loading Cycle-Counting module...
[17630.739464] 	Cycles: 748
[17630.747001] Goodbye Cycle-Counter.

Como assim, no userspace obtive uma contagem menor de ciclos do que no kernspace?

E agora, Zé?!

Sempre dá para melhorar um bocado…

Eis duas formas de escrever uma função. A segunda forma fica meio “ofuscada”, mas, como veremos, o tamanho e performance valem a pena… A função é usada no T50, que deixei no primeiro modelo para ficar mais legível:

static const char *suffixes[] = { "st", "nd", "rd", "th" };

const char *get_ordinal_suffix(unsigned int n)
{
  /* 11, 12 & 13 have 'th' suffix, not 'st, nd or rd'. */
  if ((n < 11) || (n > 13))
    switch (n % 10)
    {
    case 1:
      return suffixes[0];
    case 2:
      return suffixes[1];
    case 3:
      return suffixes[2];
    }

  return suffixes[3];
}

Bem simples e aparentemente direto… O código gerado é esse:

bits 64

section .rodata

suffix1:   db "st",0
suffix2:   db "nd",0
suffix3:   db "rd",0
suffix4:   db "th",0

section .text

global get_oridinal_suffix
get_ordinal_suffix:
  lea  edx,[rdi-11]
  mov  eax,suffix4
  cmp  edx,2
  jbe  .L1

  ; Isso é n = n % 10!
  mov  eax,edi
  mov  edx,3435973837
  mul  edx
  shr  edx,3
  lea  eax,[rdx + rdx*4]
  add  eax,eax
  sub  edi,eax

  mov  eax,suffix2
  cmp  edi,2
  je   .L1
  mov  eax,suffix3
  cmp  edi,3
  je   .L1
  cmp  edi,1
  mov  edx,suffix4
  mov  eax,suffix1
  cmovne rax,rdx

.L1:
  ret

Não fique espantado com a mágica que o compilador faz para calcular o resto inteiro da divisão por 10. Ele prefere usar uma multiplicação do que divisão por questão de performance (multiplicações são lentas, mas divisões são verdadeiras lesmas!).

Na rotina acima não há muito como escapar do cálculo do resto da divisão por 10 por causa das duas exceções (11 e 12), mas podemos melhorar um cadinho o switch usando um macete:

const char *get_ordinal_suffix2(unsigned int n)
{
  if (n >= 11 && n <= 13) return suffixes[3];

  return suffixes["\003\000\001\002\003"
                  "\003\003\003\003\003"[n % 10]];
}

O uso de uma string literal como endereço base para a própria string é perfeitamente válido em C já que a string literal nada mais é do que a declaração dos seus bytes e o retorno é o endereço base. Note que cada “caracter” do array foi codificado como sendo o índice de outro array (suffixes) e, por isso, a rotina final será assim:

...
section .rodata

_lstr: db 3,0,1,2,3,3,3,3,3,3,3,3,3,3,3,3,0
suffixes: dq suffix1, suffix2, suffix3, suffix4

section .text
...
  global get_ordinal_suffix2
get_ordinal_suffix2:
  lea  edx,[rdi-11]
  mov  eax,suffix4
  cmp  edx,2
  jbe  .L1

  ; Isso ainda é n = n % 10!
  mov  eax,edi
  mov  edx,3435973837
  mul  edx
  shr  edx,3
  lea  eax,[rdx + rdx*4]
  add  eax,eax
  sub  edi,eax

  movsx eax,byte [_lstr + rdi]
  mov  rax,[suffixes + rax*8]
.L1:
  ret  

As 10 instruções do switch foram substituídas por apenas duas!

Aliás, quanto ao “macete” da string, acima, podemos fazer uso de coisas como essas sem problemas:

char c1 = "frederico"[5]; // c1 = 'r'.
char c2 = 5["frederico"]; // c2 = 'r';
char k = (n % 10)["9876543210"]; // desde que n seja unsigned.

Todas essas construções são perfeitamente válidas…

O macete do uso de estruturas para acessar argumentos de funções…

Alguns de meus leitores acharam estranho a maneira com que acesso os argumentos de funções passadas pela pilha, como é o caso de convenções de chamadas usadas no modo i386, na maioria dos sistemas operacionais. Algumas pessoas estão acostumadas a usar um prólogo e um epílogo nas suas rotinas e usam o registrador EBP como ponteiro base, assim:

MyFunc:
  push ebp     ; Prólogo
  mov  ebp,esp

  mov  eax,[ebp+8] ; Pega o 1º argumento.
  ...

  pop  ebp     ; Epílogo
  ret

Ok… esse é o método padrão, mas apresenta um problema: Se você errar o offset em relação a EBP vai pegar o valor errado! E se pudéssemos explicitar a posição, na pilha, usando símbolos? Well… podemos! Usando estruturas. O mesmo fragmento de código, acima, poderia ser escrito assim:

struc MyFuncStack
  .oldebp:   resd 1 ; ESP aponta para o EBP empilhado.
  .retaddr:  resd 1 ; CALL empilha o endereço de retorno.
  .arg1:     resd 1 ; Primeiro argumento.
endstruc

MyFunc:
  push ebp     ; Prólogo
  mov  ebp,esp

  mov  eax,[ebp+MyFuncStack.arg1] ; Pega o 1º argumento.
  ...

  pop  ebp     ; Epílogo
  ret

A expressão MyFuncStack.arg1, no NASM, será traduzida como o offset de arg1 em relação ao início da estrutura, ou seja, 8. Isso evitará erros e, uma vez que você se acostume com esse “macete”, torna seu código mais legível.

Aliás, usar um prólogo e um epílogo é supérfluo. Podemos escrever o mesmo fragmento de rotina assim:

struc MyFuncStack
  .retaddr:  resd 1
  .arg1:     resd 1
endstruc

MyFunc:
  mov  eax,[esp+MyFuncStack.arg1] ; Pega o 1º argumento.
  ...
  ret

Mas… e se eu quiser usar variáveis locais alocadas na pilha, como faço? Basta alterar a estrutura e modificar ESP de acordo:

struc MyFuncStack
  .local1:   resd 1 ; Nossa variável local
  .localsize:
  .retaddr:  resd 1
  .arg1:     resd 1
endstruc

MyFunc:
  add  esp,MyFuncStack.localsize
  mov  eax,[esp+MyFuncStack.arg1] ; Pega o 1º argumento.
  ...
  sub  esp,MyFuncStack.localsize
  ret

Note que a estrutura do seu stack frame precisa conter o estado em que você deixou a pilha… É o exemplo do uso do epílogo e do prólogo: Coloquei, lá em cima, um oldebp no frame… se eu tivesse salvo algo mais na pilha, teria que colocar no frame também, por exemplo:

struc MyFuncStack
  .local1:   resd 1 ; Nossa variável local
  .localsize:
  .oldebx:   resd 1
  .oldebp:   resd 1
  .retaddr:  resd 1
  .arg1:     resd 1
endstruc

MyFunc:
  push ebp
  push ebx
  add  esp,MyFuncStack.localsize
  mov  eax,[esp+MyFuncStack.arg1] ; Pega o 1º argumento.
  ...
  sub  esp,MyFuncStack.localsize
  pop  ebx
  pop  ebp
  ret

Assim eu posso usar EBP para outra coisa que não a base da pilha, por exemplo…

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).