上一篇說完加減乘的優化,這篇來說說除法,首先先打個鋪墊,除法的優化涉及到各種數學公式,這裏我們主要探討一下結論,具體的證明可以參考《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