1. 从一个获取CPUID的小程序说起1.1 为什么选CPUID作为汇编入门案例很多人学汇编一上来就被各种寄存器、寻址方式、指令集给劝退了。我当初也是抱着厚厚一本指令手册啃了三天结果连个Hello World都写不利索。后来一位做底层开发的朋友跟我说别急着啃手册先找一个能跑起来的小程序把它拆碎了理解比看一百页理论都管用。他当时给我的就是这个获取CPUID的程序。为什么选它因为CPUID这条指令本身就很有意思——它是CPU提供给软件的一扇窗口通过它你能直接问CPU你是谁你支持什么特性而实现这个问答过程只需要用到汇编里最核心的几个概念寄存器传参、指令调用、结果存储、系统调用输出。麻雀虽小五脏俱全。CPUID指令的基本用法是把功能号放进EAX寄存器执行CPUID指令然后CPU会把结果分别放到EAX、EBX、ECX、EDX四个寄存器里。比如EAX0时返回的是厂商ID字符串EAX1时返回的是处理器版本信息和特性标志位。整个过程不涉及内存分配、不涉及复杂算法就是纯粹的寄存器操作特别适合用来理解汇编的工作方式。这个程序能做什么简单说它能在Linux环境下运行打印出你的CPU厂商信息、型号、支持的特性标志。适合谁看如果你已经会一门高级语言C、Python都行想了解程序在底层到底怎么跑的或者你正在学操作系统、编译原理需要一点汇编基础来打通任督二脉那这个案例就是为你准备的。哪怕你之前完全没碰过汇编跟着走一遍也能有个直观感受。1.2 先搞清楚ATT汇编和Intel汇编的区别在动手写代码之前有个坑必须先填上ATT汇编和Intel汇编是两种不同的语法风格写错了编译器直接报错而且报错信息往往让人摸不着头脑。最直观的区别是操作数顺序。Intel风格是目标在前源在后比如mov eax, 1意思是把1放到eax里。ATT风格反过来是源在前目标在后同样的操作写成movl $1, %eax。我第一次切换的时候因为顺序搞反程序跑出来的结果完全不对排查了半天才发现是操作数顺序的问题。另一个显著区别是寄存器前面要加百分号%立即数前面要加美元符号$。Intel风格里直接写eax和1ATT风格必须写成%eax和$1。这个规则没有例外忘了加符号编译器会直接报错。还有指令后缀的问题。ATT汇编里指令后面要跟一个字母表示操作数大小b表示字节8位w表示字16位l表示双字32位q表示四字64位。比如movl表示移动32位数据movq表示移动64位数据。Intel汇编通常靠寄存器名来推断大小ATT则要求你显式写出来。对比项Intel风格ATT风格操作数顺序目标, 源源, 目标寄存器前缀无%立即数前缀无$指令后缀通常无b/w/l/q内存寻址[baseindex*scaledisp]disp(base,index,scale)我个人的建议是如果你主要看Linux内核源码或者GCC生成的汇编那就老老实实学ATT风格因为这是Unix世界的普通话。如果你更习惯Windows下的开发环境Intel风格可能更顺手。但不管选哪个先把一种搞熟另一种看懂就行别两种混着写那是自找麻烦。2. 程序整体设计与思路拆解2.1 程序要完成哪些事情这个获取CPUID的程序逻辑上分四步走第一步调用CPUID指令拿到厂商ID字符串第二步把拿到的数据保存到内存缓冲区第三步把缓冲区的内容打印到终端第四步正常退出程序。听起来简单但每一步都涉及汇编的核心概念。第一步考验的是对CPUID指令的理解——功能号怎么传、结果放在哪。第二步考验的是内存寻址——怎么把寄存器里的数据写到内存里。第三步考验的是系统调用——Linux下怎么输出字符串。第四步考验的是程序退出机制。我之所以强调这个四步拆解是因为很多初学者一上来就想写完整程序结果写到一半卡住了不知道哪出了问题。把步骤拆开每一步单独验证出错了也容易定位。比如你可以先写个只调用CPUID然后退出的程序用调试器看看寄存器里的值对不对确认无误后再加输出功能。2.2 为什么用系统调用而不是C库函数你可能会问为什么不用printf多方便啊。原因很简单用printf就需要链接C库程序的体积会变大而且你得理解调用约定、栈对齐这些额外概念。用系统调用syscall虽然原始一点但胜在直接——你直接告诉内核我要输出这段文字内核就帮你输出中间没有黑盒。Linux下输出字符串用的是write系统调用编号是1x86_64架构下。调用方式是rax放系统调用号rdi放文件描述符1表示标准输出rsi放字符串地址rdx放字符串长度然后执行syscall指令。退出程序用的是exit系统调用编号是60rdi放退出码。这种寄存器传参syscall指令的模式是Linux下汇编程序与内核交互的标准方式。理解了这一套你就能自己写各种系统调用的小工具了比如读文件、创建进程、发送信号等等。2.3 内存布局的规划程序需要一块内存来存放CPUID返回的厂商ID字符串。厂商ID是12个字节比如GenuineIntel就是12个字符加上换行符和可能的结束符我分配了16个字节的缓冲区。在汇编里分配内存有两种方式一种是用.bss段声明未初始化的空间一种是用.data段声明已初始化的数据。对于缓冲区用.bss段更合适因为不需要初始值。声明方式是.section .bss .lcomm buffer, 16.lcomm表示本地公共符号分配16字节的零初始化空间。这块内存在程序加载时由内核分配程序运行时可以直接读写。注意缓冲区大小一定要留足余量。CPUID返回的厂商ID是12字节但如果你后续想扩展程序比如加上型号字符串的输出缓冲区就要相应扩大。我见过有人只分配了12字节结果写入换行符的时候越界了程序直接崩溃。3. 核心细节解析与实操要点3.1 CPUID指令的详细用法CPUID指令的完整语义是这样的执行前EAX寄存器存放功能号也叫leaf号。执行后EAX、EBX、ECX、EDX四个寄存器分别存放返回结果。不同的功能号返回不同的信息。功能号0返回的是厂商ID字符串和最大功能号。厂商ID字符串的存放方式是EBX放前4个字节EDX放中间4个字节ECX放后4个字节。注意这个顺序不是按EBX、ECX、EDX排的而是EBX、EDX、ECX。我第一次写的时候按常规顺序排结果打印出来是乱码后来查手册才发现这个反直觉的顺序。功能号1返回的是处理器版本信息和特性标志位。EAX的高4位是家族号低4位是型号EBX的高8位是品牌索引低8位是CLFLUSH行大小ECX和EDX是各种特性标志位比如是否支持SSE、AVX等。功能号0x80000000返回扩展功能号的最大值功能号0x80000002到0x80000004返回处理器品牌字符串。如果你想获取完整的CPU型号名称需要依次调用这三个功能号每次拿16个字节拼起来就是48字节的品牌字符串。# 获取厂商ID的代码片段 movl $0, %eax # 功能号0 cpuid # 执行CPUID # 此时EBX、EDX、ECX中存放厂商ID实操心得CPUID指令会破坏EAX、EBX、ECX、EDX四个寄存器的值。如果你在调用CPUID之前有其他数据存在这些寄存器里一定要先保存到栈上或内存里。我当初写一个综合程序的时候忘了保存EBX结果后面用到的变量全乱了排查了很久才找到原因。3.2 寄存器使用与保存策略在x86_64架构下寄存器分为调用者保存caller-saved和被调用者保存callee-saved两类。调用者保存的寄存器有RAX、RCX、RDX、RSI、RDI、R8-R11被调用者保存的有RBX、RBP、R12-R15。CPUID指令会修改RAX、RBX、RCX、RDX。其中RBX是被调用者保存寄存器按照约定如果你在函数中使用了RBX应该先把它压栈保存函数返回前再恢复。虽然我们这个程序是直接写的没有函数调用的概念但养成好习惯很重要。我的做法是在程序开头把RBX压栈程序结束前弹栈恢复。这样即使后续扩展程序加入了函数调用也不会因为RBX被破坏而出问题。pushq %rbx # 保存RBX # ... 程序主体 ... popq %rbx # 恢复RBX另外CPUID返回的厂商ID分散在三个寄存器里需要分别写入内存。写入的时候要注意顺序EBX的值写到缓冲区偏移0处EDX的值写到偏移4处ECX的值写到偏移8处。这个顺序不能错错了打印出来就是乱码。3.3 系统调用的参数传递Linux x86_64的系统调用约定是这样的系统调用号放在RAX参数依次放在RDI、RSI、RDX、R10、R8、R9然后执行syscall指令。返回值放在RAX。write系统调用的参数是RDI文件描述符RSI缓冲区地址RDX要写入的字节数。exit系统调用的参数是RDI退出码。这里有个细节write系统调用返回的是实际写入的字节数如果出错返回负值。我们这个程序比较简单不检查返回值但实际项目中最好检查一下确认数据真的写出去了。# write系统调用示例 movq $1, %rax # 系统调用号1 write movq $1, %rdi # 文件描述符1 stdout movq $buffer, %rsi # 缓冲区地址 movq $13, %rdx # 写入13字节12字节厂商ID 1字节换行 syscall注意缓冲区地址要用$buffer这种形式表示取buffer的地址。如果写成buffer那就是取buffer里的内容结果完全不对。这个坑我踩过程序编译通过但运行没输出后来用调试器一看RSI里放的是0因为buffer初始值是0。3.4 数据输出格式的处理CPUID返回的厂商ID是12个字节的ASCII字符没有结束符。直接输出的话终端上会显示12个字符然后紧接着下一行内容看起来不整齐。所以我在缓冲区第12个字节处放了一个换行符ASCII码10输出的时候写13个字节这样打印完厂商ID后会自动换行。如果你想输出更友好的格式比如CPU Vendor: GenuineIntel那就需要先输出提示字符串再输出厂商ID。提示字符串可以放在.data段里用.ascii或.asciz声明。.section .data prompt: .ascii CPU Vendor: prompt_len . - prompt这里prompt_len . - prompt是汇编里常用的计算字符串长度的技巧。.表示当前地址减去字符串起始地址就是字符串长度。这种方法比手动数字符个数靠谱多了改字符串的时候不用同步改长度。4. 完整实操过程与核心环节实现4.1 开发环境准备我用的环境是Linux x86_64工具链是GNU asgas和ld。如果你用的是其他Unix-like系统工具链名字可能略有不同但基本用法一致。检查工具是否齐全as --version ld --version如果显示版本信息就说明装好了。没装的话在Debian系上用apt install binutils在Red Hat系上用yum install binutils。我建议再装一个调试器gdb或者lldb都行。汇编程序出错的时候光看代码很难定位问题用调试器单步执行、查看寄存器值效率会高很多。gdb --version实操心得写汇编的时候我习惯把编译和链接写成一个Makefile每次改完代码直接make就行不用手动敲两遍命令。虽然这个程序很小但养成自动化的习惯对后续写大程序有帮助。4.2 完整代码逐段解析下面是完整的程序代码我按段拆开讲解。.section .data # 这里暂时没有需要初始化的数据.data段存放已初始化的全局变量。这个程序暂时不需要但保留这个段声明是个好习惯后续扩展的时候直接在这里加数据就行。.section .bss .lcomm buffer, 16.bss段存放未初始化的数据。.lcomm buffer, 16分配16字节的本地公共空间标签是buffer。这块空间在程序加载时被清零。.section .text .global _start.text段存放代码。.global _start声明_start为全局符号链接器会把它作为程序入口点。_start: # 第一步调用CPUID获取厂商ID movl $0, %eax cpuid把0放入EAX执行CPUID。执行后EBX、EDX、ECX中存放厂商ID字符串。# 第二步把结果写入缓冲区 movl %ebx, buffer movl %edx, buffer4 movl %ecx, buffer8把EBX的值写到buffer偏移0处EDX的值写到偏移4处ECX的值写到偏移8处。注意这里用的是movl因为每个寄存器是32位正好4字节。# 第三步在末尾添加换行符 movb $10, buffer12把ASCII码10换行符写到buffer偏移12处。movb表示移动一个字节。# 第四步调用write系统调用输出 movq $1, %rax movq $1, %rdi movq $buffer, %rsi movq $13, %rdx syscall系统调用号1write文件描述符1stdout缓冲区地址buffer写入13字节12字节厂商ID 1字节换行。# 第五步调用exit系统调用退出 movq $60, %rax movq $0, %rdi syscall系统调用号60exit退出码0。4.3 编译、链接与运行把上面的代码保存为cpuid.s然后执行as -o cpuid.o cpuid.s ld -o cpuid cpuid.o ./cpuid如果一切正常你会看到类似这样的输出GenuineIntel或者AuthenticAMD这取决于你的CPU厂商。注意如果你在64位系统上编译但用了32位的指令可能会报错。比如movl %ebx, buffer在64位下buffer的地址是64位的但movl只移动32位数据地址会被截断。正确的做法是用movq或者确保buffer在低4GB地址空间内。我当初就因为这个原因程序编译通过但运行时段错误后来改成movl %ebx, buffer(%rip)才解决。4.4 用调试器验证每一步程序跑通了不代表你理解了每一步。我建议用gdb单步执行一遍看看每个寄存器在每个步骤后的值。gdb ./cpuid (gdb) break _start (gdb) run (gdb) stepi (gdb) info registersstepi是单步执行一条指令info registers查看所有寄存器的值。执行完CPUID后看看EBX、EDX、ECX里是不是ASCII字符。比如EBX里应该是Genu0x756e6547EDX里是ineI0x49656e69ECX里是ntel0x6c65746e。这种验证方式比盯着代码看有效得多。我当初就是通过调试器才发现厂商ID的存放顺序是EBX、EDX、ECX而不是我以为的EBX、ECX、EDX。5. 常见问题与排查技巧实录5.1 编译链接报错速查错误信息可能原因解决方法undefined symbol: _start没有声明.global _start在代码开头加.global _startinvalid instruction suffix指令后缀写错检查操作数大小补上b/w/l/qjunk at end of line注释符号用错ATT汇编用#注释不是//或;relocation truncated to fit地址超出范围用(%rip)相对寻址或改用64位指令segmentation fault内存访问越界检查缓冲区大小和写入偏移5.2 运行结果不对的排查思路如果程序能跑但输出是乱码按以下顺序排查第一确认CPUID的功能号是否正确。功能号0返回厂商ID功能号1返回版本信息。如果你把功能号搞错了拿到的数据自然不对。第二确认寄存器到缓冲区的写入顺序。EBX写偏移0EDX写偏移4ECX写偏移8。这个顺序是Intel手册规定的不能改。第三确认输出长度。厂商ID是12字节加上换行符是13字节。如果你写12字节换行符不会输出写14字节会多输出一个字节的垃圾数据。第四用调试器查看缓冲区内容。在write系统调用之前设断点用x/16xb buffer查看缓冲区里的原始字节确认是不是ASCII字符。实操心得我排查这类问题的时候习惯先把输出重定向到文件然后用xxd或od查看十六进制内容。终端有时候会把控制字符显示成乱码看不出问题。用十六进制查看器就一目了然了。5.3 跨平台兼容性注意事项这个程序是在Linux x86_64下写的换到其他平台需要调整。比如在32位Linux下系统调用号不同write是4exit是1寄存器名也不同EAX而不是RAX。在ARM架构下指令集完全不一样CPUID指令可能不存在需要用其他方式获取CPU信息。如果你想让程序在多个平台下都能编译可以用条件汇编#ifdef __x86_64__ # 64位代码 #else # 32位代码 #endif不过对于初学者来说我建议先把一个平台搞熟别一开始就追求跨平台。把x86_64下的CPUID程序吃透再去看其他架构的对应实现会容易很多。5.4 扩展练习获取更多CPU信息掌握了基本框架后可以尝试扩展程序获取更多CPU信息。比如调用功能号1获取处理器版本和特性标志位调用功能号0x80000002到0x80000004获取完整的品牌字符串解析特性标志位判断CPU是否支持SSE、AVX等指令集这些扩展练习能帮你巩固寄存器操作、内存寻址、位运算等汇编核心技能。我当初就是通过不断扩展这个程序慢慢把汇编的常用指令都摸熟了。最后分享一个小技巧写汇编的时候别怕代码长。高级语言里一行代码能搞定的事汇编里可能要写十行。这是正常的不是你的问题。关键是每一步都要清楚自己在干什么寄存器里放的是什么内存里存的是什么。想不清楚就画图把寄存器和内存的状态画出来比在脑子里空想靠谱得多。