汇编语言:转移指令
转移指令的概念
能够修改 IP 或同时修改 CS 和 IP 的指令被称为转移指令,它们的作用是控制 CPU 去执行内存中另一处代码。在 8086 中,根据修改范围的不同,转移被分为两类:
| 转移指令的类型 | 转移指令的效果 |
|---|---|
| 段内转移 | 只修改 IP |
| 段间转移 | 同时修改 CS 和 IP,又进一步细分为短转移和近转移 |
| 短转移 | 可修改 -128~127 字节 |
| 近转移 | 可修改 -32768~32767 字节 |
除了无条件转移 jmp,loop、条件转移、过程调用和中断,本质上都属于转移指令,只是触发条件和用途不同。
offset 操作符
offset 是汇编编译器提供的符号,用来取得标号在段内的偏移地址。通过 offset 可以在编译阶段就把偏移地址算好并填入指令中,例如代码段中的 start 作为第一条指令的标号,其偏移地址为 0;若第一条指令长度为 3 字节,则紧接着的标号 s 的偏移地址就是 3。

考虑一个程序,它将程序体内 s 处的一条指令拷贝到 s0 处的另一块内存空间中。首先找到要复制的指令在内存中的位置,利用 offset 获取标号 s(即 mov ax, bx)的偏移地址,存入 SI 作为源指针。然后再使用 offset 获取标号 s0(原为两个 nop)的偏移地址,存入 DI 作为目的指针。mov ax, bx 的机器码为 8B C3,长度为 2 个字节,因此可以使用 mov [di], ax。
assume cs:codesg
codesg segment
start:
mov ax, cs
mov ds, ax
mov si, offset s
mov di, offset s0
mov ax, [si]
mov [di], ax
s: mov ax, bx
s0: nop
nop
mov ax, 4c00h
int 21h
codesg ends
end start
使用 Debug 的运行效果如下:


案例中展示的代码自复制在早期病毒和引导扇区编程中非常常见,现代操作系统由于引入了 NX(No-eXecute)位和地址空间布局随机化(ASLR),数据段通常不可执行、代码段通常不可写。直接在内存中修改或移动代码的行为会被硬件直接拦截,以防止恶意代码注入。
jmp 指令
jmp 是无条件转移指令,既可以只修改 IP,也可以同时修改 CS 和 IP。CPU 执行 jmp 时主要依赖两类信息,一是转移的目的地址,二是转移的距离类型,距离类型又可以分为段间转移、段内短转移、段内近转移。不同类型的 jmp 在语法形式和机器码组织上有差异,但底层逻辑都是通过改变 CS:IP 让 CPU 去执行另一处代码。
短转移和近转移
“jmp short 标号”实现的是段内短转移,对 IP 的修改范围在 -128~127 字节之间。在之前的程序中很容易误以为机器码里保存的是标号 s 的偏移地址,但实际上机器码中并不包含转移的目的地址,只包含位移量。考虑如下两个程序,它们的功能都是执行 jmp 跳过 add ax,1,只对 ax 做一次加 1。在第一段程序中, inc ax 的偏移地址是 0008H。
assume cs:code
code segment
start:
mov ax, 0
jmp short s
add ax, 1
s:
inc ax
mov ax, 4c00h
int 21h
code ends
end start

而第二段程序中由于前面多了一条 mov bx,0,inc ax 的偏移地址变成了 000BH。
assume cs:code
code segment
start:
mov ax, 0
mov bx, 0
jmp short s
add ax, 1
s:
inc ax
mov ax, 4c00h
int 21h
code ends
end start
然而,在 Debug 中查看它们的机器码时,会发现 jmp short s 对应的机器码都是 EB 03,这说明 CPU 执行 jmp 时并不依赖标号 s 的绝对偏移地址。真正起作用的是 EB 03 中的 03,它是一个 8 位位移量,表示从当前 IP 再向后移动 3 个字节。在 CPU 进行取指流程时先把 EB 03 读入指令缓冲器,随后自动将 IP 加上这条指令的长度(2 字节),此时 IP 已经指向了 add ax,1;接着执行 EB 03,CPU 将当前 IP 再加上位移量 3,最终 IP 指向 inc ax。因此无论 inc ax 的绝对地址是多少,只要它与 jmp 下一条指令之间的字节距离是 3,机器码就是 EB 03。

“jmp short 标号”的原理可以用公式表达为:(IP) = (IP) + 8 位位移。其中,8 位位移由编译器在编译阶段计算,公式为:位移 = 标号处的地址 − jmp 指令后第一个字节的地址。 由于位移只有 8 位,采用补码表示,因此范围是 -128~127。段内近转移 “jmp near ptr 标号” 使用的是 16 位位移,公式相同,只是位移范围扩大到 -32768~32767。
在现代 x86 架构中,相对位移跳转仍然常用,如 x86-64 的
jmp rel8/rel32。只不过实模式的 CS:IP 限制已被保护模式和长模式的复杂寻址取代,转移指令的底层实现也需要与分页、权限检查、分支预测等机制。
远转移
“jmp far ptr 标号”实现的是段间转移,也称为远转移。它在执行时会同时修改 (CS) = 标号所在段的段地址、(IP) = 标号在段中的偏移地址。far ptr 表示令编译器处理这条 jmp 指令时,要携带一个“段地址:偏移地址”。
考虑如下程序,它通过 jmp far ptr s 生成一个段间转移,中间插入的 db 256 dup (0) 拉开距离。
assume cs:codesg
codesg segment
start:
mov ax, 0
mov bx, 0
jmp far ptr s
db 256 dup (0)
s:
add ax, 1
inc ax
mov ax, 4c00h
int 21h
codesg ends
end start
jmp far ptr s 对应的机器码是 EA 0B 01 6C 07,其中 EA 是操作码。后面的 4 字节依次是:低字 0B 01 为偏移地址 010BH,高字 6C 07 为段地址 010BH。在段间转移中,CPU 将直接从指令中读取新的 CS 和 IP,因此即使整个代码段被加载到完全不同的段地址处,只要机器码不变则转移仍然准确。

在现代 x86 架构中,直接远转移在 32 位保护模式下已被限制,在 64 位模式下基本消失。
转移地址的读取
转移地址在寄存器中的 jmp 的格式很简单:jmp 16位reg,功能是修改 (IP) = (16位寄存器)。这是一种段内转移,只影响 IP、不改变 CS。例如 jmp ax 执行后,CPU 会跳转到当前 CS 中偏移为 AX 值的那条指令继续执行。
当转移地址存放在内存单元中时,jmp 为段内和段间提供了两种形式。段内转移的指令形式是 jmp word ptr 内存单元地址,功能是从该内存单元读取一个字作为新的 IP。例如:
mov ax, 0123H
mov ds:[0], ax
jmp word ptr ds:[0]
执行后,(IP) = 0123H。内存地址可以用任意合法寻址方式给出,比如 [bx]、[bx+1] 等。该形式本质上是间接跳转,跳转目标在运行时才确定。

第二种是段间转移,格式为 jmp dword ptr 内存单元地址。用 dword ptr 是因为要连续存放两个 16 位值,低地址处是偏移地址,高地址处是段地址。功能为:(IP) = (内存单元地址),(CS) = (内存单元地址 + 2)。例如:
mov ax, 0123H
mov ds:[0], ax
mov word ptr ds:[2], 0
jmp dword ptr ds:[0]
执行后,(CS) = 0,(IP) = 0123H,CPU 将跳转到 0000:0123H 处执行。需要注意,内存中数据的填写顺序须符合偏移在前、段地址在后,同时远转移同样不受当前 CS 的限制,可以跨段跳转。

考虑令如下程序在 jmp word ptr [bx+1] 后 CS:IP 指向第一条指令,[bx+1] 指向 data 段偏移 1 的位置。此时需要令 data 段前两个字节是 00 00,这样偏移 1 处恰好是第二个字的低字节,而第二个字被初始化为 0。
assume cs:code, ds:data
data segment
db 4 dup (0)
data ends
code segment
start: mov ax, data
mov ds, ax
mov bx, 0
jmp word ptr [bx+1]
mov ax, 4c00h
int 21h
code ends
end start
因此读取到的 IP = 0,CS 保持不变,CS:IP 指向程序第一条指令。运行后用 Debug 查看,IP 会被修改为 0000。

考虑如下程序,使 jmp dword ptr ds:[0] 后 CS:IP 指向第一条指令。jmp dword ptr ds:[0] 需要从 ds:[0] 读一个双字,低字作为 IP,高字作为 CS。为了让跳转目标恰好是程序第一条指令,必须把 IP 设为 offset start,CS 设为当前代码段段地址 code。
assume cs:code, ds:data
data segment
dd 12345678h
data ends
code segment
start: mov ax, data
mov ds, ax
mov bx, 0
mov [bx], offset start
mov [bx+2], code
jmp dword ptr ds:[0]
mov ax, 4c00h
int 21h
code ends
end start
运行后,CS:IP 将指向 start 处。

jcxz 指令
jcxz 是条件转移指令,它只能是短转移,在它的机器码中只包含 8 位位移,IP 的修改范围被严格限制在 -128~127 字节之间。其语义可以理解为:如果 (CX) = 0就跳转,否则顺序执行。从功能上看,“jcxz 标号”等价于高级语言中的 if ((cx) == 0) goto 标号;。
例如考虑一个在 2000H 段中内存区域中线性扫描,第一个值为 0 的字节并把它的偏移地址存入 DX 的程序。思路是用 BX 作偏移指针,每次取出一个字节放入 CL,并通过 mov ch, 0 构造完整的 CX;当 CX 为 0 时,jcxz ok 生效,跳转到 ok 标号处,将当前 BX 的值(即该 0 字节的偏移地址)存入 DX;若 CX 不为 0,则 inc bx 继续向后扫描。
assume cs:code
code segment
start:
mov ax, 2000H
mov ds, ax
mov bx, 0
s:
mov cl, [bx]
mov ch, 0
jcxz ok
inc bx
jmp short s
ok:
mov dx, bx
mov ax, 4c00h
int 21h
code ends
end start

需要注意的是,jcxz 只能向前或向后跳转不超过 127 字节,因此标号 ok 必须离跳转指令足够近,否则编译时会报“转移位移超界”。
loop 指令
loop 是 8086 中的循环控制指令,它的执行过程分为两步:先将 (CX) = (CX) - 1,然后判断 CX 是否为 0;若不为 0,则跳转到标号处继续循环;若为 0,则顺序执行后续指令。和 jcxz 一样,loop 也只能是短转移,机器码中只包含 8 位位移,修改 IP 的范围同样是 -128~127 字节。从语义上看,loop 标号等价于:
cx--;
if (cx != 0) goto 标号;
同样要求在 2000H 段中查找第一个值为 0 的字节,思路是先读取第一个字节,然后用 loop 构造循环;每次循环先 inc bx,再读新字节并刷新 CX;当 CX 为 0 时,loop s 停止循环,此时 BX 已经指向“0 字节的下一个位置”。因此用 dec bx 回退一个字节,再将 BX 存入 DX。
assume cs:code
code segment
start:
mov ax, 2000H
mov ds, ax
mov bx, 0
mov cl, [bx]
mov ch, 0
s:
inc bx
mov cl, [bx]
mov ch, 0
loop s
ok:
dec bx
mov dx, bx
mov ax, 4c00h
int 21h
code ends
end start

loop 是先减 CX 再判断,因此循环体中第一次判断的 CX 已经是减过的值,其逻辑和高级语言中的 do { ... } while (--cx); 更为接近。
根据位移进行转移的意义
jmp short、jmp near ptr、jcxz、loop 这些指令都有一个共同点:机器码中只包含位移,不包含目的地址,其现实意义在于支持程序在内存中的浮动装配。假设一段程序被加载到内存的不同位置,只要指令之间的相对距离不变,所有基于位移的转移指令都不需要修改。
例如 loop s,无论这段代码被加载到哪个段地址,只要标号 s 距离 loop 指令的字节数是 -4,那么机器码中就是 E2 FC(FC 是 -4 的补码)。如果机器码中保存的是 s 的绝对偏移地址,则一旦程序加载基址改变,所有转移指令都必须重新计算地址,否则就会跳转到错误的位置。位移避免了这一问题,让早期操作系统的加载器和覆盖管理更加简单。
在现代 x86 架构中,相对位移跳转依然常用(如
jmp rel8、jmp rel32),并且在引入 ASLR(地址空间布局随机化)后显得更为重要。
正因为基于位移的转移指令范围有限,汇编编译器会在编译阶段对超界的情况进行检查。例如以下这段程序无法通过编译,原因是 jmp short s 最多只能向后跳 127 字节,而此处 s 距离 jmp 已经超过 128 字节,编译器会直接报错。

类似的,jcxz 和 loop 也有同样限制。在 Debug 中可以直接写的 jmp 2000:0100 这种绝对地址远转移的指令,但是在 MASM/TASM 源程序中将不被编译器识别。如果在源程序里这样写,同样会导致编译错误。转移指令的分类如下表总结:
| 指令格式 | 转移类型 | 是否修改 CS | 目的地址来源 | IP 修改方式 | 位移/地址长度 | 典型机器码特征 | 常见注意点 |
|---|---|---|---|---|---|---|---|
jmp short 标号 |
段内短转移 | 否 | 位移 | (IP) = (IP) + 8位位移 |
8 位(-128~127) | EB xx |
所有条件转移、循环转移的本质模板;机器码中无目的地址 |
jmp near ptr 标号 |
段内近转移 | 否 | 位移 | (IP) = (IP) + 16位位移 |
16 位(-32768~32767) | E9 xx xx |
比 short 跳转范围大,但仍不跨段 |
jmp far ptr 标号 |
段间远转移 | 是 | 指令中直接给出 | (CS) = 段地址;(IP) = 偏移地址 |
32 位(CS:IP) | EA xx xx xx xx(低字=IP,高字=CS) |
实模式中少有的“绝对地址跳转”;Debug 中 jmp cs:ip 的源程序等价形式 |
jmp 16位reg |
段内转移 | 否 | 寄存器 | (IP) = (reg) |
16 位 | FF E0~E7 系列 |
常用于运行时动态跳转,如函数指针、查表 |
jmp word ptr mem |
段内转移 | 否 | 内存(1 个字) | (IP) = (mem) |
16 位 | FF 26 xx 等 |
间接跳转,内存中只需填偏移地址 |
jmp dword ptr mem |
段间远转移 | 是 | 内存(2 个字) | (IP) = (mem);(CS) = (mem+2) |
32 位 | FF 2E xx 等 |
内存低字为 IP,高字为 CS;顺序写错必崩 |
jcxz 标号 |
段内短转移 | 否 | 位移 | (CX)==0 ? (IP) = (IP) + 8位位移 : 顺序执行 |
8 位 | E3 xx |
所有条件转移都是短转移;CX 非 0 时直接下落 |
loop 标号 |
段内短转移 | 否 | 位移 | (CX)--; if (CX!=0) (IP) = (IP) + 8位位移 |
8 位 | E2 xx |
先减 CX 再判断;CX=0 时退出循环 |
中断(int) |
段间转移 | 是 | 中断向量表 | (CS) = (IVT[4n+2]);(IP) = (IVT[4n]) |
32 位 | CD nn |
本章未展开,但原理同“内存远转移”;后续第 12~13 章重点 |
实验 8
考虑如下一个程序,在一开始就在 s 处写了两条 nop,然后把 s2 处的 jmp short s1 的机器码复制到 s 处。
assume cs:codesg
codesg segment
mov ax,4c00h
int 21h
start:
mov ax,0
s:
nop
nop
mov di,offset s
mov si,offset s2
mov ax,cs:[si]
mov cs:[di],ax
s0:
jmp short s
s1:
mov ax,0
int 21h
mov ax,0
s2:
jmp short s1
nop
codesg ends
end start
看起来似乎会跳来跳去,但程序可以正常返回,不会死循环也不会异常退出。s2: 处的 jmp short s1 是一条短转移,机器码中只包含“从当前 IP 向后位移”的信息。在 Debug 中可以看到,这条指令位移为 -10,其机器码是 EB F6。

当这条指令被复制到 s: 处后,CPU 执行 s0: jmp short s,跳到 s: 执行那条被复制过来的指令。

CPU 执行这条指令时,会先计算 IP += 指令长度,然后再加上位移。由于指令已经被挪到了新的位置,当前 IP 与 s1: 之间的真实距离已经变了,但位移量还是原来的 F6。经过计算,最终 IP 会指向 mov ax, 4c00h 的位置,于是程序顺利执行 int 21h,正常返回。

短转移指令的目的地址不是由标号决定的,而是由“当前 IP + 位移”决定的。当把指令从一个地方搬到另一个地方时,标号的含义已经失效,真正起作用的只有位移量。
参考资料
《汇编语言(第四版)》,王爽 编著,清华大学出版社

浙公网安备 33010602011771号