얼마 전 x86_64 어셈블리 프로그래밍에 관한 블로그 포스트 시리즈를 작성하기 시작했습니다. 관련 포스트는 asm
태그로 찾을 수 있습니다.
최근에는 바빠서 새로운 포스트가 없었지만, 오늘부터 다시 어셈블리에 대한 포스트를 작성할 예정이며, 매주 포스트를 시도할 것입니다.
오늘은 문자열과 문자열 작업에 대해 살펴보겠습니다. 여전히 NASM 어셈블러와 Linux x86_64를 사용할 것입니다.
어셈블리 프로그래밍 언어에 대해 이야기할 때 문자열 데이터 타입에 대해 논의할 수 없습니다. 실제로 우리는 바이트 배열을 다루고 있습니다. 간단한 예제를 작성해 보겠습니다.
문자열 데이터를 정의하고 이를 뒤집어 결과를 표준 출력에 작성하는 작업을 시도할 것입니다. 이 작업은 새로운 프로그래밍 언어를 배우기 시작할 때 일반적으로 접하게 되는 간단하고 인기 있는 과제입니다. 구현을 살펴보겠습니다.
우선, 초기화된 데이터를 정의합니다. 이는 데이터 섹션에 배치될 것입니다 (섹션에 대한 내용은 이전 포스트에서 읽어보세요):
section .data
SYS_WRITE equ 1
STD_OUT equ 1
SYS_EXIT equ 60
EXIT_CODE equ 0
NEW_LINE db 0xa
INPUT db "Hello world!"
여기서 네 가지 상수를 볼 수 있습니다:
SYS_WRITE
- 'write' 시스템 호출 번호STD_OUT
- 표준 출력 파일 디스크립터SYS_EXIT
- 'exit' 시스템 호출 번호EXIT_CODE
- 종료 코드
시스템 호출 목록은 여기에서 확인할 수 있습니다. 또한 다음이 정의되어 있습니다:
NEW_LINE
- 새 줄 (\n) 기호INPUT
- 우리가 뒤집을 입력 문자열
다음으로, 문자열을 뒤집어 넣을 버퍼를 위한 bss 섹션을 정의합니다:
section .bss
OUTPUT resb 12
좋아요 이제 데이터와 결과를 넣을 버퍼를 준비했으므로, 코드를 위한 text 섹션을 정의할 수 있습니다.
_start 루틴부터 시작해 보겠습니다:
_start:
mov rsi, INPUT
xor rcx, rcx
cld
mov rdi, $ + 15
call calculateStrLength
xor rax, rax
xor rdi, rdi
jmp reverseStr
여기서 새로운 부분이 있습니다. 어떻게 작동하는지 살펴보겠습니다: 우선, 2행에서 INPUT 주소를 rsi 레지스터에 넣습니다. 표준 출력으로 쓰는 것과 마찬가지로, rcx 레지스터를 0으로 초기화합니다.
rcx는 문자열의 길이를 계산하는 카운터 역할을 합니다. 4행에서 cld 명령어를 볼 수 있습니다. 이 명령어는 df 플래그를 0으로 설정합니다. 이는 문자열의 길이를 계산할 때 문자열의 기호를 왼쪽에서 오른쪽으로 처리하기 위해 필요합니다. 다음으로 calculateStrLength 함수를 호출합니다.
5행의 mov rdi, $ + 15 명령어를 누락했는데, 이에 대해 조금 이따 설명하겠습니다. 이제 calculateStrLength 구현을 살펴보겠습니다:
calculateStrLength:
;; 문자열의 끝인지 확인
cmp byte [rsi], 0
;; 문자열의 끝이라면 함수 종료
je exitFromRoutine
;; rsi에서 바이트를 al로 로드하고 rsi를 증가
lodsb
;; 심볼을 스택에 푸시
push rax
;; 카운터 증가
inc rcx
;; 다시 루프
jmp calculateStrLength
이제 calculateStrLength 함수에서 문자열을 스택에 푸시한 후, 어떻게 _start로 돌아가는지 살펴보겠습니다. 이 작업을 수행하려면 ret 명령어를 사용할 수 있습니다. 그러나 다음과 같은 코드가 있을 경우 문제가 발생할 수 있습니다:
exitFromRoutine:
;; return to _start
ret
이 코드가 작동하지 않는 이유는 무엇일까요? 이는 조금 복잡합니다. 함수가 호출될 때, 함수의 매개변수는 오른쪽에서 왼쪽으로 스택에 푸시됩니다.
그런 다음, 반환 주소가 스택에 푸시되어 함수가 끝난 후 어디로 돌아가야 하는지 알 수 있습니다. 그러나 calculateStrLength 함수에서 문자열의 각 문자를 스택에 푸시하였고, 이제 스택의 최상단에 반환 주소가 없기 때문에 함수가 어디로 돌아가야 하는지 알 수 없습니다.
mov rdi, $ + 15
이 문제를 해결하기 위해, 코드에서 다음과 같은 명령어를 살펴보겠습니다:
여기서:
$
- 현재 위치를 반환합니다.$$
- 현재 섹션의 시작 위치를 반환합니다.
mov rdi, $ + 15는 현재 위치에서 15바이트를 더한 위치를 반환합니다. 이는 calculateStrLength 호출 이후에 실행될 코드의 주소를 저장하기 위함입니다.
구체적으로, mov rdi, $ + 15 명령어는 calculateStrLength 호출 이후의 주소를 rdi 레지스터에 저장합니다.
이제 objdump 유틸리티를 사용하여 reverse 파일을 열어보겠습니다:
objdump -D reverse
reverse: file format elf64-x86-64
Disassembly of section .text:
00000000004000b0 <_start>:
4000b0: 48 be 41 01 60 00 00 movabs $0x600141,%rsi
4000b7: 00 00 00
4000ba: 48 31 c9 xor %rcx,%rcx
4000bd: fc cld
4000be: 48 bf cd 00 40 00 00 movabs $0x4000cd,%rdi
4000c5: 00 00 00
4000c8: e8 08 00 00 00 callq 4000d5 <calculateStrLength>
4000cd: 48 31 c0 xor %rax,%rax
4000d0: 48 31 ff xor %rdi,%rdi
4000d3: eb 0e jmp 4000e3 <reverseStr>
우리는 이제 반환 주소가 스택에 올바르게 푸시되고 함수가 반환되는 방법을 이해했습니다. 이렇게 하면 _start로 정확하게 돌아올 수 있습니다. calculateStrLength 함수 호출 후, rax와 rdi를 0으로 초기화하고 reverseStr 레이블로 점프합니다.
이제 reverseStr 레이블의 구현을 살펴보겠습니다:
exitFromRoutine:
;; 반환 주소를 스택에 다시 푸시
push rdi
;; _start로 반환
ret
이제 우리는 _start로 돌아옵니다. calculateStrLength를 호출한 후, rax와 rdi에 0을 저장하고 reverseStr 레이블로 점프합니다.
reverseStr의 구현은 다음과 같습니다:
reverseStr:
cmp rcx, 0
je printResult
pop rax
mov [OUTPUT + rdi], rax
dec rcx
inc rdi
jmp reverseStr
여기서는 문자열의 길이를 나타내는 카운터를 확인하고, 카운터가 0이 되면 모든 기호를 버퍼에 썼으므로 이를 출력할 수 있습니다. 카운터를 확인한 후, 스택에서 rax 레지스터로 첫 번째 기호를 팝하여 OUTPUT 버퍼에 씁니다.
rdi를 추가하여 기호가 버퍼의 첫 번째 바이트에 쓰이지 않도록 합니다. 그 후 rdi를 증가시켜 OUTPUT 버퍼의 다음 위치로 이동하고, 길이 카운터를 감소시킨 후 레이블의 시작으로 점프합니다.
reverseStr가 실행된 후에는 OUTPUT 버퍼에 문자열이 역순으로 저장되어 있으며, 새 줄을 추가하여 결과를 stdout에 쓸 수 있습니다.
printResult:
mov rdx, rdi
mov rax, 1
mov rdi, 1
mov rsi, OUTPUT
syscall
jmp printNewLine
printNewLine:
mov rax, SYS_WRITE
mov rdi, STD_OUT
mov rsi, NEW_LINE
mov rdx, 1
syscall
jmp exit
그리고 프로그램을 종료합니다:
exit:
mov rax, SYS_EXIT
mov rdi, EXIT_CODE
syscall
이제 모든 것이 완료되었습니다. 프로그램을 다음 명령어로 컴파일할 수 있습니다:
all:
nasm -g -f elf64 -o reverse.o reverse.asm
ld -o reverse reverse.o
clean:
rm reverse reverse.o
실행 결과:
물론 문자열 및 바이트 조작을 위한 많은 다른 명령어가 있습니다:
REP
- RCX가 0이 아닌 동안 반복합니다.MOVSB
- 바이트 문자열을 복사합니다 (MOVSW, MOVSD 등도 사용 가능합니다..)CMPSB
- 바이트 문자열 비교SCASB
- 바이트 문자열 스캔STOSB
- 문자열에 바이트를 기록합니다