FreeBSD Assembly Language Programming

G. Adam Stanislav 저

Whiz Kid Technomagic

http://www.int80h.org/bsdasm

최진희 <jhchoi@os.korea.ac.kr> 역

 

Preface

유닉스(unix) 환경에서의 어셈블리 언어 프로그래밍(assembly language programming)은 거의 문서화되지 않았습니다. 이것은 많은 사람들이 여러 유닉스 시스템은 서로 다른 마이크로프로세서(microprocessor)를 기반으로 동작하고 있기 때문에 이식성(portability)을 위해서라면 거의 모든 코드를 C로 써야 한다고 생각해 왔기 때문입니다.

실제로는 C 조차 완벽한 이식성을 제공하지 못하기 때문에 하나의 유닉스에서 다른 종류의 유닉스로 포팅(porting)할 때 대부분의 C 프로그램은 수정되어야 합니다. 일반적으로 이런 프로그램은 컴파일된 시스템에 의존적인 조건문(conditional statements)을 잔뜩 사용한 경우가 많습니다.

모든 유닉스 소프트웨어가 C나 그 밖의 고수준 언어(high level language)로 씌여져야 한다고 믿고 있다 할지라도 어셈블리 프로그래머는 여전히 필요합니다. 어셈블리 프로그래머가 없다면 C 라이브러리(library)에서 커널에 접근하는 부분을 누가 쓰겠습니까?

이 튜토리얼(tutorial)에서 필자는 어셈블리 언어를 이용하여 유닉스 프로그램- 특히 FreeBSD -을 쓰는 방법을 간단히 설명할 것입니다. 이 문서가 언젠가는 FreeBSD 어셈블리 언어의 완벽한 가이드가 될 수 있으면 더 바랄 것이 없습니다.

이 튜토리얼은 어셈블리 언어의 기본 내용을 설명하진 않습니다. 하지만 이러한 기본 내용을 설명한 수많은 자료들이 있으니 참조하시기 바랍니다.(어셈블리 언어를 완벽하게 설명한 온라인 가이드를 얻고 싶다면 Randall Hyde의 Art of Assembly Language를 추천하며, 인쇄된 책을 원한다면 Jeff Duntemann의 Assembly Language Step-by-Step을 추천합니다.) 어쨌든 일단 이 튜토리얼을 끝까지 읽은 후에는 대부분의 어셈블리 프로그래머가 빠르고 효율적인 FreeBSD 프로그램을 작성할 수 있도록 구성했습니다.

Chapter 1 The Tools

1.1. The Assembler

어셈블리 언어 프로그래밍을 할 때 가장 중요한 도구는 어셈블러, 즉 어셈블리 언어 코드를 기계어(machine language)로 변환해 주는 프로그램입니다.

FreeBSD에서 사용할 수 있는 어셈블러로는 크게 두 가지 종류의 것이 있습니다. 그 중 하나는 as (1)인데, 전통적인 유닉스 어셈블리 언어의 문법을 따르고 있으며 일반적으로 시스템에 기본 탑재되어 있습니다.

다른 하나는 /usr/ports/devel/nasm입니다. 이것은 인텔(Intel)의 문법을 따르는데, 여러 운영 체제를 위한 어셈블리 코드를 작성할 수 있다는 큰 장점이 있죠. 이 어셈블러는 별도로 설치해야 하지만 공개 소프트웨어이기 걱정할 필요는 없습니다.

이 튜토리얼은 nasm의 문법을 사용하는데, 이는 다른 운영체제 환경에서 어셈블리 언어를 사용하던 대부분의 프로그래머들이 이해하기 쉽도록 하기 위한 배려입니다. 또, 솔직히 말하자면 필자가 사용하고 있는 어셈블러이기 때문이기도 하구요.

1.2. The Linker

대부분의 컴파일러와 마찬가지로 어셈블러의 결과물(output)은 실행 가능한 파일로 링크(link)될 필요가 있습니다. 표준 ld (1) 링커(linker)는 FreeBSD에 탑재되어 있는데, 어떤 어셈블러에 의해서 만들어진 코드라도 실행 파일로 만들어 줍니다.

Chapter 2 System Calls

2.1. Default Calling Convention

FreeBSD는 기본적으로 C의 호출 방식(calling convention)을 사용합니다. 게다가 커널이 int 80h를 사용하여 접근되었다 할지라도, 커널은 프로그램 int 80h를 바로 사용한 것이 아니라 int 80h를 사용하는 함수를 호출했다고 가정합니다.

이러한 관례는 배우 편리하며 MS DOS에서 사용된 마이크로소프트(Microsoft)의 관례에 비해 우월한 점이 많습니다. 이렇게 말할 수 있는 대표적인 근거는 유닉스에서 사용하는 관례에 따르면 어떤 언어로 작성된 프로그램이라도 커널에 접근할 수 있다는 것이죠.

당연한 말이지만 어셈블리 언어로 작성된 프로그램도 커널에 접근할 수 있습니다. 가령, 다음과

같이 파일을 열 수 있습니다.

kernel:

int 80h ; Call kernel

ret

 

open:

push dword mode

push dword flags

push dword path

mov eax, 5

call kernel

add esp, byte 12

ret

이 코드는 매우 간결하며 이식하기도 쉽게 작성된 편이죠. 만약 어떤 코드를 다른 인터럽트를 사용하거나 매개 변수(parameter)를 다른 방식으로 넘기는 유닉스 시스템으로 포팅한다면 kernel 프로시져(precedure)만 다시 작성하면 됩니다.

하지만 어셈블리 프로그래머는 CPU 주기(cycle)을 최대한 줄이고 싶어하는 경향이 있습니다. 위의 예는 call/ret 조합(combination)이 사용되었는데, dword를 푸시(push)하는 명령어(instruction) 하나면 이 코드를 제거할 수 있죠.

open:

push dword mode

push dword flags

push dword path

mov eax, 5

push eax ; Or any other dword

int 80h

add esp, byte 16

EAX에 저장되는 5는 open이라는 커널 함수의 식별자(identifier)입니다.

2.2. Alternate Calling Convention

FreeBSD는 아주 융통성 있는 시스템입니다. 이 운영체제는 커널을 호출할 수 있는 다른 방법도 제공합니다. 하지만 이러한 호출 방식이 제대로 동작하려면 리눅스(Linux) 애뮬레이션(emulation)이 시스템에 설치되어 있어야 합니다.

리눅스는 유닉스 계열의 시스템 중 하나지만 커널은 레지스터에 매개 변수를 넘기는 마이크로소프트의 시스템 콜(call) 관례를 따르고 있습니다. 함수의 번호가 EAX 레지스터에 저장된다는 것은 유닉스의 호출 관례와 같죠. 하지만 매개 변수는 스택에 넘겨지는 것이 아니라 EBX, ECX, EDX, ESI, EDI, EBP 레지스터에 넘겨진다는 차이점이 있습니다.

open:

mov eax, 5

mov ebx, path

mov ecx, flags

mov edx, mode

int 80h

이 관례는 유닉스의 관례에 비해 커다란 단점이 있습니다. 즉, 프로그래머가 커널의 기능을 이용할 때마다 레지스터들을 스택에 푸시하고 요청 작업이 끝나면 다시 팝(pop)해야 한다는 것이죠. 이러한 단점은 코드를 크고 느리게 만듭니다. 그럼에도 불구하고 FreeBSD는 프로그래머에게 선택권을 주고 있습니다.

만약 프로그래머가 마이크로소프트/리눅스 호출 관례를 선택한다면 시스템에게 이 호출 관례를 사용한다는 사실을 통보해 주어야 합니다. 즉, 프로그램이 어셈블되고 링크된 후에 실행 파일에 다음과 같이 표시를 해 두어야 한다는 뜻입니다.

% brandelf -f Linux filename

2.3. Which Convention Should You Use?

만약 FreeBSD에 국한된 코드를 작성하고 있다면 유닉스 호출 관례에 따라야 합니다. 이러한 호출 방식은 보다 빠르고, 레지스터에 전역 변수(global variable)를 저장할 수 있게 해주며, 실행 파일에 표시를 할 필요도 없다는 장점이 있습니다. 게다가 프로그램이 설치될 시스템에 리눅스 애뮬레이션 패키지를 설치해야 한다는 제약 조건도 제거할 수 있습니다.

대부분의 경우에는 리눅스에서도 돌아가는 이식성 있는 프로그램을 작성하면서도 FreeBSD 사용자에게 가능한 한 효율적으로 실행되는 프로그램을 작성하고 싶을 것입니다. 따라서 우선 기본적인 내용을 좀 더 설명한 후에 이식성과 효율성을 동시에 만족시킬 수 있는 방법을 설명하도록 하겠습니다.

Chapter 3 Call Numbers

어떤 서비스를 원하는지 커널에게 알리기 위해 EAX 레지스터에 식별 번호를 저장합니다. 물론 그 번호가 어떤 서비스를 지정하는 것인지 알아둘 필요가 있죠.

3.1. The syscalls file

식별 번호는 syscalls 파일에 기록되어 있습니다. locate syscalls 명령을 내리면 시스템에 있는 다양한 형태의 syscalls 파일을 찾아 주는데, 이 파일들은 모두 syscalls.master 파일에서 자동 생성된 것들입니다.

기본적인 유닉스 호출 관례를 위해 정의된 마스터 파일(master file)은 /usr/src/sys/ kern/syscalls.master 에서 찾을 수 있습니다. 만약 리눅스 애뮬레이션 모드에서 구현된 마이크로소프트의 호출 관례를 사용하고 싶다면 /usr/src/sys/i386/linux/syscalls.master를 참조하시기 바랍니다.

N.B.: FreeBSD와 Linux가 호출 관례만 달리 사용하는 것은 아니다는 사실에 주목해야 합니다. 이것은 같은 함수에 다른 식별 번호를 할당한 경우도 있기 때문입니다.

syscalls.master에는 시스템 콜들이 어떻게 구성되어 있는지에 대한 설명이 있습니다.

 

0 STD NOHIDE { int nosys(void); } syscall nosys_args int

1 STD NOHIDE { void sys_exit(int rval); } exit sys_exit_args void

2 STD POSIX { int fork(void); }

3 STD POSIX { ssize_t read(int fd, void *buf, size_t nbyte); }

4 STD POSIX { ssize_t write(int fd, const void *buf, size_t nbyte); }

5 STD POSIX { int open(char *path, int flags, int mode); }

6 STD POSIX { int close(int fd); }

etc...

.

가장 왼쪽의 숫자가 EAX에 저장될 값입니다.

그리고 가장 오른쪽에 있는 값이 어떤 매개 변수가 푸시되어야 하는지를 나타내는 값입니다. 이 값들은 오른쪽에서 왼쪽의 순서로 푸시됩니다.

EXAMPLE 3.1: 가령, 파일을 열기 위해 mode를 먼저 푸시해야 하고 flags와 path가 저장된 주소를 푸시한다는 의미가 됩니다.

Chapter 4 Return Values

만약 시스템 콜이 어떤 결과값을 리턴(return)하지 않는다면 유용하게 사용할 수 없는 경우도 많이 있을 것입니다. 가령, 열린 파일(open file)의 파일 기술자(file descriptor), 버퍼에서 읽은 바이트 수, 시스템 시간 등이 유용하게 사용될 수 있는 리턴값의 예가 되겠죠.

게다가 에러가 발생했을 때, 시스템이 사용자에게 에러를 통보해 줄 수단도 필요합니다. 이러한 에러의 경우에는 파일이 없다든지, 시스템 자원이 고갈되었다든지, 유효하지 않은 매개 변수가 넘어 온 경우 등이 해당됩니다.

4.1. Man Pages

유닉스 시스템에서 사용되는 다양한 시스템 콜들에 대한 정보를 얻을 수 있는 전통적인 방법은 맨 페이지(man page)를 이용하는 것입니다. FreeBSD는 맨 페이지의 2절에서 시스템 콜을 설명하고 있지만 가끔 3절에서 소개되는 시스템 콜도 있습니다.

가령, open(2)를 보면 다음과 같은 정보를 얻을 수 있습니다.

만약 성공하면 open() 함수는 양의 정수를 리턴하는데, 이것은 연 파일의 기술자입니다. 이 함수가 -1을 리턴하면 함수의 호출이 실패한 것이고 errno 변수에 에러의 종류가 기록됩니다.

유닉스와 FreeBSD를 처음 접하는 어셈블리 프로그래머라면 바로 errno는 어디에 있고 어떻게 그 값을 얻을 수 있는가라는 질문을 던질 것입니다.

N.B.: 맨 페이지에 있는 정보들은 C 프로그램을 위한 것들입니다. 하지만 어셈블리 프로그래머들에게는 이 이상의 부가적인 정보가 필요합니다.

4.2. Where Are The Return Values?

불행하게도 리턴 값이 저장되는 장소는 시스템 콜의 종류에 따라 다릅니다. 대부분의 시스템 콜은 EAX에 리턴 값을 저장하지만 전부 여기에 저장하는 것은 아니죠. 따라서 처음 사용하는 시스템 콜은 리턴된 후에 EAX 레지스터를 검사해 보는 것이 좋은 프로그래밍 습관입니다. 만약 EAX에 리턴값이 없다면 다른 곳을 찾아봐야 할 것입니다.

N.B.: 필자가 사용했던 모든 시스템 콜은 EAX에 리턴값을 저장했습니다. 단 한가지 예외가 있었는데, SYS_fork가 바로 그것이죠. 이 시스템 콜은 EDX 레지스터에 리턴값을 저장했습니다. 물론 필자가 모든 시스템 콜을 사용해 본 것은 아니기 때문에 EAX 레지스터를 검사해 보는 과정을 생략해선 안됩니다.

TIP: 만약 특정 시스템 콜의 리턴값이 저장되는 위치를 도저히 알 수가 없다면 libc 라이브러리의 소스 코드를 분석해 보는 것이 좋을 것입니다. 이 라이브러리에서 커널과 어떻게 인터페이스를 형성하는 지에 주목하시기 바랍니다.

4.3. Where Is errno?

사실은 어디에도 errno는 없습니다. errno는 유닉스 커널이 아닌 C 언어의 한 부분이죠. 커널 서비스에 바로 접근할 때 error 코드는 일반적인 리턴값이 저장되는 EAX에 기록됩니다.

이것은 대단히 흥미 있는 구조를 형성합니다. 에러가 없다면 에러 코드도 당연히 없을 것이고 에러가 있다면 리턴값도 의미가 없을 것입니다. 따라서 하나의 레지스터에 에러 코드와 리턴값을 각각의 경우에 저장하는 것이죠.

4.4 Determining an Error Occurred

표준 FreeBSD의 호출 관례를 사용할 때, 시스템 콜이 성공하면 carry flag가 클리어(clear)되고 실패하면 설정됩니다.

리눅스 애뮬레이션 모드를 사용할 때는 EAX에 저장된 값이 양의 정수면 호출이 성공한 것이고 그 값은 리턴값이 됩니다. 에러가 발생했을 경우에는 EAX에 저장된 값이 음수겠죠.

Chapter 5 Creating Portable Code

이식성은 일반적으로 어셈블리 언어가 유리한 점은 아닙니다. 하지만 어셈블리 프로그램을 서로 다른 플랫폼(platform)을 위해 작성하는 경우-특히 nasm으로-가 있을 수 있죠. 필자는 Windows와 FreeBSD 같이 다른 운영체제를 위한 어셈블리 언어 라이브러리를 작성해 왔습니다.

어느 정도 차이는 있겠지만 유사한 구조를 가진 두 플랫폼에서 실행되는 코드를 작성하고자 한다면 이 일이 결코 불가능한 것이 아니라는 사실에 주목할 필요가 있습니다.

가령, FreeBSD는 유닉스이며 Linux는 유닉스와 유사한 구조를 가진 운영체제입니다. 필자는 이 둘 사이의 차이점을 단지 세 가지 밖에 언급하지 않았습니다. 즉, 호출 관례와 함수 번호, 그리고 리턴값의 저장 위치가 그것인데, 이 외의 구조는 거의 동일하기 때문에 굳이 언급하지 않은 것이었음을 기억하시기 바랍니다.

5.1. Dealing With Function Numbers

많은 경우에 함수 번호는 같습니다. 하지만 같지 않은 함수가 있더라도 그리 큰 문제가 되진 않습니다. 코드를 작성할 때, 번호를 사용하는 대신 실행될 시스템에 따라 달리 선언되는 상수(constant)를 사용하면 이 문제는 해결됩니다.

%ifdef LINUX

%define SYS_execve 11

%else

%define SYS_execve 59

%endif

5.2. Dealing With Conventions

호출 관례와 리턴값(errno 문제)의 문제는 매크로를 이용하면 어렵지 않게 해결할 수 있습니다.

%ifdef LINUX

 

%macro system 0

call kernel

%endmacro

 

align 4

kernel:

push ebx

push ecx

push edx

push esi

push edi

push ebp

 

mov ebx, [esp+32]

mov ecx, [esp+36]

mov edx, [esp+40]

mov esi, [esp+44]

mov ebp, [esp+48]

int 80h

 

pop ebp

pop edi

pop esi

pop edx

pop ecx

pop ebx

 

or eax, eax

js .errno

clc

ret

 

.errno:

neg eax

stc

ret

 

%else

 

%macro system 0

int 80h

%endmacro

 

%endif

5.3. Dealing With Other Portability Issues

위에서 제시한 해결 방법으로 대부분의 경우에 FreeBSD와 Linux 사이의 이식성 있는 코드를 작성할 수 있지만 일부의 커널 서비스는 정말 심각할 만큼 다른 경우가 있습니다.

이런 경우, 특정 시스템 콜을 위한 핸들러(handler)를 플랫폼마다 각각 작성하고 조건부 어셈블리(conditional assembly)를 사용할 필요가 있습니다. 다행히도 대부분의 코드에서 커널 서비스를 호출하는 부분은 많지 않으므로 조건문이 그리 많이 나오지는 않을 것입니다.

5.4 Using a Library

이식성 문제를 피하는 또 한가지 방법은 시스템 콜의 라이브러리를 작성하여 주요 코드에서 사용하는 것입니다. 즉, FreeBSD를 위한 라이브러리를 작성하고 이와는 별도로 Linux를 위한 라이브러리를 작성하여 사용하는 방법을 말하는 것이죠.

라이브러리를 작성할 때, 각 시스템 콜을 분리된 별도의 함수(만약 여러분이 전통적인 어셈블리 언어의 용어를 선호한다면 프로시져라 표현할 수도 있습니다.)로 작성하십시오. 또한 매개 변수를 넘기는 C의 호출 관례를 사용하십시오. 그러나 여전히 호출 번호(call number)를 넘기기 위해 EAX는 사용해야 할 것입니다. 이러한 경우에 명백히 다른 함수들에는 레이블(label)을 붙여 두면 FreeBSD 라이브러리는 매우 단순해 질 것입니다.

 

sys.open:

sys.close:

[etc...]

int 80h

ret

Linux 라이브러리에는 보다 많은 다른 함수들이 필요하겠지만 매개 변수의 수가 같은 시스템 콜들을 묶어 단순화시킬 수 있습니다.

sys.exit:

sys.close:

[etc... one-parameter functions]

push ebx

mov ebx, [esp+12]

int 80h

pop ebx

jmp sys.return

 

...

 

sys.return:

or eax, eax

js sys.err

clc

ret

 

sys.err:

neg eax

stc

ret

라이브러리를 이용하여 이식성을 높이는 방법은 프로그래머가 플랫폼 마다 다른 파일에 코드를 분리하여 작성해야 하므로 처음에는 불편하겠지만 많은 장점이 있습니다. 먼저, 라이브러리는 한번 작성해 두면 여러 프로그램에서 사용할 수 있다는 장점이 있죠. 게다가 라이브러리를 다른 프로그래머를 위해 공개할 수도 있으며, 다른 사람이 작성한 라이브러리를 사용하여 개발 효율을 높일 수도 있을 것입니다. 하지만 뭐라 해도 가장 큰 장점을 꼽으라면 역시 이식성입니다. 플랫폼 마다 라이브러리를 작성해 놓으면 프로그램을 다른 운영체제로 포팅할 때, 프로그램은 그대로 두고 라이브러리만 다시 작성하면 된다는 장점이 있습니다.

만약 라이브러리를 사용하는 방식이 마음에 들지 않으면 별도의 어셈블리 언어 파일에 모든 시스템 콜을 저장해 놓고 작성한 프로그램을 그 파일과 링크 시키는 것이 그나마 괜찮은 방법이 될 것입니다. 프로그램을 포팅할 때 반드시 거쳐야 하는 과정은 작성한 프로그램과 링크하여 다시 새로운 개체 파일(object file)을 생성해 내는 것입니다.

5.5. Using an Include File

소프트웨어를 소스 코드 형태로 발표한다면 매크로들을 별도의 파일로 모아 놓고 주 코드에서 인클루드(include)시키는 방식을 택할 수 있습니다.

이러한 형태로 작성된 소프트웨어를 포팅할 때는 인클루드된 파일만 재작성 하여 포팅을 마무리 지을 수도 있습니다. 즉, 라이브러리나 외부 개체 파일도 필요치 않으며 프로그램 코드도 수정할 필요 없이 포팅을 마무리할 수 있다는 뜻입니다.

N.B.: 이 방법이 바로 이 튜토리얼에서 사용할 포팅 방식입니다. 필자는 사용할 인클루트 파일을 system.inc라 정하고 새로운 시스템 콜을 다룰 때마다 추가할 것 입니다.

그럼 표준 파일 기술자를 정의하면서 system.inc 파일의 내용을 작성해 나가도록 하겠습니다.

 

%define stdin 0

%define stdout 1

%define stderr 2

다음으로 각 시스템 콜의 심볼릭 이름(symbolic name)을 생성합니다.

 

%define SYS_nosys 0

%define SYS_exit 1

%define SYS_fork 2

%define SYS_read 3

%define SYS_write 4

; [etc...]

그리고 코드 전체에서 사용되지 않는 프로시져 중 코드 길이가 짧고 긴 이름을 가진 것들을 추가시킵니다. 이런 프로시져들은 코드에서 자주 사용되진 않을 것입니다.

section .code

align 4

access.the.bsd.kernel:

int 80h

ret

그리고 시스템 콜 번호를 매개 변수로 갖는 매크로를 생성합니다.

 

%macro system 1

mov eax, %1

call access.the.bsd.kernel

%endmacro

마지막으로 각 시스템 콜에 해당하는 매크로를 생성합니다. 이 매크로는 매개 변수가 없습니다.

 

%macro sys.exit 0

system SYS_exit

%endmacro

 

%macro sys.fork 0

system SYS_fork

%endmacro

 

%macro sys.read 0

system SYS_read

%endmacro

 

%macro sys.write 0

system SYS_write

%endmacro

 

; [etc...]

필자는 더 많은 시스템 콜을 다루면서 이 파일에 여러 내용을 추가 시켜 나갈 것입니다.

Chapter 6 Our First Program

이제 첫 프로그램을 작성할 준비가 되었습니다. 첫 프로그램은 모든 프로그래밍 언어를 배울 때 거의 의무적으로 접하게 되는 hello, world 프로그램입니다.

1: %include 'system.inc'

2:

3: section .data

4: hello db 'Hello, World!', 0Ah

5: hbytes equ $-hello

6:

7: section .code

8: global _start

9: _start:

10: push dword hbytes

11: push dword hello

12: push dword stdout

13: sys.write

14:

15: push dword 0

16: sys.exit

첫번째 라인에서는 system.inc 파일에서 정의된 매크로와 코드를 인클루드 하고 있습니다.

라인 3-5는 데이터 영역입니다. 라인 3은 데이터 섹션/세그먼트로 시작하고 라인 4는 "Hello, World!"라는 문자열을 저장하고 있죠. 라인 5는 라인 4에서 정의된 문자열의 길이를 값으로 하는 상수를 만듭니다.

라인 7-16은 코드를 포함하고 있습니다. FreeBSD가 모든 프로그램이 _start라는 레이블이 붙은 지점에서 시작되어야 하는 elf 파일 포맷을 사용한다는 사실에 주목하시기 바랍니다.(보다 정확히 말하자면 링커가 _start를 시작 지점으로 생각합니다.) 당연히 이 레이블은 전역 변수(global variable)이어야 하겠죠.

라인 10-13에서는 시스템에게 hello 문자열의 hbytes 만큼 stdout으로 써 달라는 요청을 하고 있습니다.

라인 15-16에서는 시스템에게 0을 리턴값으로 갖고 프로그램을 종료 시켜 달라는 요청을 하고 있습니다. SYS_exit 시스템 콜은 결코 리턴되지 않기 때문에 코드는 여기에서 끝나게 됩니다.

N.B.: 만약 독자가 MS DOS 어셈블리 언어를 익히고 유닉스 환경으로 이전해 온 것이라면 출력을 위해서 비디오 하드웨어에 직접 쓰는 코드를 작성해 봤을 것입니다. 하지만 FreeBSD나 기타 다른 유닉스 환경에서는 이 문제에 대해 걱정할 필요가 없습니다. 출력에 관련된 작업을 처리해야 한다면 stdout이라는 파일에 기록하기만 하면 됩니다. stdout은 비디오 스크린이 될 수도 있고 텔넷 터미널(telnet terminal) 혹은 실제 파일 심지어 다른 프로그램의 입력일 수도 있습니다. 이것은 전적으로 시스템이 이해하는 방식에 따라 다른 것입니다.

6.1. Assembling the Code

일단 에디터에 어셈블리 언어 코드를 타이핑하고 hello.asm이라는 이름으로 저장합시다. 이 파일을 어셈블하려면 nasm이 필요합니다.

6.1.1. Installing NASM

nasm이 없다면 다음과 같이 타이핑하여 nasm을 설치합시다.

% su

Password:your root password

# cd /usr/ports/devel/nasm

# make install

# exit

%

만약 nasm의 소스 코드를 유지하고 싶지 않다면 make install 대신 make install clean을 타이핑해야 합니다.

위의 두 방법 중 어떤 것을 택하든지 FreeBSD는 인터넷에서 nasm을 다운 받아 컴파일하고 시스템에 설치해 줄 것입니다.

N.B: 만약 시스템이 FreeBSD가 아니라면 nasm의 홈페이지에 가서 직접 소스를 구해야 합니다. 물론 여기에서 얻은 소스로도 FreeBSD 코드를 어셈블할 수 있습니다.

이제 여러분은 다음과 같이 어셈블, 링크를 수행하고 코드를 실행시킬 수 있습니다.

% nasm -f elf hello.asm

% ld -s -o hello hello.o

% ./hello

Hello, World!

%

Chapter 7 Writing Unix Filters

유닉스 애플리케이션(application)의 일반적인 타입은 필터(filter) -stdin에서 데이터를 읽고 처리한 후, stdout으로 출력하는 프로그램 -의 형태입니다.

이 장에서는 간단한 필터를 개발하면서 stdin에서 데이터를 읽는 방법과 stdout으로 데이터를 쓰는 방법에 대해 알아볼 것 입니다. 이 필터는 바이트 단위로 입력 받은 데이터를 16진수로 변환하고 변환된 데이터를 출력하는 기능이 있습니다.

%include 'system.inc'

 

section .data

hex db '0123456789ABCDEF'

buffer db 0, 0, ' '

 

section .code

global _start

_start:

; read a byte from stdin

push dword 1

push dword buffer

push dword stdin

sys.read

add esp, byte 12

or eax, eax

je .done

 

; convert it to hex

movzx eax, byte [buffer]

mov edx, eax

shr dl, 4

mov dl, [hex+edx]

mov [buffer], dl

and al, 0Fh

mov al, [hex+eax]

mov [buffer+1], al

 

; print it

push dword 3

push dword buffer

push dword stdout

sys.write

add esp, byte 12

jmp short _start

 

.done:

push dword 0

sys.exit

 

데이터 섹션(data section)에서 hex라는 배열을 만들었습니다. 이 배열은 16진수 자리수를 오름 차순으로 저장하고 있습니다. 이 배열에 이어 바로 입력과 출력 모두에 사용될 버퍼가 선언됩니다. 이 버퍼의 첫 두 바이트는 0으로 초기화되어 있습니다. 이 버퍼가 두 개의 16진수 자리수를 쓸 장소입니다.(버퍼의 첫번째 바이트는 입력을 위한 장소로도 사용됩니다.) 그리고 세 번째 바이트는 공란(space)입니다.

이 코드 섹션은 네 부분으로 구성됩니다. 한 바이트를 읽고 이것을 16진수로 변환시킨 후, 결과를 쓰고 프로그램을 종료 시킵니다.

바이트 단위로 읽기 위해 시스템에게 stdin으로 부터 한 바이트를 읽어 달라는 요청을 하고 이것을 buffer의 첫번째 바이트에 저장합니다. 그리고 시스템은 EAX에 읽은 바이트 수를 리턴합니다. 들어온 데이터가 있으면 1이 될 것이고 없으면 0이 될 것입니다. 따라서 EAX의 값을 검사해야 할 것입니다. 만약 EAX의 값이 0이면 .done으로 분기하고 0이 아니면 다음 명령어를 계속 실행합니다.

N.B.: 이 예에서는 코드를 단순화시키기 위해 에러 상황을 무시하고 있습니다.

16진수 변환은 buffer에서 바이트를 읽어 들여 변환을 수행하고 EAX 레지스터에 변환 값을 저장합니다. (실제로는 AL이죠.) 이후 상위 4비트를 하위 4비트와 분리시켜 변환해야 할 필요가 있기 때문에 바이트를 EDX로 복사합니다. 그리고 버퍼의 첫번째 두 바이트에 분리한 4비트의 변환 값을 각각 저장합니다.

다음으로 시스템에 버퍼의 3 바이트- 두 16진수 자리수와 공란- 를 stdout에 써 달라는 요청을 합니다. 그 후 프로그램의 시작 부로 분기하여 다음 바이트를 처리합니다.

처리할 입력이 더 없으면 시스템에게 0을 리턴하면서 프로그램을 종료해 달라는 요청을 하는데, 이것은 프로그램이 성공적으로 종료되었음을 뜻하는 전통적인 값입니다.

이제 이 소스 파일을 hex.asm이라는 이름으로 저장한 후 다음과 같이 타이핑합니다.(^D의 의미는 Ctrl 키를 누른 상태에서 D를 누르라는 뜻이죠.)

% nasm -f elf hex.asm

% ld -s -o hex hex.o

% ./hex

Hello, World!

48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 21 0A Here I come!

48 65 72 65 20 49 20 63 6F 6D 65 21 0A ^D %

N.B.: 만약 MS DOS 환경에서 유닉스 환경으로 이전해 온 프로그래머라면 각 라인이 0D 0A가 아닌 0A로 끝난다는 사실에 의아해 할 것입니다. 이것은 유닉스가 cr/lf의 관례를 따르지 않고 0A 값을 갖는 새 라인(new line) 문자 만으로 라인의 끝을 표시하기 때문입니다.

예제 코드에서 어떤 점을 개선할 수 있을까요? 많은 부분이 있겠지만 출력 형식이 특히 미비합니다. 즉, 텍스트의 한 라인을 변환하여 화면에 출력하는데, 새로운 텍스트의 변환문이 이전의 출력과 공란 하나만으로 구분되어 출력되는 것은 썩 유쾌하지 못한 일입니다. 이러한 점은 다음 코드와 같이 다음 라인을 출력하기 전에 다음 라인으로 출력 위치를 재조정하도록 코드를 수정할 수 있을 것입니다.

%include 'system.inc'

 

section .data

hex db '0123456789ABCDEF'

buffer db 0, 0, ' '

 

section .code

global _start

_start:

mov cl, ' '

 

.loop:

; read a byte from stdin

push dword 1

push dword buffer

push dword stdin

sys.read

add esp, byte 12

or eax, eax

je .done

 

; convert it to hex

movzx eax, byte [buffer]

mov [buffer+2], cl

cmp al, 0Ah

jne .hex

mov [buffer+2], al

 

.hex:

mov edx, eax

shr dl, 4

mov dl, [hex+edx]

mov [buffer], dl

and al, 0Fh

mov al, [hex+eax]

mov [buffer+1], al

 

; print it

push dword 3

push dword buffer

push dword stdout

sys.write

add esp, byte 12

jmp short .loop

 

.done:

push dword 0

sys.exit

 

우리는 CL 레지스터에 공란을 저장해왔습니다. 마이크로소프트 윈도우와는 달리 유닉스의 시스템 콜은 결과 값을 리턴하기 위해 사용하는 레지스터를 제외하고는 어떤 레지스터의 값도 수정하지 않기 때문에 공란을 CL 레지스터에 저장하는 것이 문제될 건 없습니다.

이것은 프로그래머가 CL 레지스터의 값을 한번만 설정해 주면 된다는 것을 의미합니다. 따라서 .loop라는 레이블을 추가하고 다음 바이트를 처리해야 할 때 _start로 분기하지 않고 .loop으로 분기합니다. 또한 .hex 레이블을 추가하여 buffer의 세번째 바이트로 공란이나 새 라인 문자를 저장하는데 사용합니다.

일단 hex.asm에 이러한 변화를 반영했으면 다음과 같이 타이핑하십시오.

% nasm -f elf hex.asm

% ld -s -o hex hex.o

% ./hex

Hello, World!

48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 21 0A

Here I come!

48 65 72 65 20 49 20 63 6F 6D 65 21 0A

^D %

새 버젼은 좀 나아 보이지만 코드 자체가 비효율적인 면이 많습니다. 이런 이유로 필자는 한 바이트 당 두번 호출되는 시스템 콜을 만들고 있습니다.(한번은 읽고 한번은 출력합니다.)

Chapter 8 Buffered Input and Output

입력과 출력을 버퍼링하면 코드를 좀 더 효율적으로 구현할 수 있습니다. 이런 이유로 한번에 전체 바이트를 읽어올 입력 버퍼를 만든 후, 버퍼에서 한 바이트씩 가져와 처리하는 형식을 도입할 것입니다. 또, 출력 버퍼를 만들어서 이 버퍼가 가득 찰 때에만 stdout에 출력 하도록 수정할 것입니다.

프로그램은 더 이상의 입력이 없을 때 종료됩니다. 하지만 마지막에는 여전히 커널에게 출력 버퍼를 stdout에 써달라는 요청을 해야 합니다. 물론 버퍼가 비어 있다면 이러한 요청을 할 필요가 없겠죠. 프로그램의 종료 시 출력 버퍼의 내용을 정리해야 한다는 사실을 잊고 출력이 완전하지 않다고 투덜대지 않기 바랍니다.

%include 'system.inc'

 

%define BUFSIZE 2048

 

section .data

hex db '0123456789ABCDEF'

 

section .bss

ibuffer resb BUFSIZE

obuffer resb BUFSIZE

 

section .code

global _start

_start:

sub eax, eax

sub ebx, ebx

sub ecx, ecx

mov edi, obuffer

 

.loop:

; read a byte from stdin

call getchar

 

; convert it to hex

mov dl, al

shr al, 4

mov al, [hex+eax]

call putchar

 

mov al, dl

and al, 0Fh

mov al, [hex+eax]

call putchar

 

mov al, ' '

cmp dl, 0Ah

jne .put

mov al, dl

 

.put:

call putchar

jmp short .loop

 

align 4

getchar:

or ebx, ebx

jne .fetch

 

call read

 

.fetch:

lodsb

dec ebx

ret

 

read:

push dword BUFSIZE

mov esi, ibuffer

push esi

push dword stdin

sys.read

add esp, byte 12

mov ebx, eax

or eax, eax

je .done

sub eax, eax

ret

 

align 4

.done:

call write ; flush output buffer

push dword 0

sys.exit

 

align 4

putchar:

stosb

inc ecx

cmp ecx, BUFSIZE

je write

ret

 

align 4

write:

sub edi, ecx ; start of buffer

push ecx

push edi

push dword stdout

sys.write

add esp, byte 12

sub eax, eax

sub ecx, ecx ; buffer is empty now

ret

이제 .bss라고 명명되어 있는 소스 코드의 세번째 섹션을 살펴봅시다. 이 섹션은 실행 파일에 포함되어 있지 않기 때문에 초기화할 수가 없습니다. 필자는 db 대신에 resb를 사용하는데, 이것은 단지 요청된 크기만큼 초기화되지 않은 메모리를 예약하는 역할을 합니다.

시스템이 레지스터를 수정하지 않는다는 사실은 큰 장점이 될 수 있습니다. 레지스터의 값이 변하지 않기 때문에 여러 용도로 레지스터를 사용하는데, 만약 레지스터 값이 변한다면 .data 섹션에 전역 변수를 선언하고 저장해 두어야 할 것입니다. 이 점이 바로 레지스터로 매개 변수를 전달하는 마이크로소프트의 호출 관례에 비해 스택에 시스템 콜의 매개 변수를 전달하는 유닉스 호출 관례가 우수하다고 말하는 이유입니다. 이러한 이유로 필자는 여러 용도로 레지스터를 활용하여 프로그램을 작성합니다.

EDI와 ESI 레지스터는 다음에 읽을 바이트나 쓸 바이트를 가리키는 포인터로 사용됩니다. 또한 EBX와 ECX는 두 버퍼에 남아 있는 바이트의 수를 세는 데 사용되기 때문에 프로그램이 출력을 해야 할 상황인지 입력을 받아야 할 상황인지를 판단할 수 있습니다.

이제 이 프로그램이 어떻게 동작하는 지 살펴보도록 합시다.

% nasm -f elf hex.asm

% ld -s -o hex hex.o

% ./hex

Hello, World!

Here I come!

48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 21 0A

48 65 72 65 20 49 20 63 6F 6D 65 21 0A

^D %

예상치 못했던 문제가 하나 있습니다. 이 프로그램은 ^D를 누를 때까지 출력 버퍼의 내용을 모두 출력하지 않습니다. 하지만 이러한 문제는 새 라인 문자를 0A로 변환할 때마다 출력 버퍼의 내용을 출력하도록 코드를 수정하면 간단히 해결될 수 있습니다. 필자는 수정한 세 라인에 > 표시를 해 두었습니다.(hex.asm의 코드에 > 까지 복사하진 마시기 바랍니다.)

%include 'system.inc'

 

%define BUFSIZE 2048

 

section .data

hex db '0123456789ABCDEF'

 

section .bss

ibuffer resb BUFSIZE

obuffer resb BUFSIZE

 

section .code

global _start

_start:

sub eax, eax

sub ebx, ebx

sub ecx, ecx

mov edi, obuffer

 

.loop:

; read a byte from stdin

call getchar

 

; convert it to hex

mov dl, al

shr al, 4

mov al, [hex+eax]

call putchar

 

mov al, dl

and al, 0Fh

mov al, [hex+eax]

call putchar

 

mov al, ' '

cmp dl, 0Ah

jne .put

mov al, dl

 

.put:

call putchar

> cmp al, 0Ah

> jne .loop

> call write

jmp short .loop

 

align 4

getchar:

or ebx, ebx

jne .fetch

 

call read

 

.fetch:

lodsb

dec ebx

ret

 

read:

push dword BUFSIZE

mov esi, ibuffer

push esi

push dword stdin

sys.read

add esp, byte 12

mov ebx, eax

or eax, eax

je .done

sub eax, eax

ret

 

align 4

.done:

call write ; flush output buffer

push dword 0

sys.exit

 

align 4

putchar:

stosb

inc ecx

cmp ecx, BUFSIZE

je write

ret

 

align 4

write:

sub edi, ecx ; start of buffer

push ecx

push edi

push dword stdout

sys.write

add esp, byte 12

sub eax, eax

sub ecx, ecx ; buffer is empty now

ret

이제 결과를 확인해 봅시다.

% nasm -f elf hex.asm

% ld -s -o hex hex.o

% ./hex

Hello, World!

48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 21 0A

Here I come!

48 65 72 65 20 49 20 63 6F 6D 65 21 0A

^D %

644 바이트의 실행 파일 치곤 나쁘지 않은 결과가 아닙니까?

8.1. How To Unread a Character

WARNING: 이 절에서 다룰 내용은 다소 어려운 주제에 속합니다. 하지만 컴파일러 이론에 익숙한 프로그래머에게는 재미있는 주제가 될 수 있을 것입니다. 만약 이 절을 건너 뛰고 싶다면 다음 장으로 건너 뛴 후, 나중에 읽어도 상관없습니다.

우리의 예제 프로그램에는 필요 없는 특징이지만 보다 복잡한 필터들은 문자를 읽기 전에 미리 다음 문자를 읽어서 특정 조건을 만족하는지 확인하는 과정이 필요합니다.(이를 look-ahead라 합니다.) 만약 다음 문자가 특정한 문자라면(즉, 공란이나 특수 문자가 아니고) 이 값은 현재 처리하고 있는 토큰(token)의 일부이고 그 이외의 문자라면 현재 토큰이 아닐 것입니다.

텍스트 문자열로 구성된 입력 스트림(input stream)을 파싱(parsing)해야 하는 상황을 생각해 봅시다.(컴파일러를 구현한다면 필수적인 작업입니다.) 만약 한 문자나 숫자가 다른 문자에 연이어 나온다면 이것은 처리하고 있는 토큰의 일부입니다. 또, 다음 문자가 공란이나 특수 문자라면 현재 토큰의 일부가 아니라는 판단이 가능합니다.

이것은 재미있는 문제를 발생시킵니다. 입력 스트림의 뒤 부분에 있는 다음 문자를 미리 읽어 판단한 후, 어떻게 다시 입력 스트림으로 되돌릴 수 있으며, 추후 이 문자를 어떻게 다시 읽을 수 있겠습니까?

한 가지 가능한 방법은 다음 문자를 하나의 문자 변수에 저장하고 플래그를 설정하는 것입니다. 우리는 getchar가 플래그를 검사하여 설정되어 있을 경우에는 입력 버퍼 대신 문자 변수에서 바이트를 읽어 오고 플래그를 클리어 하도록 수정 할 수 있습니다. 물론 처리 속도는 저하되겠죠.

C 언어에는 ungetc() 함수가 있는데, 이 함수가 바로 위와 같은 목적으로 사용됩니다. 우리 코드에서 이러한 기능을 구현할 수 있는 가장 빠른 방법은 무엇이겠습니까? 필자는 독자들에게 앞으로 돌아가서 getchar 프로시져의 구현을 살펴보고 한번 고민해 보기를 권하고 싶습니다. 그리고 나서 생각이 정리되면 필자의 해결 방법을 보아 주기 바랍니다.

미리 읽은 문자를 다시 스트림으로 돌려 놓는 일은 처리할 버퍼의 시작으로 어떤 문자부터 할 것이냐의 문제로 귀결됩니다.

먼저 EBX의 값을 검사하여 버퍼가 비어 있는지 여부를 검사하는데, EBX의 값이 0이면 read 프로시져를 호출합니다.

프로그램이 처리할 문자가 있다면 lodsb를 사용하고 EBX의 값을 감소시킵니다. lodsb 명령어는 다음의 코드와 동일한 일을 수행합니다.

 

mov al, [esi]

inc esi

읽어온 바이트는 read 프로시져가 호출되기 전에는 여전히 버퍼에 남아 있습니다. 프로그램은 read가 언제 호출될지 정확히 알 수 없지만 getchar가 다시 호출되기 전에는 read도 호출되지 않는다는 것 정도는 알고 있습니다. 따라서 마지막으로 읽었던 문자를 다시 입력 스트림으로 되돌리기 위해서 해야 하는 일은 ESI 레지스터의 값을 감소시키고 EBX 레지스터의 값을 증가시키는 것이죠.

ungetc:

dec esi

inc ebx

ret

하지만 주의해야 합니다. 만약 미리 보아야 할 문자가 한번에 기껏 한 문자라면 별 문제 없겠지만 한 개 이상의 문자를 미리 보아야 한다면 ungetc 프로시져가 여러 번 반복하여 호출됩니다. 대부분의 경우 이러한 경우에도 문제가 없겠지만 모든 경우에 정상 동작하는 것은 아닙니다. 왜 그럴까요?

getchar는 read를 굳이 호출하지 않아도 동작하기 때문에 미리 읽었던 바이트들은 여전히 버퍼에 남아 있으며 당연히 ungetc 프로시져도 정상적으로 동작할 것입니다. 하지만 getchar가 read를 호출하면 버퍼의 내용이 변하게 됩니다.

프로그램은 항상 getchar로 읽은 마지막 바이트에 대해 정상적으로 동작한 ungetc에 의존할 수 있지만 전에 읽었던 값들에 의존할 순 없습니다.

만약 프로그램이 한 바이트 이상을 미리 읽어 들인다면 프로그래머는 어쨌든 두 가지의 선택을 해야 합니다.

가능하다면 프로그램을 한 바이트만을 미리 읽어 들이도록 수정해야 합니다. 이것이 가장 간단한 방법일 것입니다.

수정이 불가능하다면 무엇보다 먼저 프로그램이 한번에 입력 스트림으로 되돌려야 하는 문자의 최대 수를 결정해야 합니다. 최대 수는 16의 배수로 만들어 놓는 것이 좋은데, 16의 배수가 메모리에의 워드 단위이기 때문에 정렬이 효율적입니다. 그리고 코드의 .bss 섹션을 수정하고 spare라는 버퍼를 입력 버퍼 바로 앞에 선언합시다.

section .bss

resb 16 ; or whatever the value you came up with

ibuffer resb BUFSIZE

obuffer resb BUFSIZE

또 프로그램을 미리 읽은 바이트 값을 AL 레지스터에 저장하는 것으로 수정합시다.

ungetc:

dec esi

inc ebx

mov [esi], al

ret

Chapter 9 Command Line Arguments

커맨드 라인(command line)에서 입력과 출력 파일의 이름을 넘겨 받을 수 있다면 hex 프로그램은 더욱 유용하게 사용될 수 있을 것입니다.

유닉스 시스템은 프로그램을 시작 시키기 전에 스택에 몇 개의 데이터를 푸시해 놓고 프로그램의 _start이 있는 부분으로 분기합니다. 필자는 분명 분기(jump)한다고 했는데, 분기와 호출(call)은 다르다는 사실에 유의해야 합니다. 이것은 [esp+offset]을 읽거나 데이트를 스택에 팝 연산을 적용하여 데이터에 접근할 수 있다는 사실을 의미합니다.

스택의 상단(top)에는 커맨드 라인 인자의 수가 저장되어 있습니다. 이것은 보통 argc라고 불려 왔습니다.(argument count)

커맨드 라인 인자들은 바로 연이어 argc 만큼 저장되는데, 이 인자들은 보통 argv (argument values)라는 이름으로 참조됩니다. 즉, argv[0], argv[1], ..., argv[argc-1] 같은 형식으로 얻을 수 있습니다. 이 값들은 실제 인자가 아니라 인자를 가리키는 포인터입니다. (실 인자의 메모리 주소라는 의미입니다.) 그리고 인자들은 NULL을 종료 문자로 하는 문자열입니다.

argv 리스트의 끝은 NULL 포인터가 설정되어 있습니다. 즉 포인터 값으로 0이 설정되어 있다는 뜻이죠. 커맨드 라인 인자에 대해서는 할 말이 많지만 우리의 목적에는 이 정도의 내용으로 충분합니다.

N.B.: MS DOS 프로그래밍 환경에 익숙한 사람들에게 유닉스 환경과의 가장 큰 차이점은 각 인자들이 분리된 문자열로 구성되어 있다는 것이고, 두 번째 차이점은 받을 수 있는 인자의 수에는 어떤 한계가 없다는 것입니다.

이 정도 알았다면 hex.asm의 다음 버젼을 제작할 준비는 끝난 것입니다. 하지만 다음 버젼 제작에 들어가기에 앞서 system.inc 파일에 몇 개의 라인을 추가해야 합니다.

먼저 시스템 콜 번호 리스트에 두 개의 새로운 엔트리를 추가합니다.

%define SYS_open 5

%define SYS_close 6

그리고 파일의 끝에 두개의 매크로를 추가합니다.

%macro sys.open 0

system SYS_open

%endmacro

 

%macro sys.close 0

system SYS_close

%endmacro

다음이 수정된 버젼의 hex.asm 코드입니다.

%include 'system.inc'

 

%define BUFSIZE 2048

 

section .data

fd.in dd stdin

fd.out dd stdout

hex db '0123456789ABCDEF'

 

section .bss

ibuffer resb BUFSIZE

obuffer resb BUFSIZE

 

section .code

align 4

err:

push dword 1 ; return failure

sys.exit

 

align 4

global _start

_start:

add esp, byte 8 ; discard argc and argv[0]

 

pop ecx

jecxz .init ; no more arguments

 

; ECX contains the path to input file

push dword 0 ; O_RDONLY

push ecx

sys.open

jc err ; open failed

 

add esp, byte 8

mov [fd.in], eax

 

pop ecx

jecxz .init ; no more arguments

 

; ECX contains the path to output file

push dword 420 ; file mode (644 octal)

push dword 0200h | 0400h | 01h

; O_CREAT | O_TRUNC | O_WRONLY

push ecx

sys.open

jc err

 

add esp, byte 12

mov [fd.out], eax

 

.init:

sub eax, eax

sub ebx, ebx

sub ecx, ecx

mov edi, obuffer

 

.loop:

; read a byte from input file or stdin

call getchar

 

; convert it to hex

mov dl, al

shr al, 4

mov al, [hex+eax]

call putchar

 

mov al, dl

and al, 0Fh

mov al, [hex+eax]

call putchar

 

mov al, ' '

cmp dl, 0Ah

jne .put

mov al, dl

 

.put:

call putchar

cmp al, dl

jne .loop

call write

jmp short .loop

 

align 4

getchar:

or ebx, ebx

jne .fetch

 

call read

 

.fetch:

lodsb

dec ebx

ret

 

read:

push dword BUFSIZE

mov esi, ibuffer

push esi

push dword [fd.in]

sys.read

add esp, byte 12

mov ebx, eax

or eax, eax

je .done

sub eax, eax

ret

 

align 4

.done:

call write ; flush output buffer

 

; close files

push dword [fd.in]

sys.close

 

push dword [fd.out]

sys.close

 

; return success

push dword 0

sys.exit

 

align 4

putchar:

stosb

inc ecx

cmp ecx, BUFSIZE

je write

ret

 

align 4

write:

sub edi, ecx ; start of buffer

push ecx

push edi

push dword [fd.out]

sys.write

add esp, byte 12

sub eax, eax

sub ecx, ecx ; buffer is empty now

ret

이번 버젼에서는 fd.in과 fd.out이라는 두 개의 변수를 .data 섹션에 추가했는데, 이 변수들은 입력과 출력 파일 기술자를 저장하는데 사용됩니다.

.code 섹션에서는 stdin과 stdout을 [fd.in]과 [fd.out]과 같은 참조로 대체 시켰습니다.

이번 버젼의 .code 섹션은 간단한 에러 핸들러로 시작되는데, 이 핸들러는 1을 리턴하며 프로그램을 종료 시키는 단순한 일을 합니다. 에러 핸들러는 _start 앞에 있기 때문에 에러가 발생한 곳에서 가깝게 위치합니다.

당연한 말이지만 프로그램은 여전히 _start에서 시작합니다. 프로그램이 시작되면 먼저 스택에서 argc와 argv[0]을 제거합니다. 이 값들은 우리 프로그램에서는 사용되지 않습니다.

그 후 argv[1]을 팝하여 ECX 레지스터에 저장하는데, 이것은 jecxz로 NULL 포인터를 다룰 수 있는 것처럼 ECX 레지스터가 포인터 연산에 특히 적합하기 때문입니다. 만약 argv[1]이 NULL이 아니라면 프로그램은 첫번째 인자를 이름으로 갖는 파일을 엽니다. 그리고 argv[1]이 NULL이라면 이전 버젼의 프로그램도 동일하게 동작합니다.(stdin에서 읽어서 stdout으로 출력하는 버젼) 입력 파일을 열지 못하게 되면 (가령 그런 이름의 파일이 없는 이유로) 에러 핸들러로 분기하고 프로그램을 종료하게 됩니다.

이와는 달리 첫번째 인자 검사에서 에러가 발생하지 않으면 두 번째 인자를 검사합니다. 역시 두 번째 인자가 있다면 파일을 엽니다. 그리고 두 번째 인자가 없다면 stdout으로 결과를 출력합니다. 마찬가지로 출력 파일을 열지 못하는 상황이 발생하면 에러 핸들러로 분기합니다.(파일이 있다 해도 파일의 쓰기 허가권-permisiion-가 없다면 파일을 열 수 없을 것입니다.)

프로그램이 종료될 때, 열었던 파일들을 닫는다는 사실을 제외하면 코드의 나머지는 이전 버젼과 동일합니다. 이미 언급한 바와 같이 [fd.in]과 [fd.out]을 사용했다는 사실에 주목하시기 바랍니다.

이 프로그램의 실행 파일은 이제 768 바이트의 크기가 되었습니다.

여전히 개선할 점이 있을까요? 물론입니다. 모든 프로그램은 어떤 방향으로든 개선될 수 있습니다. 이런 의미에서 다음과 같이 개선할 수 있는 점들을 모아 보았습니다.

- 에러 핸들러가 stderr로 메시지를 출력할 수 있도록 수정합니다.

- read와 write 기능에 대한 에러 핸들러를 추가합니다.

- 입력 파일을 열 때 stdin을 닫고, 출력 파일을 열 때 stdout을 닫도록 수정합니다.

- -i 와 -o같은 커맨드 라인 스위치(command line switch)를 추가하여 입력과 출력 파일을 어 떤 순서로 나열하거나 stdin으로 읽고 파일에 쓰는 등의 부가적인 기능을 제공할 수 있도록 수 정합니다.

- 커맨드 라인 인자가 부적절하면 사용 방법을 출력해 주도록 수정한다.

필자는 이러한 추가 사항을 독자에게 과제로 남겨둘 생각입니다. 독자들은 이미 위와 같은 기능을 구현하는데 필요한 모든 지식을 갖추고 있습니다.

Chapter 10 Unix Enviroment

유닉스의 개념 중 중요한 것으로 환경(enviroment)를 들 수 있는데, 이는 여러 환경 변수(enviroment variable)로 정의됩니다. 환경 변수 중 일부는 시스템이 설정하며, 또 일부는 사용자, 쉘(shell), 다른 프로그램을 로드시키는 프로그램이 설정합니다.

10.1. How to Find Enviroment Variables

필자는 프로그램이 실행될 때, 스택에는 argc, argv, 그리고 그 밖의 다른 값들이 저장된다는 사실을 설명한 바 있습니다. 그 밖의 다른 값이라는 것이 바로 환경(enviroment), 보다 정확히 말하자면 NULL로 끝나는 환경 변수의 배열을 가리키는 포인터입니다. 이 값은 보통 env라는 변수로 참조됩니다.

env의 구조는 NULL로 끝나는 메모리 주소의 리스트인 argv와 동일하지만 envc가 없다는 사실이 다릅니다. 하지만 NULL 값을 검색하면 원소의 수를 알아낼 수 있습니다.

이 변수는 보통 name=value 형태로 저장되지만 어떤 환경 변수들은 =이 없이 저장되기도 합니다. 따라서 이 가능성을 고려해야 할 필요가 있습니다.

10.2. webvar

이 절에서 필자는 유닉스의 env 명령어가 동작하는 것처럼 환경 변수들을 출력해 줄 수 있는 코드를 작성할 것입니다. 하지만 이 프로그램을 간단한 어셈블리 CGI 유틸리티로 작성해 보는 것도 재미있는 작업이리라 생각합니다.

10.2.1. CGI: A Quick Overview

필자는 홈페이지에 자세한 CGI 튜토리얼을 올려 놓았지만 여기에서 CGI를 간단히 소개하겠습니다.

- 웹 서버(web server)는 환경 변수를 설정함으로써 CGI 프로그램과 통신합니다.

- CGI 프로그램은 출력 결과를 stdout으로 보냅니다. 그리고 웹 서버는 stdout에서 출력 결과를 읽어 들입니다.

- HTTP 헤더 두 개의공백 라인 다음에 시작되어야 합니다.

- 그리고 HTML 코드를 출력합니다.

N.B.: 특정 환경 변수들은 표준 이름을 사용하지만 일부 변수들은 웹 서버에 따라 다른 이름이 사용되기도 합니다. 따라서 webvars 프로그램은 이러한 점을 진단하는 유용한 도구가 될 것입니다.

코드는 다음과 같습니다. 필자는 코드에 바로 주석과 설명을 붙여 놓았습니다.

;;;;;;; webvars.asm ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

;

; Copyright (c) 2000 G. Adam Stanislav

; All rights reserved.

;

; Redistribution and use in source and binary forms, with or without

; modification, are permitted provided that the following conditions

; are met:

; 1. Redistributions of source code must retain the above copyright

; notice, this list of conditions and the following disclaimer.

; 2. Redistributions in binary form must reproduce the above copyright

; notice, this list of conditions and the following disclaimer in the

; documentation and/or other materials provided with the

distribution.

;

; THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS

``AS IS'' AND

; ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT

LIMITED TO, THE

; IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A

PARTICULAR PURPOSE

; ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR

CONTRIBUTORS BE LIABLE

; FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR

CONSEQUENTIAL

; DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF

SUBSTITUTE GOODS

; OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS

INTERRUPTION)

; HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN

CONTRACT, STRICT

; LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)

ARISING IN ANY WAY

; OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE

POSSIBILITY OF

; SUCH DAMAGE.

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

;

; Version 1.0

;

; Started: 8-Dec-2000

; Updated: 8-Dec-2000

;

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

%include 'system.inc'

 

section .data

http db 'Content-type: text/html', 0Ah, 0Ah

db '<?xml version="1.0" encoding="UTF-8"?>', 0Ah

db '<!DOCTYPE html PUBLIC "-//W3C/DTD XHTML Strict//EN"

'

db '"DTD/xhtml1-strict.dtd">', 0Ah

db '<html xmlns="http://www.w3.org/1999/xhtml" '

db 'xml.lang="en" lang="en">', 0Ah

db '<head>', 0Ah

db '<title>Web Environment</title>', 0Ah

db '<meta name="author" content="G. Adam Stanislav" />',

0Ah

db '</head>', 0Ah, 0Ah

db '<body bgcolor="#ffffff" text="#000000" link="#0000ff" '

db 'vlink="#840084" alink="#0000ff">', 0Ah

db '<div class="webvars">', 0Ah

db '<h1>Web Environment</h1>', 0Ah

db '<p>The following <b>environment variables</b> are

defined '

db 'on this web server:</p>', 0Ah, 0Ah

db '<table align="center" width="80" border="0" cellpadding="10"

'

db 'cellspacing="0" class="webvars">', 0Ah

httplen equ $-http

left db '<tr>', 0Ah

db '<td class="name"><tt>'

leftlen equ $-left

middle db '</tt></td>', 0Ah

db '<td class="value"><tt><b>'

midlen equ $-middle

undef db '<i>(undefined)</i>'

undeflen equ $-undef

right db '</b></tt></td>', 0Ah

db '</tr>', 0Ah

rightlen equ $-right

wrap db '</table>', 0Ah

db '</div>', 0Ah

db '</body>', 0Ah

db '</html>', 0Ah, 0Ah

wraplen equ $-wrap

 

section .code

global _start

_start:

; First, send out all the http and xhtml stuff that is

; needed before we start showing the environment

push dword httplen

push dword http

push dword stdout

sys.write

 

; Now find how far on the stack the environment pointers

; are. We have 12 bytes we have pushed before "argc"

mov eax, [esp+12]

 

; We need to remove the following from the stack:

;

; The 12 bytes we pushed for sys.write

; The 4 bytes of argc

; The EAX*4 bytes of argv

; The 4 bytes of the NULL after argv

;

; Total:

; 20 + eax * 4

;

; Because stack grows down, we need to ADD that many bytes

; to ESP.

lea esp, [esp+20+eax*4]

cld ; This should already be the case, but let's be sure.

 

; Loop through the environment, printing it out

.loop:

pop edi

or edi, edi ; Done yet?

je near .wrap

 

; Print the left part of HTML

push dword leftlen

push dword left

push dword stdout

sys.write

 

; It may be tempting to search for the '=' in the env string next.

; But it is possible there is no '=', so we search for the

; terminating NUL first.

mov esi, edi ; Save start of string

sub ecx, ecx

not ecx ; ECX = FFFFFFFF

sub eax, eax

repne scasb

not ecx ; ECX = string length + 1

mov ebx, ecx ; Save it in EBX

 

; Now is the time to find '='

mov edi, esi ; Start of string

mov al, '='

repne scasb

not ecx

add ecx, ebx ; Length of name

 

push ecx

push esi

push dword stdout

sys.write

 

; Print the middle part of HTML table code

push dword midlen

push dword middle

push dword stdout

sys.write

 

; Find the length of the value

not ecx

lea ebx, [ebx+ecx-1]

 

; Print "undefined" if 0

or ebx, ebx

jne .value

 

mov ebx, undeflen

mov edi, undef

 

.value:

push ebx

push edi

push dword stdout

sys.write

 

; Print the right part of the table row

push dword rightlen

push dword right

push dword stdout

sys.write

 

; Get rid of the 60 bytes we have pushed

add esp, byte 60

 

; Get the next variable

jmp .loop

 

.wrap:

; Print the rest of HTML

push dword wraplen

push dword wrap

push dword stdout

sys.write

 

; Return success

push dword 0

sys.exit

이 코드는 1396 바이트의 실행 코드를 만들어 냅니다. 프로그램이 만들어 내는 대부분의 데이터는 출력 해야 하는 HTML입니다.

어셈블과 링크는 다른 프로그램과 다르지 않습니다.

% nasm -f elf webvars.asm

% ld -s -o webvars webvars.o

이 프로그램을 사용하려면 webvars를 웹 서버에 올려야 합니다. 따라서 웹 서버가 어떻게 설정 되었느야에 따라 webvars 실행 파일을 cgi-bin이나 .cgi 디렉토리에 저장해 두어야 할 것입니다.

그리고 브라우져(browser)로 출력 결과를 확인해 보십시오. 필자의 웹 서버에서의 출력 결과를 보고 싶다면 웹 브라우져로 http://www.int80h.org/webvars/에 접속해 보시기 바랍니다. 모든 검색 엔진에 출력 결과를 나오도록 하고 싶진 않기 때문에 의도적으로 정규 링크(regular link)를 여기에 싣진 않았습니다.

Chapter 11 Working with Files

우리는 이미 파일을 다루는 대한 기본적인 지식은 얻었습니다. 즉, 파일을 열고, 닫고, 버퍼를 이용해 읽고, 쓰는 방법을 알고 있습니다. 하지만 유닉스는 파일에 대해 더 많은 기능을 제공합니다. 우리는 이 섹션에서 그 기능 중 일부를 살펴 보고 이를 이용하여 파일 변환 유틸리티(file convention utility)를 작성해 볼 것입니다.

작성해야 할 마지막 소프트웨어가 어떤 기능을 충족시켜야 하는 지를 시작할 때부터 알고 있다면 프로그래밍은 훨씬 쉬워 집니다.

필자가 유닉스 환경에서 작성했던 첫번째 프로그램 중 하나가 tuc (text-to-Unix file converter)이었습니다. 이 프로그램은 다른 운영 체제에서 작성된 텍스트 파일을 유닉스 텍스트 파일로 변환해 줍니다. 즉, 라인의 끝을 구분하는 특수 문자를 서로 다르게 사용하는 운영 체제들 사이에 텍스트 파일을 공유할 수 있도록 해 주는 프로그램이죠.(유닉스에서 작성된 텍스트 파일을 MS DOS 용으로 변환할 수도 있습니다.) 이 프로그램은 원본과 다른 파일에 결과를 저장합니다.

필자는 tuc을 여러 모로 사용해 왔지만 항상 다른 운영체제에서 유닉스로의 변환만을 수행해 왔습니다. 이 때 결과를 다른 파일로 쓰기 보다는 변환한 파일에 덮어 쓰는 것이 낫겠다는 생각을 항상 해왔습니다. 즉, 대부분의 경우에 필자는 다음과 같이 이 프로그램을 이용해 왔습니다.

% tuc myfile tempfile

% mv tempfile myfile

이 프로그램을 다음과 같이 사용할 수 있도록 개량하고 이름을 ftuc로 정하면 더욱 쓰기 편할 것입니다.

% ftuc myfile

이런 이유로 이 장에서는 어셈블리로 ftuc를 작성하고(원래 tuc는 C로 작성되었습니다.) 커널이 프로세스에게 제공하는 다양한 파일 서비스를 살펴볼 것입니다.

언뜻 생각해 보면 파일 변환은 매우 간단한 작업입니다. 해야 할 일은 cr/lf(carrage return/line feed)를 다른 개행 문자로 바꿔 주는 일 뿐입니다. 그렇지 않습니까??

만약 개행 문자 변환 뿐이라고 생각했다면 다시 한번 재고해 보십시오. 이 접근 방법은 대부분의 경우에 동작하겠지만(적어도 MS DOS 텍스트 파일에서는) 어떤 경우에는 잘못된 동작을 수행할 것입니다.

문제는 모든 유닉스 이외의 운영체제에서 만들어지는 텍스트 파일이 cr/lf로 끝나진 않는다는 것입니다. 일부 운영체제에서는 lf 없이 cr만을 사용하며, 일부는 여러 공백 라인을 여러 개의 lf와 cr 한 개로 대체하기도 합니다.

이렇게 본다면 텍스트 파일 변환기는 라인의 끝을 표시하는 모든 가능한 조합을 처리할 수 있어야 합니다.

- cr/lf

- cr

- lf/cr

- lf

또한 위의 조건들의 조합(몇 개의 lf 다음에 cr이 나온다든지.)을 사용하는 파일을 처리해야 할 것입니다.

11.1. Finite State Machine

문제는 FSM(Finite State Machine)이라는 기술을 사용하면 쉽게 해결됩니다. FSM은 출력 값이 현재 상태에서의 입력 값 뿐 아니라 그 전 입력 값에도 관련되어 있는 디지털 회로입니다. 마이크로프로세서는 FSM의 한 예가 됩니다. 우리의 어셈블리 코드는 기계어로 어셈블 되는데, 이 기계어 중 일부는 여러 개의 어셈블리 코드가 한 바이트의 기계어를 만들어 낸 것일 수도 있고 일부는 여러 개의 어셈블리 코드가 여러 바이트의 기계어를 만들어 낸 것입니다. 마이크로프로세서는 메모리에서 단계적으로 바이트를 패치(fetch)하는데, 패치된 바이트의 일부는 어떤 결과를 내지 않고 단지 상태만 변화시키는 것들입니다. 모든 op code가 패치되면 마이크로프로세서는 어떤 결과를 만들어 내거나 레지스터의 값을 변화시킬 것입니다.

이런 이유로 모든 소프트웨어는 본질적으로 마이크로프로세서를 위한 상태 명령어(state instruction)의 연속입니다. 그럼에도 불구하고 FSM의 개념은 소프트웨어의 설계에 중요한 역할을 합니다.

우리의 텍스트 파일 변환기는 세 가지의 가능 상태가 있는 FSM으로 설계될 수 있습니다. 우리는 이러한 가능 상태를 상태 0-2라 부를 수 있지만 각각의 상태에 상징적인 이름을 부여하면 좀 더 편해질 수 있습니다.

- ordinary

- cr

- lf

우리 프로그램은 ordinary 상태에서 시작합니다. 이 상태에 있는 동안 프로그램은 다음과 같이 입력에 따라 다른 동작을 합니다.

- 입력이 cr이나 lf이외의 다른 값이라면 그대로 출력할 뿐이며, 상태도 변하지 않습니다.

- 입력이 cr이면 상태는 cr 상태로 변하고, 입력 값은 버려집니다. 따라서 아무런 출력도 만들어 지지 않습니다.

- 입력이 lf라면 상태는 lf로 변하고, 입력 값은 버려집니다.

cr 상태에 있는 동안에는 아무런 작업도 수행되지 않는데, 이것은 cr이 마지막 입력이기 때문입니다. 이 상태에 있는 우리 프로그램은 현재의 입력에 따라 다른 동작을 하게 됩니다.

- 입력이 cr이나 lf이외의 것이라면 lf과 입력 값을 출력하고 상태를 ordinary로 변경합니다.

- 입력이 cr이라면 한 줄에서 두 개 이상의 cr을 받은 것입니다. 따라서 입력을 버리고 lf를 출력합니다. 이 때 상태는 변경시키지 않습니다.

- 입력이 lf라면 lf를 출력하고 상태를 ordinary로 변경시킵니다. 이 상태가 위의 첫번째 상황과 같지 않음에 유의하십시오. 만약 두 상황을 결합시킨다면 한 개가 아닌 두 개의 lf를 출력할 것입니다.

이제 프로그램은 cr을 받고서도 처리되지 않은 lf를 받은 후의 lf 상태에 있습니다. 이 상황은 (1)대상 파일이 이미 유닉스 형식이거나 (2)한 줄(row)에 있는 여러 라인을 여러 개의 lf와 한 개의 cr로 처리한 경우, 혹은 (3) 라인이 lf/cr로 끝난 경우에 일어납니다. 이제 이 상황에서 들어온 입력을 다음과 같이 처리할 수 있습니다.

- 입력이 cr아니 lf 이외의 것이라면 lf와 입력 값을 출력하고 상태를 ordinary로 변경합니다. 이것은 정확히 cr 상태에서 똑같은 입력 값을 받았을 때와 같은 동작입니다.

- 입력이 cr이면 입력 값을 버리고 lf를 출력합니다. 그리고 상태를 orfinary로 변경시킵니다.

- 입력이 lf라면 lf를 출력하고 상태를 그대로 놔둡니다.

11.1.1. The Final State

위의 FSM은 전체 파일에 대해 동작하지만 마지막 라인이 무시될 가능성이 있습니다. 이러한 상황은 파일이 하나의 cr이나 lf로 끝날 때 발생합니다. 필자는 tuc를 설계할 때 이러한 상황을 고려하지 않고 마지막 라인을 제거했었습니다.

이 문제는 전체 파일이 처리된 후의 상태를 검사하면 쉽게 수정할 수 있습니다. 만약 상태가 ordinary가 아니라면 단순히 마지막 lf를 출력하면 됩니다.

N.B.: 프로그램에서 사용될 알고리즘을 FSM으로 표현했기 때문에 파일 변환 문제를 처리하는 디지털 전자 회로(digital electronic circuit)을 쉽게 설계할 수 있습니다. 물론, 칩을 설계하고 구현하는 것은 어셈블리 언어로 프로그램을 작성하는 것보다 훨씬 많은 노력이 드는 일입니다.

11.1.2. The Output Counter

파일 변환 프로그램이 두 문자를 결합하여 한 문자로 만들기 때문에 출력 카운터(output counter)가 필요합니다. 프로그램에서는 0으로 초기화 시키고 한 문자를 출력할 때마다 카운터를 증가시킵니다. 따라서 프로그램이 끝날 때의 카운터에는 파일 크기가 기록되어 있을 것입니다.

11.2. Implementing FSM in Software

FSM을 이용하여 어떤 문제를 해결할 때 가장 어려운 점은 문제를 분석하고 분석한 문제를 FSM으로 표현하는 것입니다. 이것이 해결되면 소프트웨어는 거의 다 쓴 것이나 마찬가지입니다.

C같은 고 수준 언어를 사용할 때는 FSM을 구현하는 몇 가지 접근 방법이 있습니다. 하나는 switch 문장을 사용하여 각각의 경우에 실행되어야 할 함수로 분기하는 것이죠. 가령 다음과 같은 형식입니다.

switch (state) {

default:

    case REGULAR:

    regular(inputchar);

    break;

case CR:

    cr(inputchar);

    break;

case LF:

    lf(inputchar);

    break;

}

또 다른 방법은 다음과 같이 함수 포인터(function pointer)의 배열을 사용하는 것입니다.

(output[state])(inputchar);

이 경우 state를 함수 포인터로 구현하고 적절한 함수를 가리키도록 설정해야 합니다.

(*state)(inputchar);

이 방법이 바로 우리 프로그램에서 사용될 방법인데, 이유는 이 방법을 어셈블리 언어로 구현하기 쉽고 매우 빠른 수행 속도를 보이기 때문입니다. 프로그램에서는 수행되어야 할 프로시져의 주소를 EBX에 넣고 다음과 같이 호출하기만 하면 됩니다.

call ebx

마이크로프로세서가 메모리에서 주소를 패치할 필요가 없기 때문에-주소가 이미 레지스터 중 하나에 저장되어 있습니다.- 코드에 주소를 직접 기록하는 것보다 빠를 수 있습니다. 필자는 빠를 수 있다고 표현했는데, 이것은 최근에 개발된 마이크로프로세서들이 캐싱(caching)을 하기 때문에 레지스터에 미리 저장되어 있는 것과 다를 바 없는 경우가 많기 때문입니다.

 

11.3. Memory Mapped Files

프로그램이 하나의 파일에 대해서만 동작하기 때문에 입력 파일에서 읽고 출력 파일에 썼던 기존의 방법을 그대로 사용할 수 없습니다.

유닉스는 파일의 전부 혹은 일부를 메모리에 맵핑(mapping)시킬 수 있는 매커니즘을 지원합니다. 이 기능을 사용하기 위해서는 우선 파일을 적절한 읽기/쓰기 플래그와 함께 엽니다. 그리고 mmap 시스템 콜을 사용하여 파일을 메모리에 맵핑시킵니다. mmap이 좋은 점은 자동으로 가상 메모리에 맵핑된다는 것입니다. 따라서 실제 메모리 크기 보다 큰 용량의 파일들을 메모리에 맵핑시킬 수 있습니다. 또, 여전히 mob.lods, stos 같은 일반 메모리 op code를 이용하여 접근할 수도 있으며 파일의 메모리 이미지에 어떤 변화가 생길 때마다 파일 시스템의 파일에 반영됩니다. 게다가 맵핑되어 있는 동안은 파일을 연 상태로 유지하지 않고도 파일에서 데이터를 읽고 파일로 데이터를 쓸 수 있습니다.

32비트 인텔 마이크로프로세서들은 4GB의 메모리-물리 메모리든 가상 메모리든-까지 접근할 수 있습니다. FreeBSD 시스템은 이 메모리 영역 중 2GB를 파일 맵핑에 사용할 수 있습니다.

문제를 단순화시키기 위해 이 튜토리얼에서는 전체 영역을 맵핑할 수 있는 파일에 한해서만 변환 기능을 지원하기로 합니다. 2GB가 넘는 텍스트 파일은 아마 거의 없을 것입니다. 만약 우리 프로그램이 이러한 상황에 직면한다면 단순히 tuc를 사용하라는 메시지를 출력하고 종료할 것입니다.(tuc는 맵핑 파일을 사용하지 않았습니다.)

syscalls.master를 보면 mmap이라는 이름의 두 시스템 콜을 발견할 수 있을 것입니다. 이것은 유닉스에 수많은 변종이 있기 때문인데, 실제 전통적인 BSD mmap은 71번으로 설정되어 있으며 POSIX mmap은 197번으로 설정되어 있습니다. FreeBSD 시스템은 초기의 BSD 환경에서 작성된 프로그램을 지원하기 위해서 두 시스템 콜을 모두 지원합니다. 하지만 새로 작성될 소프트웨어는 POSIX 버젼을 사용해야 합니다.

syscall.master 파일에는 다음과 같이 POSIX 버젼에 대한 내용이 기록되어 있습니다.

197 STD BSD { caddr_t mmap(caddr_t addr, size_t len,

int prot, \

int flags, int fd, long pad, off_t pos); }

이 시스템 콜은 mmap(2)와는 조금 다른데, 이는 mmap(2)가 C를 위해 작성된 함수이기 때문입니다.

C 버젼과 시스템 콜의 차이점은 long pad 인자인데, 이 인자가 C 버젼에는 없기 때문입니다. 하지만 FreeBSD 시스템 콜은 64비트 인자를 푸시한 후 32비트 패드(pad)를 추가했습니다. 이 경우 off_t는 64 비트 값이 됩니다.

프로그램이 메모리 맵을 더 이상 사용하지 않는다면 munmap 시스템 콜을 사용하여 맵핑을

해제 시켜야 합니다.

TIP: W. Richard Stevens의 Unix Network Programming, Volume 2 Chapter 12

를 보면 mmap에 대해 자세히 나와 있습니다.

11.4. Determining File Size

파일의 어느 정도 크기를 메모리에 맵핑시키고 싶은지를 mmap에게 알려줘야 하는데, 우리 프로그램의 경우 전체 파일을 맵핑시키기 때문에 파일의 크기를 알아야 합니다.

이 경우 fstat 시스템 콜을 사용하여 파일의 크기를 알아낼 수 있습니다. syscalls.master에는 mmap과 마찬가지로 두 가지 버젼의 fstat이 발견됩니다. (전통적인 시스템 콜 62, POSIX 시스템 콜 189) 당연한 말이지만 우리 프로그램에서는 POSIX 버젼을 사용할 것입니다.

189 STD POSIX { int fstat(int fd, struct stat *sb); }

이것은 매우 직접적인 호출입니다. 프로그램에서 이 시스템 콜에 stat 구조체의 주소와 열린 파일의 기술자를 넘기면 시스템 콜은 구조체에 정보를 채워 리턴합니다.

하지만 필자가 .bss 섹션에 stat 구조체를 선언하고 fstat 시스템 콜을 호출하자 에러가 발생했음을 알리는 캐리 플래그(carray flag)가 설정됐습니다. 필자는 스택에 구조체를 할당하도록 코드를 수정한 후에야, 모든 동작이 정상적으로 수행됨을 확인할 수 있었습니다.

11.5. Changing the File Size

우리 프로그램이 cr/lf를 lf로 변경하기 때문에 출력 파일은 입력 파일 보다 작을 수 있습니다. 그런데 입력 파일을 출력 파일로 대체하기 때문에 파일의 크기를 변경할 수 있어야 합니다.

ftruncate 시스템 콜은 이렇게 파일 크기를 변경하는 기능을 제공합니다. 이름은 잘못 지은 감이 있지만 ftruncate 시스템 콜은 파일을 자를 수도 있고(파일을 작게 만들 수도 있고) 키울 수도 있습니다.

그리고 이전의 시스템 콜들과 마찬가지로 syscalls.master 파일에서 두 버젼의 ftruncate 시스템 콜을 발견할 수 있습니다.(예전 시스템 콜 130, POSIX 시스템 콜 201) 우리는 이전과 마찬가지로 POSIX 시스템 콜을 사용합니다.

201 STD BSD { int ftruncate(int fd, int pad, off_t length); }

이 시스템 콜이 int pad를 다시 포함하고 있다는 사실에 유의하시기 바랍니다.

11.6. ftuc

우리는 이제 ftuc 프로그램을 작성하는데 필요한 모든 지식을 알게 되었습니다. 이제 system.inc 파일에 새로운 라인을 추가하면서 프로그램의 작성을 시작해 봅시다. 먼저 파일의 시작 부분 근처에 몇 개의 상수와 구조체를 선언합시다.

;;;;;;; open flags

%define O_RDONLY 0

%define O_WRONLY 1

%define O_RDWR 2

 

;;;;;;; mmap flags

%define PROT_NONE 0

%define PROT_READ 1

%define PROT_WRITE 2

%define PROT_EXEC 4

;;

%define MAP_SHARED 0001h

%define MAP_PRIVATE 0002h

 

;;;;;;; stat structure

struc stat

st_dev resd 1 ; = 0

st_ino resd 1 ; = 4

st_mode resw 1 ; = 8, size is 16 bits

st_nlink resw 1 ; = 10, ditto

st_uid resd 1 ; = 12

st_gid resd 1 ; = 16

st_rdev resd 1 ; = 20

st_atime resd 1 ; = 24

st_atimensec resd 1 ; = 28

st_mtime resd 1 ; = 32

st_mtimensec resd 1 ; = 36

st_ctime resd 1 ; = 40

st_ctimensec resd 1 ; = 44

st_size resd 2 ; = 48, size is 64 bits

st_blocks resd 2 ; = 56, ditto

st_blksize resd 1 ; = 64

st_flags resd 1 ; = 68

st_gen resd 1 ; = 72

st_lspare resd 1 ; = 76

st_qspare resd 4 ; = 80

endstruc

그리고 새로운 시스템 콜을 정의합니다.

%define SYS_mmap 197

%define SYS_munmap 73

%define SYS_fstat 189

%define SYS_ftruncate 201

이제 새로 정의된 시스템 콜을 사용하기 위한 매크로를 추가합니다.

%macro sys.mmap 0

system SYS_mmap

%endmacro

 

%macro sys.munmap 0

system SYS_munmap

%endmacro

 

%macro sys.ftruncate 0

system SYS_ftruncate

%endmacro

 

%macro sys.fstat 0

system SYS_fstat

%endmacro

다음이 전체 코드의 내용입니다.

;;;;;;; Fast Text-to-Unix Conversion (ftuc.asm) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

;;

;; Started: 21-Dec-2000

;; Updated: 22-Dec-2000

;;

;; Copyright 2000 G. Adam Stanislav.

;; All rights reserved.

;;

;;;;;;; v.1 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

%include 'system.inc'

 

section .data

db 'Copyright 2000 G. Adam Stanislav.', 0Ah

db 'All rights reserved.', 0Ah

usg db 'Usage: ftuc filename', 0Ah

usglen equ $-usg

co db "ftuc: Can't open file.", 0Ah

colen equ $-co

fae db 'ftuc: File access error.', 0Ah

faelen equ $-fae

ftl db 'ftuc: File too long, use regular tuc instead.', 0Ah

ftllen equ $-ftl

mae db 'ftuc: Memory allocation error.', 0Ah

maelen equ $-mae

 

section .code

 

align 4

memerr:

push dword maelen

push dword mae

jmp short error

 

align 4

toolong:

push dword ftllen

push dword ftl

jmp short error

 

align 4

facerr:

push dword faelen

push dword fae

jmp short error

 

align 4

cantopen:

push dword colen

push dword co

jmp short error

 

align 4

usage:

push dword usglen

push dword usg

 

error:

push dword stderr

sys.write

 

push dword 1

sys.exit

 

align 4

global _start

_start:

pop eax ; argc

pop eax ; program name

pop ecx ; file to convert

jecxz usage

 

pop eax

or eax, eax ; Too many arguments?

jne usage

 

; Open the file

push dword O_RDWR

push ecx

sys.open

jc cantopen

 

mov ebp, eax ; Save fd

 

sub esp, byte stat_size

mov ebx, esp

 

; Find file size

push ebx

push ebp ; fd

sys.fstat

jc facerr

 

mov edx, [ebx + st_size + 4]

 

; File is too long if EDX != 0 ...

or edx, edx

jne near toolong

mov ecx, [ebx + st_size]

; ... or if it is above 2 GB

or ecx, ecx

js near toolong

 

; Do nothing if the file is 0 bytes in size

jecxz .quit

 

; Map the entire file in memory

push edx

push edx ; starting at offset 0

push edx ; pad

push ebp ; fd

push dword MAP_SHARED

push dword PROT_READ | PROT_WRITE

push ecx ; entire file size

push edx ; let system decide on the address

sys.mmap

jc near memerr

 

mov edi, eax

mov esi, eax

push ecx ; for SYS_munmap

push edi

 

; Use EBX for state machine

mov ebx, ordinary

mov ah, 0Ah

cld

 

.loop:

lodsb

call ebx

loop .loop

 

cmp ebx, ordinary

je .filesize

 

; Output final lf

mov al, ah

stosb

inc edx

 

.filesize:

; truncate file to new size

push dword 0 ; high dword

push edx ; low dword

push eax ; pad

push ebp

sys.ftruncate

 

; close it (ebp still pushed)

sys.close

 

add esp, byte 16

sys.munmap

 

.quit:

push dword 0

sys.exit

 

align 4

ordinary:

cmp al, 0Dh

je .cr

 

cmp al, ah

je .lf

 

stosb

inc edx

ret

 

align 4

.cr:

mov ebx, cr

ret

 

align 4

.lf:

mov ebx, lf

ret

 

align 4

cr:

cmp al, 0Dh

je .cr

 

cmp al, ah

je .lf

 

xchg al, ah

stosb

inc edx

 

xchg al, ah

; fall through

 

.lf:

stosb

inc edx

mov ebx, ordinary

ret

 

align 4

.cr:

mov al, ah

stosb

inc edx

ret

 

align 4

lf:

cmp al, ah

je .lf

 

cmp al, 0Dh

je .cr

 

xchg al, ah

stosb

inc edx

 

xchg al, ah

stosb

inc edx

mov ebx, ordinary

ret

 

align 4

.cr:

mov ebx, ordinary

mov al, ah

; fall through

 

.lf:

stosb

inc edx

ret

WARNING: MS DOS나 윈도우로 포맷된 디스크에 저장된 파일에는 이 프로그램을 사용하지 마시기 바랍니다. FreeBSD에서 마운트된 드라이브에서 mmap을 사용할 때, FreeBSD 코드에 미묘한 버그가 발생합니다. 이 버그는 파일이 특정 크기 이상 일때 mmap을 실행시키면 메모리를 0으로 채우고 그 영역을 파일로 그대로 복사하여 결과적으로 파일의 내용을 모두 0으로 만듭니다.

Chapter 12 Caveats

MS DOS와 윈도우 환경에서 어셈블리 언어를 공부한 사람은 최단 코스를 취하려는 경향이 있습니다. 키보드의 스캔 코드(scan code)를 읽고 비디오 메모리로 직접 스캔 코드를 쓰는 프로그램이 MS DOS 환경에서 사용되는 두 개의 고전적인 예제 중 하나입니다. 이 프로그램은 동작을 하지 않는 것은 아니지만 적절한 구현 방법이라 생각되진 않습니다.

이유가 무엇일까요? 이러한 연산을 수행할 때, PC BIOS와 MS DOS가 모두 느려진다는 것은 유명한 사실이기 때문입니다.

유닉스 환경에서도 이 비슷한 습관을 계속 보이는 사람도 있을 것입니다. 가령, 필자는 일반적인 유닉스 환경에서 키보드 스캔 코드에 접근하는 방법을 설명한 웹 사이트를 본 적이 있습니다.

이것은 적어도 유닉스 환경에서는 매우 나쁜 습관인데, 이제부터 그 이유를 설명하도록 하겠습니다.

12.1. Unix Is Protected

먼저 하드웨어에 직접 접근하는 것 자체가 불가능할 수도 있습니다. 유닉스는 보호 모드에서 동작합니다. 따라서 단지 커널과 디바이스 드라이버만이 하드웨어에 직접 접근할 수 있습니다. 아마도 몇몇 유닉스 호환 운영체제는 키보드 스캔 코드를 읽을 수 있도록 해 줄 수도 있지만 문제는 유닉스 운영체제는 이러한 직접 접근을 허용하지 않는다는 것입니다. 또한 현재 버젼의 운영체제가 직접 접근을 허용 한다 해도 다음 버젼도 허용한다는 보장이 없습니다. 따라서 힘들여 작성한 소프트웨어가 3일 천하가 될 수도 있습니다.

12.2. Unix Is an Abstraction

하지만 하드웨어 직접 접근을 허용하는 유닉스 호환 운영체제에서 조차 직접 접근하는 시도를 하지 않아야 할 진정 중요한 이유는 따로 있습니다.(물론 디바이스 드라이버를 작성한다면 예외입니다.)

Unix is an abstraction!

MS DOS와 유닉스의 설계 철학에는 큰 차이점이 있습니다. MS DOS는 한 명의 사용자를 위해 설계된 운영체제입니다. 따라서 이 운영체제는 하나의 키보드와 하나의 비디오 스크린이 직접 부착되어 있는 컴퓨터에서 수행됩니다. 사용자 입력은 거의 키보드로 들어오며 프로그램의 출력은 대부분 스크린으로 나갑니다.

이러한 사항들이 유닉스에서는 지켜지지 않습니다. 유닉스 사용자는 한 프로그램의 출력을 재지정(redirect)하여 다른 프로그램의 입력으로 향하게 하는 다음과 같은 형식으로 많은 작업을 처리합니다.

% program1 | program2 | program3 > file1

만약 program2를 작성했다면 프로그램의 입력은 키보드에서 오는 것이 아니며, 출력도 스크린이 아닌 program3로 나갑니다. 그리고 출력은 program3을 거쳐 file1으로 향합니다.

하지만 더 많은 이유가 있습니다! 심지어 프로그램의 입력과 출력 방향이 터미널로 일정하다 하더라도 터미널이 PC라는 보장이 없습니다. 그 터미널은 프로그래머가 예상하는 곳에 비디오 메모리가 없을 수도 있으며 키보드도 PC 스타일의 스캔 코드를 사용하지 않을 수도 있습니다.

터미널이 매킨토시(Macintosh)라면 어떻겠습니까?

지금쯤 고개를 흔들고 있는 사람이 있을 지 모르겠습니다. 지금까지 살펴본 프로그램들은 모두 어셈블리 언어로 작성되었습니다. 이 프로그램들을 어떻게 매킨토시에서 수행될 수 있도록 할 수 있겠습니까? 하지만 필자는 작성한 소프트웨어를 매킨토시에서 수행시키라는 요구는 하지 않았습니다. 단지 터미널이 매킨토시일 경우에도 프로그램이 정상 수행되어야 하지 않겠느냐 하는 말을 하고 있는 것입니다.

유닉스 환경에서 터미널은 소프트웨어가 돌아가는 컴퓨터에 직접 접속해 있을 필요가 없습니다. 오스트레일리아에 사는 매킨토시 사용자가 북 아메리카에(혹은 어느 곳이든) 있는 유닉스 시스템에 telnet을 이용하여 접속하는 것이 가능합니다. 그리고 터미널이 소프트웨어가 실행되는 컴퓨터에 있지 않다 할지라도 소프트웨어는 정상적으로 실행됩니다. 만약 스캔 코드를 읽으려 한다면 잘못된 값을 얻을 수도 있을 것입니다.

어떤 하드웨어에 대해서도 동일한 가정이 성립한다면 위와 같은 모든 상황이 가능해 집니다. 여러분이 읽고 있는 파일은 직접 접근할 수 없는 디스크에 있는 것일 수도 있으며, 보고 있는 이미지가 인공위성을 거쳐 연결된 우주선의 카메라의 것일 수도 있습니다.

이것이 유닉스 환경에서 데이터가 어디로 들어오고 어디로 나가는 지를 가정하면 안 되는 이유입니다. 시스템은 어느 상황에서도 하드웨어에 물리적인 접근을 할 수 있도록 처리할 수 있어야 합니다.

N.B.: 어느 정도의 제재(caveats)가 있지만 절대적인 규칙은 아닙니다. 예외도 가능하죠. 가령, 로컬 머신(local machine)에서만 돌아가는 텍스트 에디터를 작성한다면 보다 향상된 제어를 위해 직접 스캔 코드를 읽어 들이고 싶을 수도 있습니다. 필자는 무엇을 하고 무엇을 하지 말라는 제재 사항을 말하고 싶은 것이 아니라 MS DOS에서 유닉스 환경으로 이제 막 이전한 사람들이 생각지 않고 있는 함정을 알려 주려는 것 뿐입니다.

물론 창조적인 사람들은 종종 규칙을 깨뜨립니다. 당부하고 싶은 것은 최소한 자기가 기존의 규칙을 깨고 있다는 사실을 인지하고 규칙을 깨는 이유도 알면서 새로운 규칙을 만들라는 것입니다.

Appendix A Assembly Language Pearls

다음은 필자가 이 튜토리얼을 쓰기 시작한 이래 기고했던 다른 어셈블리 언어 관련 기사입니다. 기사들은 이 튜토리얼의 내용 중 일부를 읽을 만한 분량으로 줄인 것들입니다.

- String Length -어셈블리 언어에서 텍스트 문자열의 길이를 계산하는 방법으로 소개하고 있다.

- Smallest Unix Program - 가장 작은 유닉스 프로그램이 얼마나 우리를 위축시키는 지 확인해 보시기 바랍니다.

Appendix B BSD Style Copyright

BSD 스타일의 저작권으로 소프트웨어를 출시하고자 한다면 FreeBSD 시스템 도처에 위치해 있는 bsd-style-copyright를 찾아 참조할 수 있습니다. 그리고 이 저작권은 C 스타일의 주석을 사용한다는 사실도 기억해 두십시오.

필자는 BSD 스타일의 저작권을 어셈블리 언어 프로그램까지 포함하도록 편집하였습니다. 이 저작권을 보고 싶다면 nasm-compatible BSD-style copyright를 다운 받고 자신의 이름과 소스 코드를 기록하기 바랍니다.

Appendix C Acknowledgements

이 튜토리얼은 FreeBSD hackers 메일링 리스트의 많은 뛰어난 프로그래머들의 도움이 없었다면 작성되기 어려웠을 것입니다. 그들은 인내를 가지고 필자의 질문에 답해 주었으며, FreeBSD와 유닉스 시스템 프로그래밍의 내부를 파헤쳐 보고자 했던 필자에게 올바른 길을 제시해 주었습니다.

Thomas M. Sommers는 필자에게 FreeBSD 시스템 프로그래밍의 문을 열어준 사람입니다. 그의 How do I write "Hello, world" in FreeBSD assembler? 웹 페이지는 필자가 FreeBSD 환경에서 접한 첫번째 어셈블리 예제였습니다.

Jake Burkholder는 필자의 모든 질문에 성의 있게 답해 주었으며 필자에게 다양한 어셈블리 언어 소스 코드를 제공해 준 사람입니다.

Copytight ⓒ 2000 G. Adam Stanislav.

All rights reserved.