sys_write 시스템 콜 Hello World – 리눅스 x86-64 어셈블리어 2

sys_write 시스템 콜

이전 포스팅에서 실행했던 Hello World에 대해서 분석해볼 차례입니다. Nasm x86-64의 Hello World 는 sys_write 를 사용합니다.

리눅스 커널 소스 코드의 arch/x86/entry/syscalls/syscall_64.tbl 에 64비트 시스템 콜 테이블이 정리되어 있습니다. 시스템 콜의 id는 1이고 호출은 write() 그리고 시스템콜의 핸들러는 sys_write 입니다.

어셈블리어에서 시스템 콜의 사용법은 약속된 레지스터에 인수를 올린 후에 소프트웨어 인터럽트로 시스템 콜을 발생시키면 됩니다. sys_write는 다음의 규칙을 사용합니다.

sys_write

raxsystem callrdirsirdxr10r8r9
1sys_writeunsigned int fdconst char *bufsize_t count

rax 는 시스템 콜의 id입니다. 64비트와 32비트의 id가 다르니 주의합니다. 이것은 64비트입니다. 다음 rdi는 file descriptor로 유닉스 계열 운영체제에서는 모든 것을 파일(file)로 간주합니다. 커널이 조작하려는 모든 대상에는 file descriptro가 부여됩니다.

file은 디스크에 저장된 그 file이 아니라 추상화된 byte stream 입니다. 그냥 연속하는 값(숫자) 입니다. 지금 콘솔 스크린에 문자 한줄을 출력하려고 하는데 갑자기 파일이 나오고 스트림(stream)이라느니 이해가 잘 안갈 수도 있습니다. 그런데 생각해 보면 당연합니다.

모니터에 Hello World라는 문자열을 쓰려고 합니다. Hello World의 아스키 코드는 72,101,108,108,111,32,87,111,114,108,100 입니다. sys_write에 이 문자열의 첫번째 주소를 rsi 레지스터에 이동시키면 커널은 해당 file descriptor에 rdx에 저장된 숫자만큼 쓰기(write) 작업을 합니다. 콘솔 스크린은? 역시 값으로 이루어져 있습니다. 이 콘솔은 위에서 file descriptor 1로 지정합니다. 1은 standard output 을 의미합니다.

그러니까 모든 것이 파일로 취급이 가능한 것은 바이트 값을 쓴다는 공통점 때문입니다. 어디에 쓸지 그 메모리 주소와 무엇을 얼마나 써야할지만 알면 그게 file 이건 콘솔 화면이건 종이를 출력하는 프린터건 뭐건 간에 전부 write할 수 있습니다. 반대로 read도 마찬가지 입니다. file을 read할 수도 있고 키보드의 버퍼를 read 할 수도 있고 마이크에서 음성을 read할 수 있고, 콘솔에 있는 byte stream을 read할 수 있습니다. 요컨데 값이라면 읽고(read) 쓰는(write) 행위가 가능합니다. 운영체제가 모든 것을 file로 간주한다는 것은 참으로 만능적인 생각입니다.

file descriptor (파일 디스크립터)

한글로 번역하면 파일 설명자, 파일 묘사자(?) 같이 어색하니 그냥 파일 디스크립터가 어감이 좋습니다. 커널이 다루는 프로세스의 모든 객체에는 파일 디스크립터가 있습니다.(줄여서 FD) bash 셸에서 어셈블리어 프로그램을 실행시킬 때는 기본적으로 3개의 파일 디스크립터를 상속받는데 0, 1, 2 입니다.

Standard Input 이 0, Standard Output이 1, Standard Error가 2 입니다. 기본값으로 0은 키보드, 1은 콘솔 화면, 2도 콘솔 화면(에러 메시지)로 되있습니다. sys_write의 FD를 1로 지정하면 콘솔에 문자열을 출력합니다. 조금 반복적인 설명같지만 원리를 이해한다면 딱히 다른 시스템 콜을 배우지 않아도 man 페이지만 보고서도 사용법을 알아낼 수 있을 겁니다. 시스템 콜은 미래에도 계속 추가될 것으로 보이니까 좀 더 자세히 알아두는게 손해는 아닐 겁니다.

예제 코드

시스템 콜에 대한 설명을 읽고 다시 Hello World 를 본다면 많은 것이 보일 것 입니다.

문자열을 출력하기 위해서 section .data 에 아스키코드가 들어있는 바이트를 정의합니다. “Hello World!”,10은 실제로는 [72,101,108,108,111,32,87,111,114,108,100,10]로 바이트로 저장합니다. 10진수로 쓴 것은 사람이 읽기 편하기 위함이고 메모리 상에는 0과1로 저장되어 있습니다. 메모리 뷰어가 16진수를 사용하는 것은 0xFFFF FFFF 로 표기하면 1111 1111 1111 1111 1111 1111 1111 1111 인 2진수를 4개씩 묶기가 좋아서 입니다. 10진수는 16진수처럼 깨끗하게 안묶어집니다. 자세한 내용은 아스키 코드 테이블 을 참고합니다.

len의 시작 주소인 $에서 바로 직전의 변수인 msg의 주소를 빼면 msg의 바이트 길이가 나오는데요. 이는 바로 붙어있기 때문에 카운팅이 가능한 방식입니다. 지금은 예제를 위해 단순화 시켰지만 C언어의 strlen 과 같은 서브루틴을 별도로 구현할 필요가 있습니다. 아니면 그냥 링킹할 때 C언어 라이브러리 함수를 사용해도 됩니다.

section .data
    msg: db "Hello World!",10
    len: equ $-msg ; memory calculation
    
section .text
    global _start
_start:
    mov rax, 1    ; system call id 1 sys_write
    mov rdi, 1    ; FD stdout 1
    mov rsi, msg  ; memory ADDR of bytes
    mov rdx, len  ; byte length (count)
    syscall

_last:
    mov rax, 60
    mov rdi, 0
    syscall

section .text 는 명령어(instruction) 세그먼트입니다. 문자열은 준비되었습니다. 이제 시스템 콜을 하면 됩니다. 위에서 설명한 것 처럼 첫번째는 sys_write 호출 아이디 1번을 선택합니다. 다음은 파일 디스크립터 1 즉 콘솔 화면입니다. 그리고 msg 주소를 전달하고 마지막으로 카운팅된 바이트의 수가 들어갑니다. 이러면 인수가 다 세팅이 끝났습니다. 이 상태에서 syscall을 호출하면 커널이 인수들을 확인하여 콘솔에 hello world 를 출력합니다.

_last: 레이블이 코드를 제어 하는 것은 아닙니다. 레이블은 그냥 메모리 주소를 부르기 쉽게 바꾼 것 입니다. 아직 프로그램이 끝나지 않았으니 다음 라인이 실행됩니다. mov rax, 60은 sys_exit 로 프로그램을 종료합니다. rdi 는 종료시 상태값입니다. 0은 정상 종료를 의미합니다.

man 페이지

이제 시스템 콜을 어떻게 사용하는지는 알았는데 각각 인수가 다르고 사용법이 다르니까 상세 정보가 필요합니다. man page에는 리눅스 프로그래머들을 위한 문서(메뉴얼)가 있습니다.

터미널에서 man man 을 실행하면 섹션에 대한 정보를 볼 수 있습니다. System call 에 대한 메뉴얼은 2번입니다.

       1   Executable programs or shell commands
       2   System calls (functions provided by the kernel)
       3   Library calls (functions within program libraries)
       4   Special files (usually found in /dev)
       5   File formats and conventions, e.g. /etc/passwd
       6   Games
       7   Miscellaneous (including macro packages and conventions), e.g. man(7), groff(7)
       8   System administration commands (usually only for root)
       9   Kernel routines [Non standard]

man 2 write를 실행하면 2번 섹션에서 write의 메뉴얼을 찾습니다. 거기에는 C언어 문법으로 write가 있습니다. write 함수의 인수와 64비트 시스템 콜 테이블 을 비교해보면 어셈블리어의 사용법도 알아낼 수 있습니다. 해당 시스템 콜의 상세사항은 man 페이지의 description에서 읽고 어셈블리어에서의 구현은 시스템 콜 테이블의 인수를 보면 됩니다.

       #include <unistd.h>

       ssize_t write(int fd, const void *buf, size_t count);

리눅스는 C언어로 작성되었고 GCC 컴파일러는 최적화가 잘되어있어서 GNU/Linux의 C언어 라이브러리를 사용할 일이 좀 더 많지만 반대로 어셈블리어 코드를 C언어에서 사용하기도 합니다. 커널에서 C언어와 어셈블리어를 별개로 보는 것 보다는 따지고 보면 같은 내용이라고 볼 수도 있습니다. 리눅스 커널을 초기 버전을 공개한 리누스 토발즈는 지금도 대부분의 시간을 C언어로 프로그래밍 하고 있으며 C의 코드만 봐도 머리속에서 기계어 수준으로 해석이 가능하다고 인터뷰에서 말한 적도 있습니다. 어셈블리어로 시스템 콜을 호출해보면 그게 무슨 뜻인지 대략적으로 나마 이해가 갑니다.

또 리눅스의 소스 코드 중에 어셈블리어는 GNU Assembler(gas 혹은 as)로 작성이 되어 있는데 NASM 과는 조금 다른 문법이지만 시스템 콜의 사용방식이나 본질은 같습니다.

요약

sys_write 시스템 콜에 대하여 알아봤습니다. 어셈블리어는 처음에 개념을 잘 잡으면 그 다음에는 수월해집니다. 반면 기초가 부실하면 뒤로 갈수록 어려움이 많습니다. 프로그래밍 언어 중에 대표적으로 C언어가 기초가 부족하면 앞으로 진행이 안되는 언어인데요. 어셈블리어는 더 밑바닥이므로 기초가 중요합니다. 그 기초라는 것은 컴퓨터 구조에 대한 이해입니다. 다행인 것은 요즘은 구글에 검색을 잘하면 일반적인 지식은 충분히 얻을 수 있습니다. 의외로 어셈블리어를 배우기에 훨씬 좋은 환경이 되었습니다.

Leave a Comment