:從CPUID小程序理解寄存器與系統(tǒng)調(diào)用)
1. 從一個獲取CPUID的小程序說起1.1 為什么選CPUID作為匯編入門案例很多人學匯編一上來就被各種寄存器、尋址方式、指令集給勸退了。我當初也是抱著厚厚一本指令手冊啃了三天結(jié)果連個Hello World都寫不利索。后來一位做底層開發(fā)的朋友跟我說別急著啃手冊先找一個能跑起來的小程序把它拆碎了理解比看一百頁理論都管用。他當時給我的就是這個獲取CPUID的程序。為什么選它因為CPUID這條指令本身就很有意思——它是CPU提供給軟件的一扇窗口通過它你能直接問CPU你是誰你支持什么特性而實現(xiàn)這個問答過程只需要用到匯編里最核心的幾個概念寄存器傳參、指令調(diào)用、結(jié)果存儲、系統(tǒng)調(diào)用輸出。麻雀雖小五臟俱全。CPUID指令的基本用法是把功能號放進EAX寄存器執(zhí)行CPUID指令然后CPU會把結(jié)果分別放到EAX、EBX、ECX、EDX四個寄存器里。比如EAX0時返回的是廠商ID字符串EAX1時返回的是處理器版本信息和特性標志位。整個過程不涉及內(nèi)存分配、不涉及復(fù)雜算法就是純粹的寄存器操作特別適合用來理解匯編的工作方式。這個程序能做什么簡單說它能在Linux環(huán)境下運行打印出你的CPU廠商信息、型號、支持的特性標志。適合誰看如果你已經(jīng)會一門高級語言C、Python都行想了解程序在底層到底怎么跑的或者你正在學操作系統(tǒng)、編譯原理需要一點匯編基礎(chǔ)來打通任督二脈那這個案例就是為你準備的。哪怕你之前完全沒碰過匯編跟著走一遍也能有個直觀感受。1.2 先搞清楚ATT匯編和Intel匯編的區(qū)別在動手寫代碼之前有個坑必須先填上ATT匯編和Intel匯編是兩種不同的語法風格寫錯了編譯器直接報錯而且報錯信息往往讓人摸不著頭腦。最直觀的區(qū)別是操作數(shù)順序。Intel風格是目標在前源在后比如mov eax, 1意思是把1放到eax里。ATT風格反過來是源在前目標在后同樣的操作寫成movl $1, %eax。我第一次切換的時候因為順序搞反程序跑出來的結(jié)果完全不對排查了半天才發(fā)現(xiàn)是操作數(shù)順序的問題。另一個顯著區(qū)別是寄存器前面要加百分號%立即數(shù)前面要加美元符號$。Intel風格里直接寫eax和1ATT風格必須寫成%eax和$1。這個規(guī)則沒有例外忘了加符號編譯器會直接報錯。還有指令后綴的問題。ATT匯編里指令后面要跟一個字母表示操作數(shù)大小b表示字節(jié)8位w表示字16位l表示雙字32位q表示四字64位。比如movl表示移動32位數(shù)據(jù)movq表示移動64位數(shù)據(jù)。Intel匯編通??考拇嫫髅麃硗茢啻笮TT則要求你顯式寫出來。對比項Intel風格ATT風格操作數(shù)順序目標, 源源, 目標寄存器前綴無%立即數(shù)前綴無$指令后綴通常無b/w/l/q內(nèi)存尋址[baseindex*scaledisp]disp(base,index,scale)我個人的建議是如果你主要看Linux內(nèi)核源碼或者GCC生成的匯編那就老老實實學ATT風格因為這是Unix世界的普通話。如果你更習慣Windows下的開發(fā)環(huán)境Intel風格可能更順手。但不管選哪個先把一種搞熟另一種看懂就行別兩種混著寫那是自找麻煩。2. 程序整體設(shè)計與思路拆解2.1 程序要完成哪些事情這個獲取CPUID的程序邏輯上分四步走第一步調(diào)用CPUID指令拿到廠商ID字符串第二步把拿到的數(shù)據(jù)保存到內(nèi)存緩沖區(qū)第三步把緩沖區(qū)的內(nèi)容打印到終端第四步正常退出程序。聽起來簡單但每一步都涉及匯編的核心概念。第一步考驗的是對CPUID指令的理解——功能號怎么傳、結(jié)果放在哪。第二步考驗的是內(nèi)存尋址——怎么把寄存器里的數(shù)據(jù)寫到內(nèi)存里。第三步考驗的是系統(tǒng)調(diào)用——Linux下怎么輸出字符串。第四步考驗的是程序退出機制。我之所以強調(diào)這個四步拆解是因為很多初學者一上來就想寫完整程序結(jié)果寫到一半卡住了不知道哪出了問題。把步驟拆開每一步單獨驗證出錯了也容易定位。比如你可以先寫個只調(diào)用CPUID然后退出的程序用調(diào)試器看看寄存器里的值對不對確認無誤后再加輸出功能。2.2 為什么用系統(tǒng)調(diào)用而不是C庫函數(shù)你可能會問為什么不用printf多方便啊。原因很簡單用printf就需要鏈接C庫程序的體積會變大而且你得理解調(diào)用約定、棧對齊這些額外概念。用系統(tǒng)調(diào)用syscall雖然原始一點但勝在直接——你直接告訴內(nèi)核我要輸出這段文字內(nèi)核就幫你輸出中間沒有黑盒。Linux下輸出字符串用的是write系統(tǒng)調(diào)用編號是1x86_64架構(gòu)下。調(diào)用方式是rax放系統(tǒng)調(diào)用號rdi放文件描述符1表示標準輸出rsi放字符串地址rdx放字符串長度然后執(zhí)行syscall指令。退出程序用的是exit系統(tǒng)調(diào)用編號是60rdi放退出碼。這種寄存器傳參syscall指令的模式是Linux下匯編程序與內(nèi)核交互的標準方式。理解了這一套你就能自己寫各種系統(tǒng)調(diào)用的小工具了比如讀文件、創(chuàng)建進程、發(fā)送信號等等。2.3 內(nèi)存布局的規(guī)劃程序需要一塊內(nèi)存來存放CPUID返回的廠商ID字符串。廠商ID是12個字節(jié)比如GenuineIntel就是12個字符加上換行符和可能的結(jié)束符我分配了16個字節(jié)的緩沖區(qū)。在匯編里分配內(nèi)存有兩種方式一種是用.bss段聲明未初始化的空間一種是用.data段聲明已初始化的數(shù)據(jù)。對于緩沖區(qū)用.bss段更合適因為不需要初始值。聲明方式是.section .bss .lcomm buffer, 16.lcomm表示本地公共符號分配16字節(jié)的零初始化空間。這塊內(nèi)存在程序加載時由內(nèi)核分配程序運行時可以直接讀寫。注意緩沖區(qū)大小一定要留足余量。CPUID返回的廠商ID是12字節(jié)但如果你后續(xù)想擴展程序比如加上型號字符串的輸出緩沖區(qū)就要相應(yīng)擴大。我見過有人只分配了12字節(jié)結(jié)果寫入換行符的時候越界了程序直接崩潰。3. 核心細節(jié)解析與實操要點3.1 CPUID指令的詳細用法CPUID指令的完整語義是這樣的執(zhí)行前EAX寄存器存放功能號也叫l(wèi)eaf號。執(zhí)行后EAX、EBX、ECX、EDX四個寄存器分別存放返回結(jié)果。不同的功能號返回不同的信息。功能號0返回的是廠商ID字符串和最大功能號。廠商ID字符串的存放方式是EBX放前4個字節(jié)EDX放中間4個字節(jié)ECX放后4個字節(jié)。注意這個順序不是按EBX、ECX、EDX排的而是EBX、EDX、ECX。我第一次寫的時候按常規(guī)順序排結(jié)果打印出來是亂碼后來查手冊才發(fā)現(xiàn)這個反直覺的順序。功能號1返回的是處理器版本信息和特性標志位。EAX的高4位是家族號低4位是型號EBX的高8位是品牌索引低8位是CLFLUSH行大小ECX和EDX是各種特性標志位比如是否支持SSE、AVX等。功能號0x80000000返回擴展功能號的最大值功能號0x80000002到0x80000004返回處理器品牌字符串。如果你想獲取完整的CPU型號名稱需要依次調(diào)用這三個功能號每次拿16個字節(jié)拼起來就是48字節(jié)的品牌字符串。# 獲取廠商ID的代碼片段 movl $0, %eax # 功能號0 cpuid # 執(zhí)行CPUID # 此時EBX、EDX、ECX中存放廠商ID實操心得CPUID指令會破壞EAX、EBX、ECX、EDX四個寄存器的值。如果你在調(diào)用CPUID之前有其他數(shù)據(jù)存在這些寄存器里一定要先保存到棧上或內(nèi)存里。我當初寫一個綜合程序的時候忘了保存EBX結(jié)果后面用到的變量全亂了排查了很久才找到原因。3.2 寄存器使用與保存策略在x86_64架構(gòu)下寄存器分為調(diào)用者保存caller-saved和被調(diào)用者保存callee-saved兩類。調(diào)用者保存的寄存器有RAX、RCX、RDX、RSI、RDI、R8-R11被調(diào)用者保存的有RBX、RBP、R12-R15。CPUID指令會修改RAX、RBX、RCX、RDX。其中RBX是被調(diào)用者保存寄存器按照約定如果你在函數(shù)中使用了RBX應(yīng)該先把它壓棧保存函數(shù)返回前再恢復(fù)。雖然我們這個程序是直接寫的沒有函數(shù)調(diào)用的概念但養(yǎng)成好習慣很重要。我的做法是在程序開頭把RBX壓棧程序結(jié)束前彈?;謴?fù)。這樣即使后續(xù)擴展程序加入了函數(shù)調(diào)用也不會因為RBX被破壞而出問題。pushq %rbx # 保存RBX # ... 程序主體 ... popq %rbx # 恢復(fù)RBX另外CPUID返回的廠商ID分散在三個寄存器里需要分別寫入內(nèi)存。寫入的時候要注意順序EBX的值寫到緩沖區(qū)偏移0處EDX的值寫到偏移4處ECX的值寫到偏移8處。這個順序不能錯錯了打印出來就是亂碼。3.3 系統(tǒng)調(diào)用的參數(shù)傳遞Linux x86_64的系統(tǒng)調(diào)用約定是這樣的系統(tǒng)調(diào)用號放在RAX參數(shù)依次放在RDI、RSI、RDX、R10、R8、R9然后執(zhí)行syscall指令。返回值放在RAX。write系統(tǒng)調(diào)用的參數(shù)是RDI文件描述符RSI緩沖區(qū)地址RDX要寫入的字節(jié)數(shù)。exit系統(tǒng)調(diào)用的參數(shù)是RDI退出碼。這里有個細節(jié)write系統(tǒng)調(diào)用返回的是實際寫入的字節(jié)數(shù)如果出錯返回負值。我們這個程序比較簡單不檢查返回值但實際項目中最好檢查一下確認數(shù)據(jù)真的寫出去了。# write系統(tǒng)調(diào)用示例 movq $1, %rax # 系統(tǒng)調(diào)用號1 write movq $1, %rdi # 文件描述符1 stdout movq $buffer, %rsi # 緩沖區(qū)地址 movq $13, %rdx # 寫入13字節(jié)12字節(jié)廠商ID 1字節(jié)換行 syscall注意緩沖區(qū)地址要用$buffer這種形式表示取buffer的地址。如果寫成buffer那就是取buffer里的內(nèi)容結(jié)果完全不對。這個坑我踩過程序編譯通過但運行沒輸出后來用調(diào)試器一看RSI里放的是0因為buffer初始值是0。3.4 數(shù)據(jù)輸出格式的處理CPUID返回的廠商ID是12個字節(jié)的ASCII字符沒有結(jié)束符。直接輸出的話終端上會顯示12個字符然后緊接著下一行內(nèi)容看起來不整齊。所以我在緩沖區(qū)第12個字節(jié)處放了一個換行符ASCII碼10輸出的時候?qū)?3個字節(jié)這樣打印完廠商ID后會自動換行。如果你想輸出更友好的格式比如CPU Vendor: GenuineIntel那就需要先輸出提示字符串再輸出廠商ID。提示字符串可以放在.data段里用.ascii或.asciz聲明。.section .data prompt: .ascii CPU Vendor: prompt_len . - prompt這里prompt_len . - prompt是匯編里常用的計算字符串長度的技巧。.表示當前地址減去字符串起始地址就是字符串長度。這種方法比手動數(shù)字符個數(shù)靠譜多了改字符串的時候不用同步改長度。4. 完整實操過程與核心環(huán)節(jié)實現(xiàn)4.1 開發(fā)環(huán)境準備我用的環(huán)境是Linux x86_64工具鏈是GNU asgas和ld。如果你用的是其他Unix-like系統(tǒng)工具鏈名字可能略有不同但基本用法一致。檢查工具是否齊全as --version ld --version如果顯示版本信息就說明裝好了。沒裝的話在Debian系上用apt install binutils在Red Hat系上用yum install binutils。我建議再裝一個調(diào)試器gdb或者lldb都行。匯編程序出錯的時候光看代碼很難定位問題用調(diào)試器單步執(zhí)行、查看寄存器值效率會高很多。gdb --version實操心得寫匯編的時候我習慣把編譯和鏈接寫成一個Makefile每次改完代碼直接make就行不用手動敲兩遍命令。雖然這個程序很小但養(yǎng)成自動化的習慣對后續(xù)寫大程序有幫助。4.2 完整代碼逐段解析下面是完整的程序代碼我按段拆開講解。.section .data # 這里暫時沒有需要初始化的數(shù)據(jù).data段存放已初始化的全局變量。這個程序暫時不需要但保留這個段聲明是個好習慣后續(xù)擴展的時候直接在這里加數(shù)據(jù)就行。.section .bss .lcomm buffer, 16.bss段存放未初始化的數(shù)據(jù)。.lcomm buffer, 16分配16字節(jié)的本地公共空間標簽是buffer。這塊空間在程序加載時被清零。.section .text .global _start.text段存放代碼。.global _start聲明_start為全局符號鏈接器會把它作為程序入口點。_start: # 第一步調(diào)用CPUID獲取廠商ID movl $0, %eax cpuid把0放入EAX執(zhí)行CPUID。執(zhí)行后EBX、EDX、ECX中存放廠商ID字符串。# 第二步把結(jié)果寫入緩沖區(qū) movl %ebx, buffer movl %edx, buffer4 movl %ecx, buffer8把EBX的值寫到buffer偏移0處EDX的值寫到偏移4處ECX的值寫到偏移8處。注意這里用的是movl因為每個寄存器是32位正好4字節(jié)。# 第三步在末尾添加換行符 movb $10, buffer12把ASCII碼10換行符寫到buffer偏移12處。movb表示移動一個字節(jié)。# 第四步調(diào)用write系統(tǒng)調(diào)用輸出 movq $1, %rax movq $1, %rdi movq $buffer, %rsi movq $13, %rdx syscall系統(tǒng)調(diào)用號1write文件描述符1stdout緩沖區(qū)地址buffer寫入13字節(jié)12字節(jié)廠商ID 1字節(jié)換行。# 第五步調(diào)用exit系統(tǒng)調(diào)用退出 movq $60, %rax movq $0, %rdi syscall系統(tǒng)調(diào)用號60exit退出碼0。4.3 編譯、鏈接與運行把上面的代碼保存為cpuid.s然后執(zhí)行as -o cpuid.o cpuid.s ld -o cpuid cpuid.o ./cpuid如果一切正常你會看到類似這樣的輸出GenuineIntel或者AuthenticAMD這取決于你的CPU廠商。注意如果你在64位系統(tǒng)上編譯但用了32位的指令可能會報錯。比如movl %ebx, buffer在64位下buffer的地址是64位的但movl只移動32位數(shù)據(jù)地址會被截斷。正確的做法是用movq或者確保buffer在低4GB地址空間內(nèi)。我當初就因為這個原因程序編譯通過但運行時段錯誤后來改成movl %ebx, buffer(%rip)才解決。4.4 用調(diào)試器驗證每一步程序跑通了不代表你理解了每一步。我建議用gdb單步執(zhí)行一遍看看每個寄存器在每個步驟后的值。gdb ./cpuid (gdb) break _start (gdb) run (gdb) stepi (gdb) info registersstepi是單步執(zhí)行一條指令info registers查看所有寄存器的值。執(zhí)行完CPUID后看看EBX、EDX、ECX里是不是ASCII字符。比如EBX里應(yīng)該是Genu0x756e6547EDX里是ineI0x49656e69ECX里是ntel0x6c65746e。這種驗證方式比盯著代碼看有效得多。我當初就是通過調(diào)試器才發(fā)現(xiàn)廠商ID的存放順序是EBX、EDX、ECX而不是我以為的EBX、ECX、EDX。5. 常見問題與排查技巧實錄5.1 編譯鏈接報錯速查錯誤信息可能原因解決方法undefined symbol: _start沒有聲明.global _start在代碼開頭加.global _startinvalid instruction suffix指令后綴寫錯檢查操作數(shù)大小補上b/w/l/qjunk at end of line注釋符號用錯ATT匯編用#注釋不是//或;relocation truncated to fit地址超出范圍用(%rip)相對尋址或改用64位指令segmentation fault內(nèi)存訪問越界檢查緩沖區(qū)大小和寫入偏移5.2 運行結(jié)果不對的排查思路如果程序能跑但輸出是亂碼按以下順序排查第一確認CPUID的功能號是否正確。功能號0返回廠商ID功能號1返回版本信息。如果你把功能號搞錯了拿到的數(shù)據(jù)自然不對。第二確認寄存器到緩沖區(qū)的寫入順序。EBX寫偏移0EDX寫偏移4ECX寫偏移8。這個順序是Intel手冊規(guī)定的不能改。第三確認輸出長度。廠商ID是12字節(jié)加上換行符是13字節(jié)。如果你寫12字節(jié)換行符不會輸出寫14字節(jié)會多輸出一個字節(jié)的垃圾數(shù)據(jù)。第四用調(diào)試器查看緩沖區(qū)內(nèi)容。在write系統(tǒng)調(diào)用之前設(shè)斷點用x/16xb buffer查看緩沖區(qū)里的原始字節(jié)確認是不是ASCII字符。實操心得我排查這類問題的時候習慣先把輸出重定向到文件然后用xxd或od查看十六進制內(nèi)容。終端有時候會把控制字符顯示成亂碼看不出問題。用十六進制查看器就一目了然了。5.3 跨平臺兼容性注意事項這個程序是在Linux x86_64下寫的換到其他平臺需要調(diào)整。比如在32位Linux下系統(tǒng)調(diào)用號不同write是4exit是1寄存器名也不同EAX而不是RAX。在ARM架構(gòu)下指令集完全不一樣CPUID指令可能不存在需要用其他方式獲取CPU信息。如果你想讓程序在多個平臺下都能編譯可以用條件匯編#ifdef __x86_64__ # 64位代碼 #else # 32位代碼 #endif不過對于初學者來說我建議先把一個平臺搞熟別一開始就追求跨平臺。把x86_64下的CPUID程序吃透再去看其他架構(gòu)的對應(yīng)實現(xiàn)會容易很多。5.4 擴展練習獲取更多CPU信息掌握了基本框架后可以嘗試擴展程序獲取更多CPU信息。比如調(diào)用功能號1獲取處理器版本和特性標志位調(diào)用功能號0x80000002到0x80000004獲取完整的品牌字符串解析特性標志位判斷CPU是否支持SSE、AVX等指令集這些擴展練習能幫你鞏固寄存器操作、內(nèi)存尋址、位運算等匯編核心技能。我當初就是通過不斷擴展這個程序慢慢把匯編的常用指令都摸熟了。最后分享一個小技巧寫匯編的時候別怕代碼長。高級語言里一行代碼能搞定的事匯編里可能要寫十行。這是正常的不是你的問題。關(guān)鍵是每一步都要清楚自己在干什么寄存器里放的是什么內(nèi)存里存的是什么。想不清楚就畫圖把寄存器和內(nèi)存的狀態(tài)畫出來比在腦子里空想靠譜得多。