1. 从一个获取CPUID的小程序说起
1.1 为什么选CPUID作为汇编入门案例
很多人学汇编,一上来就被各种寄存器、寻址方式、指令集给劝退了。我当初也是,抱着厚厚一本指令手册啃了三天,结果连个"Hello World"都写不利索。后来一位做底层开发的朋友跟我说:别急着啃手册,先找一个能跑起来的小程序,把它拆碎了理解,比看一百页理论都管用。
他当时给我的就是这个获取CPUID的程序。为什么选它?因为CPUID这条指令本身就很有意思——它是CPU提供给软件的一扇"窗口",通过它你能直接问CPU:"你是谁?你支持什么特性?"而实现这个"问答"过程,只需要用到汇编里最核心的几个概念:寄存器传参、指令调用、结果存储、系统调用输出。麻雀虽小,五脏俱全。
CPUID指令的基本用法是:把功能号放进EAX寄存器,执行CPUID指令,然后CPU会把结果分别放到EAX、EBX、ECX、EDX四个寄存器里。比如EAX=0时,返回的是厂商ID字符串;EAX=1时,返回的是处理器版本信息和特性标志位。整个过程不涉及内存分配、不涉及复杂算法,就是纯粹的寄存器操作,特别适合用来理解汇编的工作方式。
这个程序能做什么?简单说,它能在Linux环境下运行,打印出你的CPU厂商信息、型号、支持的特性标志。适合谁看?如果你已经会一门高级语言(C、Python都行),想了解程序在底层到底怎么跑的,或者你正在学操作系统、编译原理,需要一点汇编基础来打通任督二脉,那这个案例就是为你准备的。哪怕你之前完全没碰过汇编,跟着走一遍也能有个直观感受。
1.2 先搞清楚AT&T汇编和Intel汇编的区别
在动手写代码之前,有个坑必须先填上:AT&T汇编和Intel汇编是两种不同的语法风格,写错了编译器直接报错,而且报错信息往往让人摸不着头脑。
最直观的区别是操作数顺序。Intel风格是"目标在前,源在后",比如mov eax, 1意思是把1放到eax里。AT&T风格反过来,是"源在前,目标在后",同样的操作写成movl $1, %eax。我第一次切换的时候,因为顺序搞反,程序跑出来的结果完全不对,排查了半天才发现是操作数顺序的问题。
另一个显著区别是寄存器前面要加百分号%,立即数前面要加美元符号$。Intel风格里直接写eax和1,AT&T风格必须写成%eax和$1。这个规则没有例外,忘了加符号编译器会直接报错。
还有指令后缀的问题。AT&T汇编里,指令后面要跟一个字母表示操作数大小:b表示字节(8位),w表示字(16位),l表示双字(32位),q表示四字(64位)。比如movl表示移动32位数据,movq表示移动64位数据。Intel汇编通常靠寄存器名来推断大小,AT&T则要求你显式写出来。
| 对比项 | Intel风格 | AT&T风格 |
|---|---|---|
| 操作数顺序 | 目标, 源 | 源, 目标 |
| 寄存器前缀 | 无 | % |
| 立即数前缀 | 无 | $ |
| 指令后缀 | 通常无 | b/w/l/q |
| 内存寻址 | [base+index*scale+disp] | disp(base,index,scale) |
我个人的建议是:如果你主要看Linux内核源码或者GCC生成的汇编,那就老老实实学AT&T风格,因为这是Unix世界的"普通话"。如果你更习惯Windows下的开发环境,Intel风格可能更顺手。但不管选哪个,先把一种搞熟,另一种看懂就行,别两种混着写,那是自找麻烦。
2. 程序整体设计与思路拆解
2.1 程序要完成哪些事情
这个获取CPUID的程序,逻辑上分四步走:第一步,调用CPUID指令拿到厂商ID字符串;第二步,把拿到的数据保存到内存缓冲区;第三步,把缓冲区的内容打印到终端;第四步,正常退出程序。
听起来简单,但每一步都涉及汇编的核心概念。第一步考验的是对CPUID指令的理解——功能号怎么传、结果放在哪。第二步考验的是内存寻址——怎么把寄存器里的数据写到内存里。第三步考验的是系统调用——Linux下怎么输出字符串。第四步考验的是程序退出机制。
我之所以强调这个四步拆解,是因为很多初学者一上来就想写完整程序,结果写到一半卡住了,不知道哪出了问题。把步骤拆开,每一步单独验证,出错了也容易定位。比如你可以先写个只调用CPUID然后退出的程序,用调试器看看寄存器里的值对不对,确认无误后再加输出功能。
2.2 为什么用系统调用而不是C库函数
你可能会问:为什么不用printf?多方便啊。原因很简单:用printf就需要链接C库,程序的体积会变大,而且你得理解调用约定、栈对齐这些额外概念。用系统调用(syscall)虽然原始一点,但胜在直接——你直接告诉内核"我要输出这段文字",内核就帮你输出,中间没有黑盒。
Linux下输出字符串用的是write系统调用,编号是1(x86_64架构下)。调用方式是:rax放系统调用号,rdi放文件描述符(1表示标准输出),rsi放字符串地址,rdx放字符串长度,然后执行syscall指令。退出程序用的是exit系统调用,编号是60,rdi放退出码。
这种"寄存器传参+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 as(gas)和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, buffer+4 movl %ecx, buffer+8把EBX的值写到buffer偏移0处,EDX的值写到偏移4处,ECX的值写到偏移8处。注意这里用的是movl,因为每个寄存器是32位,正好4字节。
# 第三步:在末尾添加换行符 movb $10, buffer+12把ASCII码10(换行符)写到buffer偏移12处。movb表示移动一个字节。
# 第四步:调用write系统调用输出 movq $1, %rax movq $1, %rdi movq $buffer, %rsi movq $13, %rdx syscall系统调用号1(write),文件描述符1(stdout),缓冲区地址buffer,写入13字节(12字节厂商ID + 1字节换行)。
# 第五步:调用exit系统调用退出 movq $60, %rax movq $0, %rdi syscall系统调用号60(exit),退出码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里应该是"Genu"(0x756e6547),EDX里是"ineI"(0x49656e69),ECX里是"ntel"(0x6c65746e)。
这种验证方式比盯着代码看有效得多。我当初就是通过调试器才发现厂商ID的存放顺序是EBX、EDX、ECX,而不是我以为的EBX、ECX、EDX。
5. 常见问题与排查技巧实录
5.1 编译链接报错速查
| 错误信息 | 可能原因 | 解决方法 |
|---|---|---|
undefined symbol: _start | 没有声明.global _start | 在代码开头加.global _start |
invalid instruction suffix | 指令后缀写错 | 检查操作数大小,补上b/w/l/q |
junk at end of line | 注释符号用错 | AT&T汇编用#注释,不是//或; |
relocation truncated to fit | 地址超出范围 | 用(%rip)相对寻址,或改用64位指令 |
segmentation fault | 内存访问越界 | 检查缓冲区大小和写入偏移 |
5.2 运行结果不对的排查思路
如果程序能跑但输出是乱码,按以下顺序排查:
第一,确认CPUID的功能号是否正确。功能号0返回厂商ID,功能号1返回版本信息。如果你把功能号搞错了,拿到的数据自然不对。
第二,确认寄存器到缓冲区的写入顺序。EBX写偏移0,EDX写偏移4,ECX写偏移8。这个顺序是Intel手册规定的,不能改。
第三,确认输出长度。厂商ID是12字节,加上换行符是13字节。如果你写12字节,换行符不会输出;写14字节,会多输出一个字节的垃圾数据。
第四,用调试器查看缓冲区内容。在write系统调用之前设断点,用x/16xb &buffer查看缓冲区里的原始字节,确认是不是ASCII字符。
实操心得:我排查这类问题的时候,习惯先把输出重定向到文件,然后用
xxd或od查看十六进制内容。终端有时候会把控制字符显示成乱码,看不出问题。用十六进制查看器就一目了然了。
5.3 跨平台兼容性注意事项
这个程序是在Linux x86_64下写的,换到其他平台需要调整。比如在32位Linux下,系统调用号不同(write是4,exit是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等指令集
这些扩展练习能帮你巩固寄存器操作、内存寻址、位运算等汇编核心技能。我当初就是通过不断扩展这个程序,慢慢把汇编的常用指令都摸熟了。
最后分享一个小技巧:写汇编的时候,别怕代码长。高级语言里一行代码能搞定的事,汇编里可能要写十行。这是正常的,不是你的问题。关键是每一步都要清楚自己在干什么,寄存器里放的是什么,内存里存的是什么。想不清楚就画图,把寄存器和内存的状态画出来,比在脑子里空想靠谱得多。