2013年1月2日 星期三

實戰呼叫慣例 - 淺談 x87 浮點運算 (完)

從最初期的呼叫系列慣例文章以來,也算是逐漸地發展出一套有系統的發文方式
從最基本開始讓閱讀的人從基本上了解程式語言底層的本質然後逐漸的推進到有
一篇簡單的實作練習,也就是上篇 從 C 呼叫組合語言函式一文,這樣的流程無非
希望讓讀者能夠有實質的進步與收穫,所以本篇當然要在推進一層
讓我們來看看怎麼使用浮點運算
這次我採用倒吃甘蔗的方式,先
跑一跑程式在來解說程式

上一次的實作可能也許你會不太滿意,想要進一步挑戰看看一個問題


extern "C" int __stdcall fasm_stdcall_avg(int a,int b)


這個函式只能輸入整數,也就是只能計算兩個整數的平均,而平均值也只能是整數,所以
當你對輸入 2 與 3 的時候就出現問題了... 糟糕 沒辦法算出 2.5,這就是問題所在囉,因此
要解決這個問題,就得要請出隱藏在 CPU 內的浮點數運算專家,也就是 80x87 浮點
運算單元,這個玩意在早期是以 co-processor 的形式存在於主機板上,有自己獨立的中斷,
當 CPU 要計算浮點運算的時候,就會把資料傳送到 80x87上,80x87將資料計算完成後,
會發出中斷,CPU 就來拿取運算結果。我開始有印象是 486DX 就已經將浮點運算單元
內建於 CPU (或許有更早期的 CPU,這得要查一查),既然 486DX 都已經內建了此單元,
所以你就不用煩惱你的電腦有沒有內建 80x87啦,我想一定是有內建此功能。所以
我這次要來玩一玩 浮點運算的暫存器跟指令集,就是我這一次要示範的外部函式
會呼叫一些浮點運算指令,然後一樣在 C 語言中呼叫並顯示,驗證是否正確。
你也許會擔心不瞭解 浮點運算格式 IEEE754,很幸運,不用了解這個玩意,這個規格書是給
寫編譯器的人跟設計浮點運算器硬體的人看,假如你還是很想了解
當然首選是 IEEE Standard for Binary Floating-Point Arithmetic ANSI/IEEE Std 754-1985
美國國家標準局最原始的文件(現在已經是 2000版不過差不了多少),以前研究看過其他文件都寫不完整, IEEE754 是一套對浮點數有嚴密定義的標準,我在這邊只用 Single 這部分作簡單介紹,首先,你也許在修計算機概論可能還記得 Single 的格式,如 圖1 所示

所以用集合表達就是 single format = {sizeof(s), sizeof(e), sizeof(f)} = {1-bit, 8-bit, 23-bit}
為了保持對 single format 原汁呈現,此圖當然出至上面那份文獻
s 就是指浮點數的正負號,e 就是指浮點數的指數部分(以2為底),f 指小數部分 (fraction)
應該大部分的人對IEEE754格式認知到此就結束了,其實只是認識了IEEE754很小一部分,
還有相關的名詞定義,捨入的方法,怎麼操作浮點數,包含 算數開平方不同浮點間的格式轉換從浮點數轉為整數二進位與十進位轉換比較怎麼定義 無窮大非數字(NaN)有號零 (Signed Zero),怎麼處理運算產生的例外 (Exception),包含 無效操作除以零OverflowUnderflowInexact ,大概這份規格每一部份能稍微了解一點,就算是比較了解 IEEE754,80x87 其實也是依照這份規格去設計硬體,在規格裡面有講到的例外情況,在 80x87 的 Control Word Register 就會有實作,但是實際上不常用,預設自動的例外處理已經很好用了。以前也是仔細念了這份規格才知道要定義一個數字也是大有學問,但是
實際使用 80x87 大部分都不用理會規格內的東西,因為被硬體的實作給隱藏起來,因此
我們要學習的是怎麼使用 80x87 的指令集與暫存器。

打開上一篇的程式,待會外部要實作的宣告改成

extern "C" float __stdcall fasm_stdcall_avg_real(float a,float b);


我們現在要在 FASM 裡面實作的 fasm_stdcall_avg_real,下面給出我已經修改好的 FASM code


;definitions of macros we use
include 'win32a.inc'

format MS COFF

;declarations
;declare procedure as public, so C code can use them
;public names must be "decorated" by prepending "_".
;stdcall procedure names must be also decorated by "@"
;and size of arguments at the end
public fasm_stdcall_avg_real as '_fasm_stdcall_avg_real@8'

;data section
section '.data' data readable writeable

    divisor dd 2.0

;code section
section '.text' code readable executable

;Implementations
fasm_stdcall_avg_real:
    ;create stack frame (prologue code)
    ;the return address is now located at ebp+4
    push ebp
    mov  ebp,esp
label .num1 dword at ebp+8  ;num1 argument is now located at ebp+8
label .num2 dword at ebp+12 ;num2 argument is now located at ebp+12

    ;allocate space for local variables in stack
    sub  esp, 8
label .loc1 dword at ebp-4
label .loc2 dword at ebp-8

    ;registers ebx esi edi need to be preserved
    push ebx esi edi
    ;========= end of prologue code =========
    ;compute average
    mov   eax, [divisor] ; 40000000h = 2.0
    mov   [.loc1], eax   ; eax = 2.0

    finit            ;initialize the math processor
    fld   [.num1]    ;st(0) <= [.num1]
    fadd  [.num2]    ;st(0) <= st(0) + [.num2]
    fdiv  [.loc1]    ;st(0) <= st(0)/2
;   fstp  [.loc2]    ;[.loc2] <= st(0)
;   fld   [.loc2]    ;return value is in st(0), i.e. st(0) <= [.loc2]

    ;========= end of epilogue code =========
    pop edi esi ebx ;restore registers
    mov esp, ebp    ;set esp back to stack frame
    pop ebp         ;destroy stack frame
    retn 2*4        ;return and restore arguments from stack


0.0+  既然要倒吃甘蔗,所以就用剪下貼上至 FASM 編輯器內,編譯出 目的檔 (obj),然後
將 obj 引入至 Visual Studio 專案中,呼叫端 C 程式如下列程式碼


#include <tchar.h>
#include <stdio.h>

extern "C" float __stdcall fasm_stdcall_avg_real(float a,float b);

int main(int argc,TCHAR argv[])
{
    float f32val;

    printf_s("Call assembly procedures from FASM...\n");
        
    f32val = fasm_stdcall_avg_real(2.0f, 3.0f);
    printf_s("fasm_stdcall_avg_real(2.0f, 3.0f)= %f\n", f32val);

    return 0;
}


完全用上一篇引入外部組合語言副程式的步驟後,經過編譯執行,你應該會得到執行結果



您是否也得到一樣的結果呢?現在,我們直接利用 80x87 作浮點,從而能夠得到 2 與 3 的平均值為 2.5。整個 FASM 裡面只呼叫 finitfldfadd 與 fdiv,可以看到其實80x87使用上很簡單,程式可以跑了,接著就來看兩件事情,第一件當然就是這些指令怎麼運作,第二件事跟 C 編譯器本身怎麼運作有關,就是當一個函式回傳 float 的時候,編譯器會產生甚麼形式的組合語言來呼叫這條函式,這個規則很重要,FASM 內寫副程式的時候要遵守這個
規則,否則在 C 語言中呼叫函式的時候,牛頭不對馬嘴,會得到錯誤的浮點數值 。

首先,看看 FASM 的原始碼可以發現,我使用了另外一種 section
這種 section 非常重要,可以用來放一些已初始化或是未初始化的全域變數,所以
被稱為資料區段一般這種區段名稱為 '.data',它的屬性是 data readable writeable
當你會令資料區段時,就能更進一步擴大使用 FASM 組譯器的威力,從範例中可以發現
我故意留下一段原本也可以跑的程式碼並且註掉它,即是

mov   eax, [divisor] ; 40000000h = 2.0

這一段讀者可以看到 data section 的威力,它就讓你像在高階語言一樣類似宣告全域變數
順便利用命名 label 的方式增加可讀性,其次可以看到其實所謂 2.0 這樣的東西,它會被
翻譯成 40000000 (16進位),也就是說其實浮點數用IEEE754編碼後,一樣是一個
32-bit 的整數,也就是說 其實也可以直接寫


mov eax, 40000000h


但是這樣可讀性很差,維護性也很差,這就是為什麼需要 data section 的關係,FASM 支援
在 data section 內 直接撰寫 浮點數,而我們知道 組語 之下 這些名稱事實上就是 位址
所以 將 label 放在 [ ] 裡面,就可以存取內容值,至於 2.0 怎麼變成 40000000,給FASM
去煩惱,它會幫我們翻譯為對應的 32-bit 整數,前面的 dd 是一種語法,範例裡面寫

divisor dd 2.0

就表示從 divisor 位址開始,後面 FASM 將解讀為 4 bytes 資料。
後面就沒甚麼神奇之處,將 eax 內的值移動至堆疊中放在 [.loc1] 預備後面給x87用。

讓我們來談談下面使用到的 x87 指令集 (finit、 fldfadd 與 fdiv)
首先我呼叫了 finit , 這個指令是使用 x87 前必須呼叫的指令,它會將 x87 復原至初始狀態
也就是說 假如原來使用者有對 x87 暫存器有做任何更變都會被清除,保證處在 x87 硬體
預設的狀態值,接下來就可以開始操作 x87 浮點運算器了。

規則很簡單,這個東西根本就是一部堆疊機,從 ST(0)~ST(7),ST(0) 是 TOS (堆疊頂)
我們的例子很簡單,只在 TOS 上操作,很單純,因此下一個指令是 fld,這個指令的意思
也很明確,載入資料至浮點數暫存器,所以 [.num1] 的值就被複製到 ST(0) ,第二條指令
是 fadd,很明顯就是做浮點數的加法,這個指令會有兩個動作,會自動彈出 ST(0) 跟輸入
的 [.num2] 相加後又放回 ST(0),所以現在 ST(0) 等於 [.num1]+[.num2],所以讀者們
應該也就知道 fdiv 的作動方式,只是這次跟 [.loc1] 作除法,所以前面要在先把 40000000
存入 [.loc1],原來其實浮點數只對浮點運算器才有意義,其實 2.0 是傳給 x87 浮點運算器
後,才有意義,40000000 在浮點運算器內根據 IEEE754 會被解讀成 2.0 。

至於後面兩行是多餘的不過很多 compiler 可能會依照這個標準產生碼跑一次

;   fstp  [.loc2]    ;[.loc2] <= st(0)
;   fld   [.loc2]    ;return value is in st(0), i.e. st(0) <= [.loc2]

fstp 這個浮點指令會把 ST(0) 彈出堆疊 然後存入我們指定的 [.loc2]
然後 fld 又把 [.loc2] 在推入 ST(0) ,顯然多此一舉,但是 C 編譯器可能會產生這種樣版碼

 第二件事情就是 C 編譯器 怎麼看待這件事情呢?
不知道讀者們發現了沒,為什麼一直談 ST(0),因為它很重要,浮點數的傳遞規則跟它有關係。原來,想讓浮點數算完的結果在 C 語言內可以收到的話, 這邊的慣例可就跟整數形態
大大不同,C 編譯器在呼叫一個回傳值是 浮點數的函式時, C 編譯器會在呼叫完這條
函式後,下面馬上接 fstp,也就是說,callee 必須把回傳值必須放在 ST(0)上,讓 caller 在外面用 fstp 彈出 ST(0) 接收結果,所以知道為什麼前面要寫那兩段無用的廢碼吧,還沒搞懂
callee 與 caller 是甚麼 ? 回頭看看前面幾篇呼叫慣例文。

看看我們的 fasm 這個可以計算浮點數平均的 subroutine 怎麼被 C 語言呼叫

;f32val = fasm_stdcall_avg_real(2.0f, 3.0f);
push        ecx  
fld         dword ptr [__real@40400000 (415770h)] 
fstp        dword ptr [esp] 
push        ecx  
fld         dword ptr [__real@40000000 (41576Ch)] 
fstp        dword ptr [esp] 
call        _fasm_stdcall_avg_real@8 (4113C8h) 
fstp        dword ptr [f32val]

很明顯可以看到 2.0 被翻譯成 40000000,3.0 被翻譯為 40400000 其實就只是 32-bit 整數
然後推入堆疊,跟整數傳遞一樣沒有任何奇特之處,不過你可以能會問說,以前的例子
直接推入整數,不是 push 32-bit number,這次有詭異,跑出了x86暫存器裡面的 ecx !?
奇怪怎麼會這樣呢? 原來,這是編譯器要推入浮點數的一種技巧,先隨便推一個值進去
堆疊,裡面的值是甚麼不重要,只是 CL (微軟的C/C++ 編譯器) 的設計是抓 ecx,這下子
esp 的值就指在目前要存放浮點數的位址,上面有看懂的話就會發現,在來個 fld 與 fstp 後
這下子的內容就是 [esp] = 40400000h,後面 2.0 也是這麼推進堆疊中,所以整個結果變成

Stack
40400000
40000000   [esp] 目前stack pointer指向的內容

執行 call 指令之後則變成

Stack
40400000
40000000 
????????    [esp] 目前stack pointer指向的內容(返回位址) 
                   (call指令位址的下一行位址)

聰明的讀者也許一定想到了一個直接了當的方法,也就是直接把參數 push 進堆疊

;f32val = fasm_stdcall_avg_real(2.0f, 3.0f);
push        40400000h ;3.0f 
push        40000000h ;2.0f
call        _fasm_stdcall_avg_real@8 (4113C8h) 
fstp        dword ptr [f32val]

沒錯,這樣也可以,可惜C程式碼的翻譯權是 C Compiler 所決定,你用 C 語言寫只能乖乖
的接受上面那種格式,即使你選擇了 Release 版編譯出來的程式碼也沒有多聰明,頂多變成
(但是別小看 Compiler,有時候會產生出令你吒舌的聰明程式碼,下一篇有舉例子~~~)

Debug Version:
push        ecx  
fld         dword ptr [__real@40400000 (415770h)] 
fstp        dword ptr [esp] 
push        ecx  
fld         dword ptr [__real@40000000 (41576Ch)] 
fstp        dword ptr [esp] 

Release Version:
fld         dword ptr [__real@40400000 (415770h)] 
fstp        dword ptr [esp+4]  
fld         dword ptr [__real@40000000 (41576Ch)] 
fstp        dword ptr [esp] 
 
在fstp下直接操作 esp+偏移量,意思是一樣,在我看來沒甚麼改進,
因為真正消耗執行時間的是 x87 指令,push 並不會浪費多少時間,
這是我在VS2008下測試的結果,其他的 Compiler 會長
甚麼模樣,讀者們可以自己多多嘗試。 
 
稍微解釋一下 Release Version 的想法為什麼是等價的效果呢 ?
千萬別忘記,call指令本身可是也會有類似 push 的效果,這個會導致
esp 會被 減4,這就是為什麼要預先加 4,這樣子的排列剛剛好就會平衡
順便也可以看到編譯器用了另外一種推入推疊的技巧,直接操作 esp+offset
恩 這樣其實也可以,這種推法會變成
 
Stack
param 2        [esp+4]
param 1        [esp]
return address [esp-4]
 
所以進入函式內真正的 esp 是 esp <= esp-4
而函式內第一條往往都是保存原本 ebp,所以會有 push ebp,變成
 
Stack
param 2        [esp+4]
param 1        [esp]   (這邊以原來 esp 當基準)
return address [esp-4] 
ebp            [esp-8] (目前實際 esp 為 esp-8)
 
所以 esp+8 這樣的法則用到浮點數規則一樣,外部用組合語言撰寫的副常式
一樣依照存取整數的模式做,沒有甚麼需要改變的地方。


最後,讀者看我說了這麼多,不知道體會到了沒,程式語言非常靈活,跟我們
說話沒甚麼兩樣,相同事情表達方式也是很多種,操作 esp 就是很好的例子
有看懂的人大概會發現 esp 的操作其實完全是相對操作,debug 與 release 產生的版本
release版整個stack會往上偏移 4,不過有甚麼關係,順序一樣就好,
對 callee 來說根本沒差!!


(完)


後記 : XR450 網路通訊還是艱苦的搞定了,真是難玩的玩具,現在走Ethernet介面,在
Windows系統上除了要會 Socket API 還要能看懂通訊規格轉化為程式,還好不知是巧合
CNN的學長找我寫 Gocator 的程式從這裡面發現解決方法,最後還是用 zAPI 搞定了這個玩具,企業級的RFID還真不是隨便都玩得到的東西,那玩意一次讀大概上百個 tag 沒甚麼問題。 網頁資料的頁面 (XML) 其實是很難搞的事情,而且效率又差,你又不可能自己寫解析器一般都用 C# Java 之類的高階語言,C++ 用 MSXML 這個 COM Library 來做也是可以,反正一提到記錄檔是採用文字的形式,又是標籤語言,基本上就是難搞兩個字~~~
 
ㄎㄎ 夜風新年快樂 : )

另外我頁面中漂亮個程式碼上色怎麼辦到的呢?
恩 使用線上程式碼上色器就可以解決

http://tohtml.com/


2 則留言:

  1. 原來還有幫程式碼上色這招

    回覆刪除
  2. \/.b 當然啦 我可是很講究這種玩意的哩 程式碼要給人看 當然要舒服阿~~~

    回覆刪除