程序源碼直接拿來給靜態分析器做靜態分析是不合適的,像編譯一樣也需要在程序的中間表示(IR)上進行分析,這樣能讓靜態分析算法比較簡潔、高效。IR也沒有絕對的標準,這節課學習的是絕大多數靜態分析器採取的IR。
1 編譯的基本流程
1.1 詞法分析
詞法分析(Lexical Analysis)會去檢查是否輸入的源代碼是若干合法單詞的組合。詞法分析器統稱Scanner,運行時需要關鍵字的詞典,以及用正則表達式描述的詞法規則。
詞法分析會爲每個合法的單詞生成Token。
1.2 語法分析
語法分析(Syntax Analysis)會去檢查這些Token的組合形式是否符合語法規則。語法分析器統稱Parser,相應的描述語法規則的方式是上下文無關文法(Context-Free Grammer)。Context-Free Grammer的表達能力比Context-Sensitive Grammer弱,但是對幾乎所有的編程語言而言,這樣的表達能力就足夠了,如果去使用後者反倒會更慢。
語法分析會生成抽象語法樹(AST)。
1.3 語義分析
語義分析(Semantic Analysis)會去檢查語義是否合理。和自然語言的語義不同,對編譯器而言,只具備檢查簡單的語義的功能,如類型檢查器(Type Checker),相應的進行類型檢查時描述規則的方法是Attribute Grammer。
語義分析會生成Decorate AST。
1.4 轉換
如果編譯器有編譯優化的功能,那麼就要通過轉換器(Translator) 轉換成中間表示(IR) 的形式,這裏的中間表示IR通常指的都是三地址碼形式(3-Address Form)。
生成IR之前的部分是編譯器前端,生成IR之後的部分是編譯器後端。編譯優化就是靜態分析的一種應用,靜態分析是在IR的基礎上做的,所以是屬於編譯器後端。這也說明了,要做靜態分析,就不得不走完編譯器前端的部分才能拿到IR,接着再在IR的基礎上做分析。
1.5 生成
爲了能執行,要將上一步的中間表示,通過代碼生成器(Code Generator) 生成機器碼(Machine Code)。
2 AST和三地址碼的比較
如對於一段簡單do-while循環的程序:
do {
i = i + 1;
} while(a[i] < v);
2.1 AST
表示成AST是這樣的:
- 比較高層,很貼合編程語言的文法結構
- 通常依賴於具體的編程語言(因爲和文法結構貼合)
- 很適合快速進行類型檢查
- 控制流信息很隱晦
2.2 三地址碼形式的IR
表示成三地址碼形式的IR是這樣的:
1: i = i + 1
2: t1 = a[i]
3: if t1 < v goto 1
- 比較低層,更貼合彙編語言和機器碼
- 通常和具體的編程語言是無關的
- 緊湊、簡潔、格式統一
- 能自然地看到控制流信息(如上面的
goto 1
) - 通常作爲靜態分析器IR的正統格式
3 三地址碼介紹
3.1 簡述
三地址碼(3-Address Code)也可簡稱3AC,其指令右側至多有一個運算符,如:
t2 = a + b + 3
可以轉換成:
t1 = a + b
t2 = t1 + 3
向三地址碼的轉換通常會引入臨時變量,這裏t1
就是臨時變量。
之所以叫三地址碼,是因爲它的每一條指令最多包含三個地址(Address),這裏的地址不是通常意義上的地址,而是下面中的一種:
- 變量,如
a
、b
- 常量,如
3
- 編譯器或靜態分析器自動生成的臨時變量,如
t1
、t2
3.2 三地址碼的理論形式
以下x
、y
、z
都是地址,即變量、常量或臨時變量。
x = y bop z
,這裏bop
是二元運算符或邏輯操作符x = uop y
,這裏uop
是一元操作符,如符、取反、類型轉換x = y
goto L
,即無條件跳轉if x goto L
,即條件跳轉if x rop y goto L
,其中rop
是關係運算符
這些是理論上的3AC的形式,具體到實現時要更復雜。
4 Soot的三地址碼IR——Jimple
Soot是爲Java服務的最有名的靜態分析器,Jimple是它所使用的中間表示,也是一種特殊的三地址碼的形式,下面是幾個Java源程序到Jimple三地址碼的例子。
4.1 for循環
注意,下面源程序中的x
因爲最後也沒用到,被Soot在得到AST時候就優化去掉了,得到的Jimple裏不再有這個變量。
在形參表裏面給出了方法參數的完整類型名,抹去了形參的名字。
第一行指令聲明字符串類型的r0
,接着聲明整形的i1
(以代替程序for循環中的i
)。
後面r0 := @parameter0: java.lang.String[];
表示變量r0
定義爲該方法的第一個參數,其類型是java.lang.String[]
。接下來爲i1
賦值爲0
。
然後在label1
中把這個for循環的行爲用三地址碼描述了(注意i1 = i1 + 1
對應的是程序裏的i++
而不是x = x + 1
),如果i1
達到10了就跳到label2
然後直接返回。
4.2 do-while循環
在這個例子裏args
對應r0
,數組arr
對應r1
,變量i
對應i1
(因爲在while條件中被使用所以不能將其消除),而arr[i]
對應臨時變量$i0
也即r1[i1]
。
注意在這個do-while例子的三地址碼中可以看到label1
下總是先執行i1 = i1 + 1
,這和do-while的語義是一致的。
4.3 方法調用(方法內部)
例子中這個方法是一個成員方法,這裏三地址碼中的r0
被定義爲this的指向,即例子中承載這個函數的MethodCall3AC這個類的對象。
函數的兩個參數para1
和para2
分別對應r1
和r2
。爲了完成函數中三個字符串拼接的操作,創建了一個臨時的StringBuilder$r3
,然後調用append
操作將變量r1
拼進來得到$r4
,再調用append
操作將常量" "
拼進來得到$r5
,再調用append
操作將變量r2
拼進來得到$r6
,最後用toString()
將其轉換成String對象$r7
,並將其返回。
在上面的Jimple代碼中還出現了specialinvoke
和virtualinvoke
,這是JVM裏四種主要方法調用中的兩種,這四種命令是:
- invokespecial:用於調用構造方法、父類方法、私有方法
- invokevirtual:用於調用普通的成員方法,進行virtual dispatch
- invokeinterface:用於調用繼承的接口的方法,不能做優化,需要檢查是否實現了接口中的方法
- invokestatic:用於調用靜態方法
在Java7之後還引入了invokedynamic,用來更方便的實現動態類型的語言在JVM上運行。
在上面的Jimple代碼中方法調用(invoke)的地方,尖括號<
和>
之間的內容是方法簽名(Method Signature),它一般會包含方法所在的類名、方法的返回值類型、形參列表中各個參數的類型,有些還會包含方法名(比如上面的例子裏append就是方法名)。
4.4 方法調用(調用方)
這裏還是4.3
例子的程序,但是是方法的調用方(即主函數部分)的Jimple。
可以看到同樣是r0
聲明爲主函數形參args
,然後$r3
是對象mc
,先用specialinvoke
調用構造方法把這個對象構造出來,然後用virtualinvoke
調用了方法foo,把兩個字符串參數傳了進去。
注意,因爲源程序中調用foo的返回變量result
沒有用到所以被Soot優化消除了,對應的Jimple裏也就沒有將調用的返回值保存到變量裏了。
4.5 類
這裏類代碼的Jimple不僅要將類名寫完整,還要將繼承的類顯式寫出來,以保全語義信息。
源程序中沒有顯式給出構造函數,Jimple中的<init>
是默認生成的構造函數,然後$r0
指向this,再用specialinvoke
調用其父類(這裏是Object
,見方法簽名)的構造函數。
接下來靜態的main
方法源程序中是空的,但也要將形參定義以下,這裏r0
對應args
。
接下來靜態的<clinit>
方法是類的靜態的初始化方法,當類被初次加載到內存裏時,就是通過調用這個<clinit>
方法來將所有的靜態屬性初始化。例子中就是將pi
初始化爲3.14
這個值,至於pi
的聲明在最上方。
5 靜態單賦值(Static Single Assignment)
5.1 簡述
這是一種經典的IR格式,SSA和普通3AC的區別在於它給每一個定義都使用了新的名稱:
左側3SA中第1/3/4行的p
在右側SSA中使用了不同的名稱p1
/p2
/p3
,而第2/5行的兩個q
也被改成了不同的名稱q1
/q2
,在賦值右側則用前面出現的名稱進行對應,不會改變整個程序的語義。
5.2 phi-function
這樣使用了不同名稱之後,爲了保證每個變量還是有唯一的定義而不會產生歧義,在控制流匯合的地方使用函數進行聚合,例如:
圖中當程序走左邊時就取,走右邊時就取,它有專門的分析算法。
5.3 優缺點
- 它可以將flow-sensitive的一條條指令變成flow-insensitive的,也就是對指令的順序不再敏感,這樣flow-insensitive的分析可以提高分析的速度,同時較好保持flow-sensitive的精度,因爲它本身就帶有了一些flow的信息
- 能更精確地找到其定義的地方,因爲每條都是新的定義
- 引入了太多的變量,如果有太多分叉還會引入很多phi-function
- 轉換到機器碼的過程會有很多低效的地方
6 控制流圖的構建
三地址碼最終是在控制流圖(Control Flow Graph) 上進行分析,這裏學習如何從3AC轉換到CFG。
在CFG中若干條指令一起組成Basic Block,作爲圖上結點存在。
6.1 基本塊(Basic Block)
基本塊是滿足下列性質的,最大的連續指令的有序集合。
- 只能有一個入口,爲其第一條指令,不存在從另一個位置進入BB的控制流
- 只能由一個出口,爲其最後一條指令,不存在從BB中間跳出去的情況
特別注意,滿足這兩條性質的Block,只要不是最大的連續指令集,就不能成爲BB,所以實際程序中每條指令最終所在的BB總是情況唯一的。
6.2 BB的構建思路
-
如果一條指令是程序中某個跳轉的目標,那麼它一定是一個BB的入口,否則會違反規則1。
-
如果一條指令包含跳轉操作,那麼它一定是一個BB的出口,否則會違反規則2。
這裏老師講的是,如果一條指令緊跟着一條含有跳轉操作的指令,那麼它一定是一個BB的入口,和這個的意思是一樣的,因爲一條指令是BB出口,它的下一條指令一定是下一個BB的入口。
6.3 BB的構建算法
先找整個程序中BB開始的指令,標記爲Leader:
- 程序第一條指令
- 跳轉的目標指令
- 帶跳轉的指令的下一條指令
接着從每個Leader開始往下,直到下一個Leader或者程序結尾之前,合起來是一個BB。
例如:
圖中標紅的就是找出的Leader。
6.4 完整CFG的構建
接下來就是在前面構造好的BB之間添加邊,很直觀:
- 兩個BB順次相連,加邊
- 從A無條件跳轉B,加邊
- 從A條件跳轉到B,加邊
注意,這裏規則1有例外,如果前一個BB的最後一條指令是無條件的goto,那麼不要按照規則1加邊。
在構建邊的同時,將指令中,goto的目標從指令標號變換成BB的名字:
最終添加Entry
和Exit
兩個特殊結點,即得到這小節最開始圖上的CFG。