匯編學習筆記-2


3.第一個程序

3.1一個源程序如何從寫出到執行

  • 程序員用編輯器寫出匯編代碼,稱之為源程序
  • 對源程序進行編譯,行成目標文件
  • 對目標文件鏈接,行成可執行文件,而可執行文件包含這兩種信息:程序(從源程序翻譯來的機器碼)和數據(源程序中定義的數據);相關描述信息(比如程序有多大,以及要占多少內存等)
  • 執行可執行文件

后面來一一講解源程序、編譯、鏈接等

3.2源程序

之前說過,匯編代碼由匯編指令、偽指令、其它符號組成,不過其它符號在這里暫且用不到
看下面這段代碼(這里,包括下面所以代碼都是Intel匯編語法):

assume cs:codesg
codesg segment
	mov ax,2
	add ax,ax
	add ax,ax
	
	mov ax,4c00H
	int 21H
codesg ends
end

中間部分,從 mov ax,2int 21H 都是匯編指令,用分號表示注釋開始,這前面已經說過很多了,下面主要說偽指令

  • codesg segment ..... codesg endssegmentends 是成對使用的偽指令,它們的作用是定義一個段前者說明段的開始,后者說明段的結束。它們在匯編語言編寫程序中是必不可少的
    在這里,定義的是一個代碼段。codesg 是這個段的段名,當然可以用其它字符。
    一個有意義的匯編代碼至少有一個段,這個段用來存放代碼
  • end:表示匯編代碼的結束,編譯器在編譯時遇見這個指令,就停止編譯。如果不加它,編譯器不知道代碼在哪結束
  • assume:中文是“假設”,它假設(也是說明了)某個寄存器和某個定義的段相關聯(更具體的作用在后面)。
    比如例子中,用 cs:codesg 說明了段寄存器和 codesg 的聯系,用 cs 指向它的段地址,說明了這一個段是代碼段,用來存放代碼(cpu也會根據 cs 的指向去執行其中的指令)

程序:源程序中最終由計算機執行、處理的指令、數據
程序最先以匯編指令的形式存在源程序中,通過編譯、鏈接,轉變為機器碼,再加上描述信息,一起存在可執行文件中

標號

一個標號代表一個地址
比如上面例子中的 codesg,它放在 segment 前面,作為一個段的名稱,這個段被編譯、鏈接程序,處理為一個段的段地址

程序的結構

還是根據上面那個例子來說
首先,我們要寫匯編指令,就要定義一個代碼段,現在把這個段命名為 codesg

codesg segment
.....
codesg ends  

然后,編些匯編指令,也就是填充上面的省略號的位置

codesg segment
	mov ax,2
	add ax,ax
	add ax,ax
	
	mov ax,4c00H
	int 21H
codesg ends

然后,要用偽指令 end 為編譯器指出程序在哪結束

codesg segment
	mov ax,2
	add ax,ax
	add ax,ax
	
	mov ax,4c00H
	int 21H
codesg ends
end

最后,我們要把 codesg 當作代碼段使用,就要用 assume 把它和 cs 聯系起來

assume cs:codesg
codesg segment
	mov ax,2
	add ax,ax
	add ax,ax
	
	mov ax,4c00H
	int 21H
codesg ends
end

就完成了

程序的返回

這個和程序如何被加載進內存來執行有關,下面在單任務操作系統 dos 的基礎下說明
一個程序 P2 在可執行文件中,必須有一個正在執行的程序 P1,把 P2 從可執行文件中加載進內存中,cpu 的控制權交給了 P2,P2 開始運行,此時 P1 暫停
P2 運行結束,cpu 的控制權交還給 P1,P1 繼續運行。
比如我在 cmd 窗口中運行一個程序,那么 cmd 把這個程序載入內存,它開始運行。當它運行結束,就又把 cpu 控制權交還給 cmd,cmd 繼續運行
那么這個交還的過程,叫程序返回

mov ax,4c00H
int 21H

程序最后的這兩句話,就是實現了這個過程
至於這兩句話的原理,等后面再說

3.3編譯、鏈接

需要:masm.exe,link.exe,ml.exe

前兩個分別用於編譯、鏈接,大概這樣 masm 1.asm 來編譯文件 1.asm,后面那個 ml.exe 是一個指令同時包含了編譯、鏈接
如果這樣調用程序,會在編譯或鏈接時給出好幾個選項,不過在現在來說並沒什么用,可以用 masm 1.asm; 來直接將所有選項選默認
編譯時如果發現程序編寫有語法錯誤會輸出錯誤信息

編譯和鏈接的作用
編譯的作用:把我們編些的匯編指令、偽指令等,轉化為機器碼
鏈接的作用:

  • 當源程序很大時,可以把它分為多個源程序文件來編輯、編譯,編譯成多個目標文件,然后再用鏈接程序將它們鏈接在一起,行成一個可執行文件
  • 程序調用了某個庫的子程序,需要將這個庫文件和該程序的目標文件鏈接在一起,行成一個可執行文件
  • 編譯后,目標文件中存有機器碼,但這其中的一些信息還不能直接用來生成可執行文件,鏈接程序將這些內容處理位最終的可執行信息(這就是比較復雜了)
    所以說,就算一個源程序沒有分成多個源程序文件,也沒有調用庫,也必須經過鏈接

3.4使用debug來跟蹤、調試程序

命令:debug 1.exe 來進入

主要的命令和之前說的一樣,但在執行到最后一句指令(就是那個 int 21H)時,要用 p 命令而不是 t
如果用 p,會顯示:program terminated normally
否則,如果繼續用 t 的話,ip 會跳轉到其它地方去,如果那樣繼續執行的話,可能會讀取到並非代碼的數據來執行,就導致了卡住死機(可以去試一下,一般沒啥問題,大不了關了重進)

但是在剛進入程序時,如果查看一下寄存器的值,發現即使沒有定義數據段,ds 和 cs 的值也不一樣,會相差 10H,由於它們是段寄存器,所以實際物理地址就差了 100H,也就是 256 個字節
原因如下圖,至於這個 PSP,它主要是被 dos 用來與這個加載進來的內存進行通訊,長度 256字節,但具體是啥不重要:

4 [bx] 與 loop

一些約定:

  • 用 () 來表示某個寄存器內的值,比如 \((ax)\) 表示的是 ax 里的值
  • idata 表示常量,或者之前說過的立即數,比如 mov ax,[idata] 就代表了 mov ax,[0]mov ax,[1]

4.1用 [bx] 來描述內存單元以及引出的一些問題

我們之前有過 mov ax,[0] 之類的代碼,意思是 \((ax)=((ds)\cdot *16+0)\),當然指的是字型數據
如果直接在 debug 中往內存填入代碼來執行,是沒問題的
但如果把這種指令寫在文件里,然后編譯鏈接,再進 debug 單步調試,會發現實際執行的代碼是 mov ax,0
也就是,編譯器把 [0] 直接處理為了 0

那么,當我們使用 masm 來編譯代碼時,就需要用到 [bx] 來描述內存單元了,應該使用指令 mov ax,[bx],意思是 \((ax)=((ds)\cdot 16+(bx))\)
注意這個 [bx] 就只能是 bx,並不是代表了寄存器,用 ax,cx,dx 等,或段寄存器,會在編譯時報錯
比如上面那個 mov ax,[0],就應該寫成:

mov bx,0
mov ax,[bx]

debug 和匯編編譯器 masm 對指令的不同處理,以及顯式的給出段地址的方法

這時就不得不提出這個問題了

比如之前應該提到過,對於數字,在 debug 中式默認十六進制的,而 masm 編譯器是默認進制,所以 masm 寫十六進制數是要加上 H,而 debug 中不能

這只是其中之一,還有就是,上面說了,如果你用 [idata] 來訪問一個內存單元,會直接被編譯為 idata,忽略了那個中括號
如果想訪問,就要 [bx],但這樣還要將 idata 先送入 bx,顯然有些麻煩
所以還有一種方法,就是 ds:[idata],來顯式的給出了段地址(不顯式的給出就是默認 ds),也就是 ds
當然,這個 ds 也可以是 ss,es 等,只要是段寄存器就行,但不能是通用寄存器或立即數

如果不是 [idata],而是 [bx],當然也可以顯式給出段地址,如 ds:[bx]

4.2 loop指令

loop 通常用來實現循環,和 cx 配合
格式是:loop 標號,執行分為兩步:

  • \((cx)=(cx)-1\)
  • 判斷 \((cx)\) 是否為 \(0\),如果不是,那就跳轉到標號繼續執行,如果是,繼續向下執行

通過 debug 來查看 loop 跳轉的原理

我們編寫下面這一段程序(實際上,就是我們后面要將的一個例子的程序,先不用管他要干啥):

assume cs:codesg
codesg segment
start:
	mov ax,0ffffH;在匯編源程序中,數組不能以字母開頭,所以開頭添加一個 0
	mov ds,ax
	mov bx,0
	mov ax,0
	mov dx,0
	mov cx,12
S:
	mov al,[bx]
	add dx,ax
	inc bx
	loop S
	
	mov ax,4c00H
	int 21H
codesg ends
end start

然后進入 debug,用 u 查看 cs:ip 指向內存的代碼,如圖:

看畫紅箭頭的地方,原本的 loop S 變成了 loop 0011,然后再看 0011 這個內存單元,發現它存的代碼是 mov al,[bx]
這,也正是我們跳轉后要執行的第一條指令(或者說要跳轉到的地方),那么也就可以知道,loop 指令就是通過把 ip 改為相應的值(這里是 0011H),來實現跳轉
loop 執行后,cs:ip 就指向了 076a:0011,也就從跳轉到的地方繼續執行了
不過要是再看一下機器碼的話,機器碼中也沒有把這個 \(11\) 體現出來。其實機器碼中的是轉移的距離(補碼形式,就是那個 F9,另外 E2 是 loop 的機器碼),后面會詳細說

4.3段前綴

我們在 “debug 和匯編編譯器 masm 對指令的不同處理” 那里講過,訪問內存時,可以顯式的給出內存單元的段地址
就像 mov ax,ds:[idata]mov ax,es:[bx]
這里的 ds,es 這些段寄存器,就是段前綴

4.4一段安全的空間

在8086模式下,隨意向一段內存寫入數據時危險的,因為那段內存可能存放着重要的系統數據或代碼
如果你嘗試寫入這些內存,dosbox模式下應該是會卡住不動,8086下就是彈出報錯窗口

操作系統管理計算機的所有資源,當然也就包括內存,所以我們在編程時,要使用操作系統分配給我們的空間,而不是隨意指定內存空間
但是,我們學習匯編語言就是要深入底層,理解計算機工作的原理,盡量面向硬件編程,不理會操作系統,所以:

我們似乎面臨一種選擇,是在操作系統中安全、規矩的編程,還是自由、直接的用匯編語言去操作真實的硬件,去了解那些早已被層層系統軟件掩蓋的真相?在大部分情況下,我們選擇后者,除非我們在學習操作系統本身的內容

在純 dos 下,可以不去理會 dos,因為 dos(運行在 cpu 實模式下)並沒有能力對硬件全面、嚴格的管理
而在 win,unix 中,它們運行在 cpu 保護模式下,不理會操作系統是不可能的,因為硬件已經被cpu提供的保護模式全面且嚴格的管理了

所以在后面的學習中,我們既想直接對硬件操作,又不想被操作系統所干涉,所以需要一段安全的空間
一般來說,0:200-0:2ff 這 \(256\) 個字節是安全的。也可以用debug查看一下這段空間,如果都是 \(0\) 的話,說明它們沒有被使用

4.5一點實例

雖然比較簡單,但還是寫一些比較好,可以編譯完以后進debug跟蹤

計算 \(ffff:0-ffff:b\)\(12\) 個單元中的字節型數據和,結果存在 dx 中

分析:

  • 由於每個字節型數據是 \(8\) 位,最大 \(FFH\),那么 \(12\) 個最大就是 \(BF4H\),沒有超過 dx 的存儲上限
  • 利用循環,循環 12 次,所以在開始要 \((cx)=12\)
  • 利用 bx 訪問內存,開始 \((bx)=0\),然后循環中每次訪問過后 \((bx)=(bx)+1\)
  • 由於內存單元是字節型,dx 是字型,不能直接相加,需要用一個 8 位寄存器中轉。這里用 al,先把內存單元的數送入 al,在把 \((dx)=(dx)+(ax)\),這樣做之前要確認 \((ah)\) 是 0
assume cs:codesg
codesg segment
start:
	mov ax,0ffffH
	mov ds,ax
	mov bx,0
	mov ax,0
	mov dx,0
	mov cx,12
S:
	mov al,[bx]
	add dx,ax
	inc bx
	loop S
	
	mov ax,4c00H
	int 21H
codesg ends
end start

還有一個:將內存單元 ffff:0-ffff:b 中的數據復制到 0:200-0:20b 中

顯然 0:200-0:20b 等價於 20:0-20:b,這樣轉換是為了讓兩個內存區間的偏移地址一樣
這樣,就可以用 [bx] 代表它們的偏移地址,然后用 loop 來實現了
但段地址不同,當然可以每次分別把 ds 賦為 ffff 和 20 來進行操作,但不如使用段前綴的知識,\((ds)=ffff,(es)=20\),這樣,就通過這兩個段前綴的表示來進行賦值了
第二種實現方法比第一種執行的命令條數更少,就讓程序更加優化了

注意 mov 內存單元,內存單元 這種指令並不合法,所以需要一個 al 來中轉一下

assume cs:codesg
codesg segment
start:
	mov ax,0ffffH
	mov ds,ax
	mov ax,0020H
	mov es,ax
	mov cx,12
S:
	mov al,ds:[bx]
	mov es:[bx],al
	inc bx
	loop S
	
	mov ax,4c00H
	int 21H
codesg ends
end start

5 包含多個段的程序

之前說過一段 \(256\) 個字節的安全空間,但如果我們程序需要的內存超過 \(256\) 字節,就需要向操作系統申請
向操作系統申請的空間都是合法、安全的,有兩種方法:一是加載程序時讓操作系統為程序分配內存,二是程序執行時申請。這里,只討論第一種

5.1在代碼段中使用數據和棧

如果需要用到數據,但是不分成多個段來聲明,可以將數據放到代碼段里
考慮這樣一個問題,將給定的八個數,將它們倒序存放
要先把數據定義出來,因為是倒序,所以需要一個棧來中轉,先把數據都入棧,然后出棧就是倒序了,看代碼,應該出了一開始定義數據什么的其它不難理解:

assume cs:codesg
codesg segment
	dw 0123H,0456H,0789H,0abcH,0defH,0fedH,0cbaH,0987H
	dw 0,0,0,0,0,0,0,0
	
start:
	mov ax,cs
	mov ss,ax
	mov sp,32;數據和棧分別占用16字節,所以 ss:sp 指向 cs:32
	mov bx,0
	mov cx,8
S1:
	push cs:[bx]
	add bx,2
	loop S1
	
	mov bx,0
	mov cx,8
S2:
	pop cs:[bx]
	add bx,2
	loop S2
	
	mov ax,4c00H
	int 21H
codesg ends
end start

先解釋一下,dw 就是 define word,定義字型數據,相應的,就也有 db,define byte,定義字節型數據
第一行定義的 8 個字型數據,就是要倒序存放數據,而第二行的 8 個 0,就是用來當棧空間使用

還有一些定義數據的方法,比如用 ? 可以只是開辟一個空間,而不指定初始值,比如 db ?,?,?,? 就是定義了四個字節型數據,初始值任意
還有 DUP 的用法,就是 num DUP(XXX),num 是重復次數,XXX 是一個表達式,比如 5 DUP(1,2,3) 就是定義五個連續的 1,2,3
定義字符串就直接用單引號或雙引號括起來就行了,每個字符一字節,和定義數差不多

那么我們定義出來的這些棧空間或數據,地址在哪?或者說應該如何訪問?
因為它們定義在代碼段中,所以可以進debug查看一下代碼段中內存的數據

發現它們定義在代碼中,codesg 的一開頭,所以自然也就在內存里代碼段的開頭 32 個字節
那棧段的段地址也就可以是代碼段的段地址,而棧頂應該指向棧空間中最高地址加一,所以需要:

mov ax,cs
mov ss,ax
mov sp,32;數據和棧分別占用16字節,所以 ss:sp 指向 cs:32

而代碼段中一開頭不是代碼,是數據和棧空間,也就不能讓 cpu 從程序開頭開始執行指令,這個時候就體現出這個 start 標號的作用了
因為后面有一句 end start,這個 end 偽指令的作用不僅是告訴編譯器編譯的結束,還有告訴編譯器程序的入口在哪
我們 start 標號后面第一個指令是 mov ax,cs,那當編譯器通過 end 知道了程序的入口在 start 標號處時,就把它當作程序第一條指令,並把相應的信息(轉化為一個入口地址)寫入可執行文件的描述信息里,這樣程序被載入內存后,cpu 通過描述信息,將 cs:ip 指向相應的值
比如在這里,入口地址應該是 \(20H\),ip 就會被 cpu 設為 \(20H\)

所以說,我們想讓 cpu 從代碼段中的某一個位置開始執行指令時,就使用 end 標號,用這個標號來指出程序的入口
如果沒有這個標號,cpu就會從程序開頭開始執行,如果那里有數據,就把數據當成了機器碼來執行,就發生了錯誤

就像這樣,如果去掉標號來進debug跟蹤,會發現程序載入后,cs:ip 指向的代碼並不是我們想要的,但再看它對應的內存數據,卻是我們定義的數據和棧空間

5.2使用多個段

發現像之前那樣把代碼、數據、棧都放到一個段里,會造成程序比較混亂。而且,如果程序較大,一個段也不夠用(最大 \(64KB\)
那么就需要定義多個段了,這里給出實現上面那個問題,並通過定義多個段來實現的代碼

assume cs:code,ds:data,ss:stack

data segment
	dw 0123H,0456H,0789H,0abcH,0defH,0fedH,0cbaH,0987H
data ends
stack segment stack
	dw 0,0,0,0,0,0,0,0
stack ends
code segment
start:
	mov ax,stack
	mov ss,ax
	mov sp,16;棧占用16字節,所以 ss:sp 指向 ss:16
	mov ax,data
	mov ds,ax;
	
	mov bx,0
	mov cx,8
S1:
	push [bx]
	add bx,2
	loop S1
	
	mov bx,0
	mov cx,8
S2:
	pop [bx]
	add bx,2
	loop S2
	
	mov ax,4c00H
	int 21H
code ends
end start

如何定義段:觀察一下這段代碼,發現定義其它段的方式和定義代碼段相似,都是先把對應的段寄存器 assume 到相應段名上,然后用 XXX segmentXXX ends 就行了
至於這句 stack segment stack,前一個 stack 是棧段的名字,然后再后面加上一個 stack,也就是后買那個,是告訴編譯器這是一個棧段
因為編譯器不會因為你 assume ss:stack 就認為你定義了棧段,必須這樣聲明,如果不這樣,在鏈接時會報 warning:no stack segment


后來加入的補充
其實不定義棧段,系統也會為你分配一個比較小的棧空間,當然在我們現在寫的棧段中也是夠用的,就是你不用定義棧也可以使用 push pop
但如果你定義了棧段,因為我們跟蹤程序在 debug 中進行,我們的程序和 debug 就公用了一個棧,因此,如果你看的仔細,會發現棧頂指針指向的那段內存會被修改為一些奇怪數據,那應該就是 debug 用的(應該是這樣),因此當定義的棧空間過小時,你往棧里放的數據可能被 debug 修改,發生錯誤
參考博客:https://www.cnblogs.com/pluse/p/10198677.html#4647784
https://blog.csdn.net/sinat_42483341/article/details/88665331

還有一點,如果你把棧空間定義到了代碼段里,如果 debug 訪問時發生了越界(定義的太小),會修改掉其它代碼導致錯誤
可能和第三章中一個實驗也有些關系(不過迷惑的是我在本機上做並沒有出現書中說的情況):

當然以上僅為發現問題后結合其它博客一點推測,如果以后發現了問題會來修改


如何獲取段的段地址,並訪問段中數據:段名,其實是相當於一個標號,而標號在編譯后會變成一個地址(之前說 loop 跳轉的原理時說過),那么 mov ax,data 就相當於把 data 標號的地址(其實就是 data 段的段地址),送入了 ax 中
又因為編譯后變成了地址,也就是一個立即數,所以也就不能寫 mov ds,data 這種指令

代碼、數據、棧段是我們“安排”的:我們安排 “code”,“data”,“stack” 這三個段分別來存放代碼,數據,棧,那如何讓 cpu 知道這種安排?

首先要知道,“code”,“data”,“stack” 只是這三個段的“名稱”,也就是一個標號
cpu和編譯器都不懂這些名稱的含義,所以不會因為你這樣命名,就去遵循你的這種安排,把這些段命名成 hahaha,xixixi 這種名字也都是一樣的

assume cs:code,ds:data,ss:stack,這句偽指令,將三個寄存器和三個段相聯系。但這是在編譯階段執行的,將定義的段和相應的寄存器聯系起來,但是cpu並不會因此就將相關段寄存器指向相應段的段地址
assume 具體的作用:大概就是和邏輯地址相關的吧,但邏輯地址還不怎么了解;同時,如果你在數據或棧段中定義了帶有長度的數據標號(數據、棧段只能定義這種標號,不能定義一般的標號,至於這種數據標號是啥在后面會說),想在代碼段中訪問,就需要 assume 了
https://www.zhihu.com/question/411008597/answer/1372976533

那么,就需要我們在代碼中手動用這些段的標號,來送入相關寄存器,畢竟內存中的內容是當作數據還是指令,完全是根據匯編指令,和什么寄存器里的值指向它

mov ax,stack
mov ss,ax
mov sp,16;這里和之前不同,棧自己在一個段里,占 16 字節,所以它的棧頂指針應該是 16

另外,各種段在內存中的順序,其實和代碼中定義的順序是一樣的,在下面的實例中也會看到這點

5.3實例

將 a,b 兩個段中的數據相加,存在 c 段相應位置

分析:我們需要三個段寄存器來指向三個段,這里用的是 ds,ss,es,其實棧段的寄存器拿來存不是棧空間的段地址也當然是可以的
然后 bx 當偏移地址,loop 循環就行了
其實因為剛才說內存中段的順序和代碼中相同,所以用一個段寄存器,然后通過不同偏移地址也是可以的,不過比較麻煩

assume cs:code;,ds:a,ss:b,es:c

a segment
	db 1,2,3,4,5,6,7,8
a ends
b segment
	db 1,2,3,4,5,6,7,8
b ends
c segment
	db 0,0,0,0,0,0,0,0
c ends

code segment
start:
	mov ax,a
	mov ds,ax
	mov ax,b
	mov ss,ax
	mov ax,c
	mov es,ax
	mov cx,8
	mov bx,0
	
S:
	mov al,[bx]
	add al,ss:[bx]
	mov es:[bx],al;不能直接從兩個內存單元間 mov
	inc bx
	loop S
	
	mov ax,4c00H
	int 21H
code ends
end start

程序在debug中運行結束后,查看內存中的值:

前三行分別是 a,b,c 段中的內容,說明剛才說的順序是對的
然后發現,我們這里定義的 8 個字節型數據,占用 8 字節,而一個段的起始地址必須是 16 的倍數,所以下一個段必須必須在上一個段向后16個字節,才會再有一個16倍數的起始地址可用,所以這也就使得一個段最小長度是 16 字節
而我們只用了8字節,剩下的8字節就自然都是 0 了

當然,通過查看寄存器中的值,也可以知道 ds,ss,es,cs 分別相差了 1,也就是這四個段的實際物理地址相差 16 字節

6 更靈活的定位內存地址以及數據處理相關

6.1 and 與 or 指令

and 是按位與,or 是按位或
與之前的 mov 相類似,and ax,5 是將 (ax) 和 5 按位與,結果存入 ax 中
第一個操作符,可以是通用寄存器或內存單元,第二個可以是立即數,通用寄存器,內存單元
特別的,不能使用 and 內存單元,立即數and 內存單元,內存單元
or 也是類似

6.2 [bx+idata]

之前有過用 [bx] 進行尋址的方式,比如 mov ax,[bx] 就是 \((ax)=((ds)\cdot 16+(bx))\)
另一種方式,可以使用 [bx+idata],例如 mov ax,[bx+2] 就是 \((ax)=((ds)\cdot 16+(bx)+2)\)mov ax,2[bx]mov ax,[bx].2mov ax,[bx][2] 這幾種語法也可以達到相同的效果

那這樣尋址有什么用?可以用這種方式進行數組的處理
比如有數組,從 ds:0 作為起始地址開始定義,那么可以用 [bx+0] 並不斷增加 bx 的值來訪問每一位
又比如兩個數組,分別從 ds:0 和 ds:10 作為其實地址來定義,每次依次同時訪問這兩個數組中下標相同的兩數,就可以用 [bx+0] 和 [bx+10] 來進行
然后用另一種語法也許會看的更明確,就是 0[bx] 和 10[bx]
這樣作為數組來訪問,放在 c 語言里就是 a[i] 和 b[i]。匯編里的起始地址的偏移地址,就是 c 里的數組名;其實 c 里的數組名也就是一個地址,實際地址就是 a+i(至於加的到底是不是精確是 i,根據數據類型來,也許是 i 的若干倍),如果寫成 i[a] 這種形式也能正常執行(但是匯編里不能寫 bx[3] 這樣的形式)

6.3 si 和 di

si 和 di 在 8086cpu 中也是和 bx 用途相近的兩個寄存器,但它們不能分成兩個 8 位寄存器使用
可以使用 mov ax,[di/si]mov ax,[di/si +idata] 這種形式,也就是和 bx 一樣

si,di,bx 一起使用的一些用法

  • [bx+si/di]:(ax)=((ds)*16+(si/di)),也可以使用 [bx][si/di],但 si 和 di 不能一起使用,例如 mov ax,[di+si] 是不正確的
    這種可以用來處理二維數組
  • [bx+si/di+idata]:(ax)=((ds)*16+(si/di)+idata),這種方式的語法也比較多,以 si 為例,例如 idata[bx+si] , idata[bx][si] , [bx][si].idata , [bx].idata[si] , [bx][si][idata] 等,可以自己寫一些編譯一下,編譯成功一般就是可以用
    注意這里 idata 可以不止有一個,比如:mov ax,5[bx+3][si+4][3].6,這種奇怪語法其實是可以編譯成功的,然后去 debug 里看一眼,機器碼對應過來是 mov ax,[bx+si+15H],加的那個常數也就是十進制下 21,是我們輸入的幾個常數的和,只不過這樣寫也沒啥意義罷了

6.4 bp

還有一個寄存器,也就是 bp
他經常和 bx,si,di 搭配使用

  • [bx/bp/si/di],就是以一個寄存器為偏移地址進行尋址,中括號里的寄存器只能是這四個,其它的都是不正確的
  • 這四個也可以有四種兩兩搭配的方法,[bx+si],[bx+di],[bp+si],[bp+di],也就是 bx,bp 以及 si,di 不能在一起出現
  • 上面說的幾種方式也都可以再加一個 idata
  • 尋址中,只要涉及到了 bp,那么如果不顯式的給出段地址,默認的段地址就是 ss(因為經常使用 bp 和其它搭配來訪問棧空間)

6.5 尋址方式、數據位置的表達

8086cpu 的尋址方式我們基本已經都接觸過了,於是這里給出一張圖涵蓋了這些方式:

對於那個“結構中的數組項”,就是比如可以用 bx 定位一個結構體,然后用一個常數來指出結構體中的一個數組的起始地址(相對這個結構體的起始地址),然后用 si 定位數組里的每個數

再說數據位置的表達,先要知道cpu要處理的數據存放在三個位置:cpu內部,內存,端口
其中第三個端口目前還沒有涉及

  • 立即數,這類數據是立即尋址,信息直接包含在指令中,指令執行前,存放在指令緩沖器中
  • 寄存器,當我們在匯編的指令中使用一個寄存器,那cpu要處理的數據就存放在寄存器里
  • 內存,就是cpu用段地址和偏移地址,來訪問內存讀取數據

6.6 cpu處理數據的長度

8086cpu,能處理長度為字節和字的數據
有這幾種方式,告訴cpu當前要處理的數據有多長

  • 通過寄存器名,比如 ax 是 16 位,al 是 8 位
  • 有些指令,默認了訪問的數據是 16 還是 8 位,比如棧操作的 push 和 pop
  • 還有一種方式,使用 X ptr,這個 X 可以是 byte 或 word
    例如,inc word ptr ds:[0]:把 ds:0 處字型數據自增;mov byte ptr [bx]:把 ds:bx 處字節型數據 mov 成 1

數據的位置,要處理的數據的長度是數據處理的兩個基本問題

6.7 div 指令

用於做除法
做除法時,如果除數是 8 位,那被除數必須是 16 位;如果除數 16 位,被除數要 32 位。原因應該是,因為除法由乘法模擬,兩個 8 位相乘就是最高 16 位,所以被除數應為 16 位(應該是這樣吧,具體不太清楚)

  • 如果是 16 位除以 8 位,被除數存在 ax 中,除數存在 X 中,調用 div X,商會被存到 al 中,余數存到 ah 中。其中 X 必須是 8 位寄存器,或用 byte ptr 聲明的內存單元
  • 如果是 32 位除以 16 位,被除數的高位在 dx 中,低位在 ax 中,除數存在 X 中,調用 div X,商會被存在 ax 中,余數在 dx 中。其中 X 必須是 16 位寄存器,或用 word ptr 聲明的內存單元

也就是,不能把立即數或段寄存器作為 div 的參數

下面這個例子,就把數據段里前兩個數的商,存到了第三個數里

assume cs:codesg,ds:datasg
datasg segment
	dd 100001;dd(define double word) 定義雙字,32 位
	dw 100
	dw 0
datasg ends

codesg segment
start:
	mov ax,datasg
	mov ds,ax
	mov ax,ds:[0]
	mov dx,ds:[2]
	div word ptr ds:[4]
	mov ds:[6],ax
	
	mov ax,4c00H
	int 21H
codesg ends
end start

6.8 實例

懶得打題目了,直接截個圖


data 里可以看作 3 個數組,由於前兩個每個元素長度相同,所以可以用一個 bx 索引,第一個年份的是 \((bx)+0\),總收入的是 \((bx)+54H\),可以自己算一下這個長度。bx 每次加四
第三個人數的,由於長度不同,不能和前面兩個一樣用 bx+idata 索引,再開一個 si,每次加二
然后 table 可以看作每個存有一個長度為 16 的數組的結構體,我是用 bp+idata 索引

assume cs:codesg,ds:datasg,es:table
datasg segment
	db '1975','1976','1977','1978','1979','1980','1981','1982','1983' ;年份
	db '1984','1985','1986','1987','1988','1989','1990','1991','1992'
	db '1993','1994','1995'
	
	dd 16,22,382,1356,2390,8000,16000,24486,50065,97479,140417,197514 ;公司總收入
	dd 345980,590827,803530,1183000,1843000,2759000,3753000,4649000,5937000
	
	dw 3,7,9,13,28,38,130,220,476,778,1001,1442,2258,2793,4037,5635,8226 ;公司人數
	dw 11542,14430,15257,17800
datasg ends
table segment
	db 21 DUP ('year summ ne ?? ');共 16 位:四位年份,四位收入,兩位人數,兩位人均收入
table ends

codesg segment
start:
	mov ax,datasg
	mov ds,ax
	mov ax,table
	mov es,ax
	mov bx,0
	mov bp,0
	mov si,0
	
	mov cx,21
S0:
	mov ax,[bx];存放年份,一次傳一個字
	mov es:[bp+0],ax
	mov ax,[bx+2H]
	mov es:[bp+2H],ax
	
	mov ax,[bx+54H];存年收入,第一個年收入從 ds:54H 開始
	mov es:[bp+5H],ax
	mov dx,[bx+56H]
	mov es:[bp+7H],dx;存入 dx 方便后面的除法(這里是高位)
	
	mov ax,[si+0A8H];存人數,第一個人數從 ds:0A8H 開始
	mov es:[bp+0AH],ax
	
	mov ax,[bx+54H];做除法,算人均收入
	div word ptr ds:[si+0A8H]
	mov es:[bp+0DH],ax
	
	add bx,4
	add si,2;si 和 di 每次增加的不一樣,所以要單獨開一個 si
	add bp,16
loop S0
	
	mov ax,4c00H
	int 21H
codesg ends
end start

7 轉移指令

可以修改 ip,或同時修改 cs 和 ip 的指令是轉移指令。之前說過的 loop 就是其中之一
只修改 ip 的是段內轉移(又分為段內短轉移和近轉移),同時修改 cs 和 ip 的是段間轉移
8086cpu 的轉移指令可以分成這幾類

  • 無條件轉移指令
  • 條件轉移指令
  • 循環指令
  • 過程
  • 中斷

其中后兩個目前還不會提到

7.1 偽指令 offset

它由編譯器處理,用處是取一個標號的偏移地址

code segment
start:
      mov ax,offset start
code ends

這段代碼,就等價於 mov ax,0

7.2 jmp

是一個無條件跳轉指令,可以只修改 ip,也可以同時修改 cs 和 ip
不同的轉移方式有不同的格式

利用 jmp 段內轉移

段內短轉移:jmp short 標號,轉到標號處繼續執行代碼
對 ip 的修改為 \([-128,127]\),也就是用一個 8 位數字表示,標號應該在這個范圍內

段內近轉移:jmp near ptr 標號,轉到標號處繼續執行代碼,near ptr 指明了這是位移 16 位的轉移
對 ip 的修改為 \([-2^{15},2^{15}-1]=[-32768,32767]\),用一個 16 為數字表示,標號應該在這個范圍內

其實也可以用 jmp 標號,用來段內轉移,具體編譯器如何編譯他看下面

利用 jmp 段間轉移

jmp far ptr 標號,轉移后 cs 變成標號所在段的段地址,ip 變成標號的偏移地址
far ptr 指明了段間轉移,也就是利用標號同時修改 cs 和 ip

轉移地址在內存或寄存器中

使用寄存器:jmp 16 位寄存器,將 ip 的值變為這個寄存器的值

使用內存:

  • jmp word ptr 內存單元,實現段內轉移,將對應內存單元的字型數據(16 位)當作偏移地址,送入 ip
  • jmp dword ptr 內存單元,實現段間轉移,把內存單元低地址的字型數據,送入 ip;高地址的字型數據,送入 cs

7.3 jmp 指令的原理以及編譯過程

向后轉移

當使用 jmp short S

assume cs:code
code segment
start:
	mov ax,0
	jmp short S
	mov ax,1
	add ax,1
S:
	mov ax,0

    mov ax,4c00h
    int 21h
code ends
end start

進入 debug 查看代碼:

發現 jmp short S 變成了 jmp 000B,然后再看 \(000B\) 處的代碼,發現正是 mov ax,0
但是再看 jmp 000B 對應的機器碼,卻是 EB06
首先,其中 EB 是 jmp short 的機器碼,那么可以確定 06 就是和 \(000B\) 有關系了
通過加入、刪除一些代碼可以找到一個規律,就是 06 其實是 ip 要跳轉的距離,jmp 那個語句起始地址是 cs:3,然后長度兩個字節,再往后跳轉 6 個字節,那么就是 \((cs):(3+2+6)=(cs):B\),當然也就是跳轉后 mov ax,0 的地址了
所以我們知道了,jmp short 標號 的機器碼為 EB+跳轉距離,注意這個距離是用補碼來表示(向前跳轉時,距離為負)

再看一個段內近轉移的,代碼如下

assume cs:code
code segment
start:
	mov ax,0
	jmp near ptr S
	db 138 DUP(0)
	add ax,1
S:
	mov ax,0

    mov ax,4c00h
    int 21h
code ends
end start

debug 中:


發現這次 jmp 的機器碼變成了三字節,其中 E9 就是 jmp near ptr 的機器碼
那么剩下三個字節,就是 008D(注意讀取順序),跳轉后是 cs:(2+4+8D)=cs:93

這樣,兩種段內轉移的指令,其實是通過跳轉的距離來進行轉移

而對於 jmp far ptr S,機器碼格式為 EA 偏移地址 段地址,共占 5 字節,就懶得再寫代碼進 debug 看了
所以說,段間轉移靠的不是距離,而是具體的地址

下面再來說向后轉移的指令編譯的過程

編譯器中有一個地址計數器 AC,每讀到一個字節的代碼 AC 的值就加一(特別的,一些定義數據等的偽指令加的數有所不同)

它肯定會先讀到 jmp 指令,此時記錄 AC 的值為 \(A_j\),那么編譯器把所有的 jmp ... S 都先當作短轉移的格式讀取,還要根據情況做這樣幾個事:

  • 對於 jmp short S,生成 EB(它的機器碼)和一個 nop(nop 就是什么都不做,占一個字節,但有一定的執行時間),也就是預留了一個字節
  • 對於 jmp Sjmp near ptr S,生成 EB 和兩個 nop
  • 對於 jmp far ptr S,生成 EB 和四個 nop

然后繼續向后編譯,直到遇到了 S,記錄此時 AC 的值是 \(A_S\),那么轉移的距離就是 \(dis=A_S-A_j\),還是分幾種情況

\(dis\le 127\),把所有幾種格式都當作 jmp short ptr,在前面預留的位置填充它的機器碼,也就是 EB dis
那么,此時 jmp Sjmp near ptr S 會在 EB dis 后面有一個 nop,jmp far ptr S 會有三個,比如下面這段代碼

assume cs:code
code segment
start:
	mov ax,0
	jmp far ptr S
	add ax,1
S:
	mov ax,0

    mov ax,4c00h
    int 21h
code ends
end start

到了 debug 里是這樣的:

\(127<dis\le 32767\),此時 jmp short S 會報錯,jmp Sjmp near ptr S 會在之前的位置填充 E9 dis
jmp far ptr S 也會填充段間遠轉移對應的機器碼

向前轉移

向前轉移的機器碼和向后轉一的差不多,就是一個補碼的問題,不再說了

主要說編譯過程
由於是會先讀到標號,所以當它讀到一個標號 S,那么就記下 AC 當前的值 \(A_S\),然后后面再讀到 jmp 這個標號時,記錄下那時 AC 的值為 \(A_j\)。這跳轉的距離 \(dis=A_S-A_j\),是要給負數

  • \(dis\ge -128\),所有的 jmp 格式都被當作段內短轉移來編譯機器碼
  • \(dis\ge -32768\)jmp short S 報錯,其它的兩種按照對應的機器碼來

還有一個問題,為什么兩種段內轉移要用轉移的距離而不是目標地址?

這樣做,是為了方便程序在內存中浮動裝配
就是只依靠它們相對的位置來進行轉移,而不用管實際的內存地址(或者說絕對的位置),那么它們處在內存中的不同位置就都能正常執行(不用管內存地址是多少)

7.4 jcxz 和 loop

這兩個都是條件跳轉指令,而且跳轉的條件和 cx 有關

jcxz 對 ip 的修改是 \([-128,127]\),格式 jcxz 標號
如果 (cx)=0,則跳轉,否則繼續向下執行
機器碼是 E3,還有一個字節的轉移距離(jcxz 也是按照距離來轉移)
可以理解為:if((cx)==0) jmp short S

loop 之前已經了解過了,如果 \((cx)\neq 0\),那么 \((cx)=(cx)-1\),並跳轉,否則向下執行
對 ip 的修改 \([-128,127]\)
機器碼 E2,一個字節的轉移距離

例如下面這個程序,就利用了 jcxz 指令,來找到 \(2000H\) 段中第一個值為零的字節型數據,並把它的偏移地址存到 dx(通過簡單的修改也可以用 loop 完成)

assume cs:code
code segment
start:
	mov ax,2000H
	mov ds,ax
	mov bx,0
	mov cx,0
S:
	mov cl,[bx]
	jcxz OK
	inc bx
	jmp short S
OK:
	mov dx,bx
	
	mov ax,4c00H
	int 21H
code ends
end start

7.5 一個奇怪的程序

看這樣一個代碼:

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

一上來就是程序返回的指令,但它確實是可以正常返回的
一步步分析:

  • S 后的一些語句,就是將 S2 標號后一個字節的代碼復制到 S 后面來
  • 然后執行到 S0,跳轉回 S
  • S 中的指令此時實際上就是 S2 中的,那么它是跳轉到 S1 嗎?並不是,因為 jmp short S1 的機器碼和向前移動的距離有關,從 * 那里向前跳轉的距離,應該是 ** 那里跳轉到 S1 的距離,算下來,就是從 * 跳轉到了程序返回的語句

7.6 通過修改顯存來進行彩色輸出

可能是一個比較有意思的實例

dos 中的 \(80\times 25\) 顯示緩沖區,在內存中由 B8000H 到 BFFFFH 共 32KB 構成。向他寫入數據,會立刻在屏幕上顯示
顯示器 25 行,80 列,每個字符 256 中屬性,再加上 ASCII 碼,一共占兩個字節。那么一屏占 4000 字節
顯示緩沖區分為八頁,每頁 4KB,一般情況在顯示器上顯示第一頁,也就是內存地址 B8000H 到 B8F9FH

在第一頁上,偏移地址 0 到 9F 是第一行的 160 字節,A0 到 13F 是第二行的,以此類推
在第一行上,偏移地址 0 和 1 是第一個字符的,2 和 3 是第二個的,以此類推
在每個字符的兩個字節內存中,低位存放 ASCII 碼,高位存屬性

關於屬性,下面是二進制形式下每一位表示的意義:

其中,閃爍要在全屏 dos 下查看,暫且不用(其實后來發現在 dosbox 中也是可以的)
可以根據 RGB 的有無來調整顏色,比如這段代碼在屏幕上的第一行第一列輸出一個紅色的 A(執行前一定要 cls 一下!不然可能會出現問題,我這是行數出錯,在這里坑了好久。。。)

assume cs:code
code segment
start:
	mov ax,0B800H
	mov ds,ax
	mov byte ptr ds:[0],'A'
	mov byte ptr ds:[1],00000100B

    mov ax,4c00h
    int 21h
code ends
end start

下面做這樣一件事:在屏幕中間輸出三行 'welcome to masm!',分別用三種不同屬性(在代碼注釋里)
首先要確定第一行第一個那個 w 在內存里的位置,因為有三行,且水平居中,那么它上面有 11 整行,同理,它左邊有 32 個字符
那么它的偏移地址就是 \(11\times 160+32\times 2=720H\),實際地址就是 B8720H
把它化成段地址,然后每次加 160(十進制)就行,具體看代碼

;分別在屏幕中間,顯式綠色、綠底紅字、白底藍字的 'welcome to masm!'
assume cs:codesg,ds:datasg,ss:stacksg
datasg segment
	db 'welcome to masm!' ;16 字節
	db 00000010B,00100100B,01110001B ;分別是三種樣式的屬性
datasg ends
stacksg segment stack
	dw 16 DUP(0)
stacksg ends

codesg segment
start:
	mov ax,datasg
	mov ds,ax
	mov ax,stacksg
	mov ss,ax
	mov si,16;指向第一個屬性的數字
	mov ax,0B872H;ax 始終指向當前行第一個字母在顯存中的段地址(把它當作段的起始)
	
	mov cx,3
S0:
	xor bx,bx;自己異或自己來清零
	mov es,ax
	xor bp,bp
	
	push ax
	push cx
	mov cx,10H
	S1:
		mov al,[bx]
		mov es:[bp],al
		mov al,[si]
		mov es:[bp+1],al
		add bp,2
		inc bx
	loop S1
	pop cx
	pop ax
	
	inc si
	add ax,0AH
loop S0
	
    mov ax,4c00h
    int 21h
codesg ends
end start

執行效果,可以數出來確實是在中間:

8 call 和 ret

8.1 ret 和 retf

從棧中取數據,更改 ip 或 ip 和 cs

  • 執行 ret\((ip)=((ss)\cdot 16+(sp)),(sp)=(sp+2)\),相當於 pop ip
  • 執行 retf\((ip)=((ss)\cdot 16+(sp)),(sp)=(sp+2),(cs)=((ss)\cdot 16+(sp)),(sp)=(sp+2)\),相當於 pop ip,pop cs

8.2 call

將 ip 或 ip 和 cs 壓棧,並轉移

  • 執行 call 標號\((sp)=(sp-2),((ss)\cdot 16+(sp))=(ip),(ip)=(ip)+16\text{位位移}\),這里的跳轉也是根據位移進行,相當於 push ip,jmp near ptr 標號
  • 執行 call far ptr 標號\((sp)=(sp-2),((ss)\cdot 16+(sp))=(cs),(sp)=(sp-2),((ss)\cdot 16+(sp))=(ip)\),然后 cs 和 ip 分別再更改位標號的段地址、偏移地址,根據具體地址進行,相當於 push cs,push ip,jmp far ptr 標號
  • 執行 call 16 位寄存器:先把 ip 壓棧,然后 16 位寄存器的值送入 ip
  • 執行 call word ptr 內存單元:ip 壓棧,對應內存單元的字型數據的值送入 ip
  • 執行 call dword ptr 內存單元:先壓棧 cs,再壓棧 ip,然后把內存單元的雙字型數據的高位送入 cs,低位送入 ip

后面三條懶得再寫數學化的表達式了

8.3 call 和 ret 配合使用

這兩個指令從執行方式來看就比較像是要配合起來使用的,一般用它們來進行子程序,或者說函數的調用
我用一個標號來表示一個函數的開始,然后標號后面寫這個函數的語句,等語句執行完,就 ret 回去
然后想調用這個函數的時候就用 call 加那個標號
調用的時候,調用前(執行過 call 語句后的,每執行一條語句 ip 都要加上指令長度)的 ip 被壓棧,然后跳轉到函數內執行,等執行完了,就到 ret 了,棧中原來的 ip 就被彈出來,ip 被修改,回到 call 語句的下一個語句來繼續執行

要注意兩個地方,下面應用的時候還會再說

  • 就是函數內的 push 和 pop 個數相同,或者通過其它方式來保證進入函數時,調用 ret 時,棧頂都是原來的 ip
  • 如果函數內要修改一些寄存器或內存的值,而這些值在函數外(調用函數的地方)也會用到,那么如果修改了就造成了錯誤,應該先把這些都壓到棧里,然后 ret 之前再彈出來。其實也不用考慮在函數外會不會用到,那樣既麻煩還不一定能復用,因為在這里調用時函數外沒用到某個寄存器,在其它地方再調用可能就用到了,所以只要把函數里要用的寄存器都壓棧即可

8.4 mul

用來做乘法,兩種調用方式

  • 兩個 8 位相乘,結果得到一個 16 位的數,一個乘數存在 al 中,調用 mul X,這個 X 就是另一個乘數,在內存單元字節型數據或 8 位寄存器中。結果存在 ax 中
  • 兩個 16 位相乘,結果得到一個 32 位的數,一個乘數存在 ax 中,調用 mul X,X 就是另一個乘數,在內存單元字型數據或 16 位寄存器中。結果的高位存在 dx 中,低位存在 ax 中。其實除法哪里 32 位被除數也是高位 dx,低位 ax

8.5 參數和結果的傳遞

一種最容易想到的方法就是約定好參數和結果分別在哪個寄存器中,比如我們實現一個計算一個數的立方的程序,約定參數在 bx 中,結果在 dx:ax 中(這樣表示高位在 dx,低位在 ax)

assume cs:codesg,ds:datasg
datasg segment
	dw 1,2,3,4,5,6,7,8
	dd 0,0,0,0,0,0,0,0
datasg ends

codesg segment
start:
	mov ax,datasg
	mov ds,ax
	xor si,si
	mov di,16
	
	mov cx,8
S:
	mov bx,[si]
	call cube
	mov [di],ax
	mov [di+2],dx
	add si,2
	add di,4
loop S

	mov ax,4c00H
	int 21H
	
cube:;(dx:ax)=(bx)^3
	mov ax,bx
	mul bx
	mul bx
ret

codesg ends
end start

那如果要傳遞的參數和結果個數很多呢?
此時用寄存器一個個存就不現實了,那可以用內存來傳遞,把參數或結果存在一段內存里,然后傳遞這段內存的首地址、長度等信息

8.6 實例

編些一些函數來體會一下這個過程

顯示字符串

在指定的行列,用指定的顏色,顯示一串以零結尾的字符串(ASCII 碼是零,不是字符是零)
參數:dh 行號,dl 列號,分別都是從零開始。cl 顏色,字符串從 ds:bx 開始
返回:無

我們以 \(B800H\) 作為段地址,然后 \((dh)\cdot 160+(dl)\cdot 2\) 作為第一個字符的偏移地址(乘 \(160\) 是每行的字節數,乘 \(2\) 是這一列的每個字符的字節數)
然后每次更改顯存內存並更改 bx 和顯存偏移地址即可
如何判斷當前是不是零了?就每次把當前字符放入 cx,然后 \((cx)=(cx)+1\),再 loop 即可,比較容易想到

assume cs:codesg,ds:datasg,ss:stacksg
datasg segment
	db 'Welcome to masm!',0
datasg ends
stacksg segment stack
	dw 32 DUP(0)
stacksg ends

codesg segment
start:
	mov ax,datasg
	mov ds,ax
	mov ax,stacksg
	mov ss,ax
	mov dh,8
	mov dl,78
	mov cl,2
	call show_str

	mov ax,4c00H
	int 21H
	
show_str:;dh 行號,dl 列號,cl 顏色,從 ds:bx 開始,輸出字符串
	push ax
	push bx
	push cx
	push dx
	push bp
	push es
	
	mov ax,0B800H
	mov es,ax;段地址
	mov ax,0A0H
	mul dh;前面有 dh 行,每行 0AH
	add dl,dl;這一行前面 dl 列,每列 2H
	mov dh,0
	add ax,dx;
	mov bp,ax;此時 bp 即第一個字符的偏移地址
	mov dl,cl;轉存 cl,因為判斷是否跳出要用到 cx
	
	do:
		mov al,[bx]
		mov es:[bp],al
		mov es:[bp+1],dl
		inc bx
		add bp,2
		mov cl,[bx]
		inc cl
	loop do
	
	pop es
	pop bp
	pop dx
	pop cx
	pop bx
	pop ax
ret
	
codesg ends
end start

解決除法溢出問題

比如一個 32 位除以一個 16 位,結果應為 16 位,但有時除數較小可能會導致結果大於 16 位的最大值,發生錯誤
那么實現一個 32 位除以 16 位,結果 32 位的函數
參數:dx:ax 為被除數,cx 除數
返回:dx:ax 商,cx 余數

先看商:

\[\lfloor\dfrac{(dx)\cdot 10000H+(ax)}{(cx)}\rfloor \]

\[10000H\cdot \lfloor\dfrac{(dx)}{(cx)}\rfloor+\lfloor\dfrac{10000H\cdot ((dx) \bmod (cx))+(ax)}{(cx)}\rfloor \]

首先式子的正確性比較顯然吧,那么看這樣是不是每一步就都不會溢出了
第一個式子(加號左邊),兩個 16 位相除,可以把他們都當成 32 位除,這樣解決了溢出,至於乘 \(10000H\),就直接把沒乘它的結果加到最終結果的高位里就行了
然后第二個式子,考慮取整符號里面的,由於分子 \(10000H\cdot ((dx) \bmod (cx))+(ax)\le 10000H\cdot ((cx)-1)+(ax)\)
那么整個分數小於等於 \(10000H+\dfrac{(ax)-10000H}{(cx)}<10000H\),所以也不會溢出

代碼實現比較簡單了,我是直接把這三個寄存器里的數先存內存,避免更改它們的值帶來的麻煩

assume cs:codesg,ds:datasg,ss:stacksg
datasg segment
	dw 32 DUP(0)
datasg ends
stacksg segment stack
	dw 32 DUP(0)
stacksg ends

codesg segment
start:
	mov ax,datasg
	mov ds,ax
	mov ax,stacksg
	mov ss,ax
	mov dx,0FH
	mov ax,4240H
	mov cx,0AH
	call divdw

	mov ax,4c00H
	int 21H
	
divdw:
	push bx
	push ds:[0]
	push ds:[4]
	push ds:[8]
	
	mov ds:[0],ax
	mov ds:[4],cx
	mov ds:[8],dx
	
	;計算 (dx)/(cx),商存在 bx,余數在 dx
	mov ax,ds:[8]
	mov dx,0
	div word ptr ds:[4];(dx)/(cx),為防止溢出用 word
	mov bx,ax
	
	;計算剩余部分
	mov ax,ds:[0]
	div word ptr ds:[4];目前剩余部分分子已經符合高位在 dx,低位在 ax,直接除
	mov cx,dx;余數放進 cx
	mov dx,bx;第一部分的結果就是總結果的高位,放入 dx
	;ax 已經是低位
	
	pop ds:[8]
	pop ds:[4]
	pop ds:[0]
	pop bx
ret

codesg ends
end start

數值顯示

將一個數以十進制形式顯示到屏幕上

那么此時我們需要一個二進制轉十進制的程序
參數:ax,要轉的數
返回:從 ds:si 開始,返回一個字符串

就每次 ax 除以 \(10\),余數存起來,然后判一下是不是已經 \((ax)=0\) 就行了
但這樣存完以后是逆序的,要再轉換順序,就一個循環執行字符串長度除以二下取整次,每次用 si 和 di 分別指向字符串兩端,往中間靠近,並交換
還要判斷是不是字符串長度為 \(1\)

然后再調用之前的顯示字符串函數

assume cs:codesg,ds:datasg,ss:stacksg
datasg segment
	db 16 DUP(0)
datasg ends
stacksg segment stack
	dw 32 DUP(0)
stacksg ends

codesg segment
start:
	mov ax,datasg
	mov ds,ax
	mov ax,stacksg
	mov ss,ax
	mov ax,12666
	xor si,si
	call dtoc
	
	xor bx,bx
	mov dh,8
	mov dl,3
	mov cl,2
	call show_str

	mov ax,4c00H
	int 21H

dtoc:;數據在 ax,轉為十進制字符,存的位置從 ds:si 開始
	push ax
	push bx
	push cx
	push dx
	push si
	push di

	do_dtoc:
		xor dx,dx;為了防止除法溢出,用 32 位除以 16 位
		mov cx,10
		div cx
		add dx,30H;轉為字符
		mov [si],dx
		inc si
		mov cx,ax
		inc cx
	loop do_dtoc
	
	;因為這樣計算是逆序的,所以還要轉換順序
	mov ax,si
	mov bx,2
	div bl
	mov ah,0;把商的位置置零
	mov cx,ax;執行次數
	jcxz cx_is_0
	mov di,si
	dec di
	xor si,si
	order:
		mov al,[si]
		mov ah,[di]
		mov [si],ah
		mov [di],al
		inc si
		dec di
	loop order
	cx_is_0:
	
	pop di
	pop si
	pop dx
	pop cx
	pop bx
	pop ax
ret

show_str:
	push ax
	push bx
	push cx
	push dx
	push bp
	push es
	
	mov ax,0B800H
	mov es,ax;段地址
	mov ax,0A0H
	mul dh;前面有 dh 行,每行 0AH
	add dl,dl;這一行前面 dl 列,每列 2H
	mov dh,0
	add ax,dx;
	mov bp,ax;此時 bp 即第一個字符的偏移地址
	
	xor ch,ch
	xor bx,bx
	mov dl,cl;轉存 cl,因為判斷是否跳出要用到 cx
	
	do:
		mov al,[bx]
		mov es:[bp],al
		mov es:[bp+1],dl
		inc bx
		add bp,2
		mov cl,[bx]
		inc cl
	loop do
	
	pop es
	pop bp
	pop dx
	pop cx
	pop bx
	pop ax
ret
	
codesg ends
end start

將之前某個實例中那個公司的各種信息按照格式輸出

還記得之前那個實例嗎,那個是存到內存里,現在是輸出,輸出成這樣:

因為年份是字符串,就以為一位往顯存里寫
然后總收入和人數就調用進制轉換和字符串顯示的函數,因為是 32 位,所以進制轉換也要變成 32 位的
再調用防止溢出的除法函數,算出人均收入,同樣輸出
其實思路很簡單,主要就是細節問題

一定注意寄存器沖突的問題!進入函數時保存所有要更改的寄存器,如果沒有進入函數,但一個本來有用途的寄存器此時要用作其它用途,也要先把它的值保存下來,想清楚每個寄存器在什么時候是表示什么!

我寫了半個下午加半個晚上,大部分時間都耗在差寄存器沖突帶來的錯上了。。。。
以及跳轉上的一些問題也要注意

assume cs:codesg,es:datasg,ds:datasg2,ss:stacksg
datasg segment
	db '1975','1976','1977','1978','1979','1980','1981','1982','1983' ;年份
	db '1984','1985','1986','1987','1988','1989','1990','1991','1992'
	db '1993','1994','1995'
	
	dd 16,22,382,1356,2390,8000,16000,24486,50065,97479,140417,197514 ;公司總收入
	dd 345980,590827,803530,1183000,1843000,2759000,3753000,4649000,5937000
	
	dw 3,7,9,13,28,38,130,220,476,778,1001,1442,2258,2793,4037,5635,8226 ;公司人數
	dw 11542,14430,15257,17800	
datasg ends
datasg2 segment
	db 16 DUP(0)
datasg2 ends
stacksg segment stack
	dw 32 DUP(0)
stacksg ends

codesg segment
start:
	mov ax,datasg2
	mov ds,ax
	mov ax,stacksg
	mov ss,ax
	mov sp,34
	mov ax,datasg
	mov es,ax
	xor si,si;年份和總收入
	mov di,0A8H;第一個人數
	mov bx,288H;第一個屏幕上要輸出的字符偏移地址,第四行第四列
	mov bp,3;bp 為當前行號
	
	mov cx,21
main:
	push cx
	push bx
	push si
	inc bp
	
	mov cx,es:[si];輸出年份,年份是字符串所以手動輸出
	mov dx,es:[si+2]
	push es
	mov ax,0B800H
	mov es,ax
	mov ax,111B;顏色
	mov es:[bx],cl
	mov es:[bx+1],al
	mov es:[bx+2],ch
	mov es:[bx+3],al
	mov es:[bx+4],dl
	mov es:[bx+5],al
	mov es:[bx+6],dh
	mov es:[bx+7],al
	pop es
	
	mov ax,es:[si+54H];輸出總收入
	mov dx,es:[si+2+54H]
	xor si,si;轉換進制從 ds:si 開始存結果
	call dtoc
	mov dx,bp
	mov dh,dl
	mov dl,22;22 列
	mov cx,111B
	xor bx,bx;顯示字符從 ds:bx 開始
	call show_str
	
	mov ax,es:[di];人數
	xor dx,dx
	xor si,si
	call dtoc
	mov dx,bp
	mov dh,dl
	mov dl,40
	mov cx,111B
	xor bx,bx
	call show_str
	
	
	pop si;計算、輸出人均收入,還原 si
	push si
	mov dx,es:[si+54H+2]
	mov ax,es:[si+54H]
	mov cx,es:[di]
	call divdw
	xor si,si
	call dtoc
	mov dx,bp
	mov dh,dl
	mov dl,58
	mov cx,111B
	xor bx,bx
	call show_str
	
	pop si
	pop bx
	pop cx
	add bx,160
	add si,4
	add di,2
	
	dec cx
	jcxz done
	jmp near ptr main
done:

	mov ax,4c00H
	int 21H

divdw:;被除數高位 dx,低位 ax,除數 cx,返回時商高位 dx,低位 ax,余數 cx
	push bx
	push ds:[0]
	push ds:[4]
	push ds:[8]
	
	mov ds:[0],ax
	mov ds:[4],cx
	mov ds:[8],dx
	
	;計算 (dx)/(cx),商存在 bx,余數在 dx
	mov ax,ds:[8]
	mov dx,0
	div word ptr ds:[4];(dx)/(cx),為防止溢出用 word
	mov bx,ax
	
	;計算剩余部分
	mov ax,ds:[0]
	div word ptr ds:[4];目前剩余部分分子已經符合高位在 dx,低位在 ax,直接除
	mov cx,dx;余數放進 cx
	mov dx,bx;第一部分的結果就是總結果的高位,放入 dx
	;ax 已經是低位

	pop ds:[8]
	pop ds:[4]
	pop ds:[0]
	pop bx
ret

dtoc:;高位 dx,低位 ax,轉為十進制字符,存的位置從 ds:si 開始
	push ax
	push bx
	push cx
	push dx
	push si
	push di

	do_dtoc:
		mov cx,10
		call divdw
		add cx,30H;轉為字符
		mov [si],cx
		inc si
		mov cx,ax
		inc cx
	loop do_dtoc
	
	;因為這樣計算是逆序的,所以還要轉換順序
	mov ax,si
	mov bx,2
	div bl
	mov ah,0;把商的位置置零
	mov cx,ax;執行次數
	jcxz cx_is_0
	mov di,si
	dec di
	xor si,si
	order:
		mov al,[si]
		mov ah,[di]
		mov [si],ah
		mov [di],al
		inc si
		dec di
	loop order
	cx_is_0:
	
	pop di
	pop si
	pop dx
	pop cx
	pop bx
	pop ax
ret

show_str:;dh 行號,dl 列號,cl 顏色,從 ds:bx 開始,輸出字符串
	push ax
	push bx
	push cx
	push dx
	push bp
	push es

	mov ax,0B800H
	mov es,ax;段地址
	mov ax,0A0H
	mul dh;前面有 dh 行,每行 0AH
	add dl,dl;這一行前面 dl 列,每列 2H
	mov dh,0
	add ax,dx;
	mov bp,ax;此時 bp 即第一個字符的偏移地址
	
	xor ch,ch
	xor bx,bx
	mov dl,cl;轉存 cl,因為判斷是否跳出要用到 cx
	
	do:
		mov al,[bx]
		mov es:[bp],al
		mov es:[bp+1],dl
		inc bx
		add bp,2
		mov cl,[bx]
		inc cl
	loop do
	
	pop es
	pop bp
	pop dx
	pop cx
	pop bx
	pop ax
ret

codesg ends
end start

7 標志寄存器

標志寄存器用來存儲計算的某些結果,為 cpu 的執行提供依據或控制其行為
與其它寄存器不同,它是每個二進制位代表一個意義(其它都是整個寄存器代表一個意義)
每一位的意義如下,空白說明在 8086cpu 中這一位無意義

7.1 ZF

第 6 位,零標志位
如果上一條指令執行的結果為 \(0\),則 \(ZF=1\),否則 \(ZF=0\)

關於“上一步的結果”:8086cpu 中有一些指令會產生結果,比如 add,sub,mul,div,inc,dec,or,and(其中 inc 和 dec 只有可能影響 AF,OF,PF,SF,ZF 標志位,但不影響 CF 標志位);還有一些比如 mov,push,pop 不影響標志位
這一點在后面也會用上

7.2 PF

第 2 位,奇偶標志位
如果上一條指令執行的結果的二進制中有偶數個 \(1\),則 \(PF=1\),否則 \(PF=0\)

7.3 SF

第 7 位,符號標志位
如果上一條指令執行結果為負\(SF=1\),如果非負\(SF=0\)

和符號有關,就是要用到補碼了,先去學補碼再看下面內容會更名白一些
一個數據以二進制保存在計算機中,它既可以代表直接轉換成十進制的數(無符號),也可以用補碼來轉換(有符號)
也就是說,cpu 執行一條指令的時候,已經有了兩種含義(當成有符號執行和當成無符號執行),結果也有兩種含義(有符號和無符號),雖然它們在計算機中的表達是一樣的,把它當成有符號還是無符號是我們的“看待”

所以說,cpu 在執行一條有結果的指令時,必然影響到 SF 的值(當然是當作有符號運算來進行影響),而我們需不需要這個影響就另說了:比如我們對這個運算的“看待”就是無符號運算,那么 SF 受到的影響就是無用的,但 cpu 對 SF 的影響還是會有,只是我們此時不需要罷了

7.4 CF

第 0 位,進位標志位
兩個 N 位數字運算時,有可能發生溢出,CF 記錄的就是溢出的這一位(第 N 位)

當減法出現借位時,CF 也會記錄借位值。比如一個八位減法 \(97H-98H\),發生借位,變成 \(197H-98H\),然后 \(CF=1\)

其實可以發現,一般來說這個 CF 也是對於無符號數的,但是如果我們把一個運算看作有符號的運算,cpu 執行指令對 CF 的影響仍然是存在的

7.5 OF

第 11 位,溢出標志位
溢出一般是對於有符號數來說的,就是如果運算過程中結果超過了機器所能表示的范圍稱為溢出
比如對於兩個 8 位數的運算,\(98+99=197\),這個 \(197\) 就超過了 8 位數的表示范圍 \([-128,127]\),發生了溢出
這樣結果變成十六進制就是 \(0C5H\),又因為是有符號運算,所以它應該被按照補碼的規則看作 \(-59\),發生了錯誤
這時就要用到 OF 了,如果上一個指令的結果發生了溢出,\(OF=1\),否則為零

注意:OF 是對有符號數有意義的標志位,而 CF 是對無符號運算有意義的
但即使一個標志位對當前的運算無意義,它也會被影響(cpu 不知道當前是有符號還是無符號)

7.6 在 debug 中查看標志寄存器

r 命令查看寄存器值時右下角會有一些字符:

7.7 adc 與 sbb

adc X,Y 就是 \(X=X+Y+CF\)
比如 adc ax,bx,意義是 \((ax)=(ax)+(bx)+CF\)

那么這樣一種指令的意義何在?比如當我們執行 add al,bl 后,\((al)=(al)+(bl)\),但這樣以后 al 可能發生進位,那么會對應的記錄到 CF 中,此時再調用 adc ah,bh,就會在把 bh 的值加到 ah 上的同時,把 CF 也加到 ah 上
那么如果之前 al 進位,也就是 \(CF=1\),多了一個 \(100H\),加到 ah 上就是加一,也就是加 CF 的值(當然沒進位 \(CF=0\) 也不會有問題)
所以 adc 的意義其實是使得更大數據的加法可以被支持,通過把 CF 的值加到高位上來解決低位出現進位的問題

比如下面這個程序,我們計算了 \(1EF000H+201000H\),並將結果存進了 ax:bx

;calc 1EF000H+201000H,result in ax:bx
assume cs:code
code segment
start:
	mov ax,001EH
	mov bx,0F00H
	add bx,1000H
	adc ax,0020H

    mov ax,4c00h
    int 21h
code ends
end start

同樣,也可以實現下面這樣的一個函數,來利用 adc 進行兩個 128 位數據的相加

;兩個 128 位數字相加,ds:si 指向第一個數,8 個字
;ds:di 指向第二個數,結果存在第一個數的位置
add128:
	push ax
	push cx
	push si
	push di
	
	sub ax,ax;將 CF 設零
	mov cx,8
	S:
		mov ax,[si]
		adc ax,[di]
		inc si
		inc si
		inc di
		inc di;用 inc 而不是 add 來防止改變 CF
	loop S
	
	pop di
	pop si
	pop cx
	pop ax
ret

再來說 abbabb X,Y 就是 \(X=X-Y-CF\)
比如 sbb ax,bx,意義是 \((ax)=(ax)-(bx)-CF\)
現在類比上一個看這個指令,意義也很明確了,實現帶借位的減法

這兩個指令也體現出了 CF 存在的意義

7.8 cmp

比較指令,對標志寄存器的影響相當於減法指令,但是它不會改變參與減法運算的兩個寄存器或內存單元的值(就是說只改變標志寄存器,不保存結果)
比如執行指令 cmp ax,ax,執行后標志寄存器:\(ZF=1,PF=1,SF=0,CF=0,OF=0\),但 ax 以前是多少還是多少
如果執行 cmp ax,bx,則:

如果 cmp 是對無符號數進行比較,那么上面的幾條也可以倒推

但如果是有符號數,就稍微復雜一些了
首先前兩條相等和不相等,當然還是一樣
如果 \((ax)<(bx)\),則會引起 \(SF=1\),但是 \(SF=1\) 卻不一定可以說明 \((ax)<(bx)\)
比如有符號 8 位減法:\(22H-0A0H=34-(-96)=82H=-126\text{(補碼轉換為原碼)},CF=1\),但是 \(34>-96\)
什么情況下會出現這種問題?\(SF=1\) 並不完全等價於結果為負數(結果為負數我們一定能說明那個小於關系),因為就像上面那個例子,運算中發生了溢出,因此出現了這種情況,所以再經過一些簡單分析,就可以得到:

  • \(SF=1,OF=0\),沒有溢出,此時 \(SF=1\) 就等價於結果為負,所以 \((ax)<(bx)\)
  • \(SF=1,OF=1\),發生溢出,溢出導致了 \(SF=1\),也就是 cpu“以為”結果為負,那么實際上應該是結果為正,那么 \((ax)>(bx)\)
  • \(SF=0,OF=0\),沒溢出,\((ax)\ge (bx)\),注意由於 \(SF=0\) 這里是大於等於
  • \(SF=0,OF=1\),溢出了,\((ax)<(bx)\),這后面兩個都是同理

這里感覺比較容易迷惑,主要就是關注有符號數溢出,在原碼上的表示超出范圍,對應到補碼上就是改變了符號

7.9 基於標志寄存器的跳轉指令

其實就是通過上面講述的 cmp 結果,對於無符號數,有這幾種:

其中各個字母縮寫的含義:not,equal,below,above,可能會幫助記憶
其實這個圖稍微有一些歧義,要知道中間那一豎欄只是一個輔助的描述,比如如果你只執行一個 je 並不會直接起到中間豎欄的作用,而只是通過 ZF 的值來進行轉移,只有當在 je 之前執行一個 cmp,它才會起到“等於則跳轉”的效果
也就是,這些指令都可以單獨使用,根據標志寄存器跳轉,但一般都是通過和 cmp 搭配使用來起到根據兩數大小來跳轉的作用
就好像 callret 一般搭配使用,但也可以單獨拿出一個來用

然后,對於有符號數,原理上是一樣的,只是檢測的標志位不同,整理出了下面這一個和無符號數跳轉指令的對應關系(以下同一個指令兩種助記符用斜杠隔開,其實可以發現它們是有規律的)

無符號 有符號 何時跳轉
je / jz je / jz 等於
jne / jnz jne / jnz 不等於
jb / jnae jl / jnge 低於
jnb / jae jnl / jge 不低於(大於等於)
ja / jnbe jg / jnle 高於
jna / jbe jng / jle 不高於(小於等於)

例子

可以用如下程序檢測 ds:si 開始的一些數據中有幾個 8

assume cs:code,ds:datasg;看 datasg 里有多少數等於 8,結果存 ax
datasg segment
	db 8,11,8,1,8,5,63,38
datasg ends
code segment
start:
	mov ax,datasg
	mov ds,ax
	xor bx,bx
	xor ax,ax
	
	mov cx,8
S:
	cmp byte ptr [bx],8
	jne next
	inc ax
	next:
	inc bx
loop S

    mov ax,4c00h
    int 21h
code ends
end start

同理,也可以統計有多少個大於,小於,不等於 8

7.10 DF 和串傳送指令

第 10 位,方向表示位,串處理指令中,控制每次 di 和 si 的加減,\(DF=0\),就加,否則就減

串傳送指令:movsbmovsw
相當於每次把 es:si 處的數據送入 ds:di 中,每次的長度分別是 byte 和 word
然后每次傳送完以后變更 si 和 di,就依靠 DF 的值

它可以和 rep 配合使用,rep movsb 就相當於:

S: movsb
loop S

所以要提前設置 cx
cldstd 分別將 DF 置為 \(0\)\(1\)

下面就是一個例子

assume cs:code,ds:datasg;把 datasg 第一個 16 字節傳送到第二個 16 字節
datasg segment
	db 'Welcome to masm!'
	db 16 DUP(0)
datasg ends
code segment
start:
	mov ax,datasg
	mov ds,ax
	mov es,ax
	xor si,si
	mov di,16
	mov cx,8
	
	cld
	rep movsw
	
    mov ax,4c00h
    int 21h
code ends
end start

7.11 pushf 和 popf

分別是把標志寄存器的值入棧、出棧。這也是一種可以直接訪問標志寄存器的方法

7.12 實例

寫一個大小寫轉換的子程序,小寫轉大寫,但是轉換的字符串不一定都是字母,要提前判斷

assume cs:code,ds:datasg
datasg segment
	db "Beginner's All-purpose Symbolic Instruction Code.",0
datasg ends
code segment
start:
	mov ax,datasg
	mov ds,ax
	xor si,si
	call letterc
	
    mov ax,4c00h
    int 21h
	
letterc:;ds:si 指向的以 0 結尾的字符串中小寫字母轉成大寫
	push ax
	push cx
	push si
	do:
		mov al,[si]
		cmp al,'a'
		jb no
		cmp al,'z'
		ja no
		and al,11011111B
		mov [si],al
		no:
		inc si
		mov cx,[si]
		inc cx
	loop do
	pop si
	pop cx
	pop ax
ret

code ends
end start


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM