☰
汇编指令编码纠错:长度后缀、寻址方式与栈帧
2026/10/29 8:52:54 网站建设 项目流程

简介:面向计算机系统基础课程学习者的第三章课后习题参考答案,对应袁春风《计算机系统基础(第二版)》教材,聚焦处理器指令系统、寻址方式、操作数格式与程序机器级表示等核心内容。答案按题号逐条整理,覆盖寻址模式判定(w、b、l 后缀与源、目的操作数的组合)、指令格式错误辨析、数据类型转换表、函数参数在栈中的存放位置(如 R[ebp]+8、+12、+16),以及 add、sub、or、test 等指令对 OF、SF、ZF、CF 标志位的影响与十六进制运算过程,并给出把机器级代码还原为 C 语言函数的思路。资源包共 1 个 PDF 文件,约 3.15MB,页面按习题顺序编排,便于对照教材边做边核对。已有 7738 人学习下载,适合课后自测、期末复习与考研专业课查漏补缺,也可作为理解程序底层执行过程的练习参照。

1. 长度后缀写错,报错的不是语义而是编码表

翻开计算机系统基础第三章的课后习题,第 4 题有 8 个小问,全是判断汇编指令哪里写错了。多数人第一反应是「汇编器在帮我检查语义」——源操作数 16 位而后缀是 b,宽度对不上,所以报错。这个理解反了。汇编器并不懂你的语义,它只是去查一张编码表:movb对应哪个操作码、后面跟几个字节的立即数、ModR/M 里的寄存器字段怎么解释。当源和目的宽度拼不出表里的任何一行,它才拒绝。真正值得搞懂的是那张表长什么样、为什么有些组合压根没有格子可放。搞清这一点,寻址方式、符号扩展、标志位这三块知识会自己串起来。

2. 长度后缀与寻址方式:一条汇编指令里到底编码了什么

2.1 b/w/l 不是注释,它决定了操作码和立即数字节数

在 AT&T 语法里,movb、movw、movl看着只差一个字母,落到机器码上是三个不同的操作码族。以 mov 为例,寄存器到内存这一族里,字节操作是0x88,字和长字共用0x89再由前缀区分;立即数到寄存器这一族里,0xB0+r是 movb imm8,0xB8+r是 movl imm32。后缀同时钉死三件事:操作数宽度、立即数字段占几个字节、寄存器编号怎么解释。

movl $1, (%eax) # C7 00 01 00 00 00 6 字节:opcode + ModR/M + imm32 movb $1, (%eax) # C6 00 01 3 字节:opcode + ModR/M + imm8

第二行如果写成movl $1, (%eax),立即数字段就必须撑到 4 字节;反过来把 32 位立即数塞进movb,编码器要么截断要么报错。习题 4 的第(2)问「源操作数是 16 位,而长度后缀是字节 b」,本质上就是这个矛盾:mov这类指令的源和目的必须同宽,后缀只说一次,源目的共享。只有movs/movz这种带两个宽度字母的指令,才允许源窄目的宽。

由此可以推出一条实用判断:看到一条mov类指令,先数后缀,再看两个操作数是不是都符合这个宽度。不符合就是编码层的问题,不是逻辑层的问题。

2.2 源与目的的组合:为什么目的操作数不能是立即数

AT&T 的书写顺序是opcode src, dst,源在左目的在右,和 Intel 语法正好相反。操作数有三种形态:立即数以$开头,寄存器以%开头,内存写成disp(base, index, scale)。习题 4 第(1)问「源操作数是立即数 0xFF,需在前面加$」,说的就是少了这个前缀,汇编器会把0xFF当成内存地址 255 去解析。

真正有意思的是第(3)问:目的操作数不能是立即数寻址。这不是「语义上没意义」被拒绝,而是编码格式里根本没有这个位置。IA-32 的指令由操作码后面跟 ModR/M 字节构成,ModR/M 拆成三段:mod(2 位,区分寄存器和各种内存寻址)、reg(3 位,通常是一个寄存器操作数)、r/m(3 位,配合 mod 表示另一个寄存器或内存)。立即数不走 ModR/M,它单独占操作码后面的 imm8/imm16/imm32 字段。也就是说,一条指令最多只有一个「立即数字段」的位置,它天然只能当源。想把立即数当目的,编码器找不到格子放它。

同样的逻辑解释了另外两问。第(5)问「不能用 8 位寄存器作为目的操作数地址所在寄存器」:ModR/M 和 SIB 里的 3 位寄存器字段,在 32 位模式下解释成 EAX 到 EDI,没有 8 位的解释方式,地址运算本身也是在 32 位 ALU 里做的。第(7)问「不存在 ESX 寄存器」:段寄存器只有 ES、CS、SS、DS、FS、GS 六个,把 ES 加上 32 位寄存器的X后缀拼成ESX,是把两套命名体系混在了一起。

第(8)问「源操作数地址中缺少变址寄存器」,指向的是 SIB 字节的结构问题。当内存操作数写成disp(base, index, scale)时,编码里必须有 base 和 index 两个字段;缺了 index,剩下那部分就不能按比例变址来解释。这类错误在手工反汇编时特别容易看走眼——机器码里字段是全的,是人的书写漏了。

2.3 比例变址:数组下标怎么被压进一条指令

SIB 字节的结构是 scale(2 位)、index(3 位)、base(3 位)。scale 两位只能表示四种比例:00 是 1 倍,01 是 2 倍,10 是 4 倍,11 是 8 倍。这不是随便挑的数字,它精确对应 C 语言里最常见的访存模式:元素大小 × 下标。char 数组比例 1,short 数组比例 2,int 和指针数组比例 4,double 或含 8 字节成员的结构体数组比例 8。

习题 21 给了一张表达式与汇编的对照表,挑三行看最清楚。设 S 是short *,首地址在 EDX,下标 i 在 ECX:

leal (%edx, %ecx, 2), %eax # S+i → R[eax] = AS + 2*i movw (%edx, %ecx, 2), %ax # S[i] → 取值 M[AS + 2*i] movw 8(%edx, %ecx, 8), %ax # S[4*i+4] → M[AS + 8*i + 8]

第三行是整张表里最能说明问题的一行。S[4*i+4]的地址按定义是AS + 2*(4i+4),展开成AS + 8i + 8。编译器没有生成「先乘 4 再乘 2」的两步,而是把2×4=8折进了 scale 字段,把2×4=8折进了 disp 字段。一条指令、一次地址计算,比例和偏移都在编码里。

对照表里还有一行值得注意:&S[i] - S的值是 i,汇编只有一条movl %ecx, %eax。按定义这是(AS+2i-AS)/2,除以 2 在编译期就完成了,运行时不需要任何运算。这说明指针减法在机器级之所以能直接得到元素个数,靠的是编译器知道元素大小,而不是 CPU 有什么特殊能力。

多维数组把这套机制用得更满。习题 23 里a[i][j][k]的地址被算成a + 4*(63*i + 9*j + k),反推维度:内层步长 1 对应 k,中层步长 9 对应 j 所以 M=9,外层步长 63 = 9×7 所以 N=7,数组总字节数 4536 = 4×18×9×7 所以 L=18。三个维度、一次 lea 配两次 imul,就把三维下标压成了一个线性偏移。

提示:手工反汇编看到(%eax,%ebx,4)这种形式,先确认元素类型是不是 4 字节,再看 base 和 index 谁对应首地址、谁对应下标。顺序搞反算出来的地址会差一个数量级。

3. 符号扩展与标志位:同一条 ALU 的三种态度

3.1 movs 与 movz:C 语言整型提升的机器语义

习题 5 那张表列了七组类型转换,核心只有两条指令:movs(符号扩展)和movz(零扩展)。它们的助记符带两个宽度字母,前一个说源多宽,后一个说目的多宽。movsbl就是把 8 位源符号扩展到 32 位目的。

movsbl %al, (%edx) # char → int:把 AL 的符号位铺满高 24 位后存 4 字节 movzbl %al, (%edx) # unsigned char → unsigned int:高 24 位补 0 movb %al, (%edx) # int → char:只取低 8 位,直接截断 movswl %ax, (%edx) # short → unsigned:仍然是符号扩展

第四行是表里最容易做错的一行。short转unsigned看着像应该零扩展,实际用的是movswl。理由在 C 语言的整型提升规则里:short先提升为int,值保持不变,再转成unsigned int。如果源值是 -1,正确结果是 0xFFFFFFFF;用零扩展会得到 0x0000FFFF,值完全变了。所以决定用 s 还是 z 的,不是目的类型有没有符号,而是源值在拓宽过程中要不要保号。

反过来int → char用movb而不是movsb,因为这是窄化,只保留低 8 位,高位直接丢弃,没有「扩展」这回事。int → unsigned int则是同宽搬运,movl一条搞定,位模式一个不变。

这里有个常被忽略的细节:扩展是「取出—扩展—写入」三步里的中间一步,不是独立指令。movsbl %al, (%edx)一条指令完成了读 8 位、扩到 32 位、写 4 字节。所以扩展指令的两个操作数宽度不必相同,这也是mov系列做不到的。

3.2 32×32 到 64:EDX:EAX 与高低位拼装

习题 10 那段代码是在算一个 64 位无符号数的乘法,被乘数 x 是 32 位,乘数 y 是 64 位,拆成 yh(高 32 位)和 yl(低 32 位)两半。

movl 12(%ebp), %eax # EAX = x movl 20(%ebp), %ecx # ECX = yh imull %eax, %ecx # ECX = yh * x 的低 32 位(双操作数 imul 只取低位) mull 16(%ebp) # EDX:EAX = yl * x,无符号 64 位积 addl %ecx, %edx # EDX = (yl*x)高32位 + (yh*x)低32位 movl 8(%ebp), %ecx # ECX = d movl %eax, (%ecx) # d 的低 32 位 = 积的低 32 位 movl %edx, 4(%ecx) # d 的高 32 位 = 拼出来的高位

原理展开成公式就一句话:x*y = x*yl + (x*yh) << 32。低 32 位完全由x*yl决定,x*yh只贡献高 32 位;而x*yh自身的低 32 位正好落在结果的第 32 到 63 位,所以要把它的低 32 位加到x*yl的高 32 位上。x*yh的高 32 位超出 64 位范围,直接丢弃。

mull是单操作数形式,隐式使用 EAX 作被乘数,结果放 EDX:EAX,正好给了 64 位空间。imull %eax, %ecx是双操作数形式,只保留低 32 位。两者混用不是随意搭配,而是「需要完整的 64 位积时用单操作数,只需要低 32 位时用双操作数」。这也解释了为什么第 4 行选无符号的mull:y 的类型是unsigned long long,用有符号版本在高位修正上会多出几条指令。

3.3 标志位:add、or、test、dec、shl 各管一摊

EFLAGS 里的 CF、OF、SF、ZF 不是每条指令都全管。加法减法最老实,四个标志全按定义算;位运算偷懒,CF 和 OF 直接清零;test做完 and 不写回结果,只留标志;inc/dec干脆不碰 CF。

拿习题 8 的几个具体数字过一遍最直观。第一问0x00000080 + 0xFFFFFFF0,无符号看是进位,有符号看是一个正数加一个负数:

0000 0000 0000 0000 0000 0000 1000 0000 + 1111 1111 1111 1111 1111 1111 1111 0000 = 1 0000 0000 0000 0000 0000 0000 0111 0000

截断到 32 位是 0x00000070,最高位是 0 所以 SF=0,结果非零所以 ZF=0。最高位产生了进位所以 CF=1。两个操作数一正一负,异号相加永不溢出,所以 OF=0。

第二问0x00000010 - 0x80000008,减法在硬件里是加补码。被减数 0x10 是正的小数,减数 0x80000008 当作有符号数是 -2147483640,正数减负数结果应该是正的 2147483656,超出 int 范围,结果变负,因此 OF=1、SF=1。无符号角度看是小数减大数,需要借位,CF=1。

第三问0x0100 or 0xFF00得 0xFF00。位运算没有进位和溢出概念,硬件干脆把 CF 和 OF 强制清 0,只根据结果设置 SF 和 ZF:最高位是 1 所以 SF=1,结果非零所以 ZF=0。

第四问testb $0x80, %dl,做的是0x80 and 0x80得 0x80,但结果不写回 DL。它的用途是只看最高位:位 7 为 1 说明这个字节按有符号解释是负数。所以习题 12 里判断x < 0用的是testb $0x80, %dl而不是cmpb $0, %dl,少一次比较、少一个立即数。

第六问decw %cx,CX 从 0x0010 变 0x000F。正数减 1,OF=0;结果最高位 0,SF=0;非零,ZF=0。注意 CF 在这里原封不动——inc/dec的设计就是不更新 CF,所以在多字节减法里 CF 存着上一字节的借位,不能被循环计数器的自减冲掉。

第五问最值得琢磨:M[0x8049380] * 32被编译成左移 5 位,0x908f12a8 << 5得0x11e25500,显然溢出,但没有任何标志位报告这件事。原因有两层:移位指令的 CF 只装最后一次移出的那一位,OF 只在移 1 位时有定义,移多位时 OF 无意义;而教材口径里乘法指令本身不设置 SF/ZF,只用 CF/OF 表示高位是否有有效信息。所以「乘出界了」这件事,机器级不会给你一个 ZF=1 这样的后悔药,只能靠程序员自己判断。

4. 栈帧与过程调用:EBP 链、参数偏移与返回地址

4.1 R[ebp]+8 起的参数布局

IA-32 的过程调用靠栈传递参数。call指令先把返回地址压栈,进入被调用函数后第一条push %ebp把调用者的帧指针也压进去,然后mov %esp, %ebp建立新帧。于是从 EBP 往上数:+0 是调用者的 EBP,+4 是返回地址,+8 开始才是第一个参数。

习题 6 里func(int *xptr, int *yptr, int *zptr)的三个指针分别落在R[ebp]+8、R[ebp]+12、R[ebp]+16。每个指针 4 字节,边界自然对齐,没有填充。参数从右往左压栈,所以最后压进去的第一个参数反而在最低地址。

知道了参数位置,函数体几乎可以逐行还原:

movl 8(%ebp), %eax # 取 xptr movl (%eax), %ecx # tempx = *xptr movl 12(%ebp), %edx # 取 yptr movl (%edx), %eax # tempy = *yptr movl %eax, (%edx) # *yptr = tempx

这段对应的是三个指针指向的值轮换:*yptr = 旧 *xptr,*zptr = 旧 *yptr,*xptr = 旧 *zptr。所以要先把三个值都读进临时变量,再依次写回,否则第二次写会覆盖掉还没用到的旧值。

参数偏移这个知识点看着简单,翻车的人不少。指针参数占 4 字节,int占 4 字节,short占 4 字节(按 4 字节边界对齐),char也占 4 字节,long long占 8 字节并对齐到 4 的倍数。习题 10 里 y 是 64 位,所以R[ebp]+16是低 32 位、R[ebp]+20是高 32 位,两个槽位紧挨着。推算偏移时先画一张从 +8 开始的槽位表,比凭感觉加要可靠得多。

4.2 局部变量的位置:sub 之后偏移是负的

函数开场做三件事:保存旧 EBP、建立新 EBP、把 ESP 往下挪给局部变量腾地方。sub $0x28, %esp就是挪出 40 字节。此后局部变量全部通过R[ebp]-k访问。

习题 18 给了一串栈指针变化:进入函数时 ESP 是 0xbc000020(返回地址已经压好),push %ebp后变 0xbc00001c,mov %esp,%ebp让 EBP 也等于 0xbc00001c,sub $0x28,%esp后 ESP 变成 0xbbfffff4。于是ebp-4是 0xbc000018,ebp-8是 0xbc000014。

leal -4(%ebp), %eax # &x = ebp-4 pushl %eax # 第二个参数入栈 leal -8(%ebp), %eax # &y = ebp-8 pushl %eax # 第一个参数入栈 call scanf

这里能看出一个规律:局部变量的地址是「EBP 减去偏移」,参数的地址是「EBP 加上偏移」,方向相反,中间隔着返回地址和旧 EBP。计算任何变量的地址前,先确认它在 EBP 的哪一侧。

还有一个容易忽略的点:每次push让 ESP 减 4,call再减 4(压返回地址)。所以「调用前 ESP 是多少」和「进入被调用函数时 ESP 是多少」差 4。习题 18 里那句「未跳转到 scanf 时 ESP 是 0xbbfffff0,从 scanf 返回后是 0xbbfffff4」,差的正是这一层。

4.3 缓冲区溢出:返回地址被改写那一刻

习题 29 给了一个带gets的函数。buf的首地址是EBP - 0x14,也就是说从 buf 到 EBP 之间只隔了 0x14 = 20 字节。这 20 字节的布局是:8 字节的 buf,接着是被调用者保存寄存器 EBX、ESI、EDI 各 4 字节(共 12 字节),再往上 4 字节是旧 EBP,再往上 4 字节是返回地址。

gets不做边界检查,输入超过 8 字节就开始写穿。写满 20 字节后,接下来 4 字节覆盖旧 EBP,再 4 字节覆盖返回地址。函数执行ret时,CPU 从栈顶弹出这个被污染的地址当跳转目标。如果攻击者精心构造输入,让这 4 字节指向一段可执行的代码,控制流就被劫持了;如果随便填,比如让地址变成0x08413938,落在未映射或不可执行的段上,就会收到 segmentation fault。

这段代码还埋了两个 bug:malloc(strlen(buf)+1)少了+1,字符串结尾的\0没地方放;返回的指针没判 NULL 就直接用。这两处和溢出是独立的错误,但经常一起出现。

注意:EBP - 0x14这种「数组到帧指针的距离」是判断溢出风险最快的指标。距离越小,可写的安全空间越窄。

5. 常见排查:那些「答案对不上」的时刻

5.1 相对转移的偏移量,基准是下条指令

现象:手工算跳转目标,结果和答案差 2 或差 5。

原因:IA-32 的条件转移和 call 都用相对寻址,偏移量是相对「下一条指令的地址」而不是当前指令地址。je是 2 字节指令,机器码74 08里的 08 要加在当前地址 +2 的位置上;call是 5 字节指令,后面的 4 字节偏移要加在当前地址 +5 的位置上。

解决:先确定指令长度,再用「当前地址 + 指令长度 + 偏移」算目标。教学材料里0x804838c处的je目标算成0x804838c+2+8=0x8048396,同一个材料里0x804838e处的call目标算成0x804838e+5+0x1e=0x80483b1,两处用的都是这个规则。8 位和 32 位偏移都是补码,0xFFFFFF00要按 -256 解释,往前跳而不是往后跳 40 多亿字节。

5.2 小端序把立即数读反了

现象:从机器码里抠出的立即数是0x00A80408,但汇编语句里写的是0x804a800。

原因:IA-32 是小端,多字节数据低字节存低地址。movl $0x804a800, (%ecx)的机器码里,立即数字段按字节是00 A8 04 08。从头按大端拼就得到错误的数。

解决:从机器码反推立即数时,按字节序号倒着拼,或者先标出每个字节的地址再按地址从高到低读。这条规则对所有多字节字段都成立,包括偏移量和地址。

5.3 结构体大小按成员长度累加,永远算不对

现象:一堆成员加起来 44 字节,实际sizeof是 48;换个平台编译又变成 40。

原因:成员之间要按各自的对齐要求填充,结构体总大小还要补齐到最大成员对齐值的整数倍。Windows 上double和long long按 8 字节对齐,Linux 32 位下double按 4 字节对齐。同一个结构体定义,char、double、int、short、char*、long、void*、long long排下来,Windows 上偏移是 0、8、16、20、24、28、32、40,末尾还要补 4 字节凑到 8 的倍数,共 48 字节。

解决:按「偏移量 = 上一个成员结束位置向上取整到本成员对齐值」逐个算,最后把总大小向上取整到最大对齐值。想省空间就把成员按长度从大到小重排,上面那个结构体能从 48 压到 40。别在不同平台上假设sizeof一样,尤其是涉及序列化和文件格式的时候。

5.4 有符号除法忘了符号扩展,或者把 sar 写成 shr

现象:y % k在 y 为负数时结果离谱,或者 idiv 直接触发除法异常。

原因:idiv的被除数是 DX:AX 组成的 32 位值,做 16 位除法前必须把 AX 的符号位铺满 DX。教材里用sarw $15, %dx手工完成这一步:算术右移 15 位,正数得 0,负数得 0xFFFF。如果误用shrw,负数的高位变成 0,被除数就从负数变成了一个大正数,商和余数全错。反过来,如果 DX 里残留了上一次运算的值而没做扩展,idiv可能因为商超出 16 位范围而抛出异常。

解决:有符号除法前用cwd(16 位)或cdq(32 位)做符号扩展,或者手写sar到符号位。无符号除法前把 DX 清零。看到sar和shr混用,基本可以判定是符号处理的 bug。

5.5 跳转表忘了减偏置,case 全部错位

现象:switch反汇编出来的跳转表查得到下标,但跳到的分支和预期差几位。

原因:跳转表是从 0 开始索引的数组,而 case 值可以是负数或从任意数开始。编译器会先做一个平移,把[min_case, max_case]映射到[0, 表长-1]。习题 16 里先算x+3再查表,说明最小 case 是 -3 或者表的下标从 -3 开始对齐;表里下标 0 和 6 都指向 default 分支,说明 case -3、3 没有对应处理。

解决:看跳转表前先找那个加法或减法,把偏置补回去,再确认表项数和 default 的位置。表里多个下标指向同一个标号,通常意味着那几个 case 共享一段代码,或者落在 default 上。

6. 设计取舍:为什么宽度和寻址要压进操作码

回过头看这套编码,会发现一个贯穿全局的取舍:IA-32 把「多宽、怎么寻址、要不要扩展」全部塞进了操作码和 ModR/M 里,而不是交给多条指令组合。一条movl 8(%edx,%ecx,4), %eax同时完成了基址加变址乘比例加偏移、32 位读、写寄存器三件事。代价是指令长度从 1 字节到 15 字节不等,解码器必须先找到指令边界才能知道下一条从哪开始。

这个代价在乱序执行的时代被放大了。现代处理器前端要把变长指令切成微操作,做法是预解码阶段先扫一遍字节流标记出每条指令的起止位置,再送进真正的解码器。同一段逻辑,RISC 风格需要三条定长指令(算地址、load、写回),但每条都是

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询