逆向-除法優化上

上一篇說完加減乘的優化,這篇來說說除法,首先先打個鋪墊,除法的優化涉及到各種數學公式,這裏我們主要探討一下結論,具體的證明可以參考《C++反彙編與逆向分析技術揭祕》,這裏做一個總結。

首先,如果除數爲變量時,是沒有任何的優化空間的,所以老老實實的上對應的彙編代碼即可,所以就不討論了。下面主要來討論下除數爲常量的情況,並且這個常量還是正數,負數情況下一篇博客介紹。

首先,先來看一下需要討論的情況

除數爲常量-除數爲正數情況
    1.除數爲2的冪-被除數無符號
    2.除數爲2的冪-被除數有符號
    3.除數爲非2的冪 - 被除數無符號	
    	根據Magic Number的是否需要進位(下面簡稱M)
            3.1 M值無進位
            3.2 M值有進位
    4.除數爲非2的冪 - 被除數有符號
    	根據Magic Number的正負情況(下面簡稱M)
            4.1 M值爲正數
            4.2 M值爲負數

這個情況是有那麼點多,不過其實總體算下來,應該就兩套公式,分別爲除數爲2的冪和除數爲非2的冪兩套,只是情況不同,有些公式的變種而已。還有對於Magic Number這個概念,後面會提的,這裏留個先個印象就好

首先,先來打一些基礎的鋪墊

1.乘法有無符號混合爲有符號乘法(IMUL)
2.除法有無符號混合爲無符號除法(DIV)
3.計算機中的除法爲向零取整
4.移位運算是向下取整(不大於的關係)

首先對於向上取整和向下取整,對於計算機的同學應該十分熟悉了,其實對於向零取整其實就是往中間的零值靠齊

3 / 2 = 1.5   //往零靠齊取1    3/2=1
-3 / 2 = -1.5 //往零靠齊取-1  -3/2=-1

還有對於第四點一定要理解,因爲在下面會有用到,移位運算,這裏說的更細一點吧,對於除法需要用到的就是右移運算,你可以這樣想,當二進制往右移動,那麼肯定有一些位需要丟失,那麼肯定是不大於的關係。

假設我們需要計算-5/2,那麼我們很容易想到,使用右移一位,那麼如下結果計算出來爲-3(因爲向下取整的原因),如果此時可以向上取整(向零取整),那麼就符合我們的數學運算結果了。

-5>>1
FFFFFFFB     >> 1
.......1011  >> 1
.......1101  -> FFFFFFFD = -3

首先,先來說下除數爲2的冪的情況,這裏我們可以使用右移,但是右移,對於負數的情況就會出現上面的問題,如下代碼

	for(int i=-20;i <= 20;++i)
	{
		printf("%d / 8 = %d\r\n",i,i/8);
		printf("%d >> 3 = %d\r\n\r\n",i,i>>3);
	} 

打印運算結果,其實可以發現,在負數中的確會有問題,那麼我們可以這樣子來調整一下,加上n-1的值,n爲除數

	for(int i=-20;i <= 20;++i)
	{
		printf("%d / 8 = %d\r\n",i,i/8);
		if(i >= 0)
			printf("%d >> 3 = %d\r\n\r\n",i,i>>3);
		else
			printf("%d >> 3 = %d\r\n\r\n",i,(i+8-1)>>3);
	} 

部分結果

-13 / 8 = -1
-13 >> 3 = -1

-12 / 8 = -1
-12 >> 3 = -1

-11 / 8 = -1
-11 >> 3 = -1

-10 / 8 = -1
-10 >> 3 = -1

此時可以發現,結果吻合了,那麼爲什麼加上n-1呢,你可以這樣子想,負數右移與實際數學運算不符合的原因是因爲右移向下取整了,那麼如何才能向上取整呢,其實我們先來加上一個n值,你會發現大部分情況是對的

printf("%d >> 3 = %d\r\n\r\n",i,(i+8)>>3);

不正確的情況如下:

-16 / 8 = -2
-16 >> 3 = -1

-8 / 8 = -1
-8 >> 3 = 0

當這個被除數可以整除時,這個結果就會大一,這個就應該很清楚了爲啥了,那麼如何避免呢,其實我們可以加上n-1的值,我們可以這樣子來考慮一下

假設原先的商爲q
如果可以被整除,加上 n-1,由於沒有加滿n,所以結果肯定還是q
如果不能被整除,加上 n-1,由於其值肯定會大於等於 (q+1) * n,所以結果爲 q+1
    因爲不能被整除,說明肯定有餘數(>0),那麼餘數加上n-1後,其值肯定>=n,所以商會+1

好了,道理講完了,其實這裏已有一個論證的公式,公式如下

我們來看一下紅框框中的那個公式,這個公式的意思的,a/b的向上取整 = 右邊部分的向下取整值。

這個公式我們從右往左看,就可以發現正好滿足我們的,右移向下取整調整到向上取整,如何調整呢?

也就是公式中橢圓圈中的部分,被除數加上(除數-1)值。

OK,下面我們就可以來看一下2有符號的情況了(1的情況無符號,說明都爲正數,那麼直接右移即可)

int main(int argc, char* argv[])
{
    printf("%d",argc/8);
    return 0;
}

首先,這裏對於1和2的情況,debug和release的情況都一致,所以拿一個進行討論其反彙編

00401298 8B 45 08             mov         eax,dword ptr [ebp+8]
0040129B 99                   cdq
0040129C 83 E2 07             and         edx,7
0040129F 03 C2                add         eax,edx
004012A1 C1 F8 03             sar         eax,3
004012A4 50                   push        eax

看完上面的彙編代碼,7這個值我們應該會有印象的,用於調整(加n-1),還有下面的右移三次也是知道的,只是奇怪的是,在我們上面的C源碼中,是有if條件判斷,也就是說這個公式只是用於負數情況的調整,那麼這裏的判斷分支呢,其實這裏的彙編代碼做了一個無分支判斷,下面具體來分析一下

00401298 8B 45 08             mov         eax,dword ptr [ebp+8]  ;取被除數

;擴展高位 if(eax >= 0)  edx=0  else edx = -1
0040129B 99                   cdq 

;and運算  if(eax >= 0)  edx=0  else edx = 0xFFFFFFFF & 7 = 7
0040129C 83 E2 07             and         edx,7  

;加上 edx if(eax >= 0)  add 0  else add 7
0040129F 03 C2                add         eax,edx

;調整完畢,進行右移取得結果
004012A1 C1 F8 03             sar         eax,3
004012A4 50                   push        eax

看完上面的代碼註釋,應該就明白了,對於正數而言,其調整需要加的值爲0,相當於不調整,這樣子就做了一個無分支判斷的優化。

 

 

下面再來說一說3和4的情況吧,這裏對於情況3和4而言(debug下不優化),我們來探討一下release的情況,其基本原理其實用到的都是同一套的公式。

假設x爲被除數,c爲除數(該值爲非2的冪),那麼由如下推導

這裏的推倒就畢竟容易了,首先推倒導到第一步,拿 1/c,此時會發現1和c都爲常量,那麼可以做常量摺疊,但是一旦做了常量摺疊,其結果非0即1,誤差太大。所以再進行調整,調整爲第三步的結構,此時n值給小了也不行,因爲小了可能會存在誤差。C越大n越大,編譯器有一套誤差規避的準則(n值又編譯器確定),那麼此時2^n / c就可在編譯期間算出,該值爲一個常量值,稱爲Magic Number。

那麼我們設2^n/c爲m,下面再來調整一下公式,使其更順眼一點

好了,下面的變形其實最終都是圍繞上面的這個式子展開的。

 

迷迷糊糊的話也沒事,我們先來看3.1的情況,這是最基礎的情況,基本上後面的變種都是以這種情況展開的,所以這種情況一定要理解哦。

int main(unsigned int argc, char* argv[])
{
    printf("%d",argc/3);
    return 0;
}

彙編代碼如下

.text:00401000                 mov     eax, 0AAAAAAABh  ;m值
.text:00401005                 mul     [esp+argc]       ;x*m
.text:00401009                 shr     edx, 1           ;右移
.text:0040100B                 push    edx

看完上面的代碼,然後再對比一下上面的公式,你會發現這段代碼是不是很像套公式,首先我們先來加上如果上述代碼就是公式,那麼我們該如何還原呢?

1.確定右移n的數值
2.確定m的數組
2.根據公式還原

  所以當我們確定下來n和m後,只需要套用後面的公式還原c即可

好了,看上面的彙編代碼,m值是很好確定的,就是0AAAAAAABh,那麼n呢,是1麼,顯然這裏肯定不是的,因爲前面有說過n值不會太小,因爲太小誤差會太大。

x*m = edx.eax
相當於edx存儲x*m的高32位
由於後面直接用了高32位的edx,相當於乘積>>32,此時再右移一位,相當於總共移動33位
.text:00401009                 shr     edx, 1           ;右移

注意,後面直接用了高32位的edx
.text:0040100B                 push    edx

所以這裏的n值應該是33,上面的需要好好理解一下,因爲最終使用的是edx,其實默認起步移動爲32位。下面可以還原了

// 2^33 / 0AAAAAAABh  = 2.99 -> 3
// argc/3

注意這裏的還原結果需向上取整即可。

 

 

下面再看一下3.2的情況,也就是M值有進位的情況

int main(unsigned int argc, char* argv[])
{
    printf("%d",argc/63);
    return 0;
}

彙編代碼

.text:00401000                 mov     ecx, [esp+argc]
.text:00401004                 mov     eax, 4104105h  ;m
.text:00401009                 mul     ecx            ;x*m
.text:0040100B                 sub     ecx, edx
.text:0040100D                 shr     ecx, 1
.text:0040100F                 add     ecx, edx
.text:00401011                 shr     ecx, 5
.text:00401014                 push    ecx

看完上面的代碼,估計只能分析前兩句,雖然樣子其實很像上面的公式,但是下面的代碼不知道是幹什麼的,這裏其實是上面的公式的一個變種,我們來根據其代碼來還原一下數學表達式。

此時ecx爲x(被除數),m值爲4104105h,後面的公式中都用ecx和m值進行代替

首先,前三行代碼很好理解

ecx * m -> edx.eax
    相乘,結果高32位存放在edx,低32位存放在eax

sub ecx,edx,說明減去的是乘積的高位    

shr ecx,1 相當於除以2                               

add ecx,edx,加上乘積高位                       

shr ecx,5 相當於除以2^5                             

好了,還原出的數學表達式就是上面的這個樣子,是不是一臉的矇蔽,這完全不像前面的基礎公式,下面我們來化簡一下

可以發現,對上面的式子做化簡後,我們原來熟悉的模板公式就出現了,此時的M = (2^32 + m)

那麼這裏爲什麼需要加上2^32呢,因爲其編譯器確定的N值推理出的Magic Number大於0xFFFFFFFF,32位存放不下的緣故,相當於產生的進位,但是編譯器只會控制進位一。

好了,因爲此時MagicNumber存放不下,所以需要進行大數運算,爲了優化除法而進行大數運算是很划不來的,所以編譯器作者就對該式子進行了簡而化繁(避免大數),這裏繁瑣的公式也就是一開始根據彙編反推的數學公式,開始的公式裏面是沒有大數運算的。

好了,說到這裏,m值我們就可以確定了,需要加上2^32,那麼如何確定n值呢,其實看懂了上面的推導,其實可以發現這個38其實也就是數右移了多少位,再加上32即可

;....
.text:0040100D                 shr     ecx, 1
;....
.text:00401011                 shr     ecx, 5  ;該右移可有可無

下面開始還原

// 2^38 / (2^32+4104105h) = 2^38 / 4363141381 = 62.99 -> 63
// argc / 63

 

下面開始分析第4種的情況,首先來看4.1,這裏位Magic Number 爲正數的情況,這種情況如果前面都理解的情況下是很好理解的,相當於是前面3.1的情況,然後需要加個一個移位的分支判斷,因爲前面說了,右移是向下取整的,所以我們得將結果進行向上調整。

int main(int argc, char* argv[])
{
    printf("%d",argc/5);
    return 0;
}

反彙編代碼

.text:00401000                 mov     ecx, [esp+argc]
.text:00401004                 mov     eax, 66666667h
.text:00401009                 imul    ecx    ;有符號
.text:0040100B                 sar     edx, 1
.text:0040100D                 mov     eax, edx  ;--
.text:0040100F                 shr     eax, 1Fh  ;--
.text:00401012                 add     edx, eax  ;--
.text:00401014                 push    edx

對比前面的3.1,就可以發現,其代碼只多出了三行,也就是0D-12的那三行,這三行主要用於負數情況,需要調整向上取整,看這三行代碼也是無分支的,所以也可以肯定這裏用到無分支優化。那麼我們還是使用上面的推導七麼,這裏的話很明顯不是,爲什麼呢,因爲推倒七是在移位以前做的調整,而此時調整很明顯是在移位後,所以我們使用推導三的結論

這個其實也很好理解,也就是說我們右移完(下整) + 1後結果就是上整了。這裏其實不理解的話可以自己帶一些數值進去測試即可。

;取結果的高32位
.text:0040100D                 mov     eax, edx 
;右移動31位,相當於取符號位  if(eax >= 0) eax = 0 else eax = 1
.text:0040100F                 shr     eax, 1Fh 
; if (eax >= 0) add 0 else add 1
.text:00401012                 add     edx, eax 

好了,由於多出來的這部分是用於調整的,所以其還原的公式是與3.1一致的,下面開始還原

// 2^33 / 66666667h = 4.99 -> 5
// -> argc / 5

 

 

OK,下面分析最後一種情況,也就是當Magic Number 爲負數的情況,我們先來看完彙編代碼後再來細說

int main(int argc, char* argv[])
{
    printf("%d",argc/7);
    return 0;
}

反彙編代碼

.text:00401000                 mov     ecx, [esp+argc]
.text:00401004                 mov     eax, 92492493h
.text:00401009                 imul    ecx
.text:0040100B                 add     edx, ecx  ;對比4.1就多出了這一行
.text:0040100D                 sar     edx, 2
.text:00401010                 mov     eax, edx
.text:00401012                 shr     eax, 1Fh
.text:00401015                 add     edx, eax
.text:00401017                 push    edx

首先,我們來看一下Magic Number,其值爲92492493h,可以發現其值的確大於了0x7FFFFFFF,也就是爲負數。那麼說到這裏其實大家可能就是有疑問了,在上面的公式意義上,Magic Number就是一個無符號數,那麼現在成了負數,是不是有問題呢?

是的,問題也就是出在這裏,所以我們需要解決如何將有符號數相乘轉爲無符號數相乘的問題?

對比4.1,其實就多出了一行代碼,那麼這行代碼的具體什麼作用呢

因爲這裏的有符號的情況,觀察其0x401009,用到也是有符號的imul,那麼在有符號數下,如果大於0x7FFFFFFF,其表示爲一個負數,而對於我們之前的除法數學模型來說,x*m,這裏Magic Number是一個無符號數,自然意義就不一樣了。所以其實多出來的這行add edx,ecx 其實用於調整的,使其還原乘以無符號數的本意。

設有符號數爲A,無符號數爲M(M在有符號數下爲負數),數據寬度爲WORD
neg(M) = (~M + 1)  //取反加一
neg(M) + M = ~M + 1 + M
neg(M) + M = 0xFFFF + 1
neg(M) + M = 0x10000
neg(M) = 0x10000 - M
任何數據在計算機中都是用二進制表示的,且都是用補碼錶示的,所以此時M的真值爲 -neg(M)

A*M(有) = A*-neg(M)
        = A*-(0x10000 - M)
        = A*(M - 0x10000)
        = A*M - A*0x10000
A*M(無) = A*M
    	= A*M - A*0x10000 + A*0x10000
    	= A*M(有) + A*0x10000

如果將M作爲無符號看看待,說明需要加上A*0x10000,這樣子最終結果就相同了
imul reg = edx.eax
此時的edx爲結果的高位,也就是10000起步,*A可調整爲 edx+A
Add edx,A

所以根據上面可以得出 A*M(有) + A * 0x10000 = A * M(無)。好了因爲上面驗證裏面的數據寬度爲WORD,然後在我們的例子裏是DWORD,所以我們需要加上的是 A * 2^32。

;edx = edx + ecx,也就是高32位加上ecx
;高32位加上ecx,其實也就相當於乘積加上了 ecx*2^32
.text:0040100B                 add     edx, ecx

這裏的ecx也就是上面的A。總的說來,因爲有符號相乘結果肯定會小,那麼我們將少的這部分加上就和無符號相乘一樣了。

下面我們可以使用任意一個數值來進行驗證下

0x14523687 * 0x98563221 = 0xC17A7F926996567  //計算器得出

打開OD進行寫入彙編驗證

可以發現,其edx.eax的拼接結果與上面的計算器中運算的結果一致。

好了,明白了4.2中多出來的這行代碼後,其實也就可以還原了,因爲4.2對於4.1來說,也是一個調整,相當於是有符號乘調整爲無符號乘,所以其基本的數學模型不變,按照4.1的方法還原即可

// 2^34 / 92492493h = 6.99 -> 7
// argc / 7

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章