- Ao reescrever em C++ um emulador de CPU para Time Travel Debugging, ficou claro que no x86/amd64 até o mesmo comportamento precisa ser tratado de forma diferente dependendo da codificação, dos prefixos e do modo de execução
int 3tem a codificação de byte únicoCC, há formas curtas comoADD EAX, imme representações alternativas como prefixos REX sem efeito, todas com impacto em desempenho e depuraçãoINC/DEC,CMPXCHG8B/CMPXCHG16Be instruções de shift/rotate tratam flags de um jeito diferente do que a intuição sugere, o que facilita a introdução de bugs no emulador- O shift count não é aplicado diretamente no tamanho do operando, e sim mascarado; por isso
shr eax,20hnão zera um registrador de 32 bits, mas mantém o valor - Segmentos ainda são usados no acesso ao TEB no Windows de 32 e 64 bits, e as diferenças de significado de
FS/GSe de como o base é determinado afetam diretamente a implementação de disassemblers e emuladores
Reescrevendo o emulador de TTD revelou as regras detalhadas do x86
- Um dos componentes do Time Travel Debugging é um emulador de CPU que registra toda a execução do processo no nível de instrução
- O emulador da primeira versão, o iDNA, era escrito quase todo em assembly e era rápido, mas difícil de manter e expandir
- Na segunda versão, a parte de emulação e depois a maior parte do restante também foi reescrita em C++, com a meta de manter boa parte do desempenho da versão em assembly e ao mesmo tempo ter uma base de código mais fácil de administrar
- Para criar um emulador de CPU, é preciso reproduzir sem falhas os detalhes do comportamento da CPU, e até regras familiares para quem já conhece x86 acabam precisando ser verificadas de novo na implementação real
Codificações x86 que expressam a mesma instrução de várias formas
- No x86, a mesma instrução pode ser representada por várias sequências de bytes
int 3pode ser codificada comoCD 03, mas também como o byte únicoCC- Como ela é usada como breakpoint de software, isso permite colocar um breakpoint até na posição de uma instrução no fim de uma página de memória cuja página seguinte não está mapeada
- Também existem codificações alternativas para encurtar casos comuns
add eax, immpode ser representada de forma mais curta como05cccccccc- Para somar o mesmo valor em
ECX, é preciso 1 byte a mais, como em81c1cccccccc
- O fato de
EAXser chamado de “Accumulator register” não é só convenção: isso gera diferenças reais de codificação, e instruções mais curtas podem melhorar o desempenho ao reduzir os dados que precisam ser trazidos da memória principal e o uso do cache de instruções - Compiladores podem aproveitar essas codificações curtas quando possível
Prefixos e o limite de 15 bytes por instrução
- Instruções x86 podem ter bytes de prefixo que alteram seu comportamento
- O prefixo REX, comum em código de 64 bits, é usado para acessar uma faixa maior de registradores do que no código de 32 bits
- A CPU também aceita prefixos REX sem efeito
4004ccé uma forma com um byte REX antes deadd al,0CCh, mas nesse caso o REX não produz efeito algum- Mesmo com dois prefixos REX seguidos, a CPU consegue executar a instrução, e vários disassemblers, incluindo o WinDbg, podem se confundir com isso
- Em CPUs compatíveis com x86, o tamanho máximo da instrução atual tem limite rígido de 15 bytes
- Instruções com mais de 15 bytes são tratadas como inválidas e geram exceção
- CPUs antigas também têm outras restrições sobre prefixos, e alguns prefixos, como
LOCK, têm condições de uso ainda mais rígidas
Interpretação que muda conforme tamanho de endereço e modo
- O prefixo Address override pode fazer o modo de 64 bits referenciar endereços de 32 bits
488d0424élea rax,[rsp]67488d0424viralea rax,[esp]por causa do prefixo0x67
- Em código de 32 bits, o Address override muda o modo de endereçamento para endereços de 16 bits
- A mesma sequência de bytes só pode ser disassemblada ou interpretada corretamente se forem conhecidos o tamanho padrão de operando e o tamanho de endereço do segmento de código
8b0424no modo de 32 bits émov eax,dword ptr [esp]8b0424no modo de 64 bits émov eax,dword ptr [rsp]
- A faixa
40~4F, antes usada paraINC regeDEC regno x86, é usada no x64 como REX prefix bytes- No modo de 32 bits,
48 03 04 24é interpretado como duas instruções:dec eaxeadd eax,dword ptr [esp] - No modo de 64 bits,
48030424é uma única instrução:add rax,qword ptr [rsp]
- No modo de 32 bits,
- Os projetistas do AMD64 reutilizaram esse amplo espaço de codificação de
INC/DECcomo novos prefixos para ampliar o conjunto de registradores no modo de 64 bits, já que essas instruções já tinham outras codificações que suportavam registradores e memória
A armadilha do INC reg no modo de 64 bits no WinDbg
- O WinDbg sempre monta instruções como se estivesse em modo de 32 bits, então tentar montar
INC regem código de 64 bits pode gerar um resultado diferente do pretendido - No exemplo,
inc eaxnão vira uma instrução real de incremento, e sim um prefixo REX inútil que modifica a instrução seguinte - Como resultado, a sequência de bytes é interpretada não como
inc, mas como um prefixo antes de uma instruçãojmp
Exceções no comportamento das flags
INC EAXparece ser parecido comADD EAX, 1, mas não é exatamente a mesma coisaADDatualiza a carry flagINCnão atualiza a carry flag
- Durante a implementação do emulador TTD, essa diferença foi implementada de forma errada no começo e o erro foi encontrado com testes unitários
- A maioria das operações aritméticas e lógicas define as flags overflow, sign, zero, auxiliary carry, parity e carry
CMPXCHGtambém define essas flags, masCMPXCHG8BeCMPXCHG16Bmodificam apenas a zero flag- Algumas instruções deixam parte das flags em estado indefinido
- Instruções de shift e rotate deixam a overflow flag indefinida quando o shift amount é maior que 1
- O comportamento real de flags indefinidas está ligado à implementação interna da operação de shift e pode variar entre arquiteturas
- Há relatos de que CPUs da linha Atom executam bit shift na ALU de um jeito mais barato e mais lento, fazendo com que os valores de flags indefinidas sejam diferentes, mas isso não foi testado diretamente
Máscara no count das instruções de shift
66c1e810éshr ax,10he faz um shift à direita de 16 bits emAX- Como
AXé um registrador de 16 bits, o resultado vira 0
- Como
c1e820éshr eax,20h, e na superfície parece uma instrução que deslocaEAX32 bits para a direita- Na prática, o valor de
EAXnão muda- Segundo o Intel SDM, o count é mascarado com
1Fh, então só os 5 bits inferiores da rotação são usados - Com o prefixo
REX.W, a máscara vira3Fh, e o shift máximo passa a ser de 63 bits
- Segundo o Intel SDM, o count é mascarado com
- Em uma entrevista da Microsoft, esse comportamento apareceu numa pergunta sobre “todas as formas de limpar um registrador de 32 bits com uma única instrução”
- O entrevistador achava que seria possível usando shift, mas a resposta foi que isso não dá para fazer em um registrador de 32 bits
Segmentos que ainda sobrevivem em código de 32 e 64 bits
- Memória segmentada pode parecer um resquício do código de 16 bits, mas ainda tem efeito real em código de 32 e 64 bits
- A maioria dos sistemas operacionais usa um modelo de memória quase flat e deixa o base address dos segmentos em 0, então isso normalmente passa despercebido
- No modo de 64 bits, a CPU sempre trata o base dos segmentos
CS,DS,ESeSScomo 0
- No modo de 64 bits, a CPU sempre trata o base dos segmentos
- Como exceção, thread local storage usa registradores de segmento extras como
FSeGS - Como correção, o base dos segmentos
FS/GStambém pode ser lido em código sem privilégio com as instruçõesrdfsbase,wrfsbase,rdgsbaseewrgsbase- Essas instruções estão disponíveis desde o Ivy Bridge, ou seja, desde 2012
Acesso ao TEB no Windows com FS/GS
- No Windows,
FSeGSsão usados para referenciar o TEB (Thread Execution Block) - A estrutura TEB tem um self pointer que aponta para o endereço flat do início da estrutura, e esse endereço também é o base do respectivo segmento
- Em processos de 32 bits, o TEB fica em
FSGetLastErrorpegaTEB.NtTib.Selfemfs:[00000018h]e depois lêLastErrorValueem[eax+34h]
- Em processos de 64 bits, o TEB fica em
GSGetLastErrorlê o ponteiro emgs:[30h]e depois pega o valor em[rax+68h]
- Processos de 32 bits rodando em um SO de 64 bits têm tanto um TEB de 32 bits quanto um TEB de 64 bits, e há contextos úteis em que é preciso acessar ambos, como no código WOW de 64 bits executado dentro do processo de 32 bits
A forma de determinar o base do segmento também muda com o modo
- A configuração da CPU que determina o base address de
FSeGSé diferente entre os modos de 32 e 64 bits - No modo de 32 bits, o valor real do registrador de segmento aponta para um segment descriptor definido na Global Descriptor Table e na Local Descriptor Table
- No modo de 64 bits, o base é controlado por dois MSRs
FS Base, chamado deIA32_FS_BASEno Intel SDMGS Base, chamado deIA32_GS_BASEno Intel SDM
- Por causa dessa estrutura, no modo de 64 bits o valor real dos registradores
FSeGSem si não importa- O que importa é o prefixo de segment override
- Ao depurar um processo de 32 bits no WinDbg, é possível usar o valor do registrador
FSpara fazer dump do conteúdo do “FS segment” - Em processos de 64 bits, isso não funciona da mesma forma, e o prefixo de segment override passa a ser mais relevante que o valor do segmento
Lições práticas para quem implementa emuladores
- Criar um emulador x86 exige tratar com precisão o comportamento real da CPU, incluindo codificação de instruções, prefixos, flags, shift count e segmentos
- Muitas dessas regras quase não têm utilidade para escrever código comum, mas viram exigências diretas na implementação de um emulador
- Houve muito aprendizado por tentativa e erro e por mentoria, e o texto também cita Darek Mihocka, que tem longa experiência com emuladores, além do emulators.com
- Para quem se interessa por otimização em x86 e comportamento de baixo nível, os materiais do site do Agner Fog são úteis
1 comentários
Comentários do Hacker News
De quebra, BSF/BSR também têm uma esquisitice. O Intel SDM diz que, se a entrada for 0, o valor de destino fica indefinido, mas a AMD documenta que, nesse caso, o destino não é modificado
No entanto, a glibc usa diretamente esse fato não documentado de que, na Intel, o destino também não é modificado [1]. Por causa disso, demorei bastante para encontrar a causa do problema no meu tradutor binário
Além disso, TZCNT/LZCNT são codificações de BSF/BSR com o prefixo F3, mas em processadores antigos que não suportam essa extensão o prefixo é silenciosamente ignorado. Então o mesmo código se comporta de forma diferente dependendo da CPU, mas pelo menos isso está documentado
Nas codificações, as pessoas costumam reclamar muito dos prefixos, mas pessoalmente não acho que isso seja o pior. Isso é bem conhecido e até certo ponto documentado. Existem esquisitices piores. Por exemplo, os bits de extensão REX/VEX/EVEX.RXB são ignorados se não se aplicarem, mas nos registradores de máscara k0-k7 eles geram #UD. Só que, quando o registrador é codificado em ModRM.rm, os bits de extensão voltam a ser ignorados
APX eleva o nível da esquisitice em mais um degrau. O prefixo REX2 consegue codificar registradores de propósito geral r16-r31, mas não xmm16-xmm31, e o prefixo EVEX tem vários layouts dependendo do opcode, com bits de extensão usados para registradores variando conforme o tipo de registrador. Registradores XMM usam X3:B3:rm e V4:X3:idx, enquanto registradores de propósito geral usam B4:B3:rm e X4:X3:idx. Já se passou um ano e ainda não consegui terminar o decodificador de APX, então não posso dar a lista completa
[1]: https://sourceware.org/bugzilla/show_bug.cgi?id=31748
Minha ideia para o EVEX é preservar os bits brutos até ler o opcode e identificar a classe EVEX. Ou seja, o plano é mantê-los até antes do valor imediato, provavelmente até antes do ModRM
Meu decodificador é basicamente baseado nas tabelas do manual, e o código no geral está razoável. A indentação não é exagerada e, na maior parte, as etapas são separadas ou fáceis de identificar. Como a saída é código JIT, não precisa ser extremamente eficiente; ser legível está ótimo. Também não é onde a maior parte do tempo é gasta
Mesmo assim, há vários casos em que o manual está errado ou não conta a história completa. As tabelas também não são atualizadas há anos e, por exemplo, nem incluem instruções de registrador K. Daqui para frente, parece que vai haver mais trabalho manual
O comentário no topo explica um pouco a situação: https://github.com/qemu/qemu/blob/59084feb256c617063e0dbe7e6...
Como mencionei acima, ainda restam algumas instruções tratadas pelo código antigo, especialmente BT/BTS/BTR/BTC. O código já foi escrito, mas ainda não foi mesclado
Só que existe o prefixo 0x66, que alterna entre modos de 16 e 32 bits. Quando ele é aplicado a BSWAP EAX, acontece uma bizarrice indefinida
Em algumas arquiteturas de CPU, como na diferença entre Intel e AMD, o prefixo era simplesmente ignorado; em outras, acontecia o que eu chamo de “troca interna”. Por exemplo, entre os quatro bytes armazenados em EAX, os bytes 1 e 2 trocavam de lugar
0x11223344 virava 0x11332244
Antigamente, x86 era o fosso defensivo da Intel, mas agora parece mais um peso de pesadelo que ela precisa carregar
Existe uma função clz(), mas, se LZCNT fosse simplesmente um BSR com semântica diferente apenas para entrada zero, o custo de adicionar uma subtração na implementação para obter compatibilidade provavelmente teria sido pequeno
Aí daria para executar o mesmo programa numa CPU emulada e verificar, a cada instrução, se o estado bate, testando se o emulador reproduz o hardware perfeitamente
Pessoa muito foda. Escrever assembly parece simples, e eu também gosto da estética vertical
A experiência mais próxima que tive de algo como o trabalho do OP foi quando tentei explicar pilha para um amigo que mexe com JS e acabamos fazendo juntos uma mini VM com uma ISA pequena: https://gist.github.com/darighost/2d880fe27510e0c90f75680bfe...
Eu poderia ter ido mais fundo, e queria ter ido, mas aí acho que teria saído do objetivo educacional original. Acho que vou mandar mensagem para ele para ver se ainda topa estudar junto. Ele ganha muito dinheiro fazendo um desenvolvimento web muito legal e não tem tempo para se aprofundar, enquanto eu estou desempregado e tenho quase um oceano infinito de tempo e energia, então não é fácil
Vale a pena ver Justine Tunney e aquele emulador dela. https://justine.lol/blinkenlights/
A documentação faz um excelente passeio por como a CPU funciona
A discussão anterior está aqui: https://news.ycombinator.com/item?id=34636699
Não dá para acreditar que já se passaram 16 meses. O tempo voa mesmo
Não concordo muito com a ideia de que “escrever um emulador de CPU é a melhor forma de realmente entender como uma CPU funciona”
A melhor forma é construir uma CPU no nível de portas lógicas, como se faz em um bom curso de ciência da computação. Foi muito divertido construir um ARM reduzido do zero
Construir um emulador de CPU moderna é um desafio mais acessível e, mesmo que só parte funcione, ainda tem muito valor educacional
A maioria dos engenheiros de software tem uma compreensão incompleta de hazards de dados, invalidação de cache e stalls de pipeline
Mas, quando você começa a tentar construir assim uma CPU que queira usar para algo significativo, o interesse por esses detalhes começa a cair. Nessa hora, a complexidade do comportamento e da especificação passa a ser mais interessante, e a abordagem do emulador é mais manejável e cobre mais tipos de comportamento
Já escrevi emuladores rápidos para umas 12 arquiteturas que não eram brinquedo, e também fiz alguns tradutores JIT, mas x86 ainda me dá PTSD. Nunca vi uma arquitetura tão bagunçada. Ela tem história e tem seus motivos, mas ainda assim é pesado
Recentemente implementei boa parte de um decodificador x86-64 como projeto paralelo [1], e fiquei bem surpreso com o quanto isso ficou mais complexo hoje em dia. Para os meus objetivos, Sandpile.org [2] foi realmente útil
[1] Mais precisamente, uma versão x86-64 do disfilter de Fabian Giesen feita para outro projeto paralelo ainda não publicado: https://gist.github.com/lifthrasiir/df47509caac2f065032ef72e...
[2] https://sandpile.org/
O disassembler 68k que fiz na faculdade foi como o momento em que o Neo diz “eu sei kung fu”. Era o elo que faltava para ir de linguagens de alto nível até transistores e depois voltar a raciocinar sobre o código no sentido inverso
Escrever um emulador completo parece que seria uma ordem de grandeza mais eficaz do que isso. Ótimo texto
Pelo visto minha memória estava errada. Eu lembrava que as variantes de salsa20 e o código de máquina estavam originalmente em cryp.to, mas o site do Dan Bernstein era https://cr.yp.to/
Na época em que, em uma startup, estávamos avaliando criptografia de dados em repouso, criptografia de streaming e afins, a página do Dan tinha várias implementações por chipset de destino e conjunto de instruções. Eram coisas compiladas de forma cruzada a partir da notação em assembly dele
Foi interessante usar a VM para ver quais conjuntos de instruções eram suportados no começo e meados dos anos 2000. Durante os testes, às vezes surgiam problemas porque a VM dizia suportar algo, mas a implementação não era totalmente compatível
É curioso ver tanta gente aqui falando de como assembly x86 é doloroso em comparação com RISC. Eu tenho exatamente o problema oposto quando separo código de volta em arquivos objeto
Para esse uso, analisar x86 é realmente fácil, e MIPS foi um pesadelo. Isso porque o que mais me importa são as referências a código e dados. No x86 existem imediatos do tamanho de ponteiro, enquanto no MIPS há pares de relocação HI16/LO16, o que se mistura com grafo de uso de registradores, fluxo de código e instruções com delay slot de desvio, gerando todo tipo de problema
Isso não significa que eu esteja elogiando x86
A maior lição ao aprender comparando assembly x86 com C é que signed/unsigned deixa de ser propriedade do tipo e passa a ser propriedade da operação
Seria bom conseguir aproveitar os flags; em algumas arquiteturas, como PPC ou armv7, isso é mais fácil, mas no x86 os flags são sobrescritos com facilidade demais, então é difícil demais aproveitar os valores