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

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


데이터 타입들

Data Types

하이레벨 언어들과 비슷하게, ARM은 다른 데이터타입에 대한 연산을 지원합니다. 우리가 불러오거나 저장할 수 있는 데이터 타입들은 부호가 없는 워드(unsigned words), 반 워드(half words), 혹은 바이트(bytes) 입니다. 이 데이터 타입들에 대해 붙는 전치사(익스텐션)들은: -h or -sh: halfwords, -b or -sb: bytes이며 워드(words)는 익스텐션이 없습니다. 부호가 있는(signed) 것과 없는(unsigned)것의 차이는 아래와 같습니다.

  • 부호가 있는 데이터 타입들은 양수와 음수를 모두 저장할 수 있으므로, 표현할 수 있는 범위(range)가 적습니다.
  • 부호가 없는 데이터 타입은 0을 포함한 큰 양수를 저장할 수 있지만 음수를 저장할 수 없으므로, 표현할 수 있는 범위가 넓습니다.

아래에 데이터 타입들이 실제 명령어로 어떻게 사용되는지 예제를 참고해 주세요.

ldr = Load Word
ldr**h** = Load unsigned Half Word
ldr**sh** = Load signed Half Word
ldr**b** = Load unsigned Byte
ldr**sb** = Load signed Bytes

str = Store Word
str**h** = Store unsigned Half Word
str**sh** = Store signed Half Word
str**b** = Store unsigned Byte
str**sb** = Store signed Byte

엔디안(Endianness)

메모리에서 바이트를 읽는 두 가지 방법이 있습니다: 리틀엔디안(Little-Endian, LE)과 빅엔디안(Big-Endian, BE). 두개의 차이는 메모리에 저장될 때 쓰는 방식입니다(번역자의 코멘트: 한국어와 아랍어의 읽는 방식 차이라고 생각하면 쉽습니다). 리틀 엔디안을 사용하는 인텔 x86 기계들의 경우 가장 낮은 바이트(least-significant-byte)가 낮은 주소에 저장됩니다. ARM 아키텍쳐는 버전 3 이전에는 리틀 엔디안을 사용했지만, 그 이후로는 바이 엔디안(bi-endian)을 사용하는데, 그말인 즉슨 엔디안을 상호 변환할 수 있다는 겁니다. ARMv6의 경우를 예를 들면, 명령어들은 고정된 리틀 엔디안을 사용하고 데이터 접근 시에는 리틀 엔디안 혹은 빅 엔디안을 프로그램 상태 레지스터(Program Status Register, CPSR)의 비트 9, E 비트를 사용해서 읽어올 수 있습니다.

Endianness

ARM 레지스터

레지스터의 갯수는 ARM 버전에 따라 달라집니다. ARM 레퍼런스 메뉴얼에 따르면, 30개의 일반 목적의 32-bit 레지스터가 있습니다(ARMv6-M과 ARMv7-M 기반의 프로세서는 예외로 합니다). 첫 16개의 레지스터는 사용자 레벨 모드에서 접근 가능하며, 추가 레지스터들은 권한 상승된 소프트웨어 실행 시(ARMv6-M과 ARMv70M 기반의 프로세서 에서는 예외로 합니다) 접근할 수 있습니다. 이 튜토리얼에서는 우리는 권한 상승된 모드에서 접근 가능한 r0-15 레지스터에 대해 배울 것입니다. 이 16개의 레지스터는 2개의 그룹으로 나뉠 수 있습니다: 일반 목적의 레지스터(R0-R11)와 특수 목적의 레지스터(R12-R15).

No.줄임말(별칭)용도
R0일반 용도
R1일반 용도
R2일반 용도
R3일반 용도
R4일반 용도
R5일반 용도
R6일반 용도
R7시스템 명령(Syscall) 숫자 보관용
R8일반 용도
R9일반 용도
R10일반 용도
R11FP프레임 포인터(Frame Pointer)
아래는 특수목적 레지스터들
R12IP내부 프로시저 콜 (Intra Procedure Call)
R13SP스택 포인터
R14LR링크 레지스터
R15PC프로그램 카운터
CPSR현재 프로그램 상태 레지스터

아래 표에 인텔 프로세서와 ARM 레지스터를 간단하게 비교해 두었습니다.

ARM설명x86
R0일반 용도EAX
R1-R5일반 용도EBX, ECX, EDX, ESI, EDI
R6-R10일반 용도-
R11(FP)프레임 포인터EBP
R12내부 프로시저 콜-
R13(SP)스택 포인터ESP
R14(LR)링크 레지스터-
R15(PC)<- 프로그램 카운터 / 명령 카운터 ->EIP
CPSR현재 프로그램 상태 레지스터/플래그EFLAGS

R0-R12: 일반적인 명령 처리 중 임시 값, 포인터(메모리 주소) 등을 저장할 때 사용합니다. 예를 들어 R0는 숫자 연산 작업 중 이전 명령의 결과를 임시로 저장할 때 사용합니다. R7은 syscall(시스템 명령 호출) 번호를 저장하는데 사용하고, R11은 스택의 범위를 추적하여 프레임 포인터로 사용합니다(추후 상세 설명 예정 입니다). 추가적으로, ARM의 함수 호출 컨벤션상 첫 4개의 인자값은 r0-r3 레지스터에 저장하도록 규정하고 있습니다.

R13: SP(스택 포인터). 스택 포인터는 스택의 가장 높은 곳을 가르킵니다. 스택은 메모리 영역 중 특정 함수의 저장 공간으로 사용되며, 함수 실행이 종료되면 반환됩니다. 그러므로 스택 포인터는 스택 내 공간을 할당하는데 사용하며, 공간 할당 시에는 가지고 있는 포인터에서 할당을 원하는 용량 만큼을 뺍니다. 우리가 32-bit 만큼을 할당하고 싶다면, 스택 포인터에서 4를 빼면 됩니다.

R14: LR(링크 레지스터). 함수 호출이 이루어 질 때 링크 레지스터는 명령이 실행된 곳에서 다음에 실행할 명령어의 메모리 주소를 가리킵니다. 이렇게 함으로서 호출된 자식 함수로부터 최초 호출한 부모 함수로 되돌아 갔을때 다음에 실행할 명령을 확인할 수 있습니다.

R15: PC(프로그램 카운터). 프로그램 카운터는 명령이 실행되면 자동으로 특정 크기만큼 증가합니다. ARM 상태에서는 4바이트 증가하고, THUMB 모드에서는 2바이트 증가합니다. 분기 명령이 실행 되었을 경우, PC는 목적지 주소를 저장합니다. 명령 실행 동안에 ARM 상태에서 PC는 현재 명령어 더하기 8 (두개의 ARM 명령어) 의 주소를 저장하고 있고, THUMB(v1) 상태에서는 더하기 4 (두개의 THUMB 명령어) 한 주소값을 저장하고 있습니다. 이 점이 바로 다음 명령어 위치를 가리키는 x86과의 다른점입니다.

어떻게 PC가 디버거에서 작동하는지 보겠습니다. 아래 프로그램은 PC의 주소를 r0에 저장하고 두 개의 랜덤 명령을 포함하고 있습니다. 어떤일이 일어나는지 확인해 봅시다.

.section .text
.global _start

_start:
  mov r0, pc
  mov r1, #2
  add r2, r1, r1
  bkpt

GDB에서 우리가 breakpoint를 _start에 걸고 아래와 같이 실행합니다.

gef> br _start
Breakpoint 1 at 0x8054
gef> run

실행 후 우리가 처음 보는 결과물은 아래와 같습니다.

$r0 0x00000000   $r1 0x00000000   $r2 0x00000000   $r3 0x00000000 
$r4 0x00000000   $r5 0x00000000   $r6 0x00000000   $r7 0x00000000 
$r8 0x00000000   $r9 0x00000000   $r10 0x00000000  $r11 0x00000000 
$r12 0x00000000  $sp 0xbefff7e0   $lr 0x00000000   **$pc 0x00008054** 
$cpsr 0x00000010 

**0x8054 <_start> mov r0, pc     <- $pc**
0x8058 <_start+4> mov r0, #2
0x805c <_start+8> add r1, r0, r0
0x8060 <_start+12> bkpt 0x0000
0x8064 andeq r1, r0, r1, asr #10
0x8068 cmnvs r5, r0, lsl #2
0x806c tsteq r0, r2, ror #18
0x8070 andeq r0, r0, r11
0x8074 tsteq r8, r6, lsl #6

위에서 우리는 PC가 다음에 실행할 명령(mov r0, pc)의 메모리 주소(0x8054)를 가지고 있음을 알고 있습니다. 이제 명령을 실행하면 r0가 PC(0x8054)의 주소를 가지고 있어야 합니다. 맞죠? 아래에서 결과를 확인해 봅시다.

**$r0 0x0000805c**   $r1 0x00000000   $r2 0x00000000   $r3 0x00000000 
$r4 0x00000000   $r5 0x00000000   $r6 0x00000000   $r7 0x00000000 
$r8 0x00000000   $r9 0x00000000   $r10 0x00000000  $r11 0x00000000 
$r12 0x00000000  $sp 0xbefff7e0   $lr 0x00000000   **$pc 0x00008058** 
$cpsr 0x00000010

**0x8058 <_start+4> mov r0, #2       <- $pc**
0x805c <_start+8> add r1, r0, r0
0x8060 <_start+12> bkpt 0x0000
0x8064 andeq r1, r0, r1, asr #10
0x8068 cmnvs r5, r0, lsl #2
0x806c tsteq r0, r2, ror #18
0x8070 andeq r0, r0, r11
0x8074 tsteq r8, r6, lsl #6
0x8078 adfcssp f0, f0, #4.0

…맞나요? 아뇨 틀렸습니다. R0에 있는 값을 보세요. 우리는 R0에 전에 미리 읽어놨던 $pc의 값(0x8054) 일것이라고 생각했는데, 우리가 전에 읽었던 실제로는 PC로부터 2 명령어가 지난 값(0x805c)을 저장하고 있습니다. 이 예제를 통해 우리가 직접 PC를 읽을 경우 PC는 바로 다음 명령을 가리킨다는 것을 알 수 있습니다. 그렇지만 디버깅을 해 보면, PC는 현재 PC값에서 다음 두 번째 명령어 위치 (0x8054 + 0x8 = 0x805C)를 가리킨다는 것을 알 수 있습니다. ARM이 이런식으로 만들어져 있는 이유는 이전 세대의 프로세서와의 호환성을 위해서 입니다.

현재 프로그램 상태 레지스터 (CPSR)

gdb를 사용해서 ARM 바이너리를 디버깅 해 보면, 가끔 아래와 같은 플래그(Flags)를 볼 수 있습니다.

Flags

레지스터 $cpsr는 현재 프로그램 상태 레지스터(Current Program Status Register, CPSR) 값을 보여주며, 해당 값을 통해 플래그 - thumb, fast, interrupt, overflow, carry, zero, negative - 를 확인할 수 있습니다. 이 플래그들은 CPSR 레지스터 내의 특정 위치의 비트 값을 통해 활성화 여부를 확인할 수 있습니다. N, Z, C, V 비트들은 x86에서 사용하는 EFLAG 레지스터의 SF, ZF, CF, OF 비트와 동일합니다. 이 비트들은 조건문 혹은 반복문을 어셈블리 레벨에서 서포트 해주는 역할을 합니다. 조건문 코드는 Part 6. 조건문 실행 및 분기 에서 다룰 예정입니다.

CPSR 레지스터

위 사진은 32-bit CPSR 레지스터의 레이아웃을 표시하며, 왼쪽편이 최상위비트(most-significant-bits)이며 오른쪽이 최하위비트(least-significant-bits) 입니다. GE와 M 섹션 및 빈 공간 제외하고 모든 각각의 셀은 1비트 크기를 나타냅니다. 각각의 비트들은 현재 상태를 표현하는데 사용됩니다.

플래그설명
N(Negative)명령의 결과가 음수일 경우 활성화 됩니다.
Z(Zero)명령의 결과가 0일경우 활성화 됩니다.
C(Carry)명령의 결과를 표현하기 위해 33번째 비트가 필요할 경우 활성화 됩니다.
V(Overflow)명령의 결과를 2의 보수인 32비트로 표현할 수 없을 경우 활성화 됩니다.
E(Endian-bit)ARM은 리틀엔디안이나 빅엔디안을 모두 사용할 수 있습니다. 0을 사용하면 리틀엔디안, 1을 사용하면 빅엔디안 모드입니다.
T(Thumb-bit)THUMB 상태이면 활성화 되며 ARM 상태이면 비활성화 됩니다.
M(Mode-bits)권한상승모드(USR, SVC 등) 일 때 활성화 됩니다.
J(Jazelle)ARM 프로세서가 하드웨어에서 Java 바이트 코드를 실행하도록 허용할 경우 활성화 됩니다.

만약 우리가 CMP 명령을 통해 1과 2를 비교한다고 생각해 봅시다. 결과는 “마이너스(negative)” 일 것입니다 - 왜냐하면 1 - 2 = -1 이니까요. 우리가 동일한 숫자 두개를 비교할 때, 예를들면 2와 2를 비교한다고 가정해 보면, Z (Zero) 플래그가 설정됩니다. 왜냐하면 2 - 2 = 0 이기 때문이죠. 다만 CMP 명령에 사용되는 레지스터들은 수정할 수 없으며, CSPR이 각각의 레지스터를 비교한 결과를 스스로 수정한다는 사실을 명심하세요.

이제 GDB에서(GEF가 설치 된 상태에서) 우리가 r1과 r0을 비교했을 때, r1=4이고 r0=2 일 때, 어떻게 보여지는지 확인해 보겠습니다. 실행 결과는 아래와 같습니다:

실행 결과

CARRY 플래그가 설정된 것을 볼 수 있습니다. 왜냐하면 우리는 cmp r1, r0 명령을 통해 r1=4, r0=2를 비교했고, 4-2=2 이기 때문입니다. 이에 반해, negative 플래그(N)은 설정되지 않았는데 왜냐하면 큰 숫자(4)와 작은 숫자(2)를 비교했기 때문입니다. 만약 우리가 cmp r0, r1 명령을 사용했다면 negative 플래그가 활성화 되었을 것입니다.

아래에 ARM Infocenter에 명시된 내용을 발췌해 보았습니다:

  • N - 명령어 실행 결과가 음수일 때 활성화
  • Z - 명령어 실행 결과가 0일때 활성화
  • C - 명령어 실행 결과 캐리(carry)가 생길 경우 활성화
  • V - 명령어 실행 결과 오버플로우가 생길 경우 활성화

캐리가 생기는 경우는 아래와 같습니다:

  • 덧셈을 한 결과값이 2^32 보다 크거나 같을 때
  • 뺄셈을 한 결과값이 양수이거나 0일 때
  • move나 논리 명령 실행 중 실행된 인라인 배럴 시프터(inline barrel shifter) 명령의 결과

(참고) 인라인 베럴 시프터(inline barrel shifter) 명령이란?

  • ARM 산술 논리 장치에는 시프트 및 회전 작업이 가능한 32 비트 배럴 시프터가 있습니다. 여러 ARM 및 Thumb 데이터 처리 및 단일 레지스터 데이터 전송 명령어에 대한 두 번째 피연산자는 데이터 처리 또는 데이터 전송이 명령어의 일부로 실행되기 전에 이동 될 수 있습니다. (참고)
  • 복잡한 명령어들을 처리할 때 사용합니다.

오버플로우는 덧셈, 뺄셈, 비교의 결과값이 2^31 보다 같거나 혹은 크거나, -2^31보다 작을 때 발생합니다.