Qual é a melhor maneira de lidar com uma CALL em assembly x64, que deve retornar para um endereço de retorno ligeiramente alterado? Principalmente no que diz respeito à eficiência/velocidade de execução. Vou explicar brevemente o que estou tentando fazer.
Fundo
Eu tenho uma linguagem de script visual interpretada e personalizada, que é compilada em código nativo. Esta linguagem possui corrotinas baseadas em pilha integradas e, anteriormente, elas ainda eram tratadas semi-interpretadas (com uma classe de pilha separada para armazenar os dados da corrotina). Estou no processo de nativizá-lo totalmente, para que apenas o RSP seja usado.
Uma parte dessas corrotinas é a capacidade de rendimento aninhado, ou seja, se uma corrotina chamar outro método de rendimento, esse método pode render internamente para suspender toda a invocação. Essas informações são tratadas por meio de uma estrutura "YieldState", armazenada em um registro. Isso significa que, para a nova variante totalmente nativa, podemos apenas chamar um método de rendimento de uma corrotina com uma instrução de chamada:
call 12345; // [rip+12345] => yieldingMethod
Pelo menos, em teoria. Como nossas corrotinas são baseadas em pilha, armazenamos variáveis locais claramente na pilha, e não em algum tipo de classe como as corrotinas sem pilha fariam. Isso requer que a limpeza (caso a corrotina seja destruída antes de terminar) seja feita por meio de outro método, que chamei de "manipulador de interrupção". A invocação desse manipulador de interrupção é bastante comum em meu caso de uso prático, mas não excessivamente. Portanto, meu objetivo era fornecer algo que fosse mais rápido que um manipulador de exceções (que geralmente requer alguma pesquisa global do quadro), mas não exigisse a configuração explícita desse endereço para cada chamada. Então o que fiz foi incorporar o endereço do manipulador de interrupção entre a chamada e o endereço de retorno - como para a versão antiga do código tínhamos que carregar o retorno manualmente, isso não era um problema:
lea rcx,[rip+25]; // 25 is the assumed byte-size up until the return address
mov rdx,rbx; // load non-native call stack
call prepareMethodYielding; // stores return-address on stack
jmp 12345; // actually call our "yieldingMethod"
mov r15,interruptAddress;
A última instrução nunca é executada - deixamos o endereço de retorno para ignorá-la. Só o temos aqui para poder consultar o manipulador de interrupções. Dado um endereço de currículo, podemos simplesmente diminuir o ponteiro em 8 e temos o endereço dessa interrupção de currículo. O "mov r15" no nosso caso serve apenas para nos permitir desmontar o código corretamente; poderíamos simplesmente incorporar o endereço sozinho, mas isso confundiria qualquer desmontador externo.
O problema real
Agora, na nova versão, não há "prepareMethodYielding", mas apenas uma chamada - pelo menos, de forma ideal. Mas a "ligação" por si só não nos permite fazer um endereço de retorno modificado, então aqui me deparo com algumas opções e quero saber qual é a melhor.
Opção A - lea + push + jmp
Nossa primeira opção é simular a "chamada", mas enviar o endereço de retorno manualmente:
lea rax,[rip+10h]
push rax
jmp A6 // yieldingMethod
Isto requer 3 instruções, mas nenhum acesso à memória.
Opção B - enviar da memória
Poderíamos reduzir o número de opções armazenando o endereço de retorno em alguma área da memória constante:
push qword ptr[rip+1234] // return-address stored here
jmp A6 // yieldingMethod
Agora precisamos apenas de um push e nenhum registro intermediário, embora agora precisemos de acesso à memória, que poderia estar potencialmente mais distante na seção de dados.
Opção C – modificar o endereço de retorno na função chamada
Outra opção que vejo seria ajustar o endereço de retorno produzido pela chamada dentro do método chamado. Todos esses métodos aqui são compilados usando minha própria convenção de chamada, portanto, não aderem ao x64 ou a qualquer outro.
// caller
call A6 // yielding method
// callee, first instruction
add qword ptr[rsp],10 // size of interrupt-embedding is always the same
Também seria apenas uma instrução, com uma pequena codificação. Embora apenas do ponto de vista do design, eu não goste muito disso, pois acopla as informações sobre a incorporação do receptor ao chamador - embora, se essa fosse a variante mais eficiente, eu ainda poderia optar por ela.
Opção D - não modifique o endereço de retorno
Nossa última opção é não modificar o endereço de retorno, mas sim alterar a forma como a pesquisa e o retorno são tratados.
call 12345; // yieldingMethod
mov r15,interruptAddress; // is actually executed now (but value is not used)
Então aqui, mudaríamos onde procuramos o endereço de interrupção (já que o endereço de retorno agora aponta na frente da instrução falsa, em vez de atrás dela). Então, ao retornar da chamada, executaríamos a instrução movebs, mas descartaríamos o valor carregado. A vantagem aqui é que o tamanho geral do código é o menor, já que não precisamos adicionar instruções adicionais que ainda não estejam lá. No entanto, estamos executando uma instrução mov de 10 bytes, que pode ser mais lenta do que algumas das outras variantes. Depende aqui do que a CPU está fazendo - se ela já decodifica a instrução falsa, mesmo que não a alcance diretamente, pode ser a melhor ideia apenas executá-la, em vez de modificar o endereço de retorno. A mesma coisa, se a CPU puder de alguma forma detectar que a instrução não tem efeito, já que seu valor nunca é lido, durante a renomeação de registros, então ele poderia ser efetivamente gratuito - atm, estou usando um registro que não é usado, para distinguir meu próprio montador; mas provavelmente faria sentido usar um registro que fosse sobrescrito logo depois, presumo. Embora eu não tenha certeza do que realmente aconteceria aqui.
Conclusão
Então, qual dessas 4 opções lhe parece mais eficiente? Também estou aberto a outras idéias, embora o design geral de como as corrotinas são feitas seja finalizado e funcional, então algo como usar uma abordagem baseada em máquina de estados que algumas corrotinas do IIRC usam não é realmente uma opção aqui.
Aqui está uma variante da opção D que pode funcionar:
A arquitetura x86 possui uma instrução nop longa
nop r/m32
, que não produz nenhum efeito. O operando desta instrução é ignorado e pode ser um operando de memória. Se você usar esta instrução com um operando modr/m que tenha um deslocamento de 32 bits, poderá incorporar efetivamente um número de 32 bits no fluxo de instruções sem nenhum dano.Embora seu endereço de interrupção seja um endereço de 64 bits, pode ser possível expressá-lo como uma distância de 32 bits de algum endereço base, permitindo que você use uma codificação mais curta. Ou use um par de instruções nop longas para codificar os 64 bits completos.
Isso poderia ser parecido com:
Uma vantagem disso é que executar um NOP após o retorno é ainda mais barato do que uma instrução para modificar o endereço de retorno. Mais importante ainda, evita uma previsão errada do preditor da pilha de endereços de retorno , que assume isso
call
eret
será emparelhado da maneira normal.