CALL e RET
Entendendo detalhadamente as instruções CALL e RET.
Quando se trata de chamadas de procedimentos existem dois conceitos relacionados ao endereço deste procedimento.
O primeiro conceito é que existem chamadas "próximas" (near) e "distantes" (far). Enquanto no near call nós apenas especificamos o offset do endereço, no far call nós também especificamos o segmento.
O outro conceito é o de endereço "relativo" (relative) e "absoluto" (absolute), que também se aplicam para saltos (jumps). Onde um endereço relativo é basicamente um número sinalizado que será somado à RIP quando o desvio de fluxo ocorrer. Enquanto o endereço absoluto é um endereço exato que será escrito no registrador RIP.

Tamanho do offset

O tamanho que o offset do endereço deve ter acompanha a largura do barramento interno. Então se estamos em real mode (16 bit), por padrão o offset deve ser de 16-bit. Ou seja, basicamente o mesmo tamanho do Instruction Pointer.

Near relative call

call rel16/rel32
Essa é a call que já usamos, não tem segredo. Ela basicamente recebe um número negativo ou positivo indicando o número de bytes que devem ser desviados. Veja da seguinte forma:
Instruction_Pointer = Instruction_Pointer + operand
A matemática básica nos diz que "mais com menos é menos", ou seja, se o operando for negativo essa soma resultará em uma subtração.

Onde está RIP?

Existe um detalhe bem simples porém importante para conseguir lidar com endereços relativos corretamente. Quando o processador for executar a instrução o Instruction Pointer já estará apontando para a instrução seguinte. Ou seja desvios de fluxo para trás precisam contar os bytes da própria instrução em si, enquanto os para frente começam contando em zero que já é a instrução seguinte na memória.
Claro que esse cálculo não é feito por nós e sim pelo assembler, mas é importante saber. Ah, e lembra do símbolo $ que eu falei que o NASM expande para o endereço da instrução atual? Veja que ele não coincide com o valor de RIP, cujo o mesmo já está apontando para a instrução seguinte.
Por exemplo poderíamos fazer uma chamada na própria instrução gerando um loop "infinito" usando a sintaxe:
bits 64
call $
Experimente ver com o ndisasm como essa instrução fica em código de máquina:
O primeiro byte (0xE8) é o opcode da instrução, que é o byte do código de máquina que identifica a instrução que será executada. Os bytes posteriores são o operando imediato (em little-endian). Repare que o endereço relativo está como 0xFFFFFFFB que equivale a -5 em decimal.

Near absolute call

call r/m
Diferente da chamada relativa que indica um número de bytes a serem somados com RIP, numa chamada absoluta você passa o endereço exato de onde você quer fazer a chamada. Você pode experimentar fazer uma chamada assim:
mov rax, rotulo
call rax
Se você passar rotulo para a call diretamente você estará fazendo uma chamada relativa porque desse jeito você estará passando um operando imediato. E a única call que recebe valor imediato é a de endereço relativo, por isso o NASM passa o endereço relativo daquele rótulo. Mas ao definir o endereço do rótulo para um registrador ou memória o assembler irá passar o endereço absoluto dele.
É importante entender que tipo de operando cada instrução recebe para evitar se confundir sobre como o assembler irá montar a instrução. E sim, saber como a instrução é montada em código de máquina é muitas vezes importante.

Far call

call seg16:off16 ; Em 16-bit
call seg16:off32 ; Em 32-bit
call mem16:16 ; Em 16-bit
call mem16:32 ; Em 32-bit
call mem16:64 ; Em 64-bit
As chamadas far (distante) são todas absolutas e recebem no operando um valor seguindo o formato de especificar um offset seguido do segmento de 16-bit. No NASM um valor imediato pode ser passado da seguinte forma:
call 0x1234:0xabcdef99
Onde o valor à esquerda especifica o segmento e o da direita o deslocamento. Detalhe que essa instrução não é suportada em 64-bit.
O segundo tipo de far call, suportado em 64-bit, é o que recebe como operando um valor na memória. Mas perceba que temos um near call que recebe o mesmo tipo de argumento, não é mesmo?
Por padrão o NASM irá montar as instruções como near e não far mas você pode evitar essa ambiguidade explicitando com keywords do NASM que são bem intuitivas. Veja:
call [rbx] ; Próximo e absoluto
call near [rbx] ; Próximo e absoluto
call far [rbx] ; Distante
O near espera o endereço do offset na memória, não tem segredo. Mas o far espera o offset seguido do segmento. Em um sistema de 32-bit vamos supor que nosso procedimento está no segmento 0xaaaa e no offset 0xbbbb1111. Em memória o valor precisa estar assim em little-endian:
11 11 bb bb aa aa
No NASM essa variável poderia ser dumpada da seguinte forma:
bits 32
my_addr: dd 0xbbbb1111 ; Deslocamento
dw 0xaaaa ; Segmento
; E usada assim:
call far [my_addr]
Basicamente o far call modifica o valor de CS e IP ao mesmo tempo, enquanto o near call apenas modifica o valor de IP.
No código de máquina a diferença entre o far e o near call que usam o operando em memória está no campo REG do byte ModR/M. O near tem o valor 2 e o far tem o valor 3. O opcode é 0xFF.
Se você não entendeu isso aqui, não se preocupa com isso. Mais para frente no livro será escrito um capítulo só para explicar o código de máquina da arquitetura.

RET

ret
retf
retn
ret imm16
retf imm16
retn imm16
Como talvez você já tenha reparado intuitivamente a chamada far também preserva o valor de CS na stack e não apenas o valor de IP (lembrando que IP já estaria apontando para a instrução seguinte na memória).
Por isso a instrução ret também precisa ser diferente dentro de um procedimento que será chamado com um far call. Ao invés de apenas ler o offset na stack ela precisa ler o segmento também, assim modificando CS e IP do mesmo jeito que o call.
Repetindo que o NASM por padrão irá montar as instruções como near então precisamos especificar para o NASM, em um procedimento que deve ser chamado como far, que queremos usar um ret far. Para isso podemos simplesmente adicionar um sufixo 'n' para especificar como near, que já é o padrão, ou o sufixo 'f' para especificar como far. Ficando:
retf ; Usado em procedimentos que devem ser chamados com far call
Existe também uma outra opção de instrução ret que recebe como operando um valor imediato de 16-bit que especifica um número de bytes a serem desempilhados da stack.
Basicamente o que ele faz é somar o valor de SP com esse número, porque como sabemos a pilha cresce "para baixo". Ou seja se subtraímos valor em SP estamos fazendo a pilha crescer, se somamos estamos fazendo ela diminuir. Por exemplo, podemos escrever em pseudo-código a instrução retf 12 da seguinte forma:
pseudo.c
RIP = pop();
CS = pop();
RSP = RSP + 12;
Export as PDF
Copy link
On this page
Tamanho do offset
Near relative call
Near absolute call
Far call
RET