2012年10月18日 星期四

C和組合語言對於組語符號引用的差異 (ARM & x86)

之前在trace U-Boot source codes的時候發現了C對於組語內符號的引用方式非常奇特
搜尋了一下相關的資料發現也有人有跟我一樣的疑問

U-Boot中的board_init_f()
(定義在:arch/arm/lib/board.c)


其中第20行可以看到將_bss_end_ofs assign給了gd->mon_len
_bss_end_ofs 其實是被宣告在start.S這個組合語言檔內
(以ARM-720T系列的CPU為例:arch/arm/cpu/arm720t/start.S)


我們可以看到第12行宣告了_bss_end_ofs 這個符號
而在這個符號位址所存放的資料為一個word型態的值,也就是:__bss_end__ - _start 的結果

_start__bss_end__ 其實是被定義在ARM U-Boot的Linker script內
分別代表程式的起始位址,以及.bss section的結束位址
(arch/arm/cpu/u-boot.lds)


先不論_start__bss_end__ 的值各別為何
總之在_bss_end_ofs 這個符號位址所存放的資料就是.bss section結束位址和程式起始位址的偏移量 (offset)

但我們從board_init_f() 中可以看到...
gd->mon_len 應該是要儲存__bss_end__ - _start 的值,可我們卻是直接將_bss_end_ofs 這個符號指定給了gd->mon_len

將符號直接指定給gd->mon_len..........???
這樣的存取方法確實是有些奇怪.....

在文章最初所提到的資料最後也有提到,C和組合語言對於組語內符號的引用是不太一樣的:
  1. readelf以及u-boot.map和System.map所給出的符號表中符號的值,實際上是表示符號所在的地址,而不是指符號本身的值
  2. 組合語言中沒有指針的概念,因此對符號的引用是"赤裸裸"的
  3. C語言則不同,對變量/符號/常量的引用必須要"透過位址"來存取,不管是全局變量還是局部變量,不同的是局部變量在生命期結束後,所佔的位址空間會被釋放而已
事實究竟是如何,我們可以自己寫個小程式來做驗證:

test_arm.c


test_arm_asm.S


先看test_arm.c中第10行所宣告的unsigned long test_c 變數
在第17行我將外部引用的test值指定給了test_c 變數
此外在第15行中我呼叫了test_assembly 這個外部引用的函式
testtest_assembly 都是我定義在test_arm_asm.S的符號
其中在test 這個符號位址所存放的資料為0x12345678
test_assembly 這個符號則是指到了一段會將test 符號位址所存放的值存到r0暫存器的一段組語
test_arm.c:17 以及test_assembly 我們可以清楚的比較C和組語對於組語內部的符號引用方式的不同

將編譯出來的程式透過objdump反組譯 (當然,是使用ARM的Cross-Toolchain):


在圖片中的第235行到237行就是對應到test_arm.c的第17行
也就是將外部引用的test 值指定給了test_c 變數 (test_c = test)
在這邊我們可以看到,編譯出來的組合語言使用了ldr指令來將0x8444的值載入到r3暫存器

ldr指令的用法可以參考這篇
簡單來說就是將該指令執行時的PC值(0x840C)加上偏移量(48 = 0x30) = 0x843C
但由於ARM-720T系列的CPU是3個pipeline stages (Fetch / Decode / Execute)
因此當該指令執行時實際上的PC值應為0x843C + 0x8 = 0x8444 (Execute和Fetch差了2個 pipeline stages)

但我們要的test (0x12345678)是存在0x8450的位址,而非0x8444
這邊就可以看到ARM對於C語言存取組語函式是採用類似"跳板"的功能
0x8444的位址存放的值是:0x8450,也就是我們所要的test (0x1234578)的位址

所以編譯出來的組合語言多了第236行:ldr   r3, [r3]
也就是先取得了0x8444位址的值:0x8450
再將0x8450位址的值(0x12345678)載入到r3暫存器
最後再存至[fp, #-20] (也就是test_c 變數的位址)
這也就印證了上述:
C語言則不同,對變量/符號/常量的引用必須要"透過位址"來存取 的論點
也就是說C語言將這些符號當作"指標"來處理
而只需要將該指標指定給變數,ARM的Compiler便會自動編譯出存取該符號位址所存放值的組合語言,並將值指定給變數

那麼組合語言對於組語內部符號引用的方式又是如何呢?
我們可以看到test_arm_asm.S內的test_assembly 是直接對test做存取:ldr   r0, test
這段codes最後編譯出來為:ldr   r0, [pc, #-20]
ldr指令所存取的偏移量算法如同上述
由此可見在組合語言中,對於存取該符號位址所存放值是直接透過該符號位址來存取的
且也沒有使用類似C語言類似"跳板"的功能
因此這邊就印證了上述:
組合語言中沒有指針的概念,因此對符號的引用是"赤裸裸" 的論點
因此,C和組合語言對於組語符號的引用方式其實是類似的:都是直接透過該符號來存取符號位址所存放的資料
(雖然這樣在C中看起來有些詭異.....)
差別僅在於ARM C語言所編出來的組語多了類似"跳板"的功能
而組合語言內部的存取則沒有這段程式

也因為C對於組語符號的引用都是直接透過該符號來存取的
因此若是想取得一組合語言內一段codes的起始位址
(ex. 我們想把組合語言內某段codes從ROM搬至RAM,此時我們就必須知道該段codes的起始位址和其長度,雖然大部分這樣的程式都是在系統開機階段直接用組合語言給做掉的...)
我們就必須多加上"&"來取址,而不是直接使用該符號
(直接使用該符號會取得該符號位址所在之指令內容,而非該段codes的起始位址)
如:test_arm.c的第11、12以及13行,我們宣告了三個指標:void * test_wrong、 void * test_correct_1void * test_correct_2
其中test_wrong 是錯誤的符號引用方法,test_correct_1test_correct_2 才是正確的符號引用方法
差別僅在於test_correct_1 引用的是test_arm.c 第4行所引用的外部函式:test_inst_1
(將test_inst_1unsigned long的方式取用)
test_correct_2 引用的則是test_arm.c 第5行所引用的外部函式:test_inst_2
(將test_int_2function的方式取用)
test_inst_1test_inst_2 則是定義在test_arm_asm.S 的兩段codes
其實會產生的只是一個nop指令 (將r0暫存器的值移到r0暫存器,等於沒有動作)

test_arm.c中第19、20和21行我們分別以錯誤和正確的方式來取得test_inst_1 和 test_int_2 的起始位址
對應到objdump反組譯的組合語言分別為:
  • test_wrong:第238 ~ 240行
  • test_correct_1:第241 ~ 242行
  • test_correct_2:第243 ~ 244行
對於test_wrong 我們直接使用了該符號,如此便會是取得test_inst_1 這個符號位址(0x8454)所存放的資料,也就是mov  r0, r0 的opcodes (e1a00000)
當然這樣對於我們要取得的是test_inst_1 這段codes的起始位址,絕對會是錯誤的結果

至於test_correct_1test_correct_2,我們則是多加了"&"來取址
差別僅在於我們將test_inst_1 以unsigned long的方式取用
test_int_2 則是以function的方式取用
(當然為了不產生warning訊息,必要的轉型是必須做的...)
其所編譯出來的組合語言則不同於test_wrong 的部份,少了類似"跳板"的功能
如此所取得的便是存放test_inst_1 (0x8454) test_inst_2 (0x8458) 起始位址的位址 (0x84480x844C)
(聽起來有點饒口,但0x84480x844C 的確才是我們要的結果...)

因此在C中若是要取得一組合語言內所定義符號之位址
我們必須加上"&"取址符號才會得到正確的結果
若是依照readelf或是System.map所給出的符號表中符號的值就以為直接對符號存取就是該符號的位址 (如上述的:test_wrong 指標的存取方式),就會導致錯誤的結果!!

回到我們原先所trace的U-Boot source codes
在對U-Boot反組譯後,我們同樣可以看到board_init_f() 中,gd->mon_len = _bss_end_ofs; 這段codes所編譯出來的組合語言:

第2154 ~ 2156行就是gd->mon_len = _bss_end_ofs; 這段codes所編譯出來的組合語言
我們可以看到這段codes用了類似"跳板"的功能去取得_bss_end_ofs 的值

讓我們再看看位於0x1FF4位址的內容:

0x1FF4做為"跳板",放了0x48的值

再來看看0x48位址的內容:

0x48位址果然就是_bss_end_ofs,其所放的值:0xB8940 就是__bss_end__ - _start 的結果

透過GDB來觀察_start 以及 __bss_end__ 的內容  (當然,這邊使用的也是ARM的Cross-Toolchain):

由於我並沒有真的將U-Boot載入執行
因此在觀察__bss_end__ 值的時候會顯示:"Cannot access memory at address 0xb8940" 的錯誤訊息
儘管如此,我們還是可以得知:_start的值為0x0,而__bss_end__ 的值則為0xB8940
因此:__bss_end__ - _start 的結果就是0xB8940 無誤!!
這樣的寫法,才能真正取得_bss_end_ofs 這個符號位址所存放的資料

------------------

看完了ARM,我也會好奇那x86上的做法是否有差???
要是x86和ARM的寫法不同,那不是每次都要注意自己寫的程式是要用在哪個平台
在寫的時候還要特別注意是否有寫錯
當然這樣的情況理論上不應該發生
所有平台的Compiler都應遵循C所定義的規範來做才對...

總之我還是寫了跟上述ARM版本同樣的小程式
差別僅在於組合語言的部份換成了x86的組合語言

test_x86.c


test_x86_asm.S


同樣,將編譯出來的程式透過objdump反組譯:

如同ARM的程式,test_ctest_assemblytest_wrongtest_correct_1 和 test_correct_2 對應到objdump反組譯的組合語言分別為:
  • test_c:第308~ 309行
  • test_assembly:第330 ~ 332行
  • test_wrong:第308 ~ 309行
  • test_correct_1:第310 ~ 311行
  • test_correct_2:第312 ~ 313行
同樣的,我們可以看到在x86中,C和組合語言對於組語內符號的引用方式都是跟ARM相同的
也就是:

  1. 組合語言中沒有指針的概念,因此對符號的引用是"赤裸裸"的
  2. C語言則不同,對變量/符號/常量的引用必須要"透過位址"來存取,不管是全局變量還是局部變量,不同的是局部變量在生命期結束後,所佔的位址空間會被釋放而已
這樣的論點,在x86平台上也是成立的
唯一的差別僅在於,在x86平台上,C對於組語內符號的引用並沒有採用如同ARM類似"跳板"的功能
而是直接將該符號位址存入暫存器中
除此之外,對於在C中若是要取得一組合語言內所定義符號之位址
我們同樣必須加上"&"取址符號才會得到正確的結果 (如同test_x86.c 中:test_correct_1test_correct_2 的用法)

------------------

在這邊真的不得不稍微抱怨一下x86的組合語言,實在是給它有夠複雜
另外現在主流的兩款編譯器:GASNASM,所使用的語法也有差
(在我範例中所使用的是:GAS)

GAS是採用AT&T的語法,NASM則是採用Intel的語法
最令人討厭的就是像mov指令
GAS (AT&T)中格式是:mov   src, dest
但在NASM (Intel)中的格式卻是完全反過來:mov   dest, src
為何在設計的時候要將格式完全顛倒過來呢?!
害我有時候在看x86組語的時候會搞不太清楚到底哪邊是source,哪邊才是destination

GASNASM在語法上還有不少的差異,這邊就不多說了,Google一下就可以看到不少的資料
文章最底下我也附上了兩個介紹GASNASM語法差異的網站

另外在上面的x86範例中,movmovl指令在使用上也有差異:

mov    register, memory 的情況下mov指令會將register所存之位址的記憶體內容存至memory
也就是:[memory] = [register]

movl   immediate, memory 的情況下則會將immediate的值直接存至memory
也就是:[memory] = immediate

所以在上述x86的範例中雖然movmovl指令看起來用法很類似
但實際上卻是完全不同的結果

x86的組語真的是比ARM的難搞太多了..........

------------------

額外參考資料:

2 則留言: