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.
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.
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:
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.
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:
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.
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:
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.
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:
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:
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:
No NASM essa variável poderia ser dumpada da seguinte forma:
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.
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:
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: