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!

Anúncios