A Intel e seu processador de 16 bits…

Mais nostalgia pra vocês… Dessa vez, sobre o 8086.

IBM PC modelo 5150. Bonito, não?

No início dos anos 80 a IBM apresentou ao mundo o IBM PC Modelo 5150, baseado no processador 8088. O 8088 é uma versão com barramento de dados de 8 bits do 8086 e essa foi a arquitetura escolhida porque já existiam muitos microcomputadores de 8 bits no mercado e componentes eletrônicos especializados para eles. A intenção não era reinventar a roda em todo o circuito.

Na época existia uma boa briga, especialmente entre a Apple, com seu Apple ][ (e mais tarde com o MacIntosh) e a IBM e os PCs… Muitos, como eu próprio, acreditavam que o aumento de 8 para 16 bits era um senhor avanço, mas que a alusão a “Personal Computer” era exagerada. O bicho era caro, grande e com cara “profissional” demais para ser “pessoal”. E, ainda por cima, era da IBM! Era quase como almejar comprar um carro zero quilômetro fabricado pela NASA!

Por incrível que pareça, os modelos de PCs da IBM, até os AT (de Advanced Technology), jamais usaram o 8086… Felizmente a empresa, logo no início, abriu toda a documentação do bicho e começaram a surgir clones e esses modificaram o circuito para uso do 8086.

Família de processadores 8086

Os 8088/8086 eram os irmãos maiores do antigo 8085 e, esse último, uma versão um pouco melhor do 8080, ambos de 8 bits. Neles tínhamos registradores de uso geral A, B, C, D, E, H e L, bem como PC (de Program Counter), SP (Stack Pointer) e Flags… Nos 8086 a Intel manteve A, B, C e D, estendendo-os para 16 bits e, por isso eles têm um X após seus nomes (X de eXtended)… A intenção por trás de H e L, nos 8080, sempre foi o uso do par HL, onde H é o MSB e L o LSB. De fato, nos 8080 e 8085 o par HL, quando usado como ponteiro, forma um pseudo registrador chamado M (de Memory). Assim, os registradores AX, BX, CX e DX puderam ser “desmembrados” em seus bytes de alta e baixa ordem, como em AH e AL.

Os registradores PC e SP continuam lá e funcionam igualzinho, mas para dar um “plus” ao conjunto de instruções — e tentar competir com o Z80 — a Intel resolveu flexibilizar o esquema de endereçamento para as instruções. É bom lembrar que no Z80 existem 3 maneiras de usar ponteiros:

  • Via endereçamento absoluto, como em LD A,(0xC010);
  • Via endereçamento indireto, como em LD A,(HL);
  • Via endereçamento indireto indexado, como em LD A,(IX+1)

A Intel resolveu criar o esquema de endereçamento com até 3 “operandos”: endereço base, índice e offset, como em MOV AL,[BX+SI+2]. Mas existe um problema: Somente os registradores BX e BP podem ser usados como “base” (por isso o B na frente do nome) e somente SI (de Source Index) e DI (de Destination Index) podem ser usados como índice (e por isso esses dois registradores existem — e não podem ser “desmembrados” como seus irmãos que terminam com X). Esse esquema de endereçamento só foi flexibilizado nos 80386 ou superiores.

Observação: O registrador BP não existe para apontar para a “base da pilha”, mas para ser usado como ponteiro e compor o “endereço base” no esquema de endereçamento dos 8086 quando se trata de acesso à pilha!

Endereços maiores que 16 bits

Na época era pouco comum obter um microcomputador que tivesse mais que uns 48 KiB de RAM. Outro atrativo do IBM PC-5150 era a “enorme” quantidade de memória que o sistema poderia suportar: 256 KiB! Mas, para que isso acontecesse era necessário que o barramento de endereços tivesse mais que 16 bits. Foi resolvido que teria 20 e uma capacidade teórica “absurda” de 1 MiB de memória acessível!!!

Mas, como especificar 20 bits de endereços com registradores de 16 bits? Ainda, a maioria dos desenvolvedores (mesmo em linguagem Assembly) estavam mais que acostumados em lidar com endereços de 16 bits nos processadores de 8… A solução foi particionar a memória em “segmentos” de 64 KiB cada. Surgem os “registradores de segmento” (que mais tarde viriam a ser renomeados de “seletores”). A única “desvantagem” é que o endereço linear base tem que ser, necessariamente, alinhado de 16 em 16 bytes (os 4 bits inferiores dos 20 são sempre zero e os registradores de segmento especificam os 16 bits superiores)… Isso causa um possível overlapping entre segmentos, quebrando a vantagem do isolamento entre eles.

Sobre esse “isolamento”, a segmentação tenta matar outro coelho: Em computadores maiores era normal que existisse memórias dedicadas. Uma para o armazenamento das instruções do programa, outra para armazenamento dos dados manipulados, outra para a pilha… Com os seletores pode-se especializar um segmento… Então, CS indica o endereço base do segmento de “código” (Code Segment); DS o de dados (Data Segment) e SS, o de pilha (Stack Segment). E só para dar uma flexibilizada (ou para facilitar o circuito interno do processador) adicionaram ES (Extra data Segment).

Com isso, dependendo do registrador de uso geral que for usado como endereço base (BX ou BP), seleciona-se, automaticamente, DS ou SS, respectivamente.

Outras restrições em relação ao 286 e 386

Em instruções de deslocamento ou rotação não se podia fazer algo como SHL AX,3. Ou se deslocava apenas 1 bit ou tínhamos que usar o registrador CL como operando da contagem do deslocamento. Isso foi flexibilizado, se não me engano, no 286.

Os 386 melhoraram um bocado, “melhorando” o tamanho dos registradores: EAX (de Enhanced AX) torna AX a WORD menos significativa de EAX, que agora tem 32 bits (mas, repare que AH e AL continuam lá, como o desmembramento de AX). E também flexibiliza o modelo de endereçamento. Agora podemos usar qualquer registrador de uso geral como base ou índice, como em MOV AL,[ECX+EDX+1] e, melhor ainda, podemos escalonar o índice, multiplicando-o por 2, 4 ou 8, como em MOV EAX,[ECX+4*EDX+1]… Podemos também usar tanto EBP quanto ESP se quisermos que a base selecione SS automaticamente. E, é claro, temos um conjunto de instruções muito maior.

O 286 também introduziu o modo protegido, melhorado no 386 (que adicionou, também, o modo paginado – para competir com o MC68030, da Motorola).

Para saber mais sobre os 8086 leia meu “Curso de Assembly”, escrito em 1994 para a RBT (baixe aqui). É material velho, mas, em essência, ainda válido.

Anúncios

Retornando tipos complexos

Já falei por aqui sobre as diversas convenções de chamada para os modos i386 e x86-64 e uma das coisas que disse é que, no caso do modo x86-64, o registrador RAX é sempre usado como valor de retorno. Seja para retornar um simples int, seja para retornar um ponteiro… Mas, o que acontece se quisermos retornar uma estrutura? Note bem, não um ponteiro para uma estrutura, mas toda ela? Vejamos:

struct vertice_s {
  float x, y, z, w; // Coordinate (homogeneous);
  float nx, ny, nz; // Normal vector;
  float r, g, b, a; // Color;
  float u, v;       // Texture coordinate;
};

struct vertice_s assign(void)
{
  struct vertice_s v;

  v.x = v.y = v.z =
  v.nx = v.ny = v.nz =
  v.r = v.g = v.b =
  v.u = v.v = 0.0f;
  v.w = v.a = 1.0f;

  return v;
}

void assign2(struct vertice_s *p)
{
  p->x = p->y = p->z =
  p->nx = p->ny = p->nz =
  p->r = p->g = p->b =
  p->u = p->v = 0.0f;
  p->w = p->a = 1.0f;
}

Aqui, a função assign() apaentemente retorna toda a estrutura local à função. No outro caso, na função assign2 passamos um ponteiro para uma estrutura e lidamos com ele dentro da função. Supreendentemente, ambas as funções fazem quase exatamente a mesma coisa. Isso pode ser observado obtendo a listagem em assembly:

bits 64

section .rodata
float1: dd 1.0

section .text

global assign
global assign2

assign:
  pxor  xmm0, xmm0
  mov   rax, rdi
  movss xmm1, dword [float1]
  movss dword [rdi+12], xmm1
  movss dword [rdi], xmm0
  movss dword [rdi+4], xmm0
  movss dword [rdi+8], xmm0
  movss dword [rdi+16], xmm0
  movss dword [rdi+20], xmm0
  movss dword [rdi+24], xmm0
  movss dword [rdi+28], xmm0
  movss dword [rdi+32], xmm0
  movss dword [rdi+36], xmm0
  movss dword [rdi+40], xmm1
  movss dword [rdi+44], xmm0
  movss dword [rdi+48], xmm0
  ret

assign2:
  pxor  xmm0, xmm0
  movss dword [rdi+48], xmm0
  movss dword [rdi+44], xmm0
  movss dword [rdi+36], xmm0
  movss dword [rdi+32], xmm0
  movss dword [rdi+28], xmm0
  movss dword [rdi+24], xmm0
  movss dword [rdi+20], xmm0
  movss dword [rdi+16], xmm0
  movss dword [rdi+8], xmm0
  movss dword [rdi+4], xmm0
  movss dword [rdi], xmm0
  movss xmm0, dword [float1]
  movss dword [rdi+40], xmm0
  movss dword [rdi+12], xmm0
  ret

A diferença óbvia é que assign retorna o ponteiro para a estrutura apontada por RDI em RAX, como se tivéssemos passado esse ponteiro para a função! A função gasta o mesmo tempo que a sua irmã, assign2, uma vez que, devido à reordenação automática nos processadores mais modernos (Ivy Bridge em diante, pelo menos), mov rax,rdi provavelmente não gastará ciclo algum.

Repare que o compilador é esperto o suficiente para perceber que se quisermos retornar uma estrutura por valor, ela terá que ser assinalada para algum objeto e esse objeto tem um endereço na memória. Se esse objeto estiver alocado na própria pilha (for uma variável local na função chamadora), RDI apontará para a pilha, caso contrário, para uma região no segmento de dados. Eis um exemplo:

extern struct vertice_s assign(void);

// Não nos interessa a imlpementação dessa função agora!
extern void showvertice(struct vertice_s *);

void f(void)
{
  struct vertice_s v;

  v = assign();
  showvertice(&v);
}

O que criará uma listagem mais ou menos assim:

f:
  sub  rsp,136   ; Aloca espaço para a estrutura.
  mov  rdi,rsp
  call assign
  mov  rdi,rsp
  call showvertice
  add  rsp,136   ; Dealoca o espaço.
  ret

Você obterá uma listagem um pouco maior que essa, dependendo das opções de compilação que usar, mas, em essência, é isso o que o compilador faz.

No modo i386 a coisa não é muito diferente. Lembre-se que no modo x86-64 o primeiro argumento da função é passado no registrador RDI. No caso do modo i386, este argumento é passado na pilha (na convenção cdecl), daí o endereço da estrutura estará apontada por RSP+4.

Em resumo: Não tenha medo de retornar estruturas e uniões por valor, é a mesma coisa que passar o ponteiro da estrutura no primeiro argumento. Mas, a coisa é bem diferente de passar argumentos por valor ou referência. Ao fazer:

void f(struct vertice_s v)
{
  struct vertice t;

  t = v;
  ...
}

Todo o conteúdo de v terá que ser copiado para dentro de t (embora o compilador possa decidir não fazê-lo, dependendo do resto da função!). Mas uma coisa é certa! Todo o conteúdo da estrutura terá que ser copiado para a pilha antes da chamada!

Então, tenha medo de passar argumentos por valor e prefira passá-los por referência (ponteiro)… mas, saiba que esse risco você não corre ao retornar por valor…

Rolling back: O medidor de ciclos que uso atualmente…

Quem estiver procurando neste blog vai encontrar diversas atualizações no “medidor de ciclos de clock” que uso para medir a performance de rotinas, usando o timestamp mantido pelo processador desde o Pentium… O fato é que ele não é preciso (e mostro porquê disso em alguns artigos por aqui), mas é melhor que nada…

Eis a versão mais atual, onde obtenho melhores resultados:

#ifndef __CYCLE_COUNTING_INCLUDED__
#define __CYCLE_COUNTING_INCLUDED__

/* ==========================================
    Quick & Dirty cycle counting...

    begin_tsc() e end_tsc()

    são usadas para contar a quantidade de ciclos
    de máquina gastos num bloco de código.

    Exemplo de uso:

      uint64_t t;

      BEGIN_TSC;      
      f();
      END_TSC(t);

    É conveniente compilar o código sob teste com a
    opção -O0, já que o compilador poderá 'sumir' com
    o código, por causa da otimização. Melhor ainda
    seria compilar a função f() num módulo separado com
    a opção -O2 para obter o código otimizado.

    As macros, em si, não são "otimizáveis", por assim dizer.
   ========================================== */

#include <stdint.h>

// Mantém o timestamp inicial.
uint64_t __local_tsc;

#ifdef __x86_64__
#define REGS1 "rbx","rcx"
#define REGS2 "rcx"
#else
#ifdef __i386__
#define REGS1 "ebx","ecx"
#define REGS2 "ecx"
#else
#error cycle counting will work only on x86-64 or i386 platforms!
#endif
#endif

// Macro: Inicia a medição.
// Uso CPUID para serializar o processador aqui.
#define BEGIN_TSC do { \
  uint32_t a, d; \
\
  __asm__ __volatile__ ( \
    "xorl %%eax,%%eax\n" \
    "cpuid\n" \
    "rdtsc\n" \
    : "=a" (a), "=d" (d) :: REGS1 \
  ); \
\
  __local_tsc = ((uint64_t)d << 32) | a; \
} while (0)

// Macro: Finaliza a medição.
// NOTA: A rotina anterior usava armazenamento temporário
//       para guardar a contagem vinda de rdtscp. Ainda,
//       CPUID era usado para serialização (que parece ser inóquo!).
//       Obtive resultados melhores retirando a serialização e
//       devolvendo os constraints para EAX e EDX, salvando apenas ECX.
// PS:   rdtscp também serializa, deixando o CPUID supérfluo!
#define END_TSC(c) do { \
  uint32_t a, d; \
\
  __asm__ __volatile__ ( \
    "rdtscp\n" \
    : "=a" (a), "=d" (d) :: REGS2 \
  ); \
\
  (c) = (((uint64_t)d << 32) | a) - __local_tsc; \
} while (0)

#endif

Façam bom proveito!

Ponteiro para um array…

Eu evito algumas construções mais “esotéricas” da linguagem C, não porque não saiba o que elas signifiquem, mas em virtude da legibilidade… Recentemente me perguntaram o que significa a declaração:

int (*p)[4];

Bem… isso ai é um ponteiro para um array de 4 inteiros. O que é bem diferente de:

int *p[4];

Que é um array de 4 ponteiros para o tipo int.

O detalhe é que deixei meio de lado a utilidade da primeira declaração. Considere isso:

int a[4][4] =
  { { 1, 2, 3, 4 },
    { 5, 6, 7, 8 },
    { 9, 0, 1, 2 },
    { 3, 4, 5, 6 } };

void showarray(int a[][4])
{
  int i, j;

  for (i = 0; i < 4; i++)
  {
    for (j = 0; j < 4; j++)
      printf("%d ", a[i][j];
    putchar('\n');
  }
  putchar('\n');
}

A rotina showarray, como declarada acima, é um jeito de mostrar todos os itens de um array. Podemos chamá-la com showarray(a); e tudo funcionará perfeitamente bem… Mas, e se quiséssemos usar ponteiros ao invés de 2 índices (i e j)?

Note que, se você tem um array simples, pode usar um ponteiro para apontar para o primeiro item e incrementar o endereço contido no ponteiro para obter o próximo item, como em:

int a[] = { 1, 2, 3, 4, 5, 0 };

void showarray(int *p)
{
  for (; *p; p++)
    printf("%d ", *p);
  putchar('\n');
}

Aqui, o compilador criará código para somar 4 ao conteúdo de p a cada vez que ele for incrementado. O valor 4 é adicionado porque sizeof(int)==4. A pergunta original é: “Dá pra fazer algo semelhante com arrays bidimensionais?”. A resposta, obviamente, é: SIM!

Ao declarar um ponteiro p como int (*p)[4];, toda vez que você incrementar o ponteiro, a ele será adicionado o valor 16 (4*sizeof(int)) e podemos escrever a rotina showarray original, assim:

void showarray(int (*p)[4])
{
  int i;

  // Não é bem a rotina original, aqui considero que
  // se o primeiro item da "linha" for zero, devemos
  // encerrar o loop.
  //
  // p++ fará o ponteiro p apontar para o início da
  // próxima linha...
  for (; (*p)[0]; p++)
  {
    for (i = 0; i < 4; i++)
      printf("%d ", (*p)[i]);
    putchar('\n');
  }
  putchar('\n');
}

O array original, é claro, deverá ser definido assim, para a rotina funcionar:

int a[][4] =
  { { 1, 2, 3, 4 },
    { 5, 6, 7, 8 },
    { 9, 0, 1, 2 },
    { 3, 4, 5, 6 },
    {} };  // note: 4 zeros aqui!

Estatisticamente insignificante…

Well… só para escrever um cadinho a respeito de alguma coisa, recentemente alguém em um grupo de Facebook que participo teve a brilhante ideia de “elaborar um algoritmo” para ganhar na MEGA SENA. Como se ninguém tivesse pensado (e ainda pensando) nisso antes, né?

Muito bem… saibam que todos os jogos já sorteados estão disponíveis para download no site da Caixa Econômica Federal neste, link, em formato ZIP que contém um arquivo HTML, mal formatado, por sinal… Baixado os resultados por ordem de sorteio (aqui) e depois de alguma filtragem, podemos obter algo assim:

04 05 30 33 41 52
09 37 39 41 43 49
10 11 29 30 36 47
01 05 06 27 42 59
01 02 06 16 19 46
07 13 19 22 40 47
...

Essa é a lista de 1960 jogos já sorteados até o último jogo antes que eu tivesse criado esse texto. E, agora, podemos brincar, não é? Infelizmente, não…

O problema é que a quantidade de sorteios feita é insignificante em relação à quantidade de jogos possíveis. Só para dar uma ideia, a quantidade de combinações possíveis é de C(n,r)=\frac{n!}{r!(n-r)!}\Rightarrow\frac{60!}{6!(60-6)!}\approx50063860. Sabendo que temos apenas 1960 jogos disponíveis para fazer alguma avaliação, isso significa que temos apenas 0,003915% de todas as amostras possíveis! Tentar divisar algum padrão nessa base é como dizer que só existem cavalos marrons porque você nunca viu um com pelagem de outra cor…

Com base nas amostras que temos, podemos chegar as mais variadas conclusões falsas. Por exemplo, se traçarmos um gráfico com o número de ocorrências de cada um dos valores, em todos os 1960 jogos, teremos:

Distribuição dos valores em todos os sorteios

Olhando para isso podemos concluir (errado!) que deveríamos ignorar os valores 26 e 55, já que eles aconteceram bem menos do que os demais. Ou, quem sabe, deveríamos prestar mais atenção justamente neles, já que eles tendem a acontecer mais, para manter o “sistema” equilibrado (errado! errado!).

Ainda, existem 6 valores que aconteceram mais que os demais (5, 10, 23, 33, 51 e 53), então, essa deve ser uma sequência mais “apostável” que as demais, não é? E os números 5 e 53 aconteceram 225 vezes nesses 1960 jogos (11% de todos os jogos, pena que não ao mesmo tempo! — de fato, ao mesmo tempo, eles aconteceram apenas 17 vezes!).

De novo, esses dados são estatisticamente insignificantes para que qualquer tipo de análise, no sentido de obtenção de padrões, seja possível e uma outra demonstração disso é uma animaçãozinha que mostra a distribuição dos valores no tempo, onde cada linha contém uma dezena de valores (a linha 1 vai de 1 até 10, a linha 2, de 11 até 20… até a 6ª linha, de 51 até 60):

1/10º de segundo por sorteio

Repare como os quadrados vão ficando rosados, mais ou menos, ao mesmo tempo e mais ou menos terminam em tonalidades similares (e você, muitas vezes, nem percebe o degrau de variação nessa animação de 10 frames por segundo! Ou seja, 1 segundo = 10 sorteios). Isso significa que a distribuição é mais ou menos uniforme no decorrer do tempo, o que torna a “adivinhação” difícil!

Outra forma de ver a variação de cada valor, acima, é a simulação do gráfico inicial em timelapse (de novo, 10 frames por segundo):

Cada ponto é a contagem do valor em uso em cada sorteio

Ok… tem um pequeno bug no programinha que fiz para criar essa animação (a barra azul do lado direito), mas você pegou a ideia…

E, se você ainda acha, que dá para obter algum padrão, pense nisso: Será que o peso da tinta dos números pintados nas bolinhas sorteadas não têm alguma influência? E quanto à quantidade de plástico usado na fabricação delas? E a “qualidade” desse material? Todas as bolinhas vieram do mesmo fabricante? E aquela “cesta” giratória (se é que ainda usam isso!), de que material é feito? Ela parece ser metálica, existem soldas? Elas são uniformes? Tanto a cesta quanto as bolinhas são perfeitamente esféricas? E quanto ao atrito? E a temperatura ambiente? E o diferencial de temperatura entre a “cesta” (de metal) e as bolinhas (de plástico)? E quem está girando as cestas? Tá com fome? Tá cansado? Tá doente? etc…

Existem muitas variáveis e poucas amostras para dizer sequer se essas variáveis devem ou não serem consideradas!

MyToyOS: Cuidados com gcc e assembler entre modos de operação

No que concerne códigos “não hosted” (freestanding), se você é como eu, usa C para a maioria das funções mais complexas, deixando assembly para aquelas que seriam mais complicadas de implementar em C. Neste artigo quero mostrar alguns problema que pode enfrentar.

Em primeiro lugar, o GCC permite o desenvolvimento de funções que serão usadas no modo real, basta usar a opção -m16 na linha de comando. Nessa modalidade ele adiciona os prefixos 0x66 e 0x67 nas instruções, para que os offsets e operandos continuem sendo de 32 bits. Mas, isso pode ser problemático com saltos e retornos de funções. Tomemos como exemplo a seguinte função:

int f(int a, int b) { return a + b; }

O compilador gera algo assim, como pode ser observado via objdump:

00000000 <f>:
   0: 67 66 8b 44 24 08   mov  eax,DWORD PTR [esp+0x8]
   6: 67 66 03 44 24 04   add  eax,DWORD PTR [esp+0x4]
   c: 66 c3               ret    

Os prefixos 0x67 e 0x66 nas instruções mov e add estão ai porque, no modo real, os registradores default são as variantes de 16 bits (ax, no caso) e precisamos do prefixo 0x66 para usarmos EAX. O prefixo 0x67 está ai porque ESP está sendo usado como base no modo de endereçamento. Mas, o que diabos esse 0x66 está fazendo antes do RET?

Infelizmente o GCC continua pensando que está no modo protegido i386 e o empilhamento dos endereços de retorno continua sendo feito em 32 bits. Isso é evidente ao se olhar os offsets dos argumentos a e b da função. De acordo com o macete de usarmos uma estrutura para acesso ao stack frame, podemos escrever a rotina acima, em NASM, assim:

struc stkfrm
.retaddr  resw 1
.a        resd 1
.b        resd 1
endstruc

bits 16

global f
f:
  mov eax,[esp+stkfrm.a]
  add eax,[esp+stkfrm.b]
  ret

O NASM, por outro lado, vai gerar o código correto para o RET:

 1                           struc stkfrm
 2 00000000 <res 00000002>   .retaddr resw 1
 3 00000002 <res 00000004>   .a  resd 1
 4 00000006 <res 00000008>   .b  resd 2
 5                           endstruc
 6                           
 7                           bits 16
 8                           
 9                           f:
10 00000000 66678B442402       mov eax,[esp+stkfrm.a]
11 00000006 666703442406       add eax,[esp+stkfrm.b]
12 0000000C C3                 ret

Em primeiro lugar, o endereço de retorno empilhado, em modo real, é de 16 bits, para uma chamada near e isso é expresso pela reserva de uma WORD no topo da pilha. Assim, os acessos a pilha serão [esp+2] e [esp+6], respectivamente para a e b. E nada de 0x66 antes de RET!

Para testar essa chamada, eis um programinha simples, em modo real, que implementa um simples setor de boot:

bits 16

global _start
_start:
  jmp   0x7c0:_main
_main:
  cld
  mov   ax,cs
  mov   ds,ax
  mov   es,ax

  ; apaga a tela:
  mov   ax,3
  int   0x10

  ; x = f(2,1);
  push  dword 1
  push  dword 2
  call  f

  ; printf("2 + 1 = %#08x\n", x);
  lea   di,[value];
  call  dword2hex
  lea   si,[msg]
  call  puts

  ;; while (1);
.L1:
  hlt
  jmp   .L1

msg:
  db    '2 + 1 = 0x'
value:
  db    '00000000 (?).',13,10,0

hextbl:
  db    '0123456789abcdef'

; Como implementada pelo GCC
f:
  mov   eax,[esp+2]
  add   eax,[esp+6]
;  mov   eax,[esp+4]    ;; GCC implementa assim...
;  add   eax,[esp+8]
;  db    0x66
  ret

; ES:DI = ptr onde armazenar os chars
; AL  = byte
; ---
; Destrói AX,BX e CX.
; Avança DI
byte2hex:
  mov   bx,ax
  shr   bx,4
  and   bx,0x0f
  mov   cl,[hextbl+bx]
  mov   bx,ax
  and   bx,0x0f
  mov   ch,[hextbl+bx]
  mov   ax,cx
  stosw
  ret

; DS:DI = ptr onde armazenar os chars.
; EAX = dword
; ---
; Destrói EAX,BX,CX e EDX.
; Avança DI
%macro cvt_upper_byte 0
  ror eax,8
  mov edx,eax
  and eax,0xff
  call byte2hex
  mov eax,edx
%endmacro

dword2hex:
  cvt_upper_byte
  cvt_upper_byte
  cvt_upper_byte
  cvt_upper_byte
  ret

puts:
  lodsb
  test  al,al
  jz    .puts_exit
  mov   ah,0x0e
  int   0x10
  jmp   puts
.puts_exit:
  ret

  times 510-($-$$) db 0
  dw    0xaa55

Compile e execute com:

$ nasm -f bin boot.asm -o boot.bin
$ qemu-system-i386 -drive file=boot.bin,index=0,media=disk,format=raw

O resultado será este:

Mude a função f para que ela fique exatamente como codificada pelo GCC e verá que isso ai não funcionará mais. A prefixação 0x66 para RET faz com que a instrução tente recuperar EIP da pilha, só que apenas IP foi empilhado… Ao mesmo tempo, GCC supõe que o ambiente ainda seja de 32 bits e, por isso, ESP+4 e ESP+8, ao invés de ESP+2 e ESP+6, são usados para obter os dados da pilha…

Outro detalhe são as chamadas. Vejamos:

int g(int a, int b) { return f(a,b)+7; }

Isso criará um código assim:

00000010 <g>:
  10: 67 66 ff 74 24 08   push  DWORD PTR [esp+0x8]
  16: 67 66 ff 74 24 08   push  DWORD PTR [esp+0x8]
  1c: 66 e8 fc ff ff ff   call  1e <g+0xe>
  22: 66 5a               pop   edx
  24: 66 83 c0 07         add   eax,0x7
  28: 66 59               pop   ecx
  2a: 66 c3               ret

Note que a instrução CALL usa um deslocamento de 32 bits ao invés de 16. Isso não é problemático, graças ao prefixo 0x66 e ao fato de que todo salto near usa endereçamento relativo do EIP (ou IP). O único problema aqui é mesmo o RET prefixado.

Rotina em ASM chamando função em C e vice-versa

Para corrigir esse problema podemos, num código para o modo real escrito em assembly, simplesmente empilhar uma WORD 0 antes de chamar a função em C:

; Chamando função f(), em asm:
  push word 0
  call f
  ...

Isso garante que, na pilha, teremos IP estendido com zeros, formando o offset de 16 bits dentro do segmento de código. Podemos até mesmo criar uma macro:

%macro pm_call 1
  push word 0
  call %1
%endmacro

Do outro lado, a chamada de uma função feita para o modo real à partir do código em C gerado pelo GCC, espera que uma DWORD seja desempilhada no RET, já que CALL, prefixado com 0x66, ira colocar EIP na pilha. Podemos copiar a WORD apontado por ESP para ESP+2 e adicionar 2 a ESP, na rotina em ASM… Lembre-se que EIP é a última coisa empilhada antes do salto:

%macro pm_ret 0
  push ax
  mov  ax,[esp+2]
  mov  [esp+4],ax
  pop  ax
  add  esp,2   ; Descarta a primeira WORD do topo da pilha.
  ret
%endmacro

Ok, é gambiarra, mas, infelizmente o GCC não gera código muito bom em 16 bits!

Uma outra forma de explicar ponto flutuante…

Já expliquei de várias formas aqui, ai vai mais uma: “Ponto flutuante é uma maneira de lidar com frações racionais”. Para entender isso, voltemos à equação que descreve um float:

\displaystyle v=(-1)^S+\left(1+\frac{F}{2^{23}} \right )\cdot2^e

Onde os valores S, F e e são os “pedaços” de um float e são inteiros e positivos:

Estrutura de um float

Note que S tem apenas 1 bit de tamanho (S de “sinal”), F (de “fração”) tem 23 bits e, portando, suporta valores entre 0 e 2^{23}-1. Assim, \frac{F}{2^{23}} sempre será menor que 1.0! Já e é expresso pela equação e=E-127. O E, maiúsculo, é o valor que consta na estrutura…

Sendo assim, 1+\frac{F}{2^{23}} sempre será uma fração racional. Isso é evidente se você pensar que, numa estrutura com quantidade limitada de bits, não dá para armazenar um valor como π (3.1415926… e não acaba nunca).

O importante aqui é lembrar que a fração racional deve ser racional em binário. O valor 0.1, em decimal, não é racional em binário. Aliás, qualquer valor que seja diferente de 0.0 que não termine, na parte fracionária, em 5, não é um valor racional em binário. Isso é fácil perceber quando você calcula cada “casa” binária “depois da vírgula”:

2^{-1}=0.5\quad2^{-2}=0.25\quad2^{-3}=0.125\quad\cdots

E se somarmos cada um desses bits “fracionários”, verá que o final sempre terá o algarismo 5, em decimal.

Regra geral: Se a parte fracionária de um valor decimal for diferente de zero e não terminar em 5, o valor em ponto flutuante não é exato! Exemplo: 454.76 não é exato com qualquer tipo de ponto flutuante (float, double, long double) porque a parte fracionária termina em 6.