匯編實驗3 轉移指令跳轉原理及其簡單應用編程
實驗任務1
源代碼
點擊查看代碼
assume cs:code, ds:data
data segment
x db 1, 9, 3
len1 equ $ - x ; 符號常量 , $指下一個數據項的偏移地址,這個示例中,是3
y dw 1, 9, 3
len2 equ $ - y ; 符號常量 , $指下一個數據項的偏移地址,這個示例中,是9
data ends
code segment
start:
mov ax, data
mov ds, ax
mov si, offset x
mov cx, len1
mov ah, 2
s1:mov dl, [si]
or dl, 30h
int 21h
mov dl, ' '
int 21h
inc si
loop s1
mov ah, 2
mov dl, 0ah
int 21h
mov si, offset y
mov cx, len2/2
mov ah, 2
s2:mov dx, [si]
or dl, 30h
int 21h
mov dl, ' '
int 21h
add si, 2
loop s2
mov ah, 4ch
int 21h
code ends
end start
實驗結果

根據 Intel 白皮書,LOOP
指令的機器碼格式為:E2 cb
(cb指一個字節單位)
cb
處是一個字節的偏移量,是一個8位有符號整數,范圍在-128 ~ 127
根據課堂和課本知識可知:LOOP
本質是一個近轉移,偏移量存儲時采用補碼表示
問題①
十六進制(補碼):F2
二進制(補碼):1111 0010
二進制(原碼):1000 1110
十進制(原碼):-14
根據LOOP指令定義:當前IP + 有符號偏移量 = 跳轉地址
當前IP
指向下一條指令開始地址,為001B
,十進制表示:27
根據公式:27 + (-14) = 13
13
的十六進制表示為:D
,跳轉地址即000D
,可以發現的確是代碼中跳轉的地址

問題②
十六進制(補碼):F0
二進制(補碼):1111 0000
二進制(原碼):1001 0000
十進制(原碼):-16
根據LOOP指令定義:當前IP + 有符號偏移量 = 跳轉地址
當前IP
為0039
,十進制表示:57
根據公式:57 + (-16) = 41
41
的十六進制表示為:29
,跳轉地址即0029
,可以發現的確是代碼中跳轉的地址

相關研究
1. 關於匯編中的標號(label)
在 Intel 白皮書中,標號一律被稱作label
目前已經學過的匯編中有兩種標號方式,一種有冒號(:),一種沒有冒號
上面的代碼中:
assume cs:code, ds:data
data segment
x db 1, 9, 3
len1 equ $ - x
...
data ends
code segment
start:
...
x
和len1
沒有冒號,而start
有冒號。根據博客《匯編語言之 有冒號的標號和沒冒號標號的區別》的說法,區別在於x
和len
既可以當做地址,也可以查看其中的內容,而start
只能作為地址使用。
但是這篇博客寫的很含糊,不明不白。因此做了以下進一步嘗試。
嘗試1:如果在data
段中給x
加上冒號寫作這樣:
data segment
x: db 1, 9, 3
len1 equ $ - x
...
會提示錯誤:Missing or unreachable CS

目前還沒搞清楚這是為什么,盲猜是因為assume
中將data
段作為數據段,里面的代碼不會被執行所導致的。
但是可以知道,在data
段中無法使用帶冒號的標號(label)
嘗試2:以下代碼段masm編譯階段會報錯:
a mov ax, 0
mov ax, word ptr a

嘗試3:以下兩個代碼段編譯和運行中均不會報錯:
a: mov ax, 0
mov ax, word ptr a
a db 1, 9, 3
mov ax, word ptr a
兩段代碼中:
第一段ax
放入的均為a
處指令開始的地址
第二段ax
放入的為數字1
嘗試4:如下代碼段編譯和運行中也不會報錯:
a db 1, 9
len1 = $ - a
mov ax, len1
b: db 1, 9
len2 = $ - b
mov ax, len2
在debug中進行反匯編:

可以看到二者沒有什么差別
可以發現:
- 不帶冒號的標號后只能跟偽指令,而帶冒號后可以跟任何指令
- 帶冒號和不帶冒號都可以作為指令的地址使用
這里只做了簡單實驗來研究加冒號和不加冒號兩種標號形式的異同點,但是資料過少且沒有時間,以后再做深入了解。
2. LOOP指令
在《Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 2 (2A, 2B, 2C & 2D): Instruction Set Reference, A-Z》(Intel白皮書)中,
LOOP指令的機器碼結構(Vol.2A 3-597):
關於LOOP指令的描述:
The target instruction is specified with a relative offset (a signed offset relative to the current value of the instruction pointer in the IP/EIP/RIP register). This offset is generally specified as a label in assembly code, but at the machine code level, it is encoded as a signed, 8-bit immediate value, which is added to the instruction pointer. Offsets of –128 to +127 are allowed with this instruction.
目標指令被指定為相對偏移量(相對於IP/EIP/RIP寄存器中指令指針的當前值的有符號偏移)。這個偏移量在匯編代碼中通常被指定為一個標號,但在機器碼層面,它被編碼為一個加在指令指針(IP)上的有符號8位立即數。這條指令允許的偏移量為-128到+127。
實驗任務2
源代碼
點擊查看代碼
assume cs:code, ds:data
data segment
dw 200h, 0h, 230h, 0h
data ends
stack segment
db 16 dup(0)
stack ends
code segment
start:
mov ax, data
mov ds, ax
mov word ptr ds:[0], offset s1 ; ds:[0] 存儲了s1的地址
mov word ptr ds:[2], offset s2 ; ds:[2] 存儲了s2的地址
mov ds:[4], cs ; ds:[4] 存儲了當前段的段地址
mov ax, stack
mov ss, ax
mov sp, 16
call word ptr ds:[0] ; word為短轉移,把 s1 處的 IP 進棧, 然后跳轉到 s1 的地址
s1: pop ax ; 把 s1 處的 IP 值送入 ax
call dword ptr ds:[2] ; dword為遠轉移,把 s2 出的 CS:IP 值進棧, 然后跳轉到 s2 處
s2: pop bx ; 把 s2 的 IP 值送入 bx
pop cx ; 把 s2 的 CS 值送入 cx
mov ah, 4ch
int 21h
code ends
end start
問題解答
根據分析:(上面代碼中的注釋為分析過程)
ax = s1 處的 IP
bx = s2 的 IP
cx = s2 的 CS
實驗結果
和分析的結果是一致的。

實驗任務3
僅實現任務中要求的源代碼
點擊查看代碼
; 僅能打印byte長度的數字(0-255),可以實現不定位數
assume ds:data, cs:code, ss:stack
data segment
x db 99, 72, 85, 63, 89, 97, 55
len equ $ - x
data ends
stack segment
dw 16 dup(?)
stack ends
code segment
start:
mov ax, data
mov ds, ax
mov ax, stack
mov ss, ax
mov sp, 32
mov cx, len ; 由於數據都是byte型,所以len就是數據個數
; print循環: 依次打印所有數字
print:
mov ah, 0 ; 數據只有一個字節,先把ah置0,子函數中除法是以ax作為被除數的
mov al, byte ptr ds:[di] ; 把數據放入al
inc di ; di指針后移
push cx ; 把cx保存起來, 子程序中會修改cx值
call printNumber ; 打印數字
call printSpace ; 打印空格
pop cx ; 恢復cx
loop print
mov ah, 4ch
int 21h
; 子程序: printNumber
; 功能: 打印數字
; 入口參數:
; 寄存器ax (待輸出的數據 --> ax)
; 局部變量說明:
; bx -> 存儲數字字符個數
printNumber:
mov bx, 0 ; 獲取之前位數為0
; 逐位獲取數字
; getEach循環: 獲取每一位,然后壓入棧中
getEach:
mov dl, 10
div dl ; 數據除10
push ax ; 將數字壓入棧中(ah余數在ax里了)
inc bx ; 位數+1
mov ah, 0 ; ah是余數,置0后ax表示除法的結果
mov cx, ax ; 除法結果賦給cx, 如果結果為0則說明所有位數都獲取完了
inc cx ; 由於loop時會-1,這里先+1,防止出現負數
loop getEach
; 打印數字
mov cx, bx ; 先把bx存的數字位數賦給cx
; printEach循環: 依次從棧中取出數字,逐位打印
printEach:
pop ax ; 取出一位數
add ah, 30h ; ah是剛才除法的余數,也就是需要得到的位數,+30h是轉成對應字符
mov dl, ah ; 放到dl, 用於打印
mov ah, 2 ; 調用int 21h的2號子程序打印
int 21h
loop printEach
ret
; 子程序: printSpace
; 功能: 打印空格
printSpace:
mov ah, 2
mov dl, 20h
int 21h
ret
code ends
end start
任務要求的實驗結果
可以成功打印要求中的2位數。

實際上,該代碼還可以打印0 ~ 255
之間的任意數字,效果如下:

改進的源代碼
點擊查看代碼
; 對task3.asm的修改, 可以打印0~2559不定位數的數字
assume ds:data, cs:code, ss:stack
data segment
; 改進: db換成dw
x dw 999, 0, 856, 1024, 36, 97, 2559
len equ $ - x
data ends
stack segment
dw 32 dup(?)
stack ends
code segment
start:
mov ax, data
mov ds, ax
mov ax, stack
mov ss, ax
mov sp, 64
; 這里需要改
mov cx, len/2 ; 由於數據都是word型,所以len/2才是數據個數
; print循環: 依次打印所有數字
print:
; 這里需要改, 數據讀進ax而不是al
mov ax, word ptr ds:[di] ; 把數據放入al
add di, 2 ; di指針后移2字節
push cx ; 把cx保存起來, 子程序中會修改cx值
call printNumber ; 打印數字
call printSpace ; 打印空格
pop cx ; 恢復cx
loop print
mov ah, 4ch
int 21h
; 子程序: printNumber
; 功能: 打印數字
; 入口參數:
; 寄存器ax (待輸出的數據 --> ax)
; 局部變量說明:
; bx -> 存儲數字字符個數
printNumber:
mov bx, 0 ; 獲取之前位數為0
; 逐位獲取數字
; getEach循環: 獲取每一位,然后壓入棧中
getEach:
mov dl, 10
div dl ; 數據除10
push ax ; 將數字壓入棧中(ah余數在ax里了)
inc bx ; 位數+1
mov ah, 0 ; ah是余數,置0后ax表示除法的結果
mov cx, ax ; 除法結果賦給cx, 如果結果為0則說明所有位數都獲取完了
inc cx ; 由於loop時會-1,這里先+1,防止出現負數
loop getEach
; 打印數字
mov cx, bx ; 先把bx存的數字位數賦給cx
; printEach循環: 依次從棧中取出數字,逐位打印
printEach:
pop ax ; 取出一位數
add ah, 30h ; ah是剛才除法的余數,也就是需要得到的位數,+30h是轉成對應字符
mov dl, ah ; 放到dl, 用於打印
mov ah, 2 ; 調用int 21h的2號子程序打印
int 21h
loop printEach
ret
; 子程序: printSpace
; 功能: 打印空格
printSpace:
mov ah, 2
mov dl, 20h
int 21h
ret
code ends
end start
改進后的實驗結果
改進后的程序可以實現打印 0 ~ 2559
之間的任意數字。

一些說明
1.關於改進的代碼
源代碼的數據存儲在字節單位,只能取0~255
之間的數字。而改進后數據存在字單位,理論上可以打印0 ~ 65535
之間的任意數字。
但是上面的實驗結果中說最大只能打印到2559
,而不是65535
,這和除法運算指令div
有關。
2. div指令的一些解釋
王爽《匯編語言(第2版)》P169關於div
指令的說明是:
根據書上的說法,任意一個16位的被除數(十六進制小於FFFF,也就是十進制小於65535的數)都可以放在ax
中進行除法運算。
但是在實際操作中(操作是:除數放在一個8位寄存器中(如bl
)),被除數放在bx
中把65535
也就是FFFFh
放在ax
中,進行十進制除10運算卻發生了錯誤。同樣的,對0FFFh
進行除10運算也出錯了。而00FFh
是不會出錯的。
這就奇了怪了。
不過按書上的說明,16位被除數放在ax
中,除法運算后的商保存在al
中,余數保存ah
中。而al
和ah
都是8位的,因此商和余數應該均小於8位。
所以,其實div
除法指令更確切的定義應該是:
如果除數為8位,被除數為16位,且進行除法運算后的商和余數均為8位,除數才能放在一個8位寄存器中,被除數放在
AX
中,且商和余數才會存在AH
和AL
中。否則,即使除數是8位,仍應當放在一個16位的寄存器中,被除數則應當放在
DX:AX
中,如果是16位被除數,則只放在AX
即可,而商存在AX
中,余數存在DX
中。
根據 Intel 白皮書(《Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 2 (2A, 2B, 2C & 2D): Instruction Set Reference, A-Z》)中的說明:
手冊的意思說,小於255的數,被除數和結果都在AX
中,而255 ~ 65535
的數結果則當DX:AX
中。
實際測試中,如果被除數放在16位寄存器中,除數是放在8位寄存器中,且商和余數都在8位范圍內,則可以正常計算且結果保存在AH
和AL
中。而如果商或余數超過8位,則會出錯。
如果8位除數放在16位寄存器中(除了DX
以外的寄存器),無論被除數是多少(0000-FFFFh
),結果都會商保存在AX
中,而余數保存在DX
中。
因此上面改進的實驗結果中提到的2559
,根據上面的解釋,商和余數都在8位以內,由於做的是除10運算,也就是十六進制除A運算,FF(商) * 0A + 09(余數) = 09FF
,09FF
即2559
,因此上面的代碼最大可以支持到打印2559
。
進一步改進的代碼
限於篇幅,這里只展示修改后的printNumber
子程序
點擊查看代碼
; 對task32.asm的修改, 可以打印0~65535不定位數的數字
; 子程序: printNumber
; 功能: 打印數字
; 入口參數:
; 寄存器ax (待輸出的數據 --> ax)
; 局部變量說明:
; bx -> 存儲數字字符個數
printNumber:
mov bx, 0 ; 獲取之前位數為0
; 逐位獲取數字
; getEach循環: 獲取每一位,然后壓入棧中
getEach:
; 改進: 除數放在16位寄存器bp中
mov bp, 10 ; 除10運算
mov dx, 0 ; 由於除數是16位寄存器,dx也是被除數一部分,需要置零
div bp ; 數據除10
push dx ; 將數字壓入棧中(余數在dx里)
inc bx ; 位數+1
mov cx, ax ; 除法商賦給cx, 如果商為0則說明所有位數都獲取完了
inc cx ; 由於loop時會-1,這里先+1,防止出現負數
loop getEach
; 打印數字
mov cx, bx ; 先把bx存的數字位數賦給cx
; printEach循環: 依次從棧中取出數字,逐位打印
printEach:
pop dx ; 取出一位數
add dl, 30h ; dl是剛才除法的余數,也就是需要得到的位數,+30h是轉成對應字符
mov ah, 2 ; 調用int 21h的2號子程序打印
int 21h
loop printEach
ret
進一步的結果
至此,代碼task33.asm
已經可以實現輸出0 ~ 65535
的任意數字了

實驗任務4
源代碼
點擊查看代碼
assume cs:code, ds:data, ss:stack
data segment
string db 'try'
len = $ - string
data ends
stack segment
dw 2 dup(?)
stack ends
code segment
start:
mov ax, data
mov ds, ax
mov ax, stack
mov ss, ax
mov sp, 2
mov cx, len ; cs: 字符串長度
mov ax, 0
mov si, ax ; si: 0
; 打印頂部的綠色字符
mov bl, 0Ah ; bl: 顏色(背景黑+高亮+綠色:0 000 1 010)
mov bh, 0 ; bh: 行號(第1行)
call printStr
; 打印底部紅色字符
mov bl, 0Ch ; bl: 顏色(背景黑+高亮+綠色:0 000 1 100)
mov bh, 24 ; bh: 行號(第25行)
call printStr
mov ah, 4ch
int 21h
; 子程序: printStr
; 功能: 在指定行、以指定顏色,在屏幕上顯示字符串
; 入口參數:
; 字符串首字符地址 --> ds:si (其中,字符串所在段的段地址—> ds, 字符串起始地址的偏移地址—> si)
; 字符串長度 --> cx
; 字符串顏色 --> bl
; 指定行 --> bh (取值:0 ~ 24)
printStr:
mov al, bh ; 把行號放在 al
mov dl, 0A0h ; 每行160字節,放在 dl 中
mul dl ; 與行號相乘獲得行起始地址, al中存的是行起始地址
mov di, ax ; di存行起始地址
mov ax, 0b800h
mov es, ax ; 顯存段地址
; 開始打印
; cx已經存了字符串數量, 直接循環就行
push si ; 先保存si, 以便下次再用
push cx ; 保存cx, 以便下次用
; 循環依次打印字符
startToPrint:
mov al, ds:[si]
mov es:[di], al ; 把ds:[si]的字符放進es:[di]
mov es:[di+1], bl ; 放入顏色
inc si
add di, 2
loop startToPrint
pop cx ; 恢復cx
pop si ; 恢復si
ret ; 打印完成, 返回
code ends
end start
實驗結果
可以看到,打印了符合預期的字符

一些記錄
-
在
printStr
子程序中,進行打印前,可以先將si
和cx
入棧保存。由於字符串需要打印兩次重復利用,而這兩個寄存器的值在打印時需要修改(si
控制讀入字符,cx
控制打印字符個數的循環),因此先壓入棧中保存,打印結束后再彈出放回寄存器,下次可以繼續重復打印這一段字符串,簡化程序編寫這樣做的好處在於:
根據高級語言編寫函數的經驗,除非需要,否則函數內部最好不要修改外部變量。而
si
和cx
作為外部變量,在內部需要進行修改,因此先保存起來,修改完成后,在函數退出前再恢復回去,這樣相當於把si
和cx
拷貝為局部變量使用,不會修改外部變量 -
字符屬性值(僅作為記錄,來自王爽《匯編語言(第2版)》P189)
實驗任務5
源代碼
點擊查看代碼
assume cs:code, ds:data
data segment
stu_no db '201983290048'
len = $ - stu_no
data ends
code segment
start:
mov ax, data
mov ds, ax
mov di, 0
call printStuNum ; 調用打印子程序
mov ah, 4ch
int 21h
; 打印子程序:
; 參數說明:
; 學號字符串存儲在 -> ds:[di]
printStuNum:
mov ax, 0b800h
mov es, ax ; 控制顯存區域段指針
mov si, 1 ; 顯存區域列指針
; 先把屏幕前24行背景打印成藍色
mov al, 24 ; 前24行
mov dl, 80 ; 每行80個字符需要修改顏色
mul dl ; 24*80, 獲得需要填充藍色的字符數
mov cx, ax
printBlue:
mov al, 17h ; 藍底+白字:0 001 0 111 -> 17h
mov es:[si], al ; 把顏色填充到位
add si, 2 ; 后移2個
loop printBlue
sub si, 1 ; 指針回退一個, 從最后一行起始位置開始
; 打印最后一行
mov ax, 80
sub ax, len ; 80列 - 學號長度
mov dl, 2
div dl ; (80 - len)/2, 就是學號左右兩側需要填充'-'的長度
mov dx, ax ; 使用dx保存'-'的長度
; 調用打印'-'的子程序, 打印學號左側的'-'
mov cx, dx
call printSeparator
; 打印學號字符串
mov cx, len
printNumber:
mov al, ds:[di] ; 低位是字符
mov ah, 17h ; 高位是顏色
mov word ptr es:[si], ax ; 按字放入
inc di
add si, 2
loop printNumber
; 再次調用打印'-'的子程序, 打印學號右側的'-'
mov cx, dx
call printSeparator
ret
; 子程序: 打印分隔符'-'
; 參數: 長度 -> cx
; 位置 -> es:[si]
printSeparator:
mov al, '-'
mov ah, 17h
mov word ptr es:[si], ax
add si, 2
loop printSeparator
ret
code ends
end start
實驗結果
代碼說明全部寫在代碼注釋中。
可以看到,成功實現了要求實現的效果。

總結與思考

- 課本上的內容說的比較簡潔,很多細節沒有說的很清楚。這樣的好處是比較易懂,缺點是如果想知道更進一步的原理就比較困難。之前偶然知道了 Intel 白皮書(《Intel® 64 and IA-32 Architectures Software Developer’s Manual》),這本手冊里可以查到 Intel 匯編指令的所有信息。常用的就是查看匯編指令的具體使用方法和機器碼等細節。
- 在研究過程中,關於loop和div兩個指令使用時產生了一些問題,由於國內搜索引擎查找匯編相關資料時得到的內容很少,幫助有限。而在查閱 Intel 白皮書后得到了很好的解決。
- 8086的實模式下控制顯存在屏幕上打印內容相當方便,只要知道了顯存的地址結構就可以隨意修改屏幕上的顏色和內容。
- 匯編中編寫子程序很像高級語言中的函數,但是比函數更靈活一點。不過由於需要來回跳轉,程序的結構性可能不如高級語言來的好。
- 在實驗任務1中對於標號進行了一些研究,但是無奈搜不到什么相關資料,只能靠猜測來解釋。
- 子程序需要合理分配寄存器,如果需要修改寄存器時最好先把寄存器的內容壓棧,操作完后再恢復,這樣可以在不同程序段中多次使用一個寄存器。