어셈블리로 코딩하면 재밌겠다 싶었지만 실제로 이렇게 코딩을 할 수 있을줄이야.
근데, 어셈블리로 코딩하는 거에 대한 체계적인 교육과정을 찾아보긴 어려웠고 인터넷에서 이곳저곳 뒤지면서 지식을 좀 얻었다.
문제
1 자리 수를 입력받고, 해당 수의 구구단 1단부터 9단까지
출력하는 ELF 바이너리 어셈블리 코딩
mul, for (int i=1; i<=9; ++i)
printf(“%d x %d = %d\n”, input, i, input*i);
외부 함수(printf, scanf) 없이 syscall로 구현
입력: 2
출력:
2 x 1 = 2
2 x 2 = 4
…
2 x 9 = 18
이것이 문제였다.
실습해본 것으로는 Hello World!를 출력하는게 있는데
(빌드 과정 참고)
global _start
section .text
_start:
mov rax, 1
mov rdi, 1
mov rsi, message
mov rdx, 14
syscall
mov rax, 60
xor rdi, rdi
syscall
section .data
message: db "Hello, World!", 10
이렇게 hello.s 파일을 만들고
nasm(?)을 이욯해서 nasm -felf64 <파일명>
링킹을 위해서 ld -o <만들 파일명> <사용할 오브젝트 파일>
이렇게 실행파일(elf)을 만든다.
syscall을 사용해서 write, read, exit 같은 함수들을 사용할텐데. 그러면 여러 자릿수를 생각해보면 문제가 있었다.
근데. 한 자릿수 구구단이라 그건 문제 조건에 의해 해결되었다.
http://blog.rchapman.org/posts/Linux_System_Call_Table_for_x86_64/
syscall로 쓰는 함수들 목록이다.
혹시 몰라 C언어로 컴파일 하고 난 뒤 어셈블리 파일을 분석해보려고 했는데, 해당 파일에서는 그냥 printf@plt 이런 식으로 사용해서 별 도움이 안 되었다.
고민 (만들기 전)
문제를 해결하고자 할 때 고민되는 점은 반복문을 구현하는 것, 그리고 출력하는 형식, 숫자를 계산하는 법이었다.
반복문은 분기 명령어와 rcx를 사용하면 될 것 같고 숫자 계산도 ascii를 사용하면 될 것이다. 가장 문제는 출력하는 형식을 어떻게 깔끔하게 하는가인데..
만들고 보니 출력 형식은 그냥 이거 쓰고 저거 쓰고 나누어서 출력하도록 했다. 딱히 형식이랄게 정해지지 않았다.
풀이
우선 처음에 만들었을 때는 코드가 꼬여서 다시 새로 만들었다.
그 과정에서 필요한 것들을 배워나갔는데,
변수를 만드는(사용하는) 방법, 함수를 만드는 방법, 반복문 주소를 넣는법 등이 있었다.
처음에 작성한 코드는
이런 식으로 작성해버렸는데, 레지스터에 넣고 사용하고 왔다갔다 하고.. 결정적으로 인자를 주소로 주느냐, 값으로 주느냐 차이 때문에 계속해서 꼬여버렸다.
위에 보면 printn에 대해서, 이는 enter로 개행문자를 넣었던 것인데 "\n",0x00 하면 안되길래 0xa (10이 \n의 asciicode이므로)를 db로 넣었더니 되었다.
db에서 d는 define이고(아마도) b는 byte이다. 이는 초기값이 있는 변수를 선언할 때 이렇게 썼었다. (어느 글에서 dx, resx에 대해서 읽었는데 https://opentutorials.org/module/1596/9765)
데이터 지시어라고 한다. resx는 해당 변수에 x의 크기로 메모리를 몇개 할당할 건지를 말한다.
어쨌든 이렇게 하니까 꼬이기도 하고, 결과는 10의 자릿수를 가질 수도 있다는 점을 간과해서 다시 작성했다.
결과값이 10의 자릿수를 가지는건 어떻게 처리할까 고민하다가, 어차피 9*9가 최대이므로 그냥 10으로 나눠서 어떻게 해보고자 div 명령어를 알아보고..
그렇게 몫을 따로 출력, 나머지를 따로 출력하도록 하여 해결했다. (몫이 0이라면 분기로 건너뜀)
그런 느낌으로 만들었는데, 코드를 보면서 설명해보겠다.
global _start
section .data
msg1 : db "input : ",0x00
enter : db 0x0a
x : db " x ",0x00
eq : db " = "
cnt : dq 0x01
section .bss
input resq 1
ten resq 1
one resq 1
section .text
_start :
mov rsi, msg1
mov rdx, 0x08
call print ;input :
call read
mov rsi, input
mov rdx, 0x01
call print ; print input
mov rsi, x
mov rdx, 0x03
call print ; print x
add qword [cnt], 0x30 ;to chr
mov rsi, cnt
mov rdx, 0x01
call print
mov rsi, eq
mov rdx, 0x03
call print
sub qword [cnt], 0x30 ;to int
mov rax, qword [input]
sub rax, 0x30
mul qword [cnt]
mov rbx, 0xa
div rbx
mov qword [ten], rax
mov qword [one], rdx
add qword [ten], 0x30
add qword [one], 0x30
cmp rax, 0x00
je 24 ; result is under 10
mov rsi, ten
mov rdx, 0x01
call print
mov rsi, one
mov rdx, 0x01
call print
mov rsi, enter
mov rdx, 0x01
call print
inc qword [cnt]
cmp qword [cnt], 0x0a
jl 0xFFFFFFFFFFFFFF07 ;loop
call exit
read :
xor rax, rax
xor rdi, rdi
lea rsi,[input]
mov rdx, 1
syscall
ret
print : ;self argument
mov rax, 1
mov rdi, 1
syscall
ret
exit :
mov rax, 60
xor rdi, rdi
syscall
ret
음 화면에 꽉 찰 정도로 코드가 길다.
우선 data 영역은 초기화된 전역변수가 들어가는 부분이므로 dx 지시자를 사용해서 초기화 할 것들이 들어간다. 여기엔 출력할 문자열과 함께 카운트 할 cnt라는 변수를 1로 초기화 해 두었다.
rcx를 사용하려고 했으나 rcx는 인자로 사용되는 레지스터이기도 했고 (여기선 사용되지 않았나..?)
rsi에 주소를 넣어서 print (syscall로 write)하는데 주소값이 필요하기 때문이기도 했다.
선언할 때는 dq를 사용한다 8byte를 넉넉히 잡아서 혹시 코드에서 사이즈를 지정하지 않았을 경우에 옆에 있는 변수들과 충돌을 막기 위함이기도 했다.
bss 영역에는 초기화되지 않은 변수가 들어간다. 시작 값을 모르는 input과 10의 자릿수를 저장할 ten, 1의 자릿수를 저장할 one이라는 변수들을 resq(8byte)로 1개씩 할당해주었다.
함수들의 구현은 read와 print, exit만 구현했다. 다른 출력들을 일일이 구현하니까 오히려 꼬이는 거 같았기에
print는 어차피 그냥 출력할거만 rsi에 넣고 사이즈만 rdx에 넣으면 되니까. 그렇게 만들었다.
syscall을 하는거기 때문에 그냥 인자만 넣어주는 것이므로 이해엔 큰 무리가 없고.
_start에서 call 했기 때문에 ret로 돌아가준다는 점만 신경쓰면 되었다.
text 영역
0x30은 '0'의 시작 번호이다(ascii code)
"input : "을 처음에 출력하고 입력받는다.
입력받은 값을 input에 넣어둔다.
input을 출력할 일이 있으면 그냥 주소를 rsi에 넣고 print를 호출한다.
mov input 하면 lea [input]과 다를 바가 없다. 그냥 주소가 들어가기 때문에.
cnt 출력도 비슷하게 하는데 cnt는 숫자가 들어가 있으므로 여기선 cnt에 0x30을 더했다가 빼는 과정이 필요하다.
결과를 계산하기 위해선 rax에 input의 값을 넣는다. rax를 사용하는 이유는 div에 있기에 이따 설명할 것.
rax에 input을 넣어두고 mul qword [cnt]만 해도 <- 참고로 qword처럼 사이즈 지정 안하면 에러남
rax에는 두 값의 곱이 저장된다. mul이나 div 함수가 그렇다.
div 함수는 몫을 rax, 나머지를 rdx에 넣는다. 그래서 ten에 rax를 넣고, one rdx를 넣은 다음 rax 값이 0이면 10의자리 출력을 건너뛰고 일의자리 출력으로 가도록 했다.
마지막에는 cnt를 inc하였고 cmp를 이용해서 cnt 값이 10보다 작으면 반복, 그러니까 크거나 같으면 반복을 나가는 식으로 하여 만들었는데,
분기에서 문제가 있었다.
나는 그대로 분기를 위해서 주소만 적어주면 되는 줄 알았는데.
이렇게 주소를 적었는데
gdb에서는 다르게 나오는 것이다. 다행히 뒤 2자리가 같은 것으로 보아 뭔가 더하기 연산이 되었구나 싶어서, 상대주소인가 싶었다.
레지스터에 절대주소 넣고 레지스터 이름으로 분기해도 된다고 하는데 에러나길래 계속 gdb를 돌려가며 상대주소를 구했다. (https://aidencom.tistory.com/190)
그래서, 이전 주소로 분기하려면 jmp에 -0x~~ 입력할 수도 없는 노릇이니. 바로 2의 보수를 취한 값이 떠올라서.
그냥
프로그래머용 계산기로 행복하게 잘 만들었다고 한다.
그래서
짜잔 잘 된 다!
다시 코드
global _start
section .data
msg1 : db "input : ",0x00
enter : db 0x0a
x : db " x ",0x00
eq : db " = "
cnt : dq 0x01
section .bss
input resq 1
ten resq 1
one resq 1
section .text
_start :
mov rsi, msg1
mov rdx, 0x08
call print ;input :
call read
mov rsi, input
mov rdx, 0x01
call print ; print input
mov rsi, x
mov rdx, 0x03
call print ; print x
add qword [cnt], 0x30 ;to chr
mov rsi, cnt
mov rdx, 0x01
call print
mov rsi, eq
mov rdx, 0x03
call print
sub qword [cnt], 0x30 ;to int
mov rax, qword [input]
sub rax, 0x30
mul qword [cnt]
mov rbx, 0xa
div rbx
mov qword [ten], rax
mov qword [one], rdx
add qword [ten], 0x30
add qword [one], 0x30
cmp rax, 0x00
je 24 ; result is under 10
mov rsi, ten
mov rdx, 0x01
call print
mov rsi, one
mov rdx, 0x01
call print
mov rsi, enter
mov rdx, 0x01
call print
inc qword [cnt]
cmp qword [cnt], 0x0a
jl 0xFFFFFFFFFFFFFF07 ;loop
call exit
read :
xor rax, rax
xor rdi, rdi
lea rsi,[input]
mov rdx, 1
syscall
ret
print : ;self argument
mov rax, 1
mov rdi, 1
syscall
ret
exit :
mov rax, 60
xor rdi, rdi
syscall
ret