算術運算通常是指,加減乘除四則運算,而計算機中的四則運算與數學中的有所不同,同樣是實現算術運算,高級語言與匯編語言的實現思路完全不同,往往一個簡單的減法運算,都要幾條指令的配合才能得出計算結果,而為了保證程序的高效率,編譯器會對其進行最大限度地優化,這就涉及到匯編代碼的逆推,如下筆記則是整理的逆推常用手法。
一般VS系列編譯器對代碼的優化有兩種方案,O1方案則可生成占用空間最小的文件,O2方案則注重執行效率最快,編譯器在Release模式下會采用O2方式對代碼效率進行優化,所以我們有必要好好研究一下其到底將代碼優化成了啥樣子,這里為了方便演示我會使用匯編語言模擬編譯器生成代碼的思路。
加法優化/減法優化
加法常量優化: 當計算結果中出現了兩個常數相加的情況,且中間該變量沒有被改變過,則就會被優化掉。
#include <stdio.h>
#include <windows.h>
int main(int argc,char * argv[])
{
int x = 10;
int y = 20;
printf("%d \n", x + y);
return 0;
}
如下,我們只看到了一個push 0x1E
編譯器發現我們的x + y
是兩個常數,則為了效率,直接將結果計算出來打印了。
如果我們將代碼這樣寫,那么加法運算將不會被優化掉,因為編譯器無法確定,表達式的結果,只能運行后動態計算。
#include <stdio.h>
#include <windows.h>
int main(int argc,char * argv[])
{
int x = 10;
int y = 0;
for (int y = 0; y < 10; y++)
{
printf("%d \n", x + y);
}
return 0;
}
如下是反匯編后得到的結果,通常情況下加法指令是ADD運算才對,下方代碼中並沒有出現Add指令,我們的加法計算其實是轉化為了lea eax, ds:[esi+0xA]
,這條指令不僅可以取地址,還可以用來計算加減等運算,lea指令允許用戶在一個時鍾周期內完成加減法的計算過程,其效率遠比add,sub指令高。
這里我直接使用匯編語言來模擬實現編譯器對加減法的實現流程,如下代碼所示。
.data
x DWORD ?
y DWORD ?
szFmt BYTE '計算結果: %d',0dh,0ah,0
.code
main PROC
; 針對加法的lea指令優化
mov dword ptr ds:[x],5
mov dword ptr ds:[y],3
mov eax,dword ptr ds:[x]
mov ebx,dword ptr ds:[y]
mov eax,dword ptr ds:[x]
lea eax,dword ptr ds:[eax + 3] ; eax = eax + 3
invoke crt_printf,addr szFmt,eax
mov eax,dword ptr ds:[x]
lea eax,dword ptr ds:[eax + ebx + 2] ; eax = eax + ebx + 2
invoke crt_printf,addr szFmt,eax
; 針對減法的lea指令優化
mov dword ptr ds:[x],6
mov eax,dword ptr ds:[x]
lea eax,dword ptr ds:[eax - 2] ; eax = eax - 2
invoke crt_printf,addr szFmt,eax
; 加減法混合優化
mov eax,10
mov ebx,15
lea ebx,dword ptr ds:[eax + ebx - 3 ] ; ebx = eax + ebx - 3
invoke crt_printf,addr szFmt,ebx
invoke ExitProcess,0
main ENDP
END main
接着我們來完成一個三數相加的案例,比如說將x+y+100
的結果輸出,匯編代碼如下。
.data
x DWORD ?
y DWORD ?
szFmt BYTE '計算結果: %d',0dh,0ah,0
.code
main PROC
mov dword ptr ds:[x],-5
mov dword ptr ds:[y],3
mov eax,dword ptr ds:[x]
mov ebx,dword ptr ds:[y]
lea ecx,dword ptr ds:[eax + ebx + 100]
invoke crt_printf,addr szFmt,ecx
invoke ExitProcess,0
main ENDP
END main
加減法中的常量傳播: 將編譯期間可計算出結果的變量轉換成常量,這樣就減少了變量的使用,如下,由於printf輸出的是一個常量,則編譯器會對其進行處理,在編譯期間計算出變量結果后,直接使用常量10來代替,於是printf("value => %d \n", x);
等價於printf("value => %d \n", 10);
.
int main(int argc,char * argv[])
{
int x = 10;
int y = 0;
printf("value => %d \n", x);
return 0;
}
加減法中的常量折疊: 當計算公式中存在多個常量進行計算時,且編譯器可以在編譯時動態計算出結果,這樣源碼中所有的常量計算過程將會被替換掉。
int main(int argc,char * argv[])
{
int x = 10;
int y = 0;
int value = 1 + 2 * 3 + 7;
printf("value => %d \n", value);
return 0;
}
如上代碼中,我們的計算表達式在整個程序運行期間沒有發生過變化,則VS編譯器在開啟O2優化后,會首先計算出int value = 1 + 2 * 3 + 7;
表達式的值並將其替換成一個常量值,在打印函數中直接打印計算后的結果,編譯器會刪除計算的變量,直接替換為常量。
常量折疊+常量傳播: 如下代碼中由於nVarOne = nVarOne + 1;
計算結果是一個常量,則在編譯會會被直接替換掉,第二句則滿足常量折疊,計算后保留常量值3,最后的加法nVarOne + nVarTwo;
雖然在加兩個變量,但變量數值未發生變化,同樣會被優化為常量.
int main(int argc,char * argv[])
{
int nVarOne = 0;
int nVarTwo = 0;
// 變量加常量加法運算
nVarOne = nVarOne + 1; // 0+1 變為 nVarOne = 1
nVarOne = 1 + 2; // 常量折疊
// 兩個常量相加的加法運算
nVarOne = nVarOne + nVarTwo;
printf("value = > %d \n", nVarOne);
return 0;
}
如果我們將初始化參數通過命令行獲取的話,由於argc在編譯期間無法被確定,所以編譯器無法在編譯時計算出結果,那么程序中的變量將不會被常量替換掉,依然執行加法或減法運算。
int main(int argc,char * argv[])
{
int nVarOne = argc;
int nVarTwo = argc;
nVarOne = nVarOne + 1;
nVarOne = nVarOne + nVarTwo;
printf("value = > %d \n", nVarOne);
return 0;
}
減法計算轉加法: 減法計算通常使用sub來時間,但計算機只會做加法,如果想要計算減法,只需要通過補碼轉換將減法轉換為加法來計算即可,例如加一個負數同樣也相當於減去一個正數。
例如: 5-2 可轉換成 5 + (0-2) => 5 + (2(取反)+1) => 5 + 2 補
int main(int argc,char * argv[])
{
int nVarOne = 0;
int nVarTwo = 0;
// 防止被優化
scanf("%d", &nVarOne);
scanf("%d", nVarTwo);
nVarOne = nVarOne - 10;
nVarOne = nVarOne + 5 - nVarTwo;
printf("varOne = %d \n", nVarOne);
return 0;
}
在某些編譯器中,減法運算會通過加法來實現,其實現匯編代碼是這個樣子的,在實際逆向過程中,加法與減法可以相互轉換,只要得到的結果是正確的均可。
.data
x DWORD ?
y DWORD ?
szFmt BYTE '計算結果: %d',0dh,0ah,0
.code
main PROC
; 針對加法的lea指令優化
mov dword ptr ds:[x],100
mov dword ptr ds:[y],3
mov eax,dword ptr ds:[x]
; 例如 100 - 10 可轉換為 => 100 + (-10)
mov eax,dword ptr ds:[x]
add eax,0FFFFFFF0h
invoke crt_printf,addr szFmt,eax
invoke ExitProcess,0
main ENDP
END main
乘法優化
在匯編語言中,乘法指令通常是使用mul/imul來計算,其分別針對的是無符號與有符號乘法,由於乘法指令在執行時所消耗的時鍾周期較長,所以在編譯時,編譯器會先嘗試將其轉換為加法,或者使用shr/shl等移位指令來替換,當兩者都無法進行優化時,才會使用原始的乘法指令計算。
使用LEA指令替換乘法: 使用lea計算乘法運算時,必須要保證乘數是2的次冪,而且范圍必須是2/4/8
.data
x DWORD ?
y DWORD ?
szFmt BYTE '計算結果: %d',0dh,0ah,0
.code
main PROC
; 針對乘法的lea指令優化
mov dword ptr ds:[x],5
mov dword ptr ds:[y],3
mov eax,dword ptr ds:[x]
xor ebx,ebx
lea ebx,dword ptr ds:[eax * 8 + 2] ; ebx = eax * 8 + 2
invoke crt_printf,addr szFmt,ebx
invoke ExitProcess,0
main ENDP
END main
如果我們計算的乘法超出了該范圍,則需要對乘法進行拆分,拆分時也應遵循2的次冪,拆分后在分開來計算,如下我們需要計算 15 * eax
的結果,拆分過程如下。
.data
x DWORD ?
y DWORD ?
szFmt BYTE '計算結果: %d',0dh,0ah,0
.code
main PROC
; 針對乘法的lea指令優化
mov dword ptr ds:[x],5
mov dword ptr ds:[y],3
; 如果使用lea計算乘法,則乘數必須是2/4/8
mov eax,dword ptr ds:[y] ; eax = 3 => 計算 15 * eax
lea edx,dword ptr ds:[eax * 4 + eax] ; edx = 4eax + eax => 5eax
lea edx,dword ptr ds:[edx * 2 + edx] ; edx = 5eax * 2 + 5eax => 15eax
invoke crt_printf,addr szFmt,edx ; edx = eax * 15 = 45
invoke ExitProcess,0
main ENDP
END main
如果計算乘法時乘數非2的次冪,這種情況下需要減去特定的值,例如當我們計算eax * 7
時,由於7非二的次冪,我們無法通過lea指令進行計算,但我們可以計算eax * 8
計算出的結果減去一個eax同樣可以得到正確的值,例如計算eax * 7 + 10
的結果。
.data
x DWORD ?
y DWORD ?
szFmt BYTE '計算結果: %d',0dh,0ah,0
.code
main PROC
; 針對乘法的lea指令優化
mov dword ptr ds:[x],5
mov dword ptr ds:[y],3
; 如果計算乘法時乘數非2的次冪,則此時需要減
mov eax,dword ptr ds:[y] ; eax = 3 => 計算 eax * 7 + 10
lea edx,dword ptr ds:[eax * 8] ; edx = eax * 8
sub edx,eax ; edx = edx - eax
add edx,10 ; edx = edx + 10
invoke crt_printf,addr szFmt,edx ; edx = eax * 7 + 10
mov eax,dword ptr ds:[y] ; eax = 3 => 計算 eax * 3 - 7
lea edx,dword ptr ds:[eax * 2] ; edx = eax * 2
add edx,eax ; edx = edx + eax
sub edx,7 ; edx = edx - 7
invoke crt_printf,addr szFmt,edx ; edx = eax * 3 - 7
invoke ExitProcess,0
main ENDP
END main
通過使用邏輯與算數,左移,同樣可以實現2的次冪的高速乘法運算,如果滿足特定條件,編譯器生成的代碼就會呈現出以下案例中所描述的代碼特點。
.data
x DWORD ?
y DWORD ?
szFmt BYTE '計算結果: %d',0dh,0ah,0
.code
main PROC
mov dword ptr ds:[x],-5
mov dword ptr ds:[y],3
; 邏輯左移(無符號乘法)
; 次方表: 1=>2 2=>4 3=>8 4=>16 5=>32 6=>64 7=>128
; 次方表: 8=>256 9=>512 10=>1024 11=>2048 12=>4096 13=>8192 14=>16384
mov eax,dword ptr ds:[y]
shl eax,1 ; eax = eax * 2 ^ 1 eax * 2
invoke crt_printf,addr szFmt,eax
mov eax,dword ptr ds:[y]
shl eax,2 ; eax = eax * 2 ^ 2 eax * 4
invoke crt_printf,addr szFmt,eax
mov eax,dword ptr ds:[y]
shl eax,3 ; eax = eax * 2 ^ 3 eax * 8
invoke crt_printf,addr szFmt,eax
; 算數左移(有符號乘法)
mov eax,dword ptr ds:[x]
sal eax,1 ; eax = eax * 2^1 eax * 2
invoke crt_printf,addr szFmt,eax
mov eax,dword ptr ds:[x]
sal eax,2 ; eax = eax * 2^2 eax * 4
invoke crt_printf,addr szFmt,eax
invoke ExitProcess,0
main ENDP
END main
乘法優化的基本就這些知識點,除了兩個未知變量的相乘無法優化外,其他形式的乘法運算均可以進行優化,如果表達式中存在一個常量值,那編譯器則會匹配各種優化策略,最后對不符合優化策略的運算進行調整,如果真的無法優化,則會使用原始乘法指令計算。
除法優化
通常情況下計算除法會使用div/idiv這兩條指令,該指令分別用於計算無符號和有符號除法運算,但除法運算所需要耗費的時間非常多,大概需要比乘法運算多消耗10被的CPU時鍾,在Debug模式下,除法運算不會被優化,但Release模式下,除法運算指令會被特定的算法經過優化后轉化為為乘法,這樣就可以提高除法運算的效率。
關於除法運算總結
- 如果被除數是一個未知數,那么編譯器無法確定數值,則編譯器會使用原始的div命令計算,程序的執行效率會變低。
- 如果除數是二的次冪,那么可以將其轉化為處理速度快的 shr a,n 指令,該指令的執行只需要1個時鍾周期,效率最高。
- 若進行二的次冪,有符號運算,則只需要使用 sha 進行快速除法運算。
除數為正2的次冪優化(無符號): 如果除數為2的次冪,那么就會使用移位運算替代除法運算,2的次冪還原非常容易,只需要找到移位次數即可得出除以的是多少。
.data
x DWORD ?
y DWORD ?
z DWORD ?
szFmt BYTE '計算結果: %d',0dh,0ah,0
.code
main PROC
mov dword ptr ds:[x],5
; ----------------------------------------------------
; 【除數為2的優化方式】
; 被除數為正數(無需擴展): eax => 5 / 2 = 2
mov eax,dword ptr ds:[x] ; 被除數
sar eax,1 ; 算數右移
invoke crt_printf,addr szFmt,eax
; ----------------------------------------------------
; 【除數為4的優化方式】
; 被除數為正數(無需擴展): eax => 5 / 4 = 1
mov eax,dword ptr ds:[x]
sar eax,2
invoke crt_printf,addr szFmt,eax
; ----------------------------------------------------
; 【除數為8的優化方式】
; 被除數為正數(無需擴展): eax => 5 / 8 = 0
mov eax,dword ptr ds:[x]
sar eax,3
invoke crt_printf,addr szFmt,eax
invoke ExitProcess,0
main ENDP
END main
除數為負2的次冪優化(有符號): 當除數為負數時,且為2的次冪的情況下,編譯器生成代碼時這樣的,其還原方式為取得shr eax,xx
中的次數,與被除數相除,最后neg取反即可。
.data
x DWORD ?
y DWORD ?
z DWORD ?
szFmt BYTE '計算結果: %d',0dh,0ah,0
.code
main PROC
mov dword ptr ds:[x],5
mov dword ptr ds:[y],10
mov dword ptr ds:[z],-10
; 除數為(有符號)負2的次冪的計算過程
mov eax,dword ptr ds:[y] ; y = 10
cdq ; 符號擴展edx : eax
sub eax,edx ; 減去符號位
sar eax,1 ; eax = 10 / -2
neg eax ; 將正數 eax 翻轉為負數 = -5
invoke crt_printf,addr szFmt,eax
mov eax,dword ptr ds:[y] ; y = 10
cdq
and edx,3
add eax,edx
sar eax,2 ; eax = 10 / -4
neg eax ; eax = -2
invoke crt_printf,addr szFmt,eax
mov eax,dword ptr ds:[z] ; z = -10
cdq
and edx,7
add eax,edx
sar eax,3 ; eax = -10 / -8
neg eax ; eax = 1 (負負得正)
invoke crt_printf,addr szFmt,eax
invoke ExitProcess,0
main ENDP
END main
除數為負數的優化(無符號): 如果被除數是一個負數,除數依然是2的次冪,則此時計算后只需要去掉neg取反即可得到正確結果,逆推方式同除數為負2的次冪優化
保持一致。
.data
x DWORD ?
y DWORD ?
z DWORD ?
szFmt BYTE '計算結果: %d',0dh,0ah,0
.code
main PROC
mov dword ptr ds:[x],-5
mov dword ptr ds:[y],10
mov dword ptr ds:[z],-10
; 被除數為(有符號)的計算過程
mov eax,dword ptr ds:[z]
cdq
sub eax,edx
sar eax,1 ; eax = -10 / 2
invoke crt_printf,addr szFmt,eax
mov eax,dword ptr ds:[x]
cdq
and edx,3
add eax,edx
sar eax,2 ; eax = -5 / 4
invoke crt_printf,addr szFmt,eax
mov eax,dword ptr ds:[z]
cdq
and edx,7
add eax,edx
sar eax,3 ; eax = -10 / 8
invoke crt_printf,addr szFmt,eax
; 如果同時為負數的情況
mov eax,dword ptr ds:[z] ; z = -10
cdq
and edx,7
add eax,edx
sar eax,3 ; eax = -10 / -8
neg eax ; eax = 1 (負負得正)
invoke crt_printf,addr szFmt,eax
invoke ExitProcess,0
main ENDP
END main
除數為正非2的次冪優化(有符號): 上方的除法運算被除數均為2的次冪,除數的范圍也被限定在了2/4/8這樣的范圍之內,如下是計算非2的次冪的計算方式,如果需要知道除數是多少則可以使用公式2^(32+n) / M
計算后得出.
.data
x DWORD ?
y DWORD ?
z DWORD ?
szFmt BYTE '計算結果: %d',0dh,0ah,0
.code
main PROC
mov dword ptr ds:[x],5
mov dword ptr ds:[y],10
mov dword ptr ds:[z],-10
; 除法(有符號)非2的冪轉換為乘法
mov ecx,dword ptr ds:[y] ; 被除數 ecx = 10 / 3 = 3
mov eax,055555556h ; eax = M值 1431655766
imul ecx
mov eax,edx ; edx = n 計算: 2^(32+n) / M
shr eax,01fh ; 計算出除數為 2.9999 => 3
add edx,eax
invoke crt_printf,addr szFmt,edx
mov ecx,dword ptr ds:[y] ; ecx = 10 / 5 = 2
mov eax,066666667h ; 此處的M模值是編譯器計算后得到的
imul ecx
sar edx,1 ; 想要知道除數是多少,只需要
mov eax,edx ; 2^(32 + edx) / M = 2^33 / 66666667 = 5
shr eax,01fh
add edx,eax
invoke crt_printf,addr szFmt,edx
mov ecx,dword ptr ds:[y] ; ecx = 10 / 6 = 1
mov eax,02AAAAAABh ; eax = 715827883
imul ecx
mov eax,edx ; 2^(32 + edx) / M = 2^32 / 2AAAAAAB = 6
shr eax,01fh
add edx,eax
invoke crt_printf,addr szFmt,edx
mov ecx,dword ptr ds:[z] ; ecx = -10 / 9 = -1
mov eax,038E38E39h ; eax = 954437177
imul ecx
sar edx,1 ; 2^(32 + edx) / M = 2^33 / 38E38E39 = 9
mov ecx,edx
shr ecx,01fh
add edx,ecx
invoke crt_printf,addr szFmt,edx
invoke ExitProcess,0
main ENDP
END main
先來看第一段匯編代碼,我們此時已知M = 055555556h 且 edx = N
帶入公式2^(32+n) / M
,由於edx沒有變化所以此處應計算2^32 / 055555556h = 2.9999
即可計算出此處是除以的3
mov ecx,dword ptr ds:[y] ; 被除數
mov eax,055555556h ; M值 => 此處的M模值是編譯器計算后得到的
imul ecx
mov eax,edx ; edx = N
shr eax,01fh
add edx,eax
invoke crt_printf,addr szFmt,edx
再來看另一段,如下所示,這段代碼中sar edx,1
edx的值發生過一次變化,所以公式中應該加上變化的一次計算得到 2^33 / 66666667 = 5
除數是5
mov ecx,dword ptr ds:[y] ; ecx = 10 / 5 = 2
mov eax,066666667h ; 此處的M模值是編譯器計算后得到的
imul ecx
sar edx,1 ; 想要知道除數是多少,只需要
mov eax,edx ; 2^(32 + edx) / M = 2^33 / 66666667 = 5
shr eax,01fh
add edx,eax
invoke crt_printf,addr szFmt,edx
除數為正數非2的次冪優化(無符號): 上方代碼中的除法計算是針對有符號數進行的,如果是針對無符號數則需要以下方式計算.
.data
x DWORD ?
y DWORD ?
z DWORD ?
szFmt BYTE '計算結果: %d',0dh,0ah,0
.code
main PROC
mov dword ptr ds:[x],-5
mov dword ptr ds:[y],10
mov dword ptr ds:[z],20
; 除法(無符號)非2的次冪(正數)轉換為乘法
xor edx,edx
mov ecx,dword ptr ds:[y] ; ecx = 10
mov eax,0AAAAAAABh ; ecx / 3 = 3
mul ecx
shr edx,1
invoke crt_printf,addr szFmt,edx
; 還原除數: 2 ^(32 + n) / M => 2 ^ (32+2) / 0CCCCCCCDh = 5
xor edx,edx
mov ecx,dword ptr ds:[y] ; ecx = 10 => 計算: 10/5
mov eax,0CCCCCCCDh ; eax = M
mul ecx
shr edx,2 ; edx= n
invoke crt_printf,addr szFmt,edx
; 還原除數: 2 ^(32 + n) / M => 2 ^ (32+2) / 0AAAAAAABh = 6
xor edx,edx
mov ecx,dword ptr ds:[y] ; ecx = 10 => 計算:10/6
mov eax,0AAAAAAABh ; eax = M
mul ecx
shr edx,2 ; edx = n
invoke crt_printf,addr szFmt,edx
;還原除數: 2 ^(32 + n) / M => 2 ^ 33 / 038E38E39h = 9
xor edx,edx
mov ecx,dword ptr ds:[z] ; ecx = 20 => 計算: 20/9
mov eax,038E38E39h ; eax = M
mul ecx
shr edx,1 ; edx = n
invoke crt_printf,addr szFmt,edx
invoke ExitProcess,0
main ENDP
END main
除數為負數非2的次冪優化(無符號):
.data
x DWORD ?
y DWORD ?
z DWORD ?
szFmt BYTE '計算結果: %d',0dh,0ah,0
.code
main PROC
mov dword ptr ds:[x],-5
mov dword ptr ds:[y],10
mov dword ptr ds:[z],20
; 還原除數: 2 ^(32 + n) / M => 2 ^ 33 / 0AAAAAAABh = nge(3) => -3
xor edx,edx
mov ecx,dword ptr ds:[z] ; ecx = 20 => 計算: 20/-3
mov eax,0AAAAAAABh ; eax = M
mul ecx
shr edx,1 ; edx = n
neg edx ; edx=6 結果neg取反
invoke crt_printf,addr szFmt,edx
; 還原除數: 2 ^(32 + n) / M => 2 ^ 62 / 040000001h = 4294967292
xor edx,edx
mov ecx,dword ptr ds:[y] ; ecx = 10 => 計算: 10 / -3
mov eax,040000001h ; eax = M
mul ecx
shr edx,01eh ; edx = n
invoke crt_printf,addr szFmt,edx
invoke ExitProcess,0
main ENDP
END main
看一下64位除法,編譯后載入觀察,一摸一樣,不用再寫了。
#include <stdio.h>
#include <windows.h>
int main(int argc,char * argv[])
{
int x, y, z;
scanf("%d", &x);
z = x / 3;
printf("%d \n", z);
z = x / 5;
printf("%d \n", z);
return 0;
}
看看 z = x / 5 + 3 - 2;
優化后變成了 z = x / 5 + 1
夠毒。
繼續改改。
int main(int argc,char * argv[])
{
int x, y, z;
scanf("%d", &x);
z = x / 3;
printf("%d \n", z);
z = x / 5 + 3 - 2;
y = z * x + 4;
printf("%d \n", y);
return 0;
}
再來看看64位版無符號數。
int main(int argc,char * argv[])
{
unsigned int x, y, z;
scanf("%d", &x);
y = x / -3;
printf("%d \n", y);
z = x / -5;
printf("%d \n", z);
return 0;
}
還原除數:2^32+1E / 0x40000001 = 2^62 / 1073741825 = 4294967292.0000000037252902949925 = FFFFFFFC
還原除數:2^32+0x1F / 0x80000003 = 2^63 / 2147483651 = 4294967290.0000000083819031598299 = FFFFFFFA