- En réécrivant en C++ un émulateur CPU pour le Time Travel Debugging, on découvre que sur x86/amd64, un même comportement doit être traité différemment selon l’encodage, les préfixes et le mode d’exécution
int 3a un encodage sur un seul octet,CC, il existe une forme courte pourADD EAX, imm, et des préfixes REX sans effet : autant de représentations alternatives qui influencent les performances et le débogageINC/DEC,CMPXCHG8B/CMPXCHG16Bet les instructions de décalage/rotation gèrent les drapeaux d’une façon contre-intuitive, ce qui peut facilement mener à des bugs d’émulateur- Le shift count n’est pas appliqué tel quel à la taille de l’opérande : il est masqué. Ainsi,
shr eax,20hne met pas un registre 32 bits à 0, mais conserve la valeur - La segmentation est toujours utilisée pour accéder au TEB sous Windows 32 et 64 bits, et les différences de sens entre
FS/GSainsi que de détermination de leur base ont un impact direct sur l’implémentation des désassembleurs et des émulateurs
Règles de détail du x86 révélées par la réécriture de l’émulateur TTD
- Un des composants de Time Travel Debugging est un émulateur CPU qui enregistre l’exécution complète d’un processus au niveau instruction
- La première version, l’émulateur d’iDNA, était presque entièrement écrite en assembleur, donc rapide, mais difficile à maintenir et à faire évoluer
- Dans la deuxième version, la partie émulation ainsi que la plupart des autres composants ont été réécrits en C++, avec pour objectif de conserver l’essentiel des performances de la version assembleur tout en obtenant une base de code plus facile à gérer
- Construire un émulateur CPU impose de reproduire sans faute les détails du comportement du processeur, et même des règles familières aux habitués du x86 doivent être revérifiées au moment de l’implémentation
Les encodages x86 permettent plusieurs représentations d’une même instruction
- En x86, une même instruction peut être représentée par plusieurs séquences d’octets
int 3peut être encodé enCD 03, mais aussi sous la forme mono-octetCC- Comme il est utilisé pour les points d’arrêt logiciels, cela permet de placer un breakpoint même sur une instruction située à la fin d’une page mémoire dont la page suivante n’est pas mappée
- Il existe aussi des encodages alternatifs plus courts pour les cas fréquents
add eax, immpeut être écrit plus brièvement comme05cccccccc- Pour ajouter la même valeur à
ECX, il faut un octet de plus, par exemple81c1cccccccc
- Si
EAXest appelé « Accumulator register », ce n’est pas seulement une convention : cela se traduit réellement dans l’encodage, et des instructions plus courtes peuvent améliorer les performances en réduisant à la fois les données à charger depuis la mémoire principale et l’usage du cache d’instructions - Les compilateurs peuvent exploiter ces encodages courts lorsqu’ils sont disponibles
Préfixes et limite de longueur de 15 octets par instruction
- Les instructions x86 peuvent avoir des octets de préfixe qui modifient leur comportement
- En code 64 bits, le préfixe REX, très courant, sert à accéder à un ensemble de registres plus large qu’en code 32 bits
- Le CPU accepte aussi des préfixes REX sans effet
4004ccest une forme où un octet REX précèdeadd al,0CChsur 8 bits, mais ici le REX n’a aucun effet- Le CPU peut même exécuter deux préfixes REX d’affilée, ce qui peut perturber plusieurs désassembleurs, y compris WinDbg
- Sur les CPU compatibles x86, la longueur maximale d’une instruction est une limite matérielle de 15 octets
- Toute instruction dépassant 15 octets est considérée comme invalide et provoque une exception
- Les anciens CPU avaient aussi d’autres limitations sur les préfixes, et certains préfixes comme
LOCKsont soumis à des conditions d’usage plus strictes
Une interprétation qui change selon la taille d’adresse et le mode
- Le préfixe d’override d’adresse permet, en mode 64 bits, de référencer une adresse 32 bits
488d0424correspond àlea rax,[rsp]67488d0424devientlea rax,[esp]à cause du préfixe0x67
- En code 32 bits, l’Address override fait basculer le mode d’adressage vers le 16 bits
- Pour désassembler ou interpréter correctement une même séquence d’octets, il faut connaître la taille d’opérande et la taille d’adresse par défaut du segment de code
- En mode 32 bits,
8b0424signifiemov eax,dword ptr [esp] - En mode 64 bits,
8b0424signifiemov eax,dword ptr [rsp]
- En mode 32 bits,
- La plage
40à4F, autrefois utilisée en x86 pourINC regetDEC reg, sert en x64 de REX prefix bytes- En mode 32 bits,
48 03 04 24est interprété comme deux instructions :dec eaxpuisadd eax,dword ptr [esp] - En mode 64 bits,
48030424est interprété comme une seule instruction :add rax,qword ptr [rsp]
- En mode 32 bits,
- Les concepteurs d’AMD64 ont réutilisé l’espace d’encodage très large de
INC/DECpour le nouveau préfixe nécessaire à l’extension du jeu de registres en mode 64 bits, ces instructions disposant déjà d’un autre encodage couvrant à la fois registre et mémoire
Le piège de INC reg en mode 64 bits dans WinDbg
- WinDbg assemble toujours les instructions comme si elles étaient en mode 32 bits, ce qui peut produire un résultat inattendu si l’on tente d’assembler
INC regdans du code 64 bits - Dans l’exemple,
inc eaxne devient pas une vraie instruction d’incrémentation, mais un préfixe REX inutile qui modifie l’instruction suivante - Au final, la séquence d’octets est interprétée non comme
inc, mais comme un préfixe placé devant une instructionjmp
Exceptions dans le comportement des drapeaux
INC EAXressemble àADD EAX, 1, mais ce n’est pas exactement la même choseADDmet à jour le carry flagINCne met pas à jour le carry flag
- Lors de l’implémentation de l’émulateur TTD, cette différence avait d’abord été codée incorrectement, et des tests unitaires l’ont détectée
- La plupart des opérations arithmétiques et logiques définissent les overflow, sign, zero, auxiliary carry, parity et carry flags
CMPXCHGdéfinit aussi ces drapeaux, maisCMPXCHG8BetCMPXCHG16Bne modifient que le zero flag- Certaines instructions laissent une partie des drapeaux dans un état indéfini
- Les instructions de shift et de rotation laissent l’overflow flag indéfini quand la quantité de décalage est supérieure à 1
- Le comportement réel des drapeaux indéfinis dépend de l’implémentation interne de l’opération de décalage et peut varier selon l’architecture
- On raconte que les CPU de la famille Atom effectuent les bit shifts dans l’ALU d’une manière moins coûteuse et plus lente, ce qui change la valeur des drapeaux indéfinis, mais cela n’a pas été vérifié directement
Le masquage du count dans les instructions de shift
66c1e810correspond àshr ax,10het décaleAXde 16 bits vers la droite- Comme
AXest un registre 16 bits, le résultat devient 0
- Comme
c1e820correspond àshr eax,20h, ce qui ressemble en apparence à une instruction décalantEAXde 32 bits vers la droite- En réalité, la valeur de
EAXne change pas- D’après l’Intel SDM, le count est masqué par
1Fh, donc seuls les 5 bits de poids faible de la rotation sont utilisés - Avec le préfixe
REX.W, le masque devient3Fh, ce qui porte le décalage maximal à 63 bits
- D’après l’Intel SDM, le count est masqué par
- Lors d’un entretien chez Microsoft, ce comportement a posé un vrai problème dans une question demandant « toutes les façons de remettre à zéro un registre 32 bits avec une seule instruction »
- L’intervieweur pensait qu’un shift permettait de le faire, mais la réponse était que ce n’était pas possible sur un registre 32 bits
Des segments toujours vivants en code 32 et 64 bits
- La mémoire segmentée peut sembler être un vestige du code 16 bits, mais elle a encore des effets réels en code 32 et 64 bits
- La plupart des OS utilisent un modèle mémoire presque plat et fixent l’adresse de base des segments à 0, ce qui la rend généralement invisible au quotidien
- En mode 64 bits, le CPU traite toujours la base des segments
CS,DS,ESetSScomme étant 0
- En mode 64 bits, le CPU traite toujours la base des segments
- Exception notable : le thread local storage utilise des registres de segment supplémentaires comme
FSouGS - Rectificatif : la base des segments
FS/GSpeut être lue même depuis du code non privilégié viardfsbase,wrfsbase,rdgsbase,wrgsbase- Ces instructions sont disponibles depuis Ivy Bridge, donc depuis 2012
Accès au TEB de Windows avec FS et GS
- Sous Windows,
FSetGSservent à référencer le TEB (Thread Execution Block) - La structure TEB contient un self pointer vers l’adresse plate du début de la structure, et cette adresse est aussi la base du segment concerné
- Dans les processus 32 bits, le TEB se trouve via
FSGetLastErrorrécupèreTEB.NtTib.Selfdepuisfs:[00000018h], puis litLastErrorValueà[eax+34h]
- Dans les processus 64 bits, le TEB se trouve via
GSGetLastErrorlit un pointeur dansgs:[30h], puis récupère la valeur à[rax+68h]
- Un processus 32 bits exécuté sur un OS 64 bits possède à la fois un TEB 32 bits et un TEB 64 bits, et dans certains contextes comme le code WOW 64 bits exécuté à l’intérieur d’un processus 32 bits, il est utile d’accéder aux deux
La façon de déterminer la base d’un segment change aussi selon le mode
- La configuration CPU qui détermine l’adresse de base de
FSetGSdiffère entre le mode 32 bits et le mode 64 bits - En mode 32 bits, la valeur réelle du registre de segment référence le segment descriptor défini dans la Global Descriptor Table et la Local Descriptor Table
- En mode 64 bits, la base est contrôlée par deux MSR
FS Base, ouIA32_FS_BASEdans l’Intel SDMGS Base, ouIA32_GS_BASEdans l’Intel SDM
- À cause de cette structure, en mode 64 bits, la valeur réelle des registres
FSetGSelle-même n’a pas d’importance- Ce qui compte, c’est le préfixe d’override de segment
- Dans WinDbg, lors du débogage d’un processus 32 bits, on peut utiliser la valeur du registre
FSpour dumper le contenu du « segment FS » - Sur un processus 64 bits, cela ne fonctionne pas de la même manière : c’est le préfixe d’override de segment qui a du sens, plus que la valeur du segment elle-même
Leçons pratiques pour les auteurs d’émulateurs
- Créer un émulateur x86 oblige à traiter très finement le comportement réel du CPU : encodage des instructions, préfixes, drapeaux, shift count, segments
- Une grande partie de ces règles est presque inutile pour écrire du code ordinaire, mais devient une exigence directe quand on implémente un émulateur
- Beaucoup de choses ont été apprises par essais-erreurs et grâce au mentorat ; l’article cite aussi Darek Mihocka, qui a une longue expérience des émulateurs, ainsi que emulators.com
- Si les optimisations x86 et le fonctionnement bas niveau vous intéressent, les ressources de Agner Fog’s website sont utiles
1 commentaires
Commentaires sur Hacker News
Au passage, BSF/BSR ont aussi leurs particularités. L’Intel SDM dit que si l’entrée vaut 0, la valeur de destination est indéfinie, tandis qu’AMD documente que, dans ce cas, la destination n’est pas modifiée.
Or glibc s’appuie telle quelle sur le fait non documenté que, même chez Intel, la destination n’est pas modifiée [1]. À cause de cela, il m’a fallu pas mal de temps pour trouver la cause du problème dans mon traducteur binaire.
Par ailleurs, TZCNT/LZCNT sont des encodages de BSF/BSR avec le préfixe F3 ; sur les anciens processeurs qui ne prennent pas en charge cette extension, le préfixe est silencieusement ignoré. Le même code se comporte donc différemment selon le CPU, mais au moins c’est documenté.
Côté encodage, les gens critiquent souvent les préfixes, mais personnellement je ne trouve pas que ce soit le pire. C’est bien connu et relativement documenté. Il existe des bizarreries plus gênantes. Par exemple, les bits d’extension REX/VEX/EVEX.RXB sont ignorés lorsqu’ils ne s’appliquent pas, mais provoquent un #UD pour les registres de masque k0-k7. Pourtant, quand le registre est encodé dans ModRM.rm, les bits d’extension sont à nouveau ignorés.
APX fait encore monter le niveau des singularités. Le préfixe REX2 peut encoder les registres généraux r16-r31, mais pas xmm16-xmm31 ; le préfixe EVEX a plusieurs agencements selon l’opcode, et les bits d’extension utilisés pour les registres changent aussi selon le type de registre. Les registres XMM utilisent X3:B3:rm et V4:X3:idx, tandis que les registres généraux utilisent B4:B3:rm et X4:X3:idx. Un an plus tard, je n’ai toujours pas terminé le décodeur APX, donc je ne peux pas donner la liste complète.
[1]: https://sourceware.org/bugzilla/show_bug.cgi?id=31748
Pour EVEX, je compte conserver les bits bruts jusqu’à la lecture de l’opcode et l’identification de la classe EVEX. Autrement dit, l’idée est de les préserver jusqu’avant les immédiats, probablement même jusqu’avant ModRM.
Mon décodeur repose globalement sur les tableaux du manuel, et le code est plutôt correct. L’indentation n’est pas excessive et les étapes sont pour la plupart séparées ou faciles à identifier. Comme la sortie est du code JIT, il n’a pas besoin d’être ultra-efficace ; je préfère qu’il soit lisible. Ce n’est pas non plus là que se passe l’essentiel du temps.
Cela dit, il y a plusieurs cas où le manuel est faux ou ne dit pas tout. Les tableaux n’ont pas été mis à jour depuis des années et, par exemple, il n’y a même pas les instructions sur les registres K. À l’avenir, il faudra sans doute davantage de travail manuel.
Le commentaire tout en haut explique un peu la situation : https://github.com/qemu/qemu/blob/59084feb256c617063e0dbe7e6...
Comme dit plus haut, il reste encore quelques instructions gérées par l’ancien code, notamment BT/BTS/BTR/BTC. Le code est écrit, mais pas encore fusionné.
Mais il existe le préfixe 0x66, qui bascule entre les modes 16 bits et 32 bits. Si on l’applique à BSWAP EAX, il se produit quelque chose d’étrange et non défini.
Sur certaines architectures de CPU, comme dans les différences Intel/AMD, le préfixe était simplement ignoré ; sur d’autres, il se produisait ce que j’appelle un « swap interne ». Par exemple, parmi les quatre octets stockés dans EAX, les octets 1 et 2 étaient échangés.
0x11223344 devenait 0x11332244.
Autrefois, x86 était le fossé défensif d’Intel ; aujourd’hui, ça ressemble plutôt à un fardeau cauchemardesque à traîner.
Il existe bien une fonction clz(), mais si LZCNT avait simplement été un BSR avec une sémantique différente uniquement pour l’entrée 0, le coût d’une soustraction supplémentaire dans l’implémentation pour obtenir la compatibilité aurait sans doute été modeste.
En exécutant le même programme sur un CPU émulé et en vérifiant à chaque instruction que l’état est identique, on pourrait tester si l’émulateur reproduit parfaitement le matériel.
C’est quelqu’un d’impressionnant. Écrire de l’assembleur me paraît simple, et j’aime aussi son esthétique en colonnes verticales.
L’expérience qui s’est rapprochée le plus d’un travail comme celui de l’OP, c’est quand j’ai essayé d’aider un ami qui fait du JS à comprendre la pile, et qu’on a créé ensemble une mini-VM avec un petit ISA : https://gist.github.com/darighost/2d880fe27510e0c90f75680bfe...
On aurait pu creuser davantage, et j’aurais aimé le faire, mais je pense que cela nous aurait éloignés de l’objectif pédagogique initial. Il faudrait que je recontacte cet ami pour voir s’il veut encore étudier ça ensemble. Ce n’est pas simple : lui gagne beaucoup d’argent avec du développement web sophistiqué et n’a pas le temps de creuser, tandis que moi je suis au chômage, avec ce qui ressemble à un océan presque infini de temps et d’énergie.
Justine Tunney et son émulateur valent aussi le détour. https://justine.lol/blinkenlights/
La documentation offre un excellent tour d’horizon de la façon dont fonctionne un CPU.
La discussion précédente est ici : https://news.ycombinator.com/item?id=34636699
J’ai du mal à croire que 16 mois se soient déjà écoulés. Le temps passe vraiment vite.
Je ne suis pas du tout d’accord avec l’idée selon laquelle « écrire un émulateur de CPU est la meilleure façon de vraiment comprendre le fonctionnement d’un CPU »
La meilleure façon, c’est de construire un CPU au niveau des portes, comme on le fait dans un bon cours d’informatique. Construire un ARM réduit à partir de zéro était vraiment amusant.
Construire un émulateur de CPU moderne est un défi un peu plus accessible, et même si seule une partie fonctionne, cela reste très formateur.
La plupart des ingénieurs logiciel ont une compréhension incomplète des aléas de données, de l’invalidation de cache et des blocages de pipeline.
Mais dès qu’on commence à fabriquer ainsi un CPU qu’on voudrait utiliser pour faire quelque chose de significatif, ces détails deviennent moins intéressants. À ce stade, la complexité du comportement et des spécifications devient plus intéressante, et l’approche par émulateur est plus facile à manier et couvre davantage de types de comportements.
J’ai écrit des émulateurs rapides pour une douzaine d’architectures qui ne sont pas des jouets, ainsi que quelques convertisseurs JIT, mais x86 me donne encore un PTSD. Je n’ai jamais vu une architecture aussi bordélique. Il y a une histoire et des raisons, mais quand même, c’est violent.
J’ai récemment implémenté, comme projet perso, une bonne partie d’un décodeur x86-64 [1], et j’ai été assez surpris de voir à quel point c’est devenu encore plus complexe ces derniers temps. Pour mon usage, Sandpile.org [2] a été vraiment utile.
[1] Plus précisément, une version x86-64 du disfilter de Fabian Giesen, créée pour un autre projet perso qui n’est pas encore public : https://gist.github.com/lifthrasiir/df47509caac2f065032ef72e...
[2] https://sandpile.org/
Le désassembleur 68k que j’ai fait à l’université a été un moment à la Neo disant « je connais le kung-fu ». C’était le chaînon manquant qui m’a permis de raisonner, depuis les langages de haut niveau jusqu’aux transistors, puis dans l’autre sens, sur le code.
Écrire un émulateur complet doit être encore un ordre de grandeur plus efficace que ça. Bon article.
Ma mémoire devait me jouer des tours. Je me souvenais que les variantes de salsa20 et le code machine étaient à l’origine sur cryp.to, mais le site de Dan Bernstein était https://cr.yp.to/.
À l’époque où, dans une startup, nous étudiions le chiffrement des données au repos, le chiffrement en streaming, etc., les pages de Dan proposaient plusieurs implémentations selon les chipsets et jeux d’instructions cibles. Elles étaient cross-compilées depuis sa représentation assembleur.
C’était intéressant d’utiliser des VM et de voir quels jeux d’instructions étaient pris en charge au début/milieu des années 2000. Pendant les tests, il arrivait que certaines VM prétendent les prendre en charge, mais que l’implémentation ne soit pas complète, ce qui causait parfois des problèmes.
C’est amusant de voir ici autant de gens dire à quel point l’assembleur x86 est pénible par rapport au RISC. Moi, j’ai le problème exactement inverse quand je sépare à nouveau du code en fichiers objet.
Pour cet usage, analyser x86 est vraiment facile, tandis que MIPS a été un cauchemar. Principalement parce que ce qui m’intéresse, ce sont les références au code et aux données. x86 a des constantes immédiates de taille pointeur, tandis que MIPS a des paires de relocalisations HI16/LO16, qui s’entremêlent avec le graphe d’utilisation des registres, le flux de code et les instructions en délai de branchement, créant toutes sortes de problèmes.
Ce n’est pas pour autant que je fais l’éloge de x86.
La grande leçon quand on apprend en comparant l’assembleur x86 et le C, c’est que signé/non signé n’est plus une propriété du type, mais de l’opération.
Ce serait bien de pouvoir exploiter les flags, et sur certaines architectures comme PPC ou armv7 c’est plus facile, mais x86 écrase les flags tellement facilement qu’il est très difficile d’en tirer parti.