Những điều kỳ lạ học được khi viết trình giả lập x86
(timdbg.com)- Trong quá trình viết lại trình giả lập CPU dùng cho Time Travel Debugging bằng C++, x86/amd64 đòi hỏi cùng một hành vi cũng phải được xử lý khác nhau tùy theo cách mã hóa, prefix và chế độ thực thi
- Có nhiều cách biểu diễn thay thế ảnh hưởng đến hiệu năng và gỡ lỗi, như mã hóa một byte
CCcủaint 3, dạng ngắn củaADD EAX, imm, hay REX prefix không có tác dụng - Các lệnh
INC/DEC,CMPXCHG8B/CMPXCHG16B, shift/rotate có cách xử lý flag khác với trực giác, nên dễ dẫn đến lỗi trong trình giả lập - Shift count không được áp dụng nguyên theo kích thước toán hạng mà bị mask, nên
shr eax,20hkhông làm thanh ghi 32-bit thành 0 mà giữ nguyên giá trị - Segment vẫn được dùng để truy cập TEB trên Windows 32-bit và 64-bit; sự khác biệt về ý nghĩa của
FS/GSvà cách xác định base ảnh hưởng trực tiếp đến việc triển khai disassembler và trình giả lập
Các quy tắc chi tiết của x86 lộ ra khi viết lại trình giả lập TTD
- Một thành phần của Time Travel Debugging là trình giả lập CPU ghi lại toàn bộ quá trình thực thi của tiến trình ở mức lệnh
- Trình giả lập của phiên bản đầu tiên, iDNA, hầu như được viết hoàn toàn bằng assembly nên nhanh, nhưng khó bảo trì và mở rộng
- Ở phiên bản thứ hai, phần giả lập và sau đó là hầu hết các phần khác được viết lại bằng C++, với mục tiêu có một codebase dễ quản lý hơn trong khi vẫn giữ phần lớn hiệu năng của phiên bản assembly
- Để tạo trình giả lập CPU, cần khớp đầy đủ mọi chi tiết trong hành vi của CPU; ngay cả các quy tắc quen thuộc với người có kinh nghiệm x86 cũng phải được kiểm chứng lại khi triển khai thực tế
Mã hóa x86 biểu diễn cùng một lệnh theo nhiều cách
- x86 có thể biểu diễn cùng một lệnh bằng nhiều chuỗi byte khác nhau
int 3cũng được mã hóa thànhCD 03, nhưng cũng có thể mã hóa bằngCCmột byte- Vì được dùng làm software breakpoint, cách này cho phép đặt breakpoint ngay cả tại vị trí lệnh ở cuối một trang bộ nhớ mà trang kế tiếp chưa được map
- Cũng có các mã hóa thay thế để làm ngắn những trường hợp phổ biến
add eax, immcó thể được biểu diễn ngắn gọn như05cccccccc- Nếu cộng cùng giá trị đó vào
ECX, cần thêm 1 byte như81c1cccccccc
- Việc
EAXđược gọi là “Accumulator register” không chỉ là quy ước mà còn tạo ra khác biệt thực sự trong mã hóa; lệnh ngắn có thể có lợi cho hiệu năng vì giảm lượng dữ liệu phải chuyển từ bộ nhớ chính và giảm mức sử dụng instruction cache - Compiler có thể tận dụng các mã hóa ngắn này khi có thể
Prefix và giới hạn độ dài lệnh 15 byte
- Lệnh x86 có thể có các byte prefix làm thay đổi hành vi
- REX prefix thường dùng trong mã 64-bit được dùng để truy cập phạm vi thanh ghi rộng hơn so với mã 32-bit
- CPU cũng chấp nhận REX prefix không có tác dụng
4004cclà dạng có một byte REX đứng trướcadd al,0CCh8-bit, nhưng trong trường hợp này REX không có tác dụng gì- Ngay cả khi gắn hai REX prefix, CPU vẫn có thể thực thi; nhiều disassembler, gồm cả WinDbg, có thể bị nhầm lẫn bởi điều này
- Trên CPU tương thích x86, độ dài lệnh hiện tại có hard limit là 15 byte
- Lệnh vượt quá 15 byte bị xem là lệnh không hợp lệ và gây exception
- Trên các CPU cũ còn có những giới hạn khác với prefix, và một số prefix như
LOCKcó điều kiện sử dụng nghiêm ngặt hơn
Cách diễn giải thay đổi theo kích thước địa chỉ và chế độ
- Address override prefix có thể khiến chế độ 64-bit tham chiếu địa chỉ 32-bit
488d0424làlea rax,[rsp]67488d0424làlea rax,[esp]do prefix0x67
- Trong mã 32-bit, Address override đổi chế độ địa chỉ sang địa chỉ 16-bit
- Ngay cả cùng một chuỗi byte cũng cần biết kích thước toán hạng và kích thước địa chỉ mặc định của code segment thì mới disassemble hoặc diễn giải đúng
8b0424ở chế độ 32-bit làmov eax,dword ptr [esp]8b0424ở chế độ 64-bit làmov eax,dword ptr [rsp]
- Dải
40~4Ftừng dùng choINC regvàDEC regtrong x86 được dùng làm REX prefix bytes trong x64- Ở chế độ 32-bit,
48 03 04 24được diễn giải thành hai lệnh:dec eaxvàadd eax,dword ptr [esp] - Ở chế độ 64-bit,
48030424được diễn giải thành một lệnhadd rax,qword ptr [rsp]
- Ở chế độ 32-bit,
- Các nhà thiết kế AMD64 đã dùng không gian mã hóa rộng của
INC/DECcho prefix mới để mở rộng tập thanh ghi trong chế độ 64-bit; các lệnh đó vốn đã có mã hóa khác hỗ trợ cả thanh ghi và bộ nhớ
Cạm bẫy của INC reg trong chế độ 64-bit với WinDbg
- Vì WinDbg luôn assemble lệnh như ở chế độ 32-bit, nếu cố assemble
INC regtrong mã 64-bit thì có thể cho kết quả khác ý định - Trong ví dụ,
inc eaxkhông trở thành lệnh tăng thật sự mà bị biến thành REX prefix vô dụng bổ nghĩa cho lệnh kế tiếp - Kết quả là chuỗi byte đó không được diễn giải là
inc, mà là prefix đứng trước lệnhjmp
Các ngoại lệ trong hành vi của flag
INC EAXtrông giốngADD EAX, 1, nhưng không hoàn toàn giốngADDcập nhật carry flagINCkhông cập nhật carry flag
- Trong quá trình triển khai trình giả lập TTD, khác biệt này ban đầu đã bị triển khai sai và được phát hiện bằng unit test
- Hầu hết các phép toán số học và logic thiết lập overflow, sign, zero, auxiliary carry, parity và carry flag
CMPXCHGcũng thiết lập các flag này, nhưngCMPXCHG8BvàCMPXCHG16Bchỉ sửa zero flag- Một số lệnh để lại một phần flag ở trạng thái không xác định
- Các lệnh shift và rotate để overflow flag ở trạng thái không xác định nếu shift amount lớn hơn 1
- Hành vi thực tế của flag không xác định liên quan đến cách triển khai nội bộ của phép shift và có thể khác nhau giữa các kiến trúc
- Có câu chuyện rằng CPU dòng Atom thực hiện bit shift trong ALU theo cách rẻ hơn và chậm hơn, khiến giá trị flag không xác định khác đi, nhưng chưa được kiểm thử trực tiếp
Mask count của lệnh shift
66c1e810làshr ax,10h, shiftAXsang phải 16 bit- Vì
AXlà thanh ghi 16-bit, kết quả là 0
- Vì
c1e820làshr eax,20h, nhìn bề ngoài có vẻ như là lệnh shiftEAXsang phải 32 bit- Trên thực tế, giá trị
EAXkhông thay đổi- Theo Intel SDM, count được mask với
1Fh, chỉ dùng 5 bit thấp của rotation - Nếu dùng prefix
REX.W, mask trở thành3Fh, nên giá trị shift tối đa là 63 bit
- Theo Intel SDM, count được mask với
- Hành vi này từng thực sự trở thành vấn đề trong một câu hỏi phỏng vấn Microsoft về “mọi cách clear thanh ghi 32-bit bằng một lệnh”
- Người phỏng vấn nghĩ có thể dùng shift, nhưng câu trả lời là không thể với thanh ghi 32-bit
Segment vẫn tồn tại trong mã 32-bit và 64-bit
- Segment memory có thể trông như di sản của mã 16-bit, nhưng vẫn có hiệu lực thực tế trong mã 32-bit và 64-bit
- Hầu hết OS dùng mô hình bộ nhớ gần như flat và đặt segment base address là 0, nên bình thường ta ít để ý
- Ở chế độ 64-bit, CPU luôn coi segment base của
CS,DS,ES,SSlà 0
- Ở chế độ 64-bit, CPU luôn coi segment base của
- Ngoại lệ là thread local storage dùng extra segment register như
FShoặcGS - Theo phần đính chính, base của segment
FS/GScó thể được đọc ngay cả từ mã không có đặc quyền bằng các lệnhrdfsbase,wrfsbase,rdgsbase,wrgsbase- Các lệnh này có từ Ivy Bridge, tức từ năm 2012
Truy cập TEB của Windows và FS/GS
- Trên Windows,
FSvàGSđược dùng để tham chiếu TEB (Thread Execution Block) - Cấu trúc TEB có một con trỏ self trỏ đến địa chỉ flat ở điểm bắt đầu cấu trúc, và địa chỉ này cũng chính là base của segment tương ứng
- Trong tiến trình 32-bit, TEB nằm ở
FSGetLastErrorlấyTEB.NtTib.Selftừfs:[00000018h], rồi đọcLastErrorValuetừ[eax+34h]
- Trong tiến trình 64-bit, TEB nằm ở
GSGetLastErrorđọc con trỏ từgs:[30h], rồi lấy giá trị từ[rax+68h]
- Tiến trình 32-bit chạy trên OS 64-bit có cả TEB 32-bit và TEB 64-bit; có những ngữ cảnh hữu ích cần truy cập cả hai TEB, chẳng hạn mã WOW 64-bit chạy bên trong tiến trình 32-bit
Cách xác định segment base cũng khác nhau theo chế độ
- Thiết lập CPU để xác định base address của
FSvàGSkhác nhau giữa chế độ 32-bit và 64-bit - Ở chế độ 32-bit, giá trị thực tế của segment register tham chiếu segment descriptor được định nghĩa trong Global Descriptor Table và Local Descriptor Table
- Ở chế độ 64-bit, base được điều khiển bởi hai MSR
FS Base,IA32_FS_BASEtrong Intel SDMGS Base,IA32_GS_BASEtrong Intel SDM
- Do cấu trúc này, trong chế độ 64-bit, bản thân giá trị thanh ghi thực tế của
FSvàGSkhông quan trọng- Điều quan trọng là segment override prefix
- Khi debug tiến trình 32-bit trong WinDbg, có thể dùng giá trị thanh ghi
FSđể dump nội dung “FS segment” - Với tiến trình 64-bit, cách tương tự không hoạt động; segment override prefix có ý nghĩa hơn giá trị segment
Bài học thực tiễn cho người triển khai trình giả lập
- Khi xây dựng trình giả lập x86, cần xử lý tỉ mỉ hành vi thực tế của CPU như mã hóa lệnh, prefix, flag, shift count và segment
- Phần lớn các quy tắc này gần như không hữu ích trong việc viết code thông thường, nhưng lại là yêu cầu trực tiếp khi triển khai trình giả lập
- Rất nhiều điều được học qua thử-sai và mentoring; Darek Mihocka, người có kinh nghiệm lâu năm về trình giả lập, cùng emulators.com cũng được giới thiệu
- Nếu quan tâm đến tối ưu hóa x86 và hành vi cấp thấp, tài liệu trên website của Agner Fog rất hữu ích
1 bình luận
Ý kiến trên Hacker News
Tiện thể, BSF/BSR cũng có điểm kỳ quặc. Intel SDM nói rằng nếu đầu vào là 0 thì giá trị đích là không xác định, nhưng AMD lại ghi rõ rằng trong trường hợp đó thanh ghi đích không bị sửa đổi
Nhưng glibc lại dùng nguyên xi hành vi không được tài liệu hóa này là ngay cả trên Intel thì thanh ghi đích cũng không bị sửa đổi [1]. Vì chuyện này mà tôi đã mất khá lâu để tìm ra nguyên nhân lỗi trong bộ chuyển đổi nhị phân của mình
Ngoài ra, TZCNT/LZCNT là mã hóa BSF/BSR có thêm tiền tố F3, nhưng trên các bộ xử lý cũ không hỗ trợ phần mở rộng này thì tiền tố sẽ bị âm thầm bỏ qua. Vì vậy cùng một đoạn mã có thể hoạt động khác nhau tùy CPU, dù ít nhất chuyện này vẫn đã được tài liệu hóa
Khi nói về mã hóa, mọi người hay chê các tiền tố, nhưng cá nhân tôi thấy đó chưa phải thứ tệ nhất. Nó khá nổi tiếng và cũng được tài liệu hóa ở mức nào đó. Còn có những điểm kỳ quặc tệ hơn. Ví dụ, các bit mở rộng REX/VEX/EVEX.RXB sẽ bị bỏ qua nếu không áp dụng được, nhưng với các thanh ghi mặt nạ k0-k7 thì lại gây ra #UD. Thế nhưng nếu thanh ghi được mã hóa trong ModRM.rm thì các bit mở rộng lại bị bỏ qua
APX còn đẩy mức độ kỳ quặc lên thêm một bậc. Tiền tố REX2 có thể mã hóa các thanh ghi đa dụng r16-r31 nhưng không mã hóa được xmm16-xmm31, còn tiền tố EVEX thì có nhiều bố cục tùy opcode, và các bit mở rộng dùng cho thanh ghi cũng thay đổi tùy loại thanh ghi. Thanh ghi XMM dùng X3:B3:rm và V4:X3:idx, còn thanh ghi đa dụng dùng B4:B3:rm và X4:X3:idx. Đã 1 năm trôi qua mà tôi vẫn chưa xong bộ giải mã APX nên chưa thể đưa ra danh sách đầy đủ
[1]: https://sourceware.org/bugzilla/show_bug.cgi?id=31748
Với EVEX, tôi định giữ lại các bit thô cho đến khi đọc được opcode và xác định lớp EVEX. Tức là dự định giữ chúng ít nhất đến trước immediate, và có lẽ cả trước ModRM
Bộ giải mã của tôi nhìn chung dựa trên các bảng trong manual, và mã phần lớn cũng ổn. Thụt lề không quá mức, các bước hầu hết đều được tách riêng hoặc dễ nhận diện. Vì đầu ra là mã JIT nên không nhất thiết phải cực kỳ hiệu quả, dễ đọc là được. Đây cũng không phải chỗ tiêu tốn phần lớn thời gian
Dù vậy, có nhiều trường hợp manual sai hoặc không nói hết toàn bộ. Các bảng cũng đã nhiều năm không được cập nhật, ví dụ còn không có cả lệnh thanh ghi K. Có vẻ về sau sẽ phải làm thủ công nhiều hơn
Phần chú thích trên cùng giải thích sơ qua bối cảnh: https://github.com/qemu/qemu/blob/59084feb256c617063e0dbe7e6...
Như đã nói ở trên, vẫn còn vài lệnh do mã cũ xử lý, đặc biệt là BT/BTS/BTR/BTC. Tôi đã viết mã cho chúng nhưng vẫn chưa merge
Nhưng lại có tiền tố 0x66 để chuyển giữa chế độ 16-bit và 32-bit. Nếu áp dụng nó cho BSWAP EAX thì sẽ xảy ra một chuyện kỳ quái không được định nghĩa
Trên một số kiến trúc CPU, giống khác biệt giữa Intel và AMD, tiền tố này đơn giản bị bỏ qua; ở nơi khác thì nó thực hiện thứ mà tôi gọi là “hoán đổi bên trong”. Ví dụ, trong bốn byte lưu trong EAX thì byte thứ 1 và thứ 2 đổi chỗ cho nhau
0x11223344 trở thành 0x11332244
Ngày trước x86 là con hào của Intel, nhưng giờ trông nó giống gánh nặng như ác mộng mà họ phải mang theo hơn
Dù đúng là có hàm clz(), nhưng nếu LZCNT đơn giản chỉ là BSR với khác biệt ở ngữ nghĩa đầu vào 0, thì cái giá phải thêm một phép trừ trong phần cài đặt để đổi lấy tương thích có lẽ cũng không lớn
Rồi cũng chạy cùng chương trình đó trên CPU được mô phỏng và kiểm tra ở mỗi lệnh xem trạng thái có giống nhau không, để thử xem trình giả lập có mô phỏng phần cứng một cách hoàn hảo hay không
Đúng là người ngầu. Viết assembly mang lại cảm giác giản dị, và tôi cũng thích cái thẩm mỹ xếp dọc của nó
Trải nghiệm gần nhất của tôi với kiểu công việc như OP là khi cố giải thích stack cho một người bạn làm JS, rồi cả hai cùng làm một mini VM với ISA nhỏ: https://gist.github.com/darighost/2d880fe27510e0c90f75680bfe...
Bọn tôi hoàn toàn có thể đào sâu hơn, và tôi cũng ước đã làm vậy, nhưng như thế có lẽ đã lệch khỏi mục tiêu giáo dục ban đầu. Chắc tôi nên nhắn bạn ấy xem giờ còn muốn học cùng không. Bạn tôi kiếm được khá nhiều tiền nhờ làm web ngầu, nên không có thời gian để đào sâu, còn tôi thì thất nghiệp nên thời gian và năng lượng gần như là một đại dương vô tận, thành ra cũng không dễ
Justine Tunney và trình giả lập của cô ấy cũng rất đáng xem. https://justine.lol/blinkenlights/
Tài liệu ở đó cho cái nhìn tổng quan rất tuyệt về cách CPU hoạt động
Có thể xem thảo luận trước đó tại đây: https://news.ycombinator.com/item?id=34636699
Không thể tin là đã trôi qua 16 tháng rồi. Thời gian đúng là bay nhanh
Tôi không thật sự đồng ý mạnh với ý rằng “viết trình giả lập CPU là cách tốt nhất để thực sự hiểu CPU hoạt động thế nào”
Cách tốt nhất là tự tạo một CPU ở mức cổng logic, như trong một môn khoa học máy tính tử tế. Việc tự làm một ARM thu gọn từ đầu thực sự rất vui
Làm trình giả lập cho CPU hiện đại là một thử thách dễ tiếp cận hơn, và ngay cả khi chỉ chạy được một phần thì vẫn có giá trị học tập lớn
Phần lớn kỹ sư phần mềm vẫn chưa hiểu đầy đủ về data hazard, cache invalidation và pipeline stall
Nhưng khi bắt đầu muốn làm một CPU có thể dùng cho việc gì đó có ý nghĩa, bạn sẽ dần bớt hứng thú với những chi tiết đó. Lúc ấy, độ phức tạp của hành vi và đặc tả trở nên thú vị hơn, và hướng tiếp cận bằng trình giả lập dễ xử lý hơn cũng như bao quát được nhiều kiểu hành vi hơn
Tôi đã viết các trình giả lập nhanh cho khoảng 12 kiến trúc không phải đồ chơi, và cũng làm vài trình biên dịch JIT, nhưng x86 đến giờ vẫn gây PTSD cho tôi. Tôi chưa từng thấy kiến trúc nào bừa bộn đến thế. Nó có lịch sử và có lý do, nhưng vẫn thật quá đáng
Gần đây tôi triển khai phần lớn của một bộ giải mã x86-64 như một side project [1], và khá ngạc nhiên khi thấy dạo này nó còn phức tạp hơn trước. Với mục đích của tôi thì Sandpile.org [2] thực sự rất hữu ích
[1] Chính xác hơn thì đây là phiên bản x86-64 của disfilter của Fabian Giesen, được làm cho một side project khác chưa công bố: https://gist.github.com/lifthrasiir/df47509caac2f065032ef72e...
[2] https://sandpile.org/
Trình disassembler 68k tôi làm ở đại học giống hệt khoảnh khắc Neo nói “Tôi biết kung fu”. Nó là mắt xích còn thiếu, giúp tôi suy luận ngược lại về mã từ ngôn ngữ bậc cao xuống transistor rồi quay trở lên
Viết cả một trình giả lập chắc còn hiệu quả hơn cả một bậc độ lớn. Bài viết hay
Có lẽ trí nhớ của tôi đã sai. Tôi nhớ các biến thể salsa20 và mã máy ban đầu nằm ở cryp.to, nhưng trang của Dan Bernstein là https://cr.yp.to/
Hồi còn ở startup đánh giá mã hóa dữ liệu lưu trữ, mã hóa luồng, v.v., trang của Dan có nhiều bản triển khai theo từng chipset đích và tập lệnh khác nhau. Chúng được cross-compile từ cách biểu diễn assembler của ông
Thú vị là khi dùng VM có thể thấy hồi đầu đến giữa những năm 2000 khi đó các tập lệnh nào được hỗ trợ. Trong lúc kiểm thử, đôi khi phát sinh vấn đề vì VM tuy tuyên bố hỗ trợ nhưng triển khai thực tế lại chưa đầy đủ
Thú vị là ở đây có nhiều ý kiến nói assembly x86 đau khổ đến mức nào khi so với RISC. Tôi lại gặp đúng vấn đề ngược lại khi tách mã trở lại thành các object file
Với mục đích này thì phân tích x86 thực sự rất dễ, còn MIPS thì như ác mộng. Chủ yếu vì thứ tôi quan tâm là các tham chiếu tới mã và dữ liệu. x86 có hằng tức thời cỡ con trỏ, còn MIPS có các cặp relocation HI16/LO16, kéo theo đủ thứ rắc rối với đồ thị sử dụng thanh ghi, luồng mã và các lệnh delay branch
Nói vậy không có nghĩa là tôi đang khen x86
Điều lớn nhất học được khi so sánh assembly x86 với C là signed/unsigned không còn là thuộc tính của kiểu mà là thuộc tính của phép toán
Sẽ tốt hơn nếu tận dụng được cờ, và ở vài kiến trúc như PPC hay armv7 thì dễ hơn, nhưng trên x86 thì cờ bị ghi đè quá dễ nên rất khó khai thác giá trị của chúng