Então, essa história começou com uma coincidência de três fatores. Eu:
- escreveu principalmente em c #;
- apenas imaginava aproximadamente como ele é organizado e funciona;
- ficou interessado em montador.
Essa mistura aparentemente inocente deu origem a uma idéia estranha: é possível de alguma forma combinar essas línguas? Adicione em C # a capacidade de fazer inserções de assembler, como no C ++.
Se você está interessado em quais consequências isso levou, seja bem-vindo ao gato.

Primeiras dificuldades
Mesmo naquele momento, percebi que é muito improvável que existam ferramentas padrão para chamar código assembler a partir do código C # - isso contradiz muito um dos conceitos importantes da linguagem: segurança de memória. Após um estudo superficial da questão (que, entre outras coisas, confirmou o palpite inicial - “fora da caixa” não existe essa possibilidade), ficou claro que, além do problema ideológico, existe um problema puramente técnico: o C #, como você sabe, é compilado em um bytecode intermediário, que interpretado ainda mais pela máquina virtual CLR. E é precisamente aqui que nos deparamos com o mesmo problema: por um lado, o compilador (daqui em diante vou me referir à Roslyn da Microsoft, já que é de fato o padrão no campo dos compiladores C #), obviamente, não pode reconhecer e converter comandos assembler de uma exibição de texto em uma representação binária, o que significa que devemos usar instruções de máquina diretamente em sua forma binária como uma inserção e, por outro lado, a máquina virtual possui seu próprio bytecode e não pode reconhecer e executar esse comandos agrupados que oferecemos a ela.
A solução teórica para esse problema é óbvia - você precisa garantir que o código de inserção binário seja executado pelo processador, ignorando a interpretação da máquina virtual. A coisa mais simples que vem à mente é armazenar o código binário como uma matriz de bytes, para a qual o controle será transferido de alguma forma no momento certo. A partir daqui, surge a primeira tarefa: você precisa criar uma maneira de transferir o controle para o que está contido em uma área de memória arbitrária.
Primeiro protótipo: "chamando" uma matriz
Esta tarefa é talvez o obstáculo mais sério para as inserções. Usando as ferramentas de linguagem, é fácil obter um ponteiro para nossa matriz, mas no mundo C # os ponteiros existem apenas nos dados e é impossível transformá-lo em um ponteiro para, digamos, uma função para que possa ser chamado mais tarde (bom, ou pelo menos eu não consegui descobrir como fazer).
Felizmente (ou infelizmente), nada é novo sob a lua e uma rápida pesquisa no Yandex pelas palavras “C #” e “assembler insert” me levou a um artigo na
edição de dezembro de 2007 da revista]] [Aker] . Tendo honestamente copiado a função de lá e adaptado às minhas necessidades, obtive
[DllImport("kernel32.dll")] extern bool VirtualProtect(int* lpAddress, uint dwSize, uint flNewProtect, uint* lpflOldProtect); public void* InvokeAsm(void* firstAsmArg, void* secondAsmArg, byte[] code) { int i = 0; int* p = &i; p += 0x14 / 4 + 1; i = *p; fixed (byte* b = code) { *p = (int)b; uint prev; VirtualProtect((int*)b, (uint)code.Length, 0x40, &prev); } return (void*)i; }
A idéia principal desse código é substituir o endereço de retorno da função
InvokeAsm()
na pilha pelo endereço da matriz de bytes para o qual você deseja transferir o controle. Depois de sair da função, em vez de continuar executando o programa, a execução do nosso código binário começará.
Lidaremos com a mágica que
InvokeAsm()
em
InvokeAsm()
com mais detalhes. Primeiro, declaramos uma variável local, que, é claro, aparece na pilha, e então obtemos seu endereço (obtendo assim o endereço do topo da pilha). Em seguida, adicionamos a ela uma certa constante mágica obtida calculando meticulosamente no depurador o deslocamento do endereço de retorno em relação ao topo da pilha, salve o endereço de retorno e, em vez disso, escreva o endereço da nossa matriz de bytes. O significado sagrado de salvar o endereço de retorno é óbvio - precisamos continuar executando o programa após nossa inserção, o que significa que precisamos saber para onde transferir o controle após ele. Em seguida, vem a chamada para a função WinAPI da biblioteca kernel32.dll -
VirtualProtect()
. É necessário para alterar os atributos da página de memória na qual o código de inserção está localizado. Obviamente, ao compilar o programa, ele aparece na seção de dados e a página de memória correspondente possui acesso de leitura e gravação. Também precisamos adicionar permissão para executar seu conteúdo. Finalmente, retornamos o endereço de retorno real armazenado. Obviamente, esse endereço não será retornado para o código chamado
InvokeAsm()
, porque execução imediatamente após o
return (void*)i;
"Falha" na inserção. No entanto, as convenções de chamada usadas pela máquina virtual (stdcall com otimização desabilitada e fastcall com habilitado) significam retornar o valor através do registro EAX, ou seja, para retornar da inserção, precisamos seguir duas instruções:
push eax
(código 0x50) e
ret
(código 0xC3).
EsclarecimentoNo futuro, falaremos sobre a arquitetura do x86 (ou melhor, IA-32) - brega devido ao fato de que naquela época eu estava pelo menos de alguma forma familiarizado com ele, ao contrário, digamos, do x86-64. No entanto, o método de transferência de controle descrito acima deve funcionar no código de 64 bits.
Por fim, você deve prestar atenção a dois argumentos não utilizados:
void* firstAsmArg
e
void* secondAsmArg
. Eles são necessários para transferir dados arbitrários do usuário para a inserção do assembler. Esses argumentos serão localizados em um local conhecido na pilha (stdcall) ou, novamente, em registros conhecidos (fastcall).
Um pouco sobre otimizaçãoVisto que, do ponto de vista do compilador, o código não entende o que é, pode inadvertidamente lançar alguma chamada de fundamental importância / alinhar algo / não salvar algum argumento "não utilizado" / de alguma forma interferir na implementação do nosso plano. Isso é parcialmente resolvido pelo [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)]
, no entanto, mesmo essas precauções não produzem o efeito desejado: por exemplo, a variável local i
, que é a chave para toda a função, acaba sendo registrada, o que obviamente estraga tudo . Portanto, para eliminar completamente a probabilidade de algo dar errado, você deve criar uma biblioteca com a otimização desabilitada (desabilite-a nas propriedades do projeto ou use a configuração de Depuração). Conseqüentemente, stdcall será usado, portanto, no futuro, procederei dessa convenção de chamada.
Aprimoramentos
Seguro é melhor que inseguro
Obviamente, não há questão de segurança (no sentido em que essa palavra é usada em C #). No entanto, o método
InvokeAsm()
descrito acima opera em ponteiros, o que significa que ele só pode ser chamado a partir do bloco marcado com a palavra-chave
unsafe
, o que nem sempre é conveniente - pelo menos requer compilação com a opção / insegura (ou a marca de seleção correspondente nas propriedades do projeto no VS). Portanto, parece lógico fornecer um shell que opere pelo menos IntPtr (na pior das hipóteses) e, idealmente, permite que o usuário especifique os tipos a serem transmitidos e retornados. Bem, isso soa genérico, escrevemos genérico, o que mais existe, alguém pergunta, para conversar? De fato - há algo.
O mais óbvio: como obter um ponteiro para um argumento cujo tipo é desconhecido? Construções do tipo
T* ptr = &arg
não
T* ptr = &arg
permitidas em C # e, em geral, não é difícil entender o motivo: o usuário pode muito bem usar um dos tipos gerenciados como parâmetro de tipo, um ponteiro para o qual não pode ser obtido. A solução poderia ser limitar um parâmetro do tipo
unmanaged
, mas, em primeiro lugar, ele apareceu apenas no C # 7.3 e, em segundo lugar, não permite a passagem de strings e matrizes como argumentos, embora o operador
fixed
permita que sejam usados (obtemos o ponteiro para o primeiro caractere ou elemento de matriz, respectivamente). Além disso, eu gostaria de dar ao usuário a oportunidade de operar, incluindo tipos controlados - desde que começamos a violar as regras do idioma, as violaremos até o fim!
Obtendo um ponteiro para um objeto gerenciado e um objeto por ponteiro
E, novamente, após uma deliberação não muito proveitosa, comecei a procurar as soluções finais. Desta vez, o
artigo sobre Habré me ajudou. Em resumo, um dos métodos propostos é escrever uma biblioteca auxiliar, e não em C #, mas diretamente em IL. Sua tarefa é enviar um objeto (na verdade uma referência ao objeto) para a pilha da máquina virtual, transmitida como argumento e, em seguida, recuperar outra coisa da pilha - por exemplo, um número ou
IntPtr
. Executando as mesmas etapas na ordem inversa, você pode converter o ponteiro (por exemplo, retornado da inserção do assembler) em um objeto. Este método é bom porque tudo o que acontece é claro e transparente. Mas há um sinal de menos: eu queria continuar com o menor número possível de arquivos, então, em vez de escrever uma biblioteca separada, decidi incorporar o código IL na principal. A única maneira que encontrei é escrever métodos stub em C #, criar o projeto, desmontar o binário usando o ildasm, reescrever o código dos métodos stub e recompilar tudo com o ilasm. Essas são algumas ações adicionais e, desde que você precise executá-las sempre que construí-las após fazer alterações no código ... Em geral, me cansei muito rapidamente e comecei a procurar alternativas.
Naquele momento, um livro maravilhoso caiu em minhas mãos, graças ao qual aprendi muito por mim mesmo - “CLR via C #”, de Jeffrey Richter. Nele, em algum ponto do século XX, falamos sobre a estrutura
GCHandle
, que possui um método
Alloc()
que pega um objeto e um dos
GCHandleType
enumeração
GCHandleType
. Portanto, se você chamar esse método passando o objeto desejado e
GCHandle.Pinned
, poderá obter o endereço desse objeto na memória. Além disso, antes de chamar
GCHandle.Free()
objeto é fixo, ou seja, totalmente protegido contra os efeitos do coletor de lixo. No entanto, existem certos problemas. Primeiro, o
GCHandle
não ajuda em nada a concluir a conversão "ponteiro → objeto", apenas "objeto → ponteiro". Mais importante, para usar
GCHandleType.Pinned
classe ou estrutura do objeto cujo endereço queremos obter deve ter o atributo
[StructLayout(LayoutKind.Sequential)]
, enquanto
LayoutKind.Auto
usado por
LayoutKind.Auto
. Portanto, esse método é adequado apenas para alguns tipos padrão e para os tipos personalizados que foram originalmente projetados com isso em mente. Não é exatamente o método universal que gostaríamos de encontrar, certo?
Bem, tente novamente. Agora vamos prestar atenção a duas funções não documentadas, as quais, no entanto, são suportadas por Roslyn:
__makeref()
e
__refvalue()
. O primeiro deles pega um objeto e retorna uma instância da estrutura
TypedReference
que armazena uma referência ao objeto e seu tipo, enquanto o segundo extrai o objeto da instância
typedReference
transmitida. Por que esses recursos são importantes para nós? Porque
TypedReference
é uma estrutura! No contexto da discussão, isso significa que podemos obter um ponteiro para ele, que, em combinação, será um ponteiro para o primeiro campo dessa estrutura. Ou seja, ele armazena o próprio link para o objeto que nos interessa. Então, para obter um ponteiro para um objeto gerenciado, precisamos ler o valor de um ponteiro para o que
__makeref()
retornará e convertê-lo em um ponteiro. Para obter um objeto por ponteiro, você deve chamar
__makeref()
de um objeto condicionalmente vazio do tipo necessário, obter um ponteiro para a instância
TypedReference
retornada, escrever um ponteiro para o objeto e chamar
__refvalue()
. O resultado é algo como este código:
public static Tout ToInstance<Tout>(IntPtr ptr) { Tout temp = default; TypedReference tr = __makeref(temp); Marshal.WriteIntPtr(*(IntPtr*)(&tr), ptr); Tout instance = __refvalue(tr, Tout); return instance; } public static void* ToPointer<T>(ref T obj) { if (typeof(T).IsValueType) { return *(void**)&tr; } else { return **(void***)&tr; } }
ObservaçãoRetornando à tarefa de escrever um invólucro seguro para InvokeAsm()
, InvokeAsm()
-se que o método de obter ponteiros usando __makeref()
e __refvalue()
, diferentemente do GCHandle.Alloc(GCHandleType.Pinned)
, não garante que nosso coletor de lixo não esteja em lugar algum. o objeto não se moverá. Portanto, o wrapper deve começar desativando o coletor de lixo e finalizando com a restauração de sua funcionalidade. A solução é bastante rude, mas eficaz.
Para quem não se lembra de códigos de operação
Então, aprendemos como chamar código binário, aprendemos como passá-lo como argumentos, não apenas valores imediatos, mas também ponteiros para qualquer coisa ... Existe apenas um problema. Onde obter o mesmo código binário? Você pode se armar com um lápis, um bloco de notas e uma tabela de opcode (por exemplo,
esta ) ou usar um editor hexadecimal com suporte a assembler x86 ou até mesmo um tradutor completo, mas todas essas opções significam que o usuário precisará usar outra coisa, exceto a biblioteca. Não era exatamente isso que eu queria, então decidi incluir meu tradutor na biblioteca, que era tradicionalmente chamada SASM (abreviação de Stack Assembler; não tem nada a ver com o
IDE ).
Isenção de responsabilidadeEu não sou bom em analisar strings, então o código do tradutor ... bem, imperfeito, para dizer o mínimo. Além disso, eu não sou forte em expressões regulares, então elas não estão lá. E, em geral - um analisador iterativo.
Provavelmente não vou falar sobre o processo de criação desse "milagre" - não há nada interessante nesta história, mas descreverei brevemente os principais recursos. Atualmente, a maioria das instruções x86 é suportada. As instruções matemáticas do coprocessador para trabalhar com números de ponto flutuante e de extensões (MMX, SSE, AVX) ainda não são suportadas. É possível declarar constantes, procedimentos, variáveis de pilha local, variáveis globais, cuja memória é alocada durante a tradução diretamente em uma matriz com código binário (se essas variáveis forem nomeadas usando rótulos, seu valor também poderá ser obtido no C # após a inserção, chamando métodos
GetBYTEVariable()
,
GetWORDVariable()
,
GetDWORDVariable()
,
GetAStringVariable()
e
GetWStringVariable()
do objeto
SASMCode
),
addr
e
invoke
estão presentes. Um dos recursos importantes é o suporte à importação de funções de bibliotecas externas usando a construção
extern < > lib < >
.
asmret
macro
asmret
é digna de um parágrafo separado. No processo de tradução, ele se desdobra em 11 instruções que formam o epílogo. O prólogo é adicionado ao início do código traduzido por padrão. Sua tarefa é salvar / restaurar o estado do processador. Além disso, o prólogo adiciona quatro constantes -
$first
,
$second
,
$this
e
$return
. Durante a conversão, essas constantes são substituídas por endereços na pilha, nos quais, respectivamente, são o primeiro e o segundo argumentos passados para a inserção do assembler, o endereço do primeiro comando de inserção e o endereço de retorno.
Sumário
O código dirá muito mais do que palavras, e seria estranho não compartilhar o resultado de um trabalho bastante longo, então convido todos os que me interessam ao
GitHub .
Se, no entanto, eu tentar generalizar de alguma forma tudo o que foi feito, então, na minha opinião, um projeto interessante e até, até certo ponto, não inútil, acabou. Por exemplo, algoritmos idênticos para classificar inserções em C # e usar inserções de assembler diferem na velocidade em mais de duas vezes (é claro, a favor do assembler). Em projetos sérios, é claro, não é recomendável usar a biblioteca resultante (efeitos colaterais imprevisíveis são possíveis, embora não muito prováveis), mas é bem possível para si mesmo.