x86 emülatörü yazarken öğrendiğim tuhaf şeyler
(timdbg.com)- Time Travel Debugging için CPU emülatörünü C++ ile yeniden yazma sürecinde, x86/amd64’te aynı davranışın bile kodlama, prefix ve çalışma moduna göre farklı ele alınması gerektiği görüldü
int 3için tek baytlıkCCkodlaması,ADD EAX, immiçin kısa biçim, etkisiz REX prefix gibi performansı ve hata ayıklamayı etkileyen çok sayıda alternatif gösterim varINC/DEC,CMPXCHG8B/CMPXCHG16B, shift/rotate komutlarında flag işleme biçimi sezgilerden farklı olduğundan emülatör hatalarına kolayca yol açabiliyor- Shift count, operand boyutu olduğu gibi uygulanmayıp maskelendiği için
shr eax,20h, 32 bitlik register’ı 0 yapmaz; değeri korur - Segmentler, 32 bit ve 64 bit Windows’ta TEB erişimi için hâlâ kullanılır;
FS/GSanlamı ve base belirleme yöntemindeki farklar disassembler ve emülatör uygulamasını doğrudan etkiler
TTD emülatörünün yeniden yazımında ortaya çıkan x86 ayrıntı kuralları
- Time Travel Debugging’in bir bileşeni, bir sürecin tüm yürütmesini komut düzeyinde kaydeden CPU emülatörüdür
- İlk sürüm olan iDNA’nın emülatörü neredeyse tamamen assembly ile yazılmıştı ve hızlıydı; ancak bakımı ve genişletilmesi zordu
- İkinci sürümde emülasyon kısmı ve sonrasında diğer bölümlerin çoğu C++ ile yeniden yazıldı; assembly sürümünün performansının büyük bölümünü korurken daha yönetilebilir bir kod tabanı hedeflendi
- CPU emülatörü yapmak için CPU davranışının tüm ayrıntılarını eksiksiz tutturmak gerekir; x86 deneyimi olanlara tanıdık gelen kurallar bile gerçek uygulamada yeniden doğrulanır
Aynı komutu birden fazla şekilde ifade eden x86 kodlaması
- x86, aynı komutu birden fazla bayt dizisiyle ifade edebilir
int 3,CD 03olarak kodlanabildiği gibi tek baytCColarak da kodlanabilir- Yazılım breakpoint’i olarak kullanıldığı için, bir sonraki sayfanın eşlenmediği bir bellek sayfasının sonundaki komut konumuna bile breakpoint koymayı mümkün kılar
- Yaygın durumları kısaltmak için alternatif kodlamalar da vardır
add eax, imm,05ccccccccgibi kısa biçimde ifade edilebilir- Aynı değeri
ECX’e eklemek için81c1ccccccccgibi 1 bayt daha gerekir
EAX’in “Accumulator register” olarak adlandırılması yalnızca bir gelenek değildir; gerçek bir kodlama farkı yaratır. Kısa komutlar ana bellekten taşınması gereken veriyi ve instruction cache kullanımını azaltarak performans açısından avantaj sağlayabilir- Derleyiciler mümkün olduğunda bu kısa kodlamalardan yararlanabilir
Prefix’ler ve 15 baytlık komut uzunluğu sınırı
- x86 komutları, davranışı değiştiren prefix baytlarına sahip olabilir
- 64 bit kodda sık kullanılan REX prefix’i, 32 bit koda göre daha geniş bir register aralığına erişmek için kullanılır
- CPU, etkisi olmayan REX prefix’lerini de kabul eder
4004cc, 8 bitlikadd al,0CChönüne REX baytı eklenmiş biçimdir; ancak bu durumda REX’in hiçbir etkisi yoktur- İki REX prefix’i eklense bile CPU bunu çalıştırabilir; WinDbg dahil birçok disassembler bundan dolayı karışabilir
- x86 uyumlu CPU’larda mevcut komut uzunluğu için 15 bayt kesin üst sınırdır
- 15 baytı aşan komut geçersiz komut sayılır ve exception üretir
- Eski CPU’larda prefix’ler için başka sınırlamalar da vardır;
LOCKprefix’i gibi kullanılabileceği koşullar daha sıkı olan prefix’ler de bulunur
Adres boyutu ve moda göre değişen yorumlama
- Address override prefix’i, 64 bit modda 32 bit adrese başvurulmasını sağlayabilir
488d0424,lea rax,[rsp]anlamına gelir67488d0424,0x67prefix’i nedeniylelea rax,[esp]anlamına gelir
- 32 bit kodda Address override, adres modunu 16 bit adrese çevirir
- Aynı bayt dizisini doğru disassemble etmek veya yorumlamak için code segment’in varsayılan operand boyutunu ve adres boyutunu bilmek gerekir
- 32 bit moddaki
8b0424,mov eax,dword ptr [esp]anlamına gelir - 64 bit moddaki
8b0424,mov eax,dword ptr [rsp]anlamına gelir
- 32 bit moddaki
- x86’da
INC regveDEC regiçin kullanılan40~4Faralığı, x64’te REX prefix bytes olarak kullanılır- 32 bit modda
48 03 04 24,dec eaxveadd eax,dword ptr [esp]olmak üzere iki komut olarak yorumlanır - 64 bit modda
48030424, tek biradd rax,qword ptr [rsp]komutu olarak yorumlanır
- 32 bit modda
- AMD64 tasarımcıları, 64 bit modda register kümesini genişletmek için
INC/DEC’in geniş kodlama alanını yeni prefix için kullandı; bu komutlar için zaten hem register’ı hem de belleği destekleyen başka bir kodlama vardı
WinDbg ve 64 bit modda INC reg tuzağı
- WinDbg komutları her zaman 32 bit moddaymış gibi assemble ettiği için, 64 bit kodda
INC regassemble etmeye çalışınca niyet edilenden farklı sonuçlar doğabilir - Örnekte
inc eax, gerçek bir artırma komutu değil, sonraki komutu niteleyen işe yaramaz bir REX prefix’ine dönüşür - Sonuç olarak ilgili bayt dizisi
incdeğil,jmpkomutunun önündeki prefix olarak yorumlanır
Flag davranışındaki istisnalar
INC EAX,ADD EAX, 1’e benzer görünür ama tamamen aynı değildirADD, carry flag’i güncellerINC, carry flag’i güncellemez
- TTD emülatörü uygulanırken bu fark başta yanlış uygulanmış ve unit test ile yakalanmıştır
- Çoğu aritmetik ve mantıksal işlem overflow, sign, zero, auxiliary carry, parity, carry flag’lerini ayarlar
CMPXCHGde bu flag’leri ayarlar; ancakCMPXCHG8BveCMPXCHG16Byalnızca zero flag’i değiştirir- Bazı komutlar flag’lerin bir kısmını tanımsız durumda bırakır
- Shift ve rotate komutları, shift amount 1’den büyükse overflow flag’i tanımsız durumda bırakır
- Tanımsız flag’lerin gerçek davranışı, shift işleminin iç uygulamasıyla ilişkilidir ve mimariden mimariye değişebilir
- Atom ailesi CPU’ların ALU’da bit shift’i daha ucuz ve daha yavaş bir yöntemle yaptığı, bu yüzden tanımsız flag değerlerinin farklılaştığı söylenir; ancak bu doğrudan test edilmemiştir
Shift komutlarında count maskeleme
66c1e810,shr ax,10hanlamına gelir veAX’i 16 bit sağa shift ederAX16 bitlik register olduğu için sonuç 0 olur
c1e820,shr eax,20hanlamına gelir ve yüzeydeEAX’i 32 bit sağa shift eden bir komut gibi görünür- Gerçekte
EAXdeğeri değişmez- Intel SDM’ye göre count,
1Fhile maskelenir ve rotation’ın yalnızca alt 5 biti kullanılır REX.Wprefix’i kullanılırsa maske3Fholur ve maksimum shift değeri 63 bit’e çıkar
- Intel SDM’ye göre count,
- Microsoft mülakatında “32 bitlik bir register’ı tek komutla clear etmenin tüm yolları” sorusunda bu davranış gerçekten gündeme gelmişti
- Mülakatı yapan kişi shift kullanılırsa bunun mümkün olduğunu düşünüyordu; ancak 32 bitlik register için bunun mümkün olmadığı yanıtı verildi
32 bit ve 64 bit kodda yaşamaya devam eden segmentler
- Segment belleği 16 bit kodun bir kalıntısı gibi görünebilir; ancak 32 bit ve 64 bit kodda da gerçek etkisi vardır
- Çoğu OS neredeyse flat bir bellek modeli kullanır ve segment base address’i 0’da tuttuğu için normalde pek fark edilmez
- 64 bit modda CPU,
CS,DS,ES,SSsegment base’lerini her zaman 0 kabul eder
- 64 bit modda CPU,
- İstisna olarak thread local storage için
FSveyaGSgibi extra segment register’ları kullanılır - Düzeltme olarak,
FS/GSsegment’lerinin base’i yetkisiz koddan dardfsbase,wrfsbase,rdgsbase,wrgsbasekomutlarıyla okunabilir- Bu komutlar Ivy Bridge’den, yani 2012’den beri kullanılabilir
Windows TEB erişimi ve FS/GS
- Windows’ta
FSveGS, TEB’e (Thread Execution Block) başvurmak için kullanılır - TEB yapısı, yapının başlangıç noktasının flat address’ini gösteren bir self pointer içerir; bu adres aynı zamanda ilgili segment’in base’idir
- 32 bit süreçlerde TEB,
FSile konumlandırılırGetLastError,fs:[00000018h]konumundanTEB.NtTib.Selfdeğerini alır, ardından[eax+34h]konumundanLastErrorValuedeğerini okur
- 64 bit süreçlerde TEB,
GSile konumlandırılırGetLastError,gs:[30h]konumundan pointer’ı okur ve[rax+68h]konumundan değeri alır
- 64 bit OS üzerinde çalışan 32 bit süreçler hem 32 bit TEB’e hem de 64 bit TEB’e sahiptir; 32 bit süreç içinde çalışan 64 bit WOW kodu gibi iki TEB’e de erişmenin yararlı olduğu bağlamlar vardır
Segment base’i belirleme yöntemi de moda göre değişir
FSveGSbase address’ini belirleyen CPU ayarı, 32 bit mod ile 64 bit modda farklıdır- 32 bit modda segment register’ının gerçek değeri, Global Descriptor Table ve Local Descriptor Table içinde tanımlanan segment descriptor’ını referans alır
- 64 bit modda base iki MSR ile kontrol edilir
FS Base, Intel SDM’deIA32_FS_BASEGS Base, Intel SDM’deIA32_GS_BASE
- Bu yapı nedeniyle 64 bit modda
FSveGS’nin gerçek register değerinin kendisi önemli değildir- Önemli olan segment override prefix’idir
- WinDbg’de 32 bit bir süreci debug ederken
FSregister değeri kullanılarak “FS segment” içeriği dump edilebilir - 64 bit süreçlerde aynı yöntem çalışmaz; segment değerinden çok segment override prefix’i anlam taşır
Emülatör geliştiricilerinin çıkaracağı pratik dersler
- Bir x86 emülatörü yapmak, komut kodlaması, prefix’ler, flag’ler, shift count ve segmentler gibi CPU’nun gerçek davranışlarını ayrıntılı biçimde ele almayı gerektirir
- Bu kuralların çoğu normal kod yazarken neredeyse hiç işe yaramaz; ancak emülatör uygulaması için doğrudan gereksinim hâline gelir
- Deneme yanılma ve mentorluk yoluyla çok şey öğrenilmiştir; eski emülatör deneyimine sahip Darek Mihocka ve emulators.com da ayrıca anılır
- x86 optimizasyonu ve düşük seviye davranışlarla ilgileniyorsanız Agner Fog’s website üzerindeki kaynaklar yararlıdır
1 yorum
Hacker News yorumları
Bu arada BSF/BSR tarafında da tuhaflıklar var. Intel SDM, girdi 0 ise hedef değerin tanımsız olduğunu söylüyor; AMD ise bu durumda hedefin değiştirilmediğini belgeliyor.
Fakat glibc, Intel’de de hedefin değiştirilmediğine dair belgelenmemiş gerçeği aynen kullanıyor [1]. Bu yüzden ikili dönüştürücümde sorunun nedenini bulmam epey uzun sürdü.
Ayrıca TZCNT/LZCNT, F3 öneki eklenmiş BSF/BSR kodlamalarıdır; bu genişletmeyi desteklemeyen eski işlemcilerde önek sessizce yok sayılır. Bu nedenle aynı kod CPU’ya göre farklı davranır ama en azından bu belgelenmiş durumda.
Kodlama tarafında insanlar önekleri sık sık kötüler ama kişisel olarak bence en kötüsü bu değil. İyi biliniyorlar ve bir ölçüde belgelenmiş durumdalar. Daha kötü tuhaflıklar da var. Örneğin REX/VEX/EVEX.RXB genişletme bitleri uygulanacak bir hedef yoksa yok sayılır, ancak maske register’ları k0-k7’de #UD’ye yol açar. Ama register ModRM.rm içinde kodlanırsa genişletme biti yine yok sayılır.
APX tuhaflık seviyesini bir kademe daha yukarı taşıyor. REX2 öneki genel amaçlı register’lar r16-r31’i kodlayabilir ama xmm16-xmm31’i kodlayamaz; EVEX önekinin opcode’a bağlı olarak birden fazla yerleşimi vardır ve register için kullanılan genişletme bitleri de register türüne göre değişir. XMM register’ları X3:B3:rm ve V4:X3:idx kullanırken, genel amaçlı register’lar B4:B3:rm ve X4:X3:idx kullanır. 1 yıl geçtiği hâlde APX decoder’ını hâlâ bitiremediğim için tam listeyi veremem.
[1]: https://sourceware.org/bugzilla/show_bug.cgi?id=31748
EVEX için, opcode’u okuyup EVEX sınıfını belirleyene kadar ham bitleri koruyan bir yaklaşıma gitmeyi planlıyorum. Yani immediate değerden önce, muhtemelen ModRM’den önceki aşamaya kadar korumayı düşünüyorum.
Decoder’ım büyük ölçüde kılavuzdaki tablolara dayanıyor ve kod genel olarak fena değil. Girintileme aşırı değil, aşamalar da çoğunlukla ayrılmış ya da kolayca ayırt edilebilir durumda. Çıktı JIT kodu olduğu için aşırı verimli olması gerekmiyor; okunabilir olması daha iyi. Zamanın çoğunun harcandığı yer de burası değil.
Yine de kılavuzun hatalı olduğu ya da her şeyi söylemediği epey durum var. Tablolar da yıllardır güncellenmemiş; örneğin K register komutları bile yok. Bundan sonra daha fazla elle çalışma gerekecek gibi görünüyor.
En üstteki yorumda durum biraz açıklanmış: https://github.com/qemu/qemu/blob/59084feb256c617063e0dbe7e6...
Yukarıda söylediğim gibi, eski kodun hâlâ işlediği birkaç komut kaldı; özellikle BT/BTS/BTR/BTC bunlardan. Kodu yazdım ama henüz birleştirmedim.
Ancak 16 bit ve 32 bit modları arasında geçiş yapan 0x66 öneki var. Bunu BSWAP EAX’e uygularsanız tanımsız, garip bir şey olur.
Bazı CPU mimarilerinde, Intel ve AMD farkındaki gibi önek basitçe yok sayılırdı; bazılarında ise benim “iç swap” dediğim davranış gerçekleşirdi. Örneğin EAX’te saklanan dört bayttan 1. ve 2. bayt yer değiştirirdi.
0x11223344, 0x11332244 olurdu.
Eskiden x86 Intel’in hendeğiydi; artık taşımak zorunda olduğu kâbus gibi bir yük gibi görünüyor.
clz() diye bir fonksiyon gerçekten var ama LZCNT, 0 girdisi semantiği dışında sadece BSR olsaydı, uyumluluk elde etmek için implementasyonda fazladan bir çıkarma yapmak küçük bir maliyet olurdu gibi geliyor.
Aynı programı emüle edilen CPU’da da çalıştırıp her komutta durumun aynı olup olmadığını doğrularsanız, emülatörün donanımı kusursuz biçimde taklit edip etmediğini test edebilirsiniz.
Harika biri. Assembly yazmak bana basit hissettiriyor ve dikey olarak uzanan estetiğini de seviyorum.
OP’ninkine biraz olsun yaklaşan tek deneyimim, JS yapan bir arkadaşıma stack’i anlamasında yardımcı olmaya çalışırken küçük bir ISA’ya sahip mini bir VM’i birlikte yapmamızdı: https://gist.github.com/darighost/2d880fe27510e0c90f75680bfe...
Daha derine inebilirdik ve keşke ben de inseydim; ama o zaman asıl eğitim amacından sapmış olurduk sanırım. Arkadaşıma hâlâ birlikte çalışmak isteyip istemediğini sormalıyım. O harika web geliştirme işleriyle çok para kazanıyor, bu yüzden derine inmeye zamanı yok; ben ise işsizim, zaman ve enerjim neredeyse uçsuz bucaksız bir deniz gibi, o yüzden kolay değil.
Justine Tunney ve onun emülatörü de görülmeye değer. https://justine.lol/blinkenlights/
Dokümantasyon, CPU’nun nasıl çalıştığına dair harika bir tur sunuyor.
Önceki tartışma burada: https://news.ycombinator.com/item?id=34636699
Aradan şimdiden 16 ay geçtiğine inanamıyorum. Zaman gerçekten hızlı akıyor
“CPU emülatörü yazmak, CPU’nun nasıl çalıştığını gerçekten anlamanın en iyi yoludur” görüşüne kesinlikle katılmıyorum
En iyi yol, iyi bir bilgisayar bilimi dersinde yapıldığı gibi CPU’yu kapı düzeyinde inşa etmektir. Sıfırdan küçültülmüş bir ARM yapmak gerçekten eğlenceliydi
Modern bir CPU emülatörü yapmak biraz daha erişilebilir bir meydan okuma; yalnızca bir kısmı çalışsa bile hâlâ büyük bir eğitim değeri taşıyor
Yazılım mühendislerinin çoğunun veri hazard’ları, önbellek geçersizleştirme ve pipeline duraklamaları konusundaki anlayışı eksik
Ama anlamlı bir işte kullanmak isteyeceğiniz bir CPU’yu böyle yapmaya başladığınızda, o ayrıntılar ilginizi kaybettirmeye başlıyor. O noktada davranışın ve spesifikasyonun karmaşıklığı daha ilginç hale geliyor; emülatör yaklaşımı daha yönetilebilir oluyor ve daha fazla davranış türünü kapsayabiliyor
Oyuncak olmayan yaklaşık 12 mimari için hızlı emülatörler yazdım, birkaç JIT dönüştürücü de yaptım ama x86 hâlâ bende PTSD yaratıyor. Bu kadar dağınık bir mimari görmedim. Bir geçmişi ve gerekçeleri var, ama yine de fena halde kötü
Son dönemde bir yan proje olarak x86-64 decoder’ın hatırı sayılır bir bölümünü uyguladım [1] ve son zamanlarda daha da karmaşık hale gelmesine epey şaşırdım. Benim amacım için Sandpile.org [2] gerçekten faydalıydı
[1] Daha doğrusu, henüz yayımlanmamış başka bir yan proje için yaptığım Fabian Giesen’in disfilter’ının x86-64 sürümü: https://gist.github.com/lifthrasiir/df47509caac2f065032ef72e...
[2] https://sandpile.org/
Üniversitede yaptığım 68k disassembler, Neo’nun “Kung fu biliyorum” dediği an gibiydi. Yüksek seviyeli dilden transistörlere, oradan tekrar geriye dönüp kod hakkında akıl yürütmemi sağlayan eksik halkaydı
Tam bir emülatör yazmanın bundan bir mertebe daha etkili olacağını düşünüyorum. Güzel yazı
Galiba hafızam yanılmış. salsa20 varyantlarının ve makine kodunun aslen cryp.to’da olduğunu hatırlıyordum, ama Dan Bernstein’ın sitesi https://cr.yp.to/ imiş
Bir startup’ta bekleyen verinin şifrelenmesi, akış şifrelemesi vb. konuları değerlendirdiğimiz dönemde Dan’in sayfalarında hedef çipset ve komut kümesine göre birden fazla uygulama vardı. Bunlar onun assembler gösteriminden çapraz derlenmişti
VM’i deneyip 2000’lerin başı-ortası döneminde hangi komut kümelerinin desteklendiğini görmek ilginçti. Testler sırasında VM’in desteklediğini iddia ettiği ama uygulamanın tam desteklemediği şeyler yüzünden bazen sorunlar çıkıyordu
Burada RISC ile karşılaştırıldığında x86 assembly’nin ne kadar acı verici olduğundan çok bahsedilmesi ilginç. Ben kodu yeniden object file’lara ayırırken tam tersi sorunla karşılaşıyorum
Bu kullanımda x86 analizi gerçekten kolay; MIPS ise kâbus gibiydi. Çünkü asıl ilgilendiğim şey kod ve veriye referanslar. x86’da pointer boyutunda immediate sabitler var; MIPS’te ise HI16/LO16 relocation çiftleri var ve bunlar register kullanım grafiği, kod akışı ve branch delay talimatlarıyla iç içe geçerek her türlü soruna yol açıyor
Yine de x86’yı övüyor değilim
x86 assembly ile C’yi karşılaştırarak öğrenilen en büyük şey, işaretli/işaretsiz olma durumunun bir tip değil, işlemin bir özelliği haline gelmesi
Flag’lerden yararlanabilmek iyi olurdu; PPC veya armv7 gibi bazı mimarilerde bu daha kolay, ama x86’da flag’ler o kadar kolay üzerine yazılıyor ki değerlerinden yararlanmak çok zor