前言
本文翻譯自Assembly Register Calling Convention Tutorial
翻譯的不對的地方還請多多包涵指正,謝謝~
匯編寄存器調用約定教程
在本篇教程,你將看到CPU使用的寄存器并探索改變傳入函數的參數。你將學到普通的蘋果計算機架構及在函數內寄存器是如何使用的。這就是被稱為架構的調用約定。
了解匯編如何工作及特定架構的調用約定的工作方式是一項非常重要的技能。它能讓你觀察沒有源碼的函數參數,并改變其參數。除此外,有些時候探尋匯編層面可能更好,因為你的源碼可能有不同的或者未知的參數名稱。
例如,假設你總是希望知道函數的第二個參數是什么,而不需要知道它的參數名稱。匯編知識能讓你在站在一個極佳的層面來操縱或觀察函數參數。
匯編101
等等,那么匯編又是甚?
你曾在沒有源碼的函數面前束手無策,或看到惡心的內存地址后面跟著一個可怕的短命令?是否曾躲在一個球內悄悄告訴自己再也不要看到這些密集的東西?好吧。。。這些就是所謂的匯編。
下面是一張Xcode的回溯圖,展示的是模擬器內一個函數的匯編。
看以上圖片,匯編可以分成若干部分。每行匯編指令包含一個操作符,可以把它想象成計算機的一個極簡的操作指令。
操作符是什么?它是計算機中可以執行簡單任務的指令。例如,看如下匯編片段:
pushq %rbx
subq $0x228, %rsp
movq %rdi, %rbx
在這段匯編塊中,可以看到有三個操作符:pushq, subq, movq
。將其當做要執行的動作。操作符后面的是源或結果標簽。這些就是操作符要操縱的對象。
上述例子中,有幾個寄存器,用rbx,rsp,rdi,rbp
來表示。%
作為前綴表明這是一個寄存器。
此外,你也會發現一個十六進制的數字常量:ox228
。$
表明這個常量是一個絕對數值。
沒必要知道這些代碼正在做什么事,因為首先你首先需要學習寄存器和函數調用約定。
注:上述例子中,注意在寄存器和常量之前有一堆
% $
這樣的字符。這是反匯編程序如何格式化程序的。但是,有兩種主要的方式來展現匯編。第一個時Intel
,第二個是AT&T
匯編。默認地,蘋果反匯編工具帶有
AT&T
匯編格式的程序,就如上面的例子一樣。盡管這是一個很好的工作格式,但誠然令人頭疼。
x86_64 vs ARM64
作為蘋果平臺的開發者,在學習匯編中你會遇到兩種主要的架構:x86_64和ARM64架構。x86_64架構一般使用的macOS的電腦上,除非你還在使用古老的Mac。
x86_64是64位的架構,意味著每個地址都持有了64個1或者0位。老款Mac使用的是32位架構,但蘋果在2010年就停止使用32位的了。在macOS下的程序都是64位兼容的,包括模擬器。也就是說,x86_64系統可以跑32位的應用程序。
如果你對現有硬件系統架構有任何疑問,可以通過以下shell命令獲取計算機的硬件架構:
uname -m
ARM64架構被使用在移動設備上比如你的iPhone,在這些設備中的能量消耗限制非常嚴格。
ARM架構強調能量保護,因此相對復雜匯編指令,它有消減過后的操作集來幫助其減少能量消耗。這對你來說是好事,因為這意味著在ARM架構中你要學習的指令更少。
以下是以上同一個函數的截圖,只不過這次是iPhone的ARM64匯編:
在許多它們的設備匯總,很多都加入到了64位的ARM處理器。Apple階段性地通過iOS版本升級將32位設備淘汰掉。例如,iPhone4s是32位的設備在iOS10就已經不支持升級了。iOS10系統支持的32位設備只有iPhone5了。
有趣的是,所有Apple Watch設備都是32位的。可能是因為32位的ARM CPU消耗更少的能量吧。這確實非常重要因為Watch的電池非常小。
考慮到最好是將重點集中到未來你需要的事情上,“Advanced Apple Debugging & Reverse Engineering”(英文書)將集中在64位的兩種架構中。此外,你將首先學習x86_64然后轉移到ARM64匯編,這樣就不會懵逼。嗯,不會太懵逼_
x86_64寄存器調用約定
在運行的程序內CPU使用一組寄存器操作數據。它們是存儲器,類似于計算機上的RAM。寄存器就在CPU上,且跟CPU使用它們的部分非常近。所以CPU上使用它們的組件訪問寄存器相當快。
大多數指令涉及到一個或多個操作符,比如將寄存器的內容寫入內存,讀取一塊內存到寄存器,或者在兩個寄存器上執行算術操作(加,減等)。
在x64(從這里開始,x64就是x86_64的縮寫),有16個通用寄存器用于機器操作數據。
這些寄存器是RAX, RBX, RCX, RDX, RDI, RSI, RSP, RBP, R8, R15
。這些名字現在沒什么意義,但待會你就知道他們每一個的重要性了。
當你在x64上調用函數,寄存器的方式和用法遵循一種非常特別的約定。它們表明了函數參數的位置及在函數完成時返回結果的位置。這很重要,因為一個編譯器編譯的代碼能和另一個編譯器編譯的代碼一起使用。
例如,請看以下簡單的OC代碼:
NSString *name = @"Zoltan";
NSLog(@"Hello world, I am %@. I'm %d, and I live in %@.", name, 30, @"my father's basement");
有四個參數傳入了NSLog
函數。一些值是直接傳入,而一個參數存儲在本地變量,以引用的方式傳入。但是,當以二進制代碼來看是,計算機不關心他們變量的名字,只考慮內存的位置。
當x64中函數調用時,以下寄存器用于參數。盡量地記住這些,因為之后你會使用地很頻繁。
- 第一個參數:RDI
- 第二個參數:RSI
- 第三個參數:RDX
- 第四個參數:RCX
- 第五個參數:R8
- 第六個參數:R9
如果參數超過六個,那么程序調用棧就會被用來傳遞那些額外的參數。
回到那個簡單的OC程序,你可以重新想象傳入的寄存器會像以下偽代碼一樣:
RDI = @"Hello world, I am %@. I'm %d, and I live in %@.";
RSI = @"Zoltan";
RDX = 30;
RCX = @"my father's basement";
NSLog(RDI, RSI, RDX, RCX);
只要NSLog
函數一開始,以上的寄存器就會想上面展示的一樣包含合適的值。
但是,只要函數開場(函數開始的階段--準備棧和寄存器)完成執行,寄存器內的值很可能會變化。生成好的匯編碼可能重寫寄存器內的值,或是簡單的拋棄掉這些引用,因為這些代碼可能不需要了。
這意味著一旦你過了函數的開場階段(通過stepping over,stepping in或者stepping out),就不再能假設寄存器內的值是你期望監測的值了,除非你正好看到匯編代碼能知道它做了些什么。
這種調用約定嚴重影響你調試or斷點策略。如果你讓任何斷點自動化,將不得不在函數開始時打斷點為了觀察或者改變參數,而不需要真正地深入到匯編層面。
Objective-C和寄存器
寄存器使用特殊的調用約定。你也可以使用那些知識應用到其他的語言上。
當OC執行方法時,一個特殊的叫objc_msgsend
C函數被調用。實際上有多個不同類型這樣的函數,但稍后會更多。這是消息轉發的核心。作為第一個參數,objc_msgsend
持有了消息被發送的對象引用。接著第二個參數是selector
,它僅僅就是一個char *
指定了該對象的要調用的方法名。最后,objc_msgsend
持有了一個可變參數,如果selector
指定了其他參數。
讓我們來看看一個真實的iOS中的例子:
[UIApplication sharedApplication];
編譯器將編譯這段代碼并生成如下偽代碼:
id UIApplicationClass = [UIApplication class];
objc_msgSend(UIApplicationClass, "sharedApplication");
第一個參數是UIApplication
類,之后是sharedApplication
函數名。一種非常簡單的方式計算函數的參數個數,就是數selector
內有多少個冒號。一個冒號代表一個參數。
這是另一個OC例子:
NSString *helloWorldString = [@"Can't Sleep; " stringByAppendingString:@"Clowns will eat me"];
編譯器將編譯這段代碼并生成如下偽代碼:
NSString *helloWorldString;
helloWorldString = objc_msgSend(@"Can't Sleep; ", "stringByAppendingString:", @"Clowns will eat me");
第一個參數是NSString
類的一個實例,之后是selector
,跟著是NSString
類的實例參數。
使用objc_msgSend
的知識,你可以在x64中使用寄存器來探索內容,你很快會完成它。
理論運用于實踐
可以下載 教程開始的工程。
本段,你將會使用到本教程資源提供的名為Registers
的工程。
用Xcode打開該工程并運行:
這是一個非常簡單的程序,只是展示了x64系統的一些寄存器內容。注意到程序并不能把每一時刻寄存器內容都展示出來,它僅僅展示了一個特殊函數調用過程中的寄存器值。這意味著你不會看到寄存器值太多的變化,因為它們很可能在這個抓取寄存器值函數調用時有著相同的值。
現在你已經大概了解這個mac系統應用程序的功能,那么在NSViewController
的 viewDidLoad
方法上打上一個斷點吧。記得使用NS
而不是UI
開頭,因為你正在寫一個Cocoa應用程序。
編譯并運行。一但調試器停止,在LLDB控制臺輸入如下指令:
(lldb) register read
該命令會列出在程序執行暫停的時候主要的寄存器。但,這實在是太多信息了。你應該選擇性地打印出寄存器并將它們視為OC(Objective-C)對象。
如果你稍微回想一下, -[NSViewController viewDidLoad]
將會轉換成如下二進制偽代碼:
RDI = UIViewControllerInstance
RSI = "viewDidLoad"
objc_msgSend(RDI, RSI)
知道x64的調用約定和objc_msgSend
的工作方式,你可以找到特定的正在被加載的NSViewController
。
輸入如下LLDB命令:
(lldb) po $rdi
你會得到如下類似信息:
<Registers.ViewController: 0x6080000c13b0>
該命令會打印出RDI寄存器持有的NSViewController
引用,你懂的,就是方法的第一個參數~
在LLDB中,在寄存器前面加上$
字符是很重要的,這樣LLDB知道你想知道寄存器的值而不是該塊源碼中的變量。是的,你在反匯編里看到的跟匯編里的不一樣!很討厭,對吧?
注:善于觀察的你可能已經注意到了在OC代碼中打斷點,在LLDB的回溯內看不到
objc_msgSend
的影子。這是因為objc_msgSend
方法簇執行了jmp
,或者在匯編內的跳轉命令。意思就是說objc_msgSend
扮演了中轉的角色,一但OC代碼開始執行,所有的關于objc_msgSend
的棧中的回溯都將消失。這是一種叫做尾遞歸調用的優化。
試試打印下RSI
,這很可能是方法的selector
。輸入如下指令:
(lldb) po $rsi
不幸的是,你會看到的類似于這樣的垃圾輸出:
140735181830794
為什么嘞?
selector
其實就是char *
。也就是說,像其他C類型一樣,LLDB不知道怎么格式化這個數據。最后,你必須顯示地將這塊引用轉換成你希望的數據結構。
試試將它強轉成正確的類型:
(lldb) po (char *)$rsi
你會看到:
"viewDidLoad"
必然,你也可以將它轉成Selector
類型,并得到相同的結果:
(lldb) po (SEL)$rsi
現在呢,是時候看看帶有參數的OC方法了。因為你斷點停在了viewDidLoad
方法,你可以假定NSView
示例已經加載完畢。一個有趣的方法叫mouseUp:
,它是NSView
的父類NSResponder
實現的。
在LLDB內,創建一個NSResponder
的mouseUp:
斷點并繼續執行(resume)。如果你不知道怎么操作,以下命令可以使用:
(lldb) b -[NSResponder mouseUp:]
(lldb) continue
現在,點擊應用窗口。并且保證你點擊的區域不在NSScrollView
之內,因為NSScrollView
會捕獲你的點擊,那么-[NSResponder mouseUp:]
斷點就到不了了。
一旦你放開鼠標或者觸控板,LLDB會停在mouseUp:
方法斷點處。通過如下命令打印出NSResponder
的引用:
(lldb) po $rdi
你會看到類似以下信息:
<NSView: 0x608000120140>
但是,selector
有個有意思的地方,它有一個冒號,意味著它有一個參數!在LLDB輸入如下命令:
(lldb) po $rdx
你會得到NSEvent
的描述:
NSEvent: type=LMouseUp loc=(351.672,137.914) time=175929.4 flags=0 win=0x6100001e0400 winNum=8622 ctxt=0x0 evNum=10956 click=1 buttonNumber=0 pressure=0 deviceID:0x300000014400000 subtype=NSEventSubtypeTouch
怎么知道它就是NSEvent
?ok,你可以查看[NSResponder mouseUp:]
的開發文檔,或者簡單地使用OC方法獲取它的類型:
(lldb) po [$rdx class]
非常酷??,是不是~
有時候為了知道在內存中一個對象的指針,使用寄存器或者斷點是非常有用的。
例如,如果你想將窗口的顏色變成紅色,但是代碼中并沒有該窗口的引用,而且還不想重新編譯代碼?你可以簡單地在一個很容易捕獲的地方創建斷點,獲取寄存器的引用并隨意操縱該對象的實例。你就將窗口變為紅色啦~
注:盡管
NSResponder
實現了mouseDown:
方法,但NSWindow
重寫了它。你可以輸出所有實現了mouseDown:
的類并猜想出它們之中哪些繼承自NSResponder
來決定是否該方法被重寫了,而不用去看源碼。輸出所有實現了mouseDown:
方法的OC類的命令是:image lookup -rn '\ mouseDown:'
第一步,移除所有之前在LLDB內打的斷點:
(lldb) breakpoint delete
About to delete all breakpoints, do you want to do that?: [Y/n]
然后再LLDB中輸入如下命令:
(lldb) breakpoint set -o -S "-[NSWindow mouseDown:]"
(lldb) continue
這里設置了一個一次性的斷點。
點擊應用。點擊之后,斷點立即停住了。然后輸入如下命令:
(lldb) po [$rdi setBackgroundColor:[NSColor redColor]]
(lldb) continue
一旦重新執行,NSWindow
將變紅~
Swift與寄存器
在Swift內探索寄存器時,會遇到兩個使匯編調試在Swift中比OC更困難的障礙。
首先,在Swift調試上下文內寄存器不可用。意味著你不得不獲取到任何你想要的數據,并使用OC調試上下文打印出傳入Swift函數的寄存器。記住你可以使用
expression -l objc -O
命令,或者使用在書中第八章(“Persisting and Customizing Commands”)的cpo
命令。幸運的是,register read
命令依然是可以使用的。其次,Swift相對于OC并不是動態的。事實上,有時候最好假設Swift像C語言一樣。如果知道了一個內存地址,你應該顯示地強轉為你想要的類型。不然Swift調試器沒有任何線索去解釋內存地址。
話雖如此,在Swift中也是使用的相同的寄存器調用約定。但是,有一個非常重要的不同點。當Swift調用一個函數時,它不需要調用objc_msgSend
函數,除非你用dynamic
對它做標記。也就是說Swift調用一個函數,之前說的用于存selector
的RSI
寄存器將包含函數的第二個參數。
理論說的差不多了,實踐一把。
回到Registers
工程,點開ViewController.swift
文件并添加如下代碼到類中:
func executeLotsOfArguments(one: Int, two: Int, three: Int,
four: Int, five: Int, six: Int,
seven: Int, eight: Int, nine: Int,
ten: Int) {
print("arguments are: \(one), \(two), \(three),
\(four), \(five), \(six), \(seven),
\(eight), \(nine), \(ten)")
}
現在在viewDidLoad
內使用合適的參數調用該方法:
override func viewDidLoad() {
super.viewDidLoad()
self.executeLotsOfArguments(one: 1, two: 2, three: 3, four: 4,
five: 5, six: 6, seven: 7,
eight: 8, nine: 9, ten: 10)
}
在executeLotsOfArguments
方法的第一行打個斷點,這樣調試器會在函數一開始的時候停住。這很重要,不然的話如果程序開始跑起來寄存器可能會有臟數據。
然后移除在-[NSViewController viewDidLoad]
的斷點。
編譯&運行,等待executeLotsOfArguments
斷點停住。
好的觀察方法是列出寄存器。在LLDB內,輸入:
(lldb) register read -f d
這樣會輸出所有寄存器并通過指定-f d
選項以十進制的格式展示。輸入類似如下:
General Purpose Registers:
rax = 7
rbx = 9
rcx = 4
rdx = 3
rdi = 1
rsi = 2
rbp = 140734799801424
rsp = 140734799801264
r8 = 5
r9 = 6
r10 = 10
r11 = 8
r12 = 107202385676032
r13 = 106652628550688
r14 = 10
r15 = 4298620128 libswiftCore.dylib`swift_isaMask
rip = 4294972615 Registers`Registers.ViewController.viewDidLoad () -> () + 167 at ViewController.swift:16
rflags = 518
cs = 43
fs = 0
gs = 0
如你所見,寄存器遵循x64調用約定。RDI, RSI, RDX, RCX, R8,R9
持有了6個參數。
你可能看到其他的參數被存儲在了其他的寄存器內。雖然這是事實,但它只是代碼中的剩余部分,用于為其余參數設置堆棧。記住,第六個參數后的參數將進入堆棧。
RAX,用于返回的寄存器
等等--還有呢!到這里,你已經了解了函數中六個寄存器是如何調用的,但是返回值呢?
幸運的是,只有一個指定的寄存器用于返回值:RAX。回到executeLotsOfArguments
函數并改變函數的返回值,像這樣:
func executeLotsOfArguments(one: Int, two: Int, three: Int,
four: Int, five: Int, six: Int,
seven: Int, eight: Int, nine: Int,
ten: Int) -> String {
print("arguments are: \(one), \(two), \(three), \(four),
\(five), \(six), \(seven), \(eight), \(nine), \(ten)")
return "Mom, what happened to the cat?"
}
在viewDidLoad
函數,改變函數的調用讓其接受返回值,但卻忽略它。
override func viewDidLoad() {
super.viewDidLoad()
let _ = self.executeLotsOfArguments(one: 1, two: 2,
three: 3, four: 4, five: 5, six: 6, seven: 7,
eight: 8, nine: 9, ten: 10)
}
在executeLotsOfArguments
函數某行設置一個斷點。再次編譯&運行,等待斷點停住。下一步,LLDB內輸入如下指令:
(lldb) finish
命令會結束完成函數的執行并停住調試器。這時,函數返回值會在RAX
內。輸入如下命令:
(lldb) register read rax
你會看到類似:
rax = 0x0000000100003760 "Mom, what happened to the cat?"
bong~ 你的返回值!
返回值RAX的知識很重要,因為它會構建你將學到的調試腳本基礎。
改變寄存器值
為了鞏固寄存器的理解,你將會在已編譯的程序內來改變寄存器。
關閉Xcode和工程及模擬器,打開終端程序,輸入如下命令:
xcrun simctl list
你將看到長串的設備列表。搜索最新iOS版本安裝的模擬器。在這之下,找到iPhone7設備。它看起來是這樣的:
iPhone 7 (269B10E1-15BE-40B4-AD24-B6EED125BC28) (Shutdown)
UDID就是你要找的。使用它并通過如下命令打開iOS模擬器(替換其中的UDID部分):
open /Applications/Xcode.app/Contents/Developer/Applications/Simulator.app --args -CurrentDeviceUDID 269B10E1-15BE-40B4-AD24-B6EED125BC28
保證模擬器已經啟動而且在主屏幕上。你可以通過按下Command + Shift + H鍵回到主屏幕。一旦模擬器準備好了,回到終端窗口將LLDB綁定到SpringBoard
程序上。
lldb -n SpringBoard
這樣會將LLDB綁定到正在模擬器上運行的SpringBoard
實例上!SpringBoard
就是在iOS上控制主屏幕的程序。
一旦綁定,輸入如下命令:
(lldb) p/x @"Yay! Debugging"
可以看到類似如下的輸出:
(__NSCFString *) $3 = 0x0000618000644080 @"Yay! Debugging!"
注意下剛剛創建的這個NSString
實例,因為很快你會用到它。現在,給UILabel
的setText:
方法設置一個斷點:
(lldb) b -[UILabel setText:]
下一步,輸入如下:
(lldb) breakpoint command add
LLDB會吐出一些輸出且進入多行編輯模式。這個命令讓你在剛剛打的斷點處添加多個額外要執行的命令。輸入如下,使用剛才的NSString
地址替換下面的內存地址:
> po $rdx = 0x0000618000644080
> continue
> DONE
回去重新看下你剛剛做的。你在UILabel
的setText:
方法上添加了一個斷點。一旦遇到該方法,你就會用一個叫Yay! Debugging!
的NSString
實例替換RDX---第三個參數。
使用continue
命令讓調試器繼續執行:
(lldb) continue
看看SpringBoard
模擬器程序什么發生了改變。從下往上掃帶出控制中心,觀察改變的地方:
試著彈出其他以模態彈出的地方,因為這樣很可能導致在新的UIViewController
(包括它的子view)會懶加載,并觸發斷點。
盡管這可能看起來是一個非常酷的巧妙的把戲,但它卻展示了通過有限的匯編和寄存器的知識能夠在程序內產生你之前沒見過的大的變化的一種富有洞察力的觀察方式。
在調試的角度來看也是非常有用的,因為你可以快速形象地確認在SpringBoard
程序內-[UILabel setText:]
的執行位置,并通過運行斷點的條件來找到準確的設置UILabel
文本的代碼行數。
繼續這個想法,任何沒有改變文本的UILabel
也告訴了你一些東西。例如,UIButton
的文本并沒有變成Yay! Debugging!
。可能UILabel
的setText:
方法是在之前調用了?或者SpringBoard
程序調用的是setAttributedText:
方法?或者它們使用的是私有的方法而對于第三方開發者來說是不公開的?
就像你看到的,使用和操縱寄存器能夠讓你更深入地了解應用程序的功能_
何去何從
哇哦!這真的很長啊,不是么?你可以下載整個教程的工程
因此你學到了什么?
- 定義調用約定的架構,指明了函數參數和返回值的位置;
- OC中,RDI是對象的引用,RSI是Selector,RDX是第一個參數,等等。。。
- 在Swift中,RDI是第一個參數,RSI是第二個參數,等等。。。這是在Swift方法沒有添加
dynamic
標識的時候。 - RAX是用于存儲返回值的,無論在OC還是Swift中。
- 保證你使用的是OC的上下文,當你在用
$
輸出寄存器的時候。