ARM Assembly에 대해 공부하던 중 좋은 글이 있어 번역 하였다.

출처: Azeria Labs (https://azeria-labs.com/writing-arm-assembly-part-4/)


ARM은 메모리 접근 시 오직 불러오기-저장(load-store, LDR and STR) 명령만을 사용 하도록 하는 불러오기-저장 모델을 사용합니다. x86에서 대부분의 명령들은 직접 메모리 안의 데이터를 접근하는 것과 달리, ARM에서는 데이터는 반드시 처리 전에 레지스터로 불러오는 과정을 거쳐야 합니다. 이 말인 즉슨 특정 메모리에 올라와 있는 32-bit 값을 ARM에서 증가 시키려면 3개의 명령어(불러오기, 증가하기, 저장)를 실행 해야 한다는 이야기 입니다.

ARM에서의 불러오기와 저장 명령의 기본을 설명하기 전에, 기본적인 예제들과 세가지 오프셋 폼, 그리고 각각의 오프셋 폼 별 존재하는 세가지 주소 모드를 배울겁니다. 각각의 예제에서는 같은 어셈블리 명령어에 다른 LDR/STR 오프셋 폼을 사용하여 단순하게 할것입니다. 이 튜토리얼을 잘 이해하기 위해서는 GDB에서 직접 실행해 보면 좋습니다.

<정리>

  1. 오프셋 폼: 직접 입력한 값을 오프셋으로
    • 주소 모드: 오프셋
    • 주소 모드: Pre-Indexed
    • 주소 모드: Post-Indexed
  2. 오프셋 폼: 레지스터를 오프셋으로
    • 주소 모드: 오프셋
    • 주소 모드: Pre-Indexed
    • 주소 모드: Post-Indexed
  3. 오프셋 폼: 스케일된 레지스터를 오프셋으로
    • 주소 모드: 오프셋
    • 주소 모드: Pre-Indexed
    • 주소 모드: Post-Indexed

첫 번째 단순한 예제

일반적으로 LDR은 메모리에서 레지스터로 값을 불러올 때 사용하며, STR은 레지스터의 값을 특정 메모리 주소에 저장할 때 사용합니다.

메모리 &lt;-&gt; 레지스터

LDR R2, [R0]   @ [R0] - R0의 값이 메모리 주소
STR R2, [R1]   @ [R1] - R1의 값이 메모리 주소
  • LDR 명령어: R0가 갖고있는 메모리 주소에 위치하고 있는 값을 R2에 저장
  • STR 명령어: R2가 갖고있는 값을 메모리에 저장

어셈블리 프로그램으로 작성한 프로그램은 아래와 같습니다.

.data          /* .data section 은 자동으로 생기며 위치를 알기 매우 쉬움 */
var1: .word 3  /* 메모리 안에 있는 variable 1 */
var2: .word 4  /* 메모리 안에 있는 variable 2 */

.text          /* 코드 섹션 시작점 */ 
.global _start

_start:
    ldr r0, adr_var1  @ var1 메모리 주소를 label adr_var1 통해 R0에 저장 
    ldr r1, adr_var2  @ var2 메모리 주소를 label adr_var2 통해 R1에 저장 
    ldr r2, [r0]      @ R0에서 가져온 (0x03) R2 위치에 저장  
    str r2, [r1]      @ R2에서 가져온 (0x03) R1 위치에 저장 
    bkpt             

adr_var1: .word var1  /* var1 주소 저장 */
adr_var2: .word var2  /* var2 주소 저장 */

위 코드의 아래쪽에 우리의 리터럴 풀(Literal Pool, 코드 섹션과 동일한 메모리 공간으로 상수, 문자열, 오프셋 등을 포지션과 관계없이 저장 및 사용하기 위해 사용)이 있는데, data 섹션에 있는 var1과 var2의 메모리 주소를 저장하기 위해 사용했습니다. var1, var2의 값이 저장된 주소를 adr_var1, adr_var2로 저장했습니다. 첫번째 LDR은 var1의 주소를 레지스터 R0에 저장합니다. 두번째 LDR은 var2의 주소를 레지스터 R1에 저장합니다. 그 후 R0에 저장된 메모리 주소를 통해 해당 메모리에 저장되어 있는 값을 R2에 불러옵니다. R2에 불러온 값은 R1에 저장되어있는 메모리 주소에 기록합니다.

우리가 레지스터에 뭔가를 불러올 때, 중괄호([ ])의 의미는 우리가 뭔가를 불러오기 위한 메모리 주소를 뜻합니다.

우리가 뭔가를 메모리 주소에 저장할 때, 중괄호([ ])의 의미는 우리가 뭔가를 저장할 때 사용할 메모리 주소를 뜻합니다.

이러한 내용들이 실제 내용보다 복잡해 보이니까, 텍스트가 아닌 구체적인 사례를 보면서 어떻게 메모리에서 값을 불러오고 저장하는지 알아봅시다.

레지스터

같은 코드를 디버거에서 보면 아래와 같습니다.

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-Relative addressing)라고 합니다. 우리가 레이블을 사용했기때문에 컴파일러가 리터럴 풀에서 값을 특정할 수 있습니다(PC+12). 이 값을 직접 계산할 수도 있고, 아니면 레이블을 사용할 수도 있습니다. 두 개의 유일한 차이점은, 당신이 수동으로 포지션을 계산 하느냐 마느냐 입니다. PC-관련 주소에 대한 자세한 내용은 이 챕터의 아래쪽에서 다루도록 하겠습니다.

추가 노트: 만약 왜 effective PC가 현재의 PC에서 왜 2개의 명령어를 앞서 있는지를 까먹었다면, Part 2를 복습하세요. 복습하기 귀찮다면… PC는 현재 주소에서 더하기 8(ARM 기준) 혹은 4(Thumb 기준)해서 저장되기 때문입니다.

어셈블리

1. 오프셋 폼: 값을 직접 오프셋으로 사용

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

위의 예제는 값(숫자)을 직접 오프셋으로 사용하는 경우입니다. 해당 값을 베이스 레지스터에서 더하거나 뺀 후 메모리 접근 시 사용합니다. 오프셋 값은 컴파일 시 결정됩니다.

.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에 저장된 메모리 주소 저장돼 있는 (0x03) R2에 저장
    str r2, [r1, #2]  @ 주소 모드: R2에 저장된 (0x03) R1에 저장된 메모리  + 2 위치에 저장함. 베이스 레지스터(R1) 값에는 변동 없음
    str r2, [r1, #4]! @ 주소 모드: pre-indexed. R2에 저장된 (0x03) R1에 저장된 메모리  + 4 저장함. 베이스 레지스터(R1) 변동: R1 = R1+4
    ldr r3, [r1], #4  @ 주소 모드: post-indexed. R1에 저장된 메모리 주소 내의 값을 R3에 저장함. 베이스 레지스터(R1) 변경됨: R1=R1+4
    bkpt

adr_var1: .word var1
adr_var2: .word var2

위 프로그램을 ldr.s로 저장하고 컴파일 후 GDB에서 실행하면 어떻게 되는지 봅시다.

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

GDB(gef 설치된 버전)에서 _start에 breakpoint를 걸고 프로그램을 실행해 봅시다.

gef> break _start
gef> run
...
gef> nexti 3 /* 다음 3개 명령 실행 */

레지스터들이 아래 값으로 채워진 것을 확인할 수 있습니다(값은 시스템 별로 상이할 수 있으니 참고하세요).

$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의 값(0x3)을 R1이 저장하고 있는 메모리 주소(0x1009c) + 오프셋(#2) = 0x1009e 위치에 저장할 것입니다.

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

다음 STR 명령은 pre-indexed 주소 모드를 사용합니다. 이 모드 적용 여부는 느낌표(!)를 통해 알 수 있습니다. 이 모드의 다른점은 베이스 레지스터가 R2의 값이 저장될 최종 메모리 주소로 업데이트 된다는 점입니다. 이 말인 즉슨, 우리는 R2에 저장된 값(0x3)을 R1이 가지고 있는 메모리 주소(0x1009c) + 오프셋(#4) = 0x100A0에 저장하고, R1을 이 주소(0x100A0)로 업데이트 한다는 것이죠.

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

마지막 LDR 명령은 post-indexed 주소 모드를 사용합니다. 이 모드는 베이스 레지스터(R1)을 최종 주소로 사용하고, 오프셋을 계산(R1+4) 후 해당 값을 업데이트 합니다. 이 말인 즉슨, R1+4가 아니라 R1(0x100A0)에 저장된 값을 찾아서 R3에 불러옵니다. 그 후 R1을 R1(0x100A0) + 오프셋(#4) = 0x100A4 로 업데이트 합니다.

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

아래에 위 과정을 그림으로 표현해 보았습니다.

레지스터

2. 오프셋 폼: 레지스터를 오프셋으로 사용

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

이 오프셋 폼은 레지스터를 오프셋으로 사용합니다. 이러한 오프셋 폼은 코드 내에서 런타임에 인덱스를 계산해서 배열을 접근할 때 사용합니다.

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

.text
.global _start

_start:
    ldr r0, adr_var1  @ adr_var1에 저장된 var1의 메모리 주소를 R0에 저장
    ldr r1, adr_var2  @ adr_var2에 저장된 var2의 메모리 주소를 R1에 저장
    ldr r2, [r0]      @ R0에 저장된 메모리 주소에서 가져온 (0x03) Offset R2에 저장
    str r2, [r1, r2]  @ 주소 모드: 오프셋. R2에 저장된 (0x03) R1에 저장된 메모리 주소에 오프셋 R2(0x03) 포함하여 저장. 베이스 레지스터 변동 없음.
    str r2, [r1, r2]! @ 주소 모드: pre-indexed. R2에 저장된 (0x03) R1에 저장된 메모리 주소에 오프셋 R2(0x03) 포함하여 저장. 베이스 레지스터 변경: R1=R1+R2
    ldr r3, [r1], r2  @ 주소 모드: post-indexed. R1에 저장된 메모리 주소에 존재하는 값을 R3에 불러옴. 베이스 레지스터 변동: R1=R1+R2
    bx lr

adr_var1: .word var1
adr_var2: .word var2

오프셋 주소 모드로 첫번째 STR 명령을 실행 하면, R2의 값 (0x3)이 메모리 주소(0x1009c + 0x3 = 0x1009F)에 저장됨

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

두 번째 STR 명령은 pre-indexed 주소 모드로 위와 같은 결과를 가지지만, 차이점은 베이스 레지스터(R1)을 계산한 메모리 값(R1+R2)으로 변경 하는 점이 있습니다.

gef> info register r1
r1 **0x1009f** 65695

마지막 LDR 명령은 post-indexed 주소 모드로 R1에서 찾은 메모리 주소의 값을 R2로 불러온 후에 베이스 레지스터 R1을 업데이트(R1+R2 = 0x1009f + 0x3 = 0x100a2) 합니다.

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

레지스터

3. 오프셋 폼: 스케일된 레지스터를 오프셋으로 사용

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

세 번째 오프셋 폼은 스케일된 레지스터를 오프셋으로 가집니다. 위의 폼에서 Rb는 베이스 레지스터이고 Rc는 직접 값 오프셋(혹은 값을 가진 레지스터)을 기반으로 오른쪽/왼쪽 쉬프팅된() 값입니다. 이 말인 즉슨, 배럴 쉬프터가 오프셋 스케일링에 사용됐다는 이야기 입니다. 이 오프셋을 사용하는 예는 배열 값을 순차적으로 접근할 때 입니다. 아래에 GDB를 통해 실행하는 예제를 참고해 주세요.

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

.text
.global _start

_start:
    ldr r0, adr_var1         
    ldr r1, adr_var2         
    ldr r2, [r0]             
    str r2, [r1, r2, LSL#2]  
    str r2, [r1, r2, LSL#2]! 
    ldr r3, [r1], r2, LSL#2  
    bkpt

adr_var1: .word var1
adr_var2: .word var2

첫번째 STR 명령은 오프셋 주소 모드를 사용하여 R2 내의 값을 특정 메모리 주소([r1, r2, LSL#2] 연산 결과)에 기록합니다. 메모리 주소를 계산해 보면, R1의 값을 베이스로 사용(이 경우에는 R1에 var2의 주소값이 들어 있음)하고, R2의 값(0x3)을 가져와서 왼쪽으로 2번 쉬프팅을 합니다. 아래 그림을 통해 어떻게 메모리 주소가 계산되는지 보겠습니다.

메모리 주소 계산

두번째 STR 명령은 pre-indexed 주소 모드를 사용합니다. 이 말은, 이전 명령과 동일한 명령을 수행하지만, 다른점은 R1을 계산한 메모리 주소 값으로 변경하는 점입니다. 다른 말로 얘기하자면, 메모리 주소(R1(0x1009c) + 왼쪽 오프셋으로 #2만큼 옮겨진(0x03 LSL#2 = 0xC) = 0x100a8)에 값을 저장한 후 R1을 0x100a8로 업데이트 합니다.

gef> info register r1
r1 **0x100a8** 65704

마지막 LDR 명령은 post-indexed 주소 모드를 사용합니다. 이 말은, R1(0x100a8)에 저장된 메모리 주소 내의 값을 R3에 저장한 후 베이스 레지스터인 R1을 계산한 값(R1(0x100a8) + R2 오프셋(0x3)을 왼쪽으로 #2 만큼 쉬프팅한 값 (0xC)=0x100b4) 으로 변경합니다.

gef> info register r1
r1 **0x100b4** 65716

정리

LDR/STR에는 세가지의 오프셋 모드가 있습니다.

  1. 직접 값을 오프셋으로 사용하는 경우
    • ldr r3, [r1, #4]
  2. 레지스터를 오프셋으로 사용하는 경우
    • ldr r3, [r1, r2]
  3. 스케일된 레지스터를 오프셋으로 사용하는 경우
    • ldr r3, [r1, r2, LSL#2]

LDR/STR의 다양한 모드를 기억하는 방법은 아래와 같습니다.

  1. 만약 **!**가 있다면 prefix 주소 모드
    • ldr r3, [r1, #4]!
    • ldr r3, [r1, r2]!
    • ldr r3, [r1, r2, LSL#2]!
  2. 만약 **베이스 레지스터가 **로 감싸져 있다면, postfix 주소 모드
    • ldr r3, [r1], #r
    • ldr r3, [r1], r2
    • ldr r3, [r1], r2, LSL#2
  3. 다른거오프셋 주소 모드
    • 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        /* load the address of the function label jump into R0 */
   ldr r1, =0x68DB00AD  /* load the value 0x68DB00AD into R1 */
jump:
   ldr r2, =511         /* load the value 511 into R2 */ 
   bkpt

이러한 명령어들은 “의사 명령어(pseudo-instructions)” 라고 부릅니다. 우리는 이 명령어 문법을 리터럴 풀에 있는 데이터를 참조하기 위해 사용합니다. 리터럴 풀은 같은 섹션에 있는 메모리 공간(왜냐하면 리터럴 풀은 코드의 일부이기 때문입니다)으로, 상수나 문자열, 오프셋 등을 저장하기 위해 사용합니다. 위의 예제에서는 의사 명령어를 사용해서 함수의 오프셋을 참조하기 위해 사용하고, 하나의 명령을 통해 32-bit 상수를 레지스터로 옮기는데에 사용됩니다. 왜냐하면 ARM은 8-bit 값만 한번에 불러올 수 있기 때문입니다. 뭐라고요?! 왜 그런지를 이해하기 위해서는, 어떻게 ARM이 값(Immediate Values)을 핸들링 하는지 알아야 합니다.

ARM에서의 값(Immediate Values) 사용

ARM 에서 레지스터에 값을 로드하는 것은 x86처럼 직관적이지 않습니다. 값을 사용할 때 제약사항이 있습니다. 어떠한 제약사항이 있으며 어떻게 해결 해야하는지가 ARM 어셈블리의 가장 재밌는 부분이며, 이러한 제약 사항을 어떻게 해결해야 하는지에 대한 팁들을 아래에 기술해 두었습니다(힌트: LDR).

우리는 각각의 ARM 명령어가 32-bit인걸 알고 있으며, 모든 명령어는 조건기반입니다. 16개의 조건 코드가 있으며, 하나의 조건 코드는 4 비트의 명령을 할당 받을 수 있습니다. 그 후 남는 2비트는 결과 레지스터로 사용하고, 2비트는 첫번째 인자 레지스터로 사용하고, 1비트는 상태값 플래그이며, 그 외에 비트는 실제 인자값 등으로 사용합니다. 중요한 것은, 비트를 명령어, 레지스터, 다른 필드 등으로 설정한 뒤에 오직 12비트만 값을 쓰기 위해 남는다는 점입니다. 12 비트이니 총 4096개의 다른 값을 가질 수 있습니다.

이 말인 즉슨 ARM 명령은 제한적인 값만을 MOV를 통해 직접 사용할 수 있다는 점입니다. 만약 숫자를 직접 사용할 수 없다면, 파트로 나누어서 여러 작은 숫자를 사용해야 할 것입니다.

하지만 여기에 더 나아가서 생각해 보면, 12비트를 단순 숫자로 사용하지 말고, 12비트를 8비트 숫자로 나눈 후 4비트 로테이션 필드(r)을 사용해서 오른쪽으로 로테이션을 해서 사용할 수도 있겠죠. 이걸 공식화 하면 이렇습니다: v = n ror 2*r. 이 공식을 사용한다면 실제 값은 오직 로테이션된 값(짝수)일 것입니다.

아래에 이러한 값들에 대한 예제입니다.

Valid values:
#256        // 1 ror 24 --> 256
#384        // 6 ror 26 --> 384
#484        // 121 ror 30 --> 484
#16384      // 1 ror 18 --> 16384
#2030043136 // 121 ror 8 --> 2030043136
#0x06000000 // 6 ror 8 --> 100663296 (0x06000000 in hex)

Invalid values:
#370        // 185 ror 31 --> 31 is not in range (0 – 30)
#511        // 1 1111 1111 --> bit-pattern can’t fit into one byte
#0x06010000 // 1 1000 0001.. --> bit-pattern can’t fit into one byte

위의 예제를 볼 때, 전체 32bit 주소를 한번에 사용하는 것은 불가능은 아니라는 결론에 도달합니다. 우리는 아래 두 가지 옵션 중 한 가지 옵션을 충족 한다면 표현의 제한을 우회할 수 있습니다.

  1. 큰 값을 작은 파트를 통해 표현할 수 있음
    1. MOV r0, #511 사용 대신
    2. 511을 두개의 파트로 나눠서 사용: MOV r0, #256과 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

만약 특정 숫자가 사용 가능한 값인지 확인하기 위해서는 직접 계산할 필요 없이 저자가 작성한 rotater.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