【ARM 匯編基礎速成4】ARM匯編內存訪問相關指令

原文鏈接 https://azeria-labs.com/memory-instructions-load-and-store-part-4/

ARM使用加載-存儲模式控制對內存的訪問,這意味著只有加載/存儲(LDR或者STR)才能訪問內存。盡管X86中允許很多指令直接操作在內存中的數據,但ARM中依然要求在操作數據前,必須先從內存中將數據取出來。這就意味著如果要增加一個32位的在內存中的值,需要做三種類型的操作(加載,加一,存儲)將數據從內存中取到寄存器,對寄存器中的值加一,再將結果放回到內存中。

為了解釋ARM架構中的加載和存儲機制,我們準備了一個基礎的例子以及附加在這個基礎例子上的三種不同的對內存地址的便宜訪問形式。每個例子除了STR/LDR的偏移模式不同外,其余的都一樣。而且這個例子很簡單,最佳的實踐方式是用GDB去調試這段匯編代碼。

第一種偏移形式:立即數作為偏移

  • 地址模式:用作偏移
  • 地址模式:前向索引
  • 地址模式:后向索引

第二種偏移形式:寄存器作為偏移

  • 地址模式:用作偏移
  • 地址模式:前向索引
  • 地址模式:后向索引

第三種偏移形式:寄存器縮放值作為偏移

  • 地址模式:用作偏移
  • 地址模式:前向索引
  • 地址模式:后向索引

基礎樣例代碼

通常,LDR被用來從內存中加載數據到寄存器,STR被用作將寄存器的值存放到內存中。

image
LDR R2, [R0]   @ [R0] - 數據源地址來自于R0指向的內存地址
@ LDR操作:從R0指向的地址中取值放到R2中

STR R2, [R1]   @ [R1] - 目的地址來自于R1在內存中指向的地址
@ STR操作:將R2中的值放到R1指向的地址中

樣例程序的匯編代碼及解釋如下:

.data          /* 數據段是在內存中動態創建的,所以它的在內存中的地址不可預測*/
var1: .word 3  /* 內存中的第一個變量 */
var2: .word 4  /* 內存中的第二個變量 */

.text          /* 代碼段開始 */ 
.global _start

_start:
    ldr r0, adr_var1  @ 將存放var1值的地址adr_var1加載到寄存器R0中 
    ldr r1, adr_var2  @ 將存放var2值的地址adr_var2加載到寄存器R1中 
    ldr r2, [r0]      @ 將R0所指向地址中存放的0x3加載到寄存器R2中  
    str r2, [r1]      @ 將R2中的值0x3存放到R1做指向的地址 
    bkpt             

adr_var1: .word var1  /* var1的地址助記符 */
adr_var2: .word var2  /* var2的地址助記符 */

在底部我們有我們的文字標識池(在代碼段中用來存儲常量,字符串,或者偏移等的內存,可以通過位置無關的方式引用),分別用adr_var1和adr_var2存儲著變量var1和var2的內存地址(var1和var2的值在數據段定義)。第一條LDR指令將變量var1的地址加載到寄存器R0。第二條LDR指令同樣將var2的地址加載到寄存器R1。之后我們將存儲在R0指向的內存地址中的值加載到R2,最后將R2中的值存儲到R1指向的內存地址中。

當我們加載數據到寄存器時,方括號“[]”意味著:將其中的值當做內存地址,并取這個內存地址中的值加載到對應寄存器。

當我們存儲數據到內存時,方括號“[]”意味著:將其中的值當做內存地址,并向這個內存地址所指向的位置存入對應的值。

聽者好像有些抽象,所以再來看看這個動畫吧:

image

同樣的再來看看的這段代碼在調試器中的樣子。

gef> disassemble _start
Dump of assembler code for function _start:
 0x00008074 <+0>:      ldr  r0, [pc, #12]   ; 0x8088 <adr_var1>
 0x00008078 <+4>:      ldr  r1, [pc, #12]   ; 0x808c <adr_var2>
 0x0000807c <+8>:      ldr  r2, [r0]
 0x00008080 <+12>:     str  r2, [r1]
 0x00008084 <+16>:     bx   lr
End of assembler dump.

可以看到此時的反匯編代碼和我們編寫的匯編代碼有出入了。前兩個LDR操作的源寄存器被改成了[pc,#12]。這種操作叫做PC相對地址。因為我們在匯編代碼中使用的只是數據的標簽,所以在編譯時候編譯器幫我們計算出來了與我們想訪問的文字標識池的相對便宜,即PC+12。你也可以看匯編代碼中手動計算驗證這個偏移是正確的,以adr_var1為例,執行到8074時,其當前有效PC與數據段還有三個四字節的距離,所以要加12。關于PC相對取址我們接下來還會接著介紹。

PS:如果你對這里的PC的地址有疑問,可以看外面第二篇關于程序執行時PC的值的說明,PC是指向當前執行指令之后第二條指令所在位置的,在32位ARM模式下是當前執行位置加偏移值8,在Thumb模式下是加偏移值4。這也是與X86架構PC的區別之所在。

image

第一種偏移形式:立即數作偏移

STR    Ra, [Rb, imm]
LDR    Ra, [Rc, imm]

在這段匯編代碼中,我們使用立即數作為偏移量。這個立即數被用來與一個寄存器中存放的地址做加減操作(下面例子中的R1),以訪問對應地址偏移處的數據。

.data
var1: .word 3
var2: .word 4

.text
.global _start

_start:
    ldr r0, adr_var1  @ 將存放var1值的地址adr_var1加載到寄存器R0中 
    ldr r1, adr_var2  @ 將存放var2值的地址adr_var2加載到寄存器R1中 
    ldr r2, [r0]      @ 將R0所指向地址中存放的0x3加載到寄存器R2中  
    str r2, [r1, #2]  @ 取址模式:基于偏移量。R2寄存器中的值0x3被存放到R1寄存器的值加2所指向地址處。
    str r2, [r1, #4]! @ 取址模式:基于索引前置修改。R2寄存器中的值0x3被存放到R1寄存器的值加4所指向地址處,之后R1寄存器中存儲的值加4,也就是R1=R1+4。
    ldr r3, [r1], #4  @ 取址模式:基于索引后置修改。R3寄存器中的值是從R1寄存器的值所指向的地址中加載的,加載之后R1寄存器中存儲的值加4,也就是R1=R1+4。
    bkpt

adr_var1: .word var1
adr_var2: .word var2

讓我們把上面的這段匯編代碼編譯一下,并用GDB調試起來看看真實情況。

$ as ldr.s -o ldr.o
$ ld ldr.o -o ldr
$ gdb ldr

在GDB(使用GEF插件)中,我們對_start下一個斷點并繼續運行程序。

gef> break _start
gef> run
...
gef> nexti 3     /* 向后執行三條指令 */

執行完上述GDB指令后,在我的系統的寄存器的值現在是這個樣子(在你的系統里面可能不同):

$r0 : 0x00010098 -> 0x00000003
$r1 : 0x0001009c -> 0x00000004
$r2 : 0x00000003
$r3 : 0x00000000
$r4 : 0x00000000
$r5 : 0x00000000
$r6 : 0x00000000
$r7 : 0x00000000
$r8 : 0x00000000
$r9 : 0x00000000
$r10 : 0x00000000
$r11 : 0x00000000
$r12 : 0x00000000
$sp : 0xbefff7e0 -> 0x00000001
$lr : 0x00000000
$pc : 0x00010080 -> <_start+12> str r2, [r1]
$cpsr : 0x00000010

下面來分別調試這三條關鍵指令。首先執行基于地址偏移的取址模式的STR操作了。就會將R2(0x00000003)中的值存放到R1(0x0001009c)所指向地址偏移2的位置0x1009e。下面一段是執行完對應STR操作后對應內存位置的值。

gef> nexti
gef> x/w 0x1009e 
0x1009e <var2+2>: 0x3

下一條STR操作使用了基于索引前置修改的取址模式。這種模式的識別特征是(!)。區別是在R2中的值被存放到對應地址,R1的值也會被更新。這意味著,當我們將R2中的值0x3存儲到R1(0x1009c)的偏移4之后的地址0x100A0后,R1的值也會被更新到為這個地址。下面一段是執行完對應STR操作后對應內存位置以及寄存器的值。

gef> nexti
gef> x/w 0x100A0
0x100a0: 0x3
gef> info register r1
r1     0x100a0     65696

最后一個LDR操作使用了基于索引后置的取址模式。這意味著基礎寄存器R1被用作加載的內存地址,之后R1的值被更新為R1+4。換句話說,加載的是R1所指向的地址而不是R1+4所指向的地址,也就是0x100A0中的值被加載到R3寄存器,然后R1寄存器的值被更新為0x100A0+0x4也就是0x100A4。下面一段是執行完對應LDR操作后對應內存位置以及寄存器的值。

gef> info register r1
r1      0x100a4   65700
gef> info register r3
r3      0x3       3

下圖是這個操作發生的動態示意圖。

image

第二種偏移形式:寄存器作偏移

STR    Ra, [Rb, Rc]
LDR    Ra, [Rb, Rc]

在這個偏移模式中,寄存器的值被用作偏移。下面的樣例代碼展示了當試著訪問數組的時候是如何計算索引值的。

.data
var1: .word 3
var2: .word 4

.text
.global _start

_start:
    ldr r0, adr_var1  @ 將存放var1值的地址adr_var1加載到寄存器R0中 
    ldr r1, adr_var2  @ 將存放var2值的地址adr_var2加載到寄存器R1中 
    ldr r2, [r0]      @ 將R0所指向地址中存放的0x3加載到寄存器R2中  
    str r2, [r1, r2]  @ 取址模式:基于偏移量。R2寄存器中的值0x3被存放到R1寄存器的值加R2寄存器的值所指向地址處。R1寄存器不會被修改。 
    str r2, [r1, r2]! @ 取址模式:基于索引前置修改。R2寄存器中的值0x3被存放到R1寄存器的值加R2寄存器的值所指向地址處,之后R1寄存器中的值被更新,也就是R1=R1+R2。
    ldr r3, [r1], r2  @ 取址模式:基于索引后置修改。R3寄存器中的值是從R1寄存器的值所指向的地址中加載的,加載之后R1寄存器中的值被更新也就是R1=R1+R2。
    bx lr

adr_var1: .word var1
adr_var2: .word var2

下面來分別調試這三條關鍵指令。在執行完基于偏移量的取址模式的STR操作后,R2的值被存在了地址0x1009c + 0x3 = 0x1009F處。下面一段是執行完對應STR操作后對應內存位置的值。

gef> x/w 0x0001009F
 0x1009f <var2+3>: 0x00000003

下一條STR操作使用了基于索引前置修改的取址模式,R1的值被更新為R1+R2的值。下面一段是執行完對應STR操作后寄存器的值。

gef> info register r1
 r1     0x1009f      65695

最后一個LDR操作使用了基于索引后置的取址模式。將R1指向的值加載到R2之后,更新了R1寄存器的值(R1+R2 = 0x1009f + 0x3 = 0x100a2)。下面一段是執行完對應LDR操作后對應內存位置以及寄存器的值。

gef> info register r1
 r1      0x100a2     65698
gef> info register r3
 r3      0x3       3

下圖是這個操作發生的動態示意圖。

image

第三種偏移形式:寄存器縮放值作偏移

LDR    Ra, [Rb, Rc, <shifter>]
STR    Ra, [Rb, Rc, <shifter>]

在這種偏移形式下,第三個偏移量還有一個寄存器做支持。Rb是基址寄存器,Rc中的值作為偏移量,或者是要被左移或右移的<shifter>次的值。這意味著移位器shifter被用來用作縮放Rc寄存器中存放的偏移量。下面的樣例代碼展示了對一個數組的循環操作。同樣的,我們也會用GDB調試這段代碼。

.data
var1: .word 3
var2: .word 4

.text
.global _start

_start:
    ldr r0, adr_var1         @ 將存放var1值的地址adr_var1加載到寄存器R0中 
    ldr r1, adr_var2         @ 將存放var2值的地址adr_var2加載到寄存器R1中 
    ldr r2, [r0]             @ 將R0所指向地址中存放的0x3加載到寄存器R2中  
    str r2, [r1, r2, LSL#2]  @ 取址模式:基于偏移量。R2寄存器中的值0x3被存放到R1寄存器的值加(左移兩位后的R2寄存器的值)所指向地址處。R1寄存器不會被修改。
    str r2, [r1, r2, LSL#2]! @ 取址模式:基于索引前置修改。R2寄存器中的值0x3被存放到R1寄存器的值加(左移兩位后的R2寄存器的值)所指向地址處,之后R1寄存器中的值被更新,也就R1 = R1 + R2<<2。
    ldr r3, [r1], r2, LSL#2  @ 取址模式:基于索引后置修改。R3寄存器中的值是從R1寄存器的值所指向的地址中加載的,加載之后R1寄存器中的值被更新也就是R1 = R1 + R2<<2。
    bkpt

adr_var1: .word var1
adr_var2: .word var2

下面來分別調試這三條關鍵指令。在執行完基于偏移量的取址模式的STR操作后,R2被存儲到的位置是[r1,r2,LSL#2],也就是說被存儲到R1+(R2<<2)的位置了,如下圖所示。

image

下一條STR操作使用了基于索引前置修改的取址模式,R1的值被更新為R1+(R2<<2)的值。下面一段是執行完對應STR操作后寄存器的值。

gef> info register r1
r1      0x100a8      65704

最后一個LDR操作使用了基于索引后置的取址模式。將R1指向的值加載到R2之后,更新了R1寄存器的值(R1+R2 = 0x100a8 + (0x3<<2) = 0x100b4)。下面一段是執行完對應LDR操作后寄存器的值。

gef> info register r1
r1      0x100b4      65716

小結

LDR/STR的三種偏移模式:

  1. 立即數作為偏移
ldr   r3, [r1, #4]
  1. 寄存器作為偏移
ldr   r3, [r1, r2]
  1. 寄存器縮放值作為偏移
ldr   r3, [r1, r2, LSL#2]

如何區分取址模式:

  1. 如果有一個嘆號!,那就是索引前置取址模式,即使用計算后的地址,之后更新基址寄存器。
ldr   r3, [r1, #4]!
ldr   r3, [r1, r2]!
ldr   r3, [r1, r2, LSL#2]!
  1. 如果在[]外有一個寄存器,那就是索引后置取址模式,即使用原有基址寄存器重的地址,之后再更新基址寄存器
ldr   r3, [r1], #4
ldr   r3, [r1], r2
ldr   r3, [r1], r2, LSL#2
  1. 除此之外,就都是偏移取址模式了
ldr   r3, [r1, #4]
ldr   r3, [r1, r2]
ldr   r3, [r1, r2, LSL#2]
  • 地址模式:用作偏移
  • 地址模式:前向索引
  • 地址模式:后向索引

關于PC相對取址的LDR指令

有時候LDR并不僅僅被用來從內存中加載數據。還有如下這操作:

.section .text
.global _start

_start:
   ldr r0, =jump        /* 加載jump標簽所在的內存位置到R0 */
   ldr r1, =0x68DB00AD  /* 加載立即數0x68DB00AD到R1 */
jump:
   ldr r2, =511         /* 加載立即數511到R2 */ 
   bkpt

這些指令學術上被稱作偽指令。但我們在編寫ARM匯編時可以用這種格式的指令去引用我們文字標識池中的數據。在上面的例子中我們用一條指令將一個32位的常量值放到了一個寄存器中。為什么我們會這么寫是因為ARM每次僅僅能加載8位的值,原因傾聽我解釋立即數在ARM架構下的處理。

在ARM中使用立即數的規律

是的,在ARM中不能像X86那樣直接將立即數加載到寄存器中。因為你使用的立即數是受限的。這些限制聽上去有些無聊。但是聽我說,這也是為了告訴你繞過這些限制的技巧(通過LDR)。

我們都知道每條ARM指令的寬度是32位,所有的指令都是可以條件執行的。我們有16中條件可以使用而且每個條件在機器碼中的占位都是4位。之后我們需要2位來做為目的寄存器。2位作為第一操作寄存器,1位用作設置狀態的標記位,再加上比如操作碼(opcode)這些的占位。最后每條指令留給我們存放立即數的空間只有12位寬。也就是4096個不同的值。

這也就意味著ARM在使用MOV指令時所能操作的立即數值范圍是有限的。那如果很大的話,只能拆分成多個部分外加移位操作拼接了。

所以這剩下的12位可以再次劃分,8位用作加載0-255中的任意值,4位用作對這個值做0~30位的循環右移。這也就意味著這個立即數可以通過這個公式得到:v = n ror 2*r。換句話說,有效的立即數都可以通過循環右移來得到。這里有一個例子

有效值:
#256        // 1 循環右移 24位 --> 256
#384        // 6 循環右移 26位 --> 384
#484        // 121 循環右移 30位 --> 484
#16384      // 1 循環右移 18位 --> 16384
#2030043136 // 121 循環右移 8位 --> 2030043136
#0x06000000 // 6 循環右移 8位 --> 100663296 (十六進制值0x06000000)

Invalid values:
#370        // 185 循環右移 31位 --> 31不在范圍內 (0 – 30)
#511        // 1 1111 1111 --> 比特模型不符合
#0x06010000 // 1 1000 0001.. --> 比特模型不符合

看上去這樣并不能一次性加載所有的32位值。不過我們可以通過以下的兩個選項來解決這個問題:

  1. 用小部分去組成更大的值。
    1. 比如對于指令 MOV r0, #511
    2. 將511分成兩部分:MOV r0, #256, and ADD r0, #255
  2. 用加載指令構造‘ldr r1,=value’的形式,編譯器會幫你轉換成MOV的形式,如果失敗的話就轉換成從數據段中通過PC相對偏移加載。
    1. LDR r1, =511

如果你嘗試加載一個非法的值,編譯器會報錯并且告訴你:Error: invalid constant。如果在遇到這個問題,你現在應該知道該怎么解決了吧。唉還是舉個栗子,就比如你想把511加載到R0。

.section .text
.global _start

_start:
    mov     r0, #511
    bkpt

這樣做的結果就是編譯報錯:

azeria@labs:~$ as test.s -o test.o
test.s: Assembler messages:
test.s:5: Error: invalid constant (1ff) after fixup

你需要將511分成多部分,或者直接用LDR指令。

.section .text
.global _start

_start:
 mov r0, #256   /* 1 ror 24 = 256, so it's valid */
 add r0, #255   /* 255 ror 0 = 255, valid. r0 = 256 + 255 = 511 */
 ldr r1, =511   /* load 511 from the literal pool using LDR */
 bkpt

如果你想知道你能用的立即數的有效值,你不需要自己計算。我這有個小腳本,看你骨骼驚奇,傳給你呦 rotator.py。用法如下。

azeria@labs:~$ python rotator.py
Enter the value you want to check: 511

Sorry, 511 cannot be used as an immediate number and has to be split.

azeria@labs:~$ python rotator.py
Enter the value you want to check: 256

The number 256 can be used as a valid immediate number.
1 ror 24 --> 256

譯者注:這作者真的是用心良苦,我都看累了,但是怎么說,反復練習加實踐,總歸是有好處的。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容