2009년 12월 30일 수요일

4장 - 수 체계와 진수 변환 2


4장 - 수 체계와 진수 변환 2

우리는 지난 시간에 10진수 변환을 해봤다. 생각과는 다르게 코딩할게 많다는 것을 깨달았을 것이다. 왜 그런가 ? 이론과 코딩간의 불일치 때문이다.
어셈블리 코딩없이도 10진수 변환 이론은 얼마든지 쌓을 수 있지만, 이론없이는 어셈블리 코드가 절대 나오지 않기 때문이다. 말은 쉽지만 코딩해보면
말보다는 훨씬 어려우며, 그런 이유로 "백견이 불여일타"라는 이 분야 명언이 있지 않은가 ? 특히 어셈블리어는 다른 언어에 비해 이론보다 코딩하는
실천이 더욱 필요한 언어이며 그만큼 고려할게 많다는 뜻이다.

이번 장은 지난 시간에 이어 2진 변환부터 시작해보자.

컴퓨터가 다루는 숫자는 2진수인데 왜 굳이 2진수로 변환을 해야하는가 ? 이런 엉뚱한 질문은 하지 않을 줄로 안다. 컴퓨터가 0101을 다루더라도
모니터로 출력할 땐 30313031로 변환되서 출력된다. 키보드 입력 또한 마찬가지로 아스키 십진수는 입출력의 기본 단위라고 할 수 있다. 우리는
계산이 복잡해질지 모르니 먼저 16진수 한자리 수 (= 2진수 4자리 수)를 2진수로 출력해보자.

10진수 출력의 기본 원리가 10으로 나눴던 거에 비해 2진수 출력의 기본 원리는 비트를 하나씩 마스킹 (masking)하면서 이동하는 방법이다. 마스킹은
and, or, xor, not 등의 비트 단위 (bitwise) 명령으로 해줄 수 있으며, 비트 이동은 회전 명령 (shift/roatate)으로 수행한다. 여기서는 and로
마스킹하려한다. 0 and 0 = 0, 0 and 1 = 0, 1 and 1 = 1 아마도 이런 논리곱 연산을 익히 들어봤을 것이므로 간략히 진리표만 살펴보면 이렇다:

and/or/xor dest, src

dest    src       and     dest      src
-----------------------------------------

0       0         and      0          0
0       1         and      0          1
1       0         and      0          0
1       1         and      1          1

dest    src       or      dest      src
-----------------------------------------
0       0         or        0         0
0       1         or        1         1
1       0         or        1         0
1       1         or        1         1

dest    src       xor     dest      src
-----------------------------------------
0       0         xor       0         0
0       1         xor       1         1
1       0         xor       1         0
1       1         xor       0         1

not dest

dest    not     dest
---------------------
0       not     1
1       not     0

기억할 것은 CPU and 명령은 그 결과를 반영하는 점이다. CPU 명령어 레퍼런스를 참조하면 "and dest, src"가 and 명령의 기본 포맷이라고
나왔을 것이다. 예로, and ax, 01b라고하면, ax는 01h로 논리곱한 결과를 담는다. 마찬가지로 and ax, bx라고 하면 두 레지스터의 and 연산 결과는
ax에 담긴다. 그 말은 bx는 변하지 않는다는 뜻이다 (나는 이를 소스 불변의 법칙)이라고 부른다. 소스 불변의 법칙이 적용되는 대표적인 명령으로
and/or/xor 등의 비트 연산 명령, shift/rotate 등의 회전 명령, add/sub 등의 산술 연산 명령, mov/lea 등의 복사 명령 등이 있다.


and/or 등의 비트 연산 명령의 작동 방식을 알았으면 또 필요한게 있으니, 비트 단위 회전 (rotate) 명령이다. 이는 ror/rol/rcr/rcl등의 회전
명령과 shr/shl/scr/scl 등의 자리 이동 (shift) 명령이 있다. 이 두 그룹의 명령은 "RCL r/m8, 1/CL/imm8" 형식의 포맷을 가진다. 즉, 연산자로
r/m8이나 CL 또는 1을 요구한다. 이 연산자를 쉬프트 카운터라고 한다. 이 연산자는 무한정 큰 값으로 정해줄 수 없으며, 1 또는 8비트 내의
값으로 정해줘야 한다. r/m8은 8비트 레지스터나 8비트 메모리를 의미한다. 그 말은 쉬프트 카운터를 메모리에 정해줄 수도 있다는 뜻이다. 가끔
(R(E))CX(CL)을 루프 카운터로 사용할 경우 메모리에 쉬프트 카운터를 정해주고 변경해가면서 연산이 가능하다는 뜻이다.

CPU 명령은 모두 CPU 안에 회로로 구현되었으며, 덧셈을 하는 대표적인 회로를 adder라고 하고, 쉬프트 (로테이트) 연산을 하는 회로를 shifter라고
한다. 그만큼 쉬프트 (로테이트) 연산은 없어서는 안될 중요한 연산 회로이며, 대부분의 디지털 컴퓨터에 하나씩은 다 구비된 회로이다. 그림으로
설명해야 옳으나 CPU 매뉴얼보면 자세히 나와있으니 생략하기로 하고, 코드를 바로 분석해보자. 


;============ hex to bin (1 hex char) ============

.model small
.stack 100h
.data
buffer db 30h, 30h, 30h, 30h

.code
main proc
  mov ax, @data
  mov ds, ax
 
  mov ax, 0Dh                 ; 테스트값
  lea si, buffer              ; si = 버퍼 포인터
  add si, sizeof buffer-1     ; si = 버퍼 마지막 바이트 포인터
  mov cx, sizeof buffer+1     ; 루프 카운터 = 버퍼 크기
  mov bx, ax                  ; bx에 임시로 ax 백업
 
L1:
  test bx, 0001h              ; 마지막 비트가 1인가 ?
  jnz L3                      ; 예, L3로 분기
 
L2:
  add byte ptr [si], 0        ; 이 명령은 생략해도 무관 (0은 더하나마나)
  jmp L4
 
L3:
  add byte ptr [si], 1        ; 바이트 포인터 값에 1을 더한다 (30h + 1 = 31h)
  jmp L4
 
L4:
  dec si                      ; 포인터 이동
  dec cx                      ; 카운터 조절
  jcxz L5                     ; 다 되었으면 출력, 덜 되었으면 비트 회전
 
  ror bx, 0001h               ; 1비트씩 우측으로 회전
  jmp L1                      ; 반복
 
L5:
  mov ah, 40h                 ; 파일 핸들 출력
  mov bx, 1                   ; 핸들 (1 = 모니터)
  mov cx, sizeof buffer       ; 출력할 바이트 길이
  lea dx, buffer              ; 출력할 버퍼 주소
  int 21h
 
  mov ah, 08h                 ; 입력 키 검사
  int 21h
 
  or al, al                   ; 키 안누르면 지연, 누르면 종료
  jz L6
 
L6:
  mov ax, 4c00h
  int 21h
 
main endp
end main


우리가 변환해줄 데이터는 16진수 한자리이며 이를 2진수로 변환하면 4자리 숫자가 될 수 있으니, 우리는 이를 데이터 섹션에 30h (아스키 십진수 0)을
다음처럼 4개 선언해줬다.

buffer db 30h, 30h, 30h, 30h

이제 이 데이터를 0이면 30h로 유지하고 1이면 31h 바꿔서 화면에 출력해주면 된다. 아래 코드가 각각 유지, 갱신하는 코드이다.

  add byte ptr [si], 0        ; 이 명령은 생략해도 무관 (0은 더하나마나)
  add byte ptr [si], 1        ; 바이트 포인터 값에 1을 더한다 (30h + 1 = 31h)
 
주석만 봐도 무슨 뜻인지 알 것이다. 이 메모리를 액세스하려면 메모리 포인터가 있어야하며 여기서는 si를 사용해서 다음처럼 초기화시켰다:

  lea si, buffer              ; si = 버퍼 포인터
  add si, sizeof buffer-1     ; si = 버퍼 마지막 바이트 포인터

lea 명령은 si에 버퍼의 옵셋 주소를 얻어낸다. 이 주소는 첫 옵셋 (옵셋 0)을 가르키는 주소이므로, 우리는 여기에 버퍼의 크기를 sizeof로 얻어
0부터 시작하므로 1을 빼주면 si는 버퍼의 마지막 메모리 주소를 가르키게된다. 왜 마지막 바이트를 포인트하였는지는 잘 알것이다 ? 뒤자리부터
저장하기 위함이다. 이제 버퍼를 좌우로 이동하려면 카운터가 필요하며 다음 코드는 카운터를 버퍼 크기만큼 정해준 것이다.

  mov cx, sizeof buffer+1     ; 루프 카운터 = 버퍼 크기
 
이제 버퍼의 끝에서부터 한자리씩 기록할 때마다 버퍼 주소와 카운터 값을 1씩 감소시키는 루프를 돌면 적절한 위치에 적절한 바이트가 기록되며,
다음 코드가 버퍼를 이동하며 카운터를 감소시키는 코드이다.

  dec si                      ; 포인터 이동
  dec cx                      ; 카운터 조절
 
그러다가 카운터가 0이되면 jcxz에 걸려서 출력후 종료하며, 0이 아닐 경우 다음 코드가 실행된다.

  ror bx, 0001h               ; 1비트씩 우측으로 회전
 
위에서 설명한 회전 (rotate) 명령을 썼으며, 1바이트 우측으로 회전했다. 그리고 각 루프를 액세스할 때마다 and 마스킹을 하기위한 코드는 다음과 같다:

  test bx, 0001h              ; 마지막 비트가 1인가 ?
 
왜 and를 쓰지 않고 test를 썼는가 ? and 명령은 앞에서 설명했듯이 dest 연산자를 갱신하기 때문이다. dest 연산자를 갱신하지 않는 암시적 and
명령이 test이다. test 명령은 and를 하되 그 결과를 플랙 레지스터에만 반영한다. 그 플랙을 테스트하여 분기 처리를 해줄 수 있다. 입출력
루틴은 이미 앞장에서 다뤘으므로 설명안해도 잘 알것이다. 원리를 아는 것이 중요하므로 별다른 최적화는 하지 않았다.

이 코드를 debug.exe로 트레이싱해보면, 마지막에 bx는 D000h로 처음 ax의 16진수 값인 000D를 뒤집어진채 들어감을 알 것이다. 이는 루프에서
총 4비트 ror를 해줬으므로 당연한 결과이다. 만약, 16비트 ror를 해주면 그 결과가 어떻게 될지 유추해보라. 추가로 디버그 윈도우에 대해
간략히 그림을 보면서 이해하자.

<그림>



보다시피 ax 라는 16비트 레지스터에 담기는 값은  0000 0001 0101 1011 처럼 이진수 16개이다. 하지만, 디버그 뿐만 아니라 대부분의 디버거는
이를 16진수로 보여준다. 즉, 디버거 자체에 2진수 16진수 변환 루틴이 있다는 뜻이다. 이 그림을 잘보고 위에서 설명한 shif/rotate 명령이
어떻게 반영될지 생각해보라. 많은 사람들이 레지스터에 16진수가 들어있다고 생각하기 때문에 그 많은 사람들이 16진수 변환을 못한다.

우리가 mov ax, 1234h라고 16진수 명령으로 코딩을 했다고 하자. 이 값은 이진수로 0001 0010 0011 0100으로 인코딩되어 레지스터에 저장된다.
기계어로 인코딩될 상수를 immediate라고 한다. 우리는 이 기계어를 8비트 레벨로 생각하여, ax의 상위 절반인 ah는 12h를 , 하위 절반인 al은
34h를 담긴 것으로 인정하여 계산에 응용할 수도 있다는 뜻이다. 만약 al이 34h를 담고 있다면 shr ax, cl (cl = 4라고 가정)하면, ax가 통째로
쉬프트되어 그 결과 ah와 al에도 영향을 준다는 뜻이다. 반면 al만 shr al, cl하면 ah 레지스터에 아무 영향을 주지 않는다. 그리고 4비트 쉬프트나
로테이트를 하면 16비트 한자리 (즉, 1 니블)가 이동한다.

이 아이디어는 32비트 EAX레지스터에도 그대로 적용된다. 즉, EAX의 상위 16비트는 이름 없는 레지스터였다. 그 상위 레지스터를 16비트
쉬프트/로테이트하여 또 다른 AX레지스터가 있는 척하고 쓰던 테크닉이 있었다. 하지만, 64비트 환경으로 넘어오면서야 겨우 R8L/R8W/R8D/R8
등의 고유한 이름을 갖게 되었다. 


이 간단한 코드의 작동 방식을 알았으므로 이제 이 아이디어를 응용하여 16비트 레지스터 값 (이는 16진수 4 자리에 해당한다)을 2진수로 찍어내는
방법을 알아보자. 늘 그렇듯이 프로그래머는 데이터의 대략적인 범위를 유저보다 먼저 알고 있어야 한다. 그렇다면 우리가 변환해야 할 데이터는
2진수 16개에 해당함을 알 것이다. 코드를 보자.


;============ hex to bin (1 word reg to ascii binary) ============

.model small
.stack 100h
.data
buffer db 4 dup (4 dup (30h))

.code
main proc
  mov ax, @data
  mov ds, ax
 
  mov ax, 1234h               ; 테스트값
  lea si, buffer              ; si = 버퍼 포인터
  add si, sizeof buffer-1     ; si = 버퍼 마지막 바이트 포인터
  mov cx, sizeof buffer+1     ; 루프 카운터 = 버퍼 크기

L1:
  test ax, 0001h              ; 마지막 비트가 1인가 ?
  jnz L3                      ; 예, L3로 분기
 
L2:
  jmp L4
 
L3:
  add byte ptr [si], 1        ; 바이트 포인터 값에 1을 더한다 (30h + 1 = 31h)
  jmp L4
 
L4:
  dec si                      ; 포인터 이동
  dec cx                      ; 카운터 조절
  jcxz L5                     ; 다 되었으면 출력, 덜 되었으면 비트 회전
 
  ror ax, 0001h               ; 1비트씩 우측으로 회전
  jmp L1                      ; 반복
 
L5:
  mov ah, 40h                 ; 파일 핸들 출력
  mov bx, 1                   ; 핸들 (1 = 모니터)
  mov cx, sizeof buffer       ; 출력할 바이트 길이
  lea dx, buffer              ; 출력할 버퍼 주소
  int 21h
 
  mov ah, 08h                 ; 입력 키 검사
  int 21h
 
  or al, al                   ; 키 안누르면 지연, 누르면 종료
  jz L6
 
L6:
  mov ax, 4c00h
  int 21h
 
main endp
end main

갈 길이 먼 관계로 위의 아이디어 그대로 코딩한 거라서 별 볼 것도 없다. 다만 차이점만 간략히 집어보면 이렇다. dup 명령을 중복해서 다음처럼
2차 배열을 생성했다:

buffer db 4 dup (4 dup (30h))

이전과는 다르게 bx에 백업하는 부분을 생략했다. 위에서 언급했듯이 16비트 레지스터를 16비트 회전시키면 원래의 값으로 돌아오기 때문이다. 마찬가지로
32비트 레지스터를 32비트 회전시켜도 원래의 값으로 돌아온다. 그리고, test 명령과 ror 명령의 대상을 ax로 바꿔줬으며, L2: 부분에 생략해도 될 코드를
아예 삭제시켜 더 단순화 시켜버렸다. L2:레이블 마저 날려버리려했으나 흐름을 빠르게 이해하자는 취지에서 코드가 애원을 하길래 살려줬다.

만약 음수라면 어떻겠는가 ? 알다시피 컴퓨터에서 모든 음수도 비트 0/1의 조합에 불과하므로 우리가 신경쓰지 않아도 된다. 여기서는 내 나름대로
간단한 방법이라고 생각되어 예로 들었지만, 늘 그렇듯이 똑같은 작동을 하는 코드라도 얼마든지 다르게 변형이 가능하다. 마스킹과 회전을 다르게
해주거나 포인터를 다르게 해줄 수도 있으며, 프로그램의 흐름 자체를 아래에서 거슬러 올라가거나 중간에서 상하로 퍼뜨리는 코드도 가능하니
연습 차원에서 도전해보라. 




이제 우리에게 남겨진 것은 16진수를 출력하는 방법이다. 코더라면 당연 ASCII 표 하나씩은 가지고 있을 것이다. 책 부록이나 인터넷을 참조하면
쉽게 구할수 있을 것이다. 인터넷으로 구한 아스키 표를 살펴보자.

<그림>

대략 살펴보면, 우리에게 필요한 0~9, A~F (보통 16진수는 대문자로 표현한다.)를 제외하고는 대부분이 16진수 출력과 아무 관련이 없으므로, 우리는
이를 걸러내 줘야함을 알 수 있다. 대략 다음과 같은 순서이다:

 __제어코드__조판기호1__0~9__조판기호2__A~F, G~Z__조판기호3__a~f, g-z__조판기호4

이제 16진 출력과 관련 없는 문자를 어떻게 걸러낼 것인가 ? cmp-jmp 명령으로 전부 비교-분기할 수도 있으며, 그 외에도 16진수를 출력하는 방법은
여러가지가 있다. 먼저 xlat 명령을 사용한 가장 쉬운 방법을 예로 들어보자.

우리는 xlat (Table Look-up Translation) 명령을 알아보려한다. 이 명령을 CPU 명령어 레퍼런스로 참조하면서 이해하면 쉬울 것이다. 이 명령의
작동 방식은 AL ← (DS:BX + ZeroExtend(AL)) 이고 명령 포맷은 "xlat (m8)"이다. 먼저 룩업 테이블이란 것이 뭔지 비유를 들어보자. 밤하늘의 별을
손가락으로 가르키면서 "저 별은 나의 별, 저 별은 너의 별"이라고 연인에게 노래를 불러준다고 하자. 별이 그려진 밤하늘이 룩업 테이블이다.
그리고 그 별을 가르키는 손가락이 테이블 포인터이다. xlat 명령은 가르켜진 밤하늘의 별이 "누구의 별"인지를 해석해주는 명령이다. 룩업 테이블은
BX가 포인트하고, 손가락이 가르켜야할 밤하늘의 "별의 좌표" 쯤에 해당하는 것이 AL에 정해지며, 둘이 더해져 "누구의 별"로 해석되어, 그 결과를
AL로 갱신한다. BX와 AL이 더해질때 만약 AL이 음수라면 양수로 간주하고 더한다.

얼핏봐도 굉장히 편리한 명령임을 알 수 있다. 특히 예로, 전화번호부라든지, 컬러 팔레트 테이블이라든지하는 큰 블럭 단위의 데이터에 효력을
발휘한다고 할 수 있는 명령이다. 하지만, 애석하게도 AL레지스터와 (R(E))BX레지스터를 고정적으로 묶어서 사용한다는 점이 단점이다. 그러므로
우리는 CPU 매뉴얼에 있는 유사코드 AL ← (DS:BX + ZeroExtend(AL))를 직접 구현해보자. 이를 구현하게 되면 두 레지스터를 반드시 고정하지 않고도
얼마든지 이 명령과 비슷한 역할을 할 코드 블럭을 만들 수 있기 때문이다. 중요한 부분만 재구성해보면 이렇다.


lea bx, table               ; bx로 룩업 테이블의 옵셋 주소를 얻어서

mov al, 12h                 ; 테스트 값
and al, 00001111b           ; al의 상위 니블은 소거하고 (zero extended)
add al, bl                  ; 테이블의 하위 바이트만 al에 더한다.


보다시피 어려울게 하나도 없다. 다만 유사코드 정의상 테이블의 옵셋 주소인 bx 중에서 하위 바이트 bl만 부분적으로 al에 더해야 한다. 만약 AL
레지스터가 두자리 16진수 xxh를 가진다면, 좌우 니블을 서로 바꿔서 이 명령을 두번 사용해야 함을 알 수 있을 것이다. 밤하늘의 별 자리 좌표가
음수여서는 곤란해서인지 (?) 상위 니블을 0확장 해준다. 코드를 살펴보자.


;============ print hex byte ==================

.model small
.stack 100h
.data
table db "0123456789ABCDEF"    ; 16개의 16진수-해당 아스키 테이블

.code
main proc
  mov ax, @data
  mov ds, ax

  xor ax, ax                    ; al을 소거한다. 이왕이면 상위 ax까지 소거한다 
  lea bx, table                 ; 테이블 옵셋 주소를 구한다

  mov al, 7Fh                   ; 테스트 값 ('7F')
 
  mov ch, al                    ; 놀고 있는 ch 레지스터에 백업한다
  mov cl, 4                     ; 쉬프트 카운터
  shr al, cl                    ; 상위 니블만 남겨둔다 (7을 먼저 출력해야함)

  xlat                          ; al = 변환된 아스키 값

L1:
  xor al, dl                    ; 바이트 레지스터 스와핑 (al <-> dl)
  xor dl, al                    ; int21h/ah=06h는 dl에 출력 인자를 정해준다
  xor al, dl

  mov ah, 06h
  int 21h                       ; 변환된거 찍고

  mov al, ch                    ; 하위 니블 가져오고
  and al, 00001111b             ; 상위 니블은 지우고

  xlat                          ; 두번째 변환

  xor al, dl                    ; 출력 인자로 다시 보내고
  xor dl, al
  xor al, dl

  int 21h                       ; 하위 니블 출력

L2:
  mov ah, 08h                   ; 지연후 종료
  int 21h

  or al, al
  jz L3

L3:
  mov ax, 4C00h
  int 21h
main endp
end main


이제 우리는 코드를 분석할 시간이다. xlat 명령은 al과 bx를 묶어서 사용하고, int 21h/ah=06h는 인자로 dl을 요구하는 한문자 출력 인터럽트이다.
그러므로 ah, al, bx 3개가 이미 묶인 상태가 된다. 여기에 우리는 쉬프트 카운터로 cl까지 하나 더 묶어두면 쓸 수 있는 레지스터가 몇개 안된다.
하지만 레지스터 몇개 묶인다고 단념할 어셈블리 프로그래머라면 얼른 단념하는게 정신 건강에 좋을 것이다. 어셈블리어가 아름다운 이유는 바로
주어진 팍팍한 여건을 극복해가는 언어이기 때문이라 생각한다.

먼저 룩업 테이블을 다음처럼 정의했다:

table db "0123456789ABCDEF"    ; 16개의 16진수-해당 아스키 테이블

보다시피 아스키 16바이트이다. 이는 30h, 31h, ... 45h 등으로 정의할 수 있지만, 괜히 코딩만 길어지니 아스키 스트링을 의미하는 ""로 묶어주면
편하다. 다음으로 lea bx, table은 단순히 이 테이블의 주소를 얻어내고, mov al, 7Fh는 테스트 값을 정해준 것이다. 가장 큰 7비트 아스키 값을
테스트로 삼았다.

  mov ch, al                    ; 놀고 있는 ch 레지스터에 백업한다
  mov cl, 4                     ; 쉬프트 카운터
  shr al, cl                    ; 상위 니블만 남겨둔다 (앞 니블을 먼저 출력해야함)
 
이제 변환을 할 준비를 해야하는데 여기서는 쉬프트 연산을 shr로 해줬다. 로테이트와 마찬가지로 쉬프트 연산도 비트를 회전한다. 차이점이라면,
쉬프트 연산은 관련 없는 비트를 지우면서 회전시키는 것이랄 수 있다. 반면 로테이트는 관련없는 비트를 유지하면서 회전시킨다. 쉬프트나 로테이트시
회전할 비트 갯수를 cl(cx, ecx)에 정해준다. 여기서는 cl로 정해줬으므로 ch는 묶이지 않고 자유로운 상태가 되므로 여기에 al값을 임시로 백업했다.
앞에서 설명했듯이 4비트를 shr로 우측 쉬프트하면 상위 4비트 (상위 니블)가 떨어져나가며 깨끗한 4자리 2진수만 남게된다. 이를 xlat으로 넘겨주면
룩업 테이블에서 이를 해당 아스키 문자로 변환한다. 2니블 = 1바이트 이므로 xlat을 두번 사용해야함을 기억해야 한다.

  xor al, dl                    ; 바이트 레지스터 스와핑 (al <-> dl)
  xor dl, al                    ; int21h/ah=06h는 dl에 출력 인자를 정해준다
  xor al, dl
 
이는 어셈블리 프로그래머만 가질 수 있는 마술과 같은 변수 값 변환 (swap) 루틴이다. 원리는 단순하다 "xor dest, src" 형식의 xor 명령은 dest를
갱신하고 src 연산자는 보존하기 때문에 이 코드가 나온다. 이 두 연산자를 서로 교차하여 xor해주면 원래의 값을 유지하면서 두 연산자의 값이 바뀐다.
xor의 마술같은 작동 방식 때문에 대부분의 암호화 코딩에서는 없어서는 안되는 중요한 명령이다. 또한 그래픽 프로그래밍에서는 특정 비트맵을
지웠다 살려내기도 한다. 여기서는 int 21h/ah=06h가 dl에 출력 인자를 줘야하며, 현재 xlat을 거쳐 변환된 아스키 코드는 al에 있으므로 al과 dl을
서로 스와핑해줬다.


  mov al, ch                    ; 하위 니블 가져오고
  and al, 00001111b             ; 상위 니블은 지우고
 

우리는 이미 두 니블 중 상위 니블을 출력한 상태이므로 남은 것은 하위 니블을 출력하는 것이다. 이를 백업해둔 ch에서 al로 복사하여, 상위 니블을
마스킹해주고 xlat으로 한번 더 변환하여 출력 인자로 스와핑해주고 int를 호출하면 하위 니블까지 출력된다. 이제 바이트 사고 방식을 워드로 확장할
시간이다. 비트 갯수가 늘어난다고 코딩이 어려울 것 같지만, 그보다 중요한 것은 논리라고 할 수 있다.


;============ print hex word + -h suffix ==================

.model small
.stack 100h
.data
table db "0123456789ABCDEF"     ; 16개의 16진수-해당 아스키 테이블
buffer db 4 dup (0), 68h        ; 4개의 널 문자 + h-접미어

.code
main proc
  mov ax, @data
  mov ds, ax

  xor ax, ax                    ; al을 소거한다. 이왕이면 상위 ax까지 소거한다
  lea bx, table                 ; 테이블 옵셋 주소를 구한다
  lea di, buffer                ; di는 저장 버퍼 주소
  mov ax, 0F123h                ; 테스트 값 (첫 F에 유념하라)
  mov cl, 4                     ; 마스킹 값 (4비트 = 1니블 단위로 쉬프트 연산할 것이다)
   
  mov dx, bx                    ; *** 룩업 테이블의 주소를 dx에 복사해둔다 (dx = bx) 
  mov si, ax                    ; 계산의 편리를 위해 테스트 값을 si에 복사해둔다. (si = ax)

L1:
  shr ax, cl                    ; 첫 니블만 걸러낸다. 
  shr ax, cl
  shr ax, cl
 
  add bx, ax                    ; 테이블 옵셋을 갱신하여 아스키 값으로 변환한다
  mov ax, [bx]                  ; 갱신된 값을 저장한다
  mov [di], ax
 
  mov bx, dx                    ; 백업 포인터를 불러온다
  mov ax, si                    ; 테스트 값을 불러온다
  add di, 1                     ; 저장 포인터를 한자리 이동한다
 
  shr ax, cl                    ; 두번째 니블을 얻어온다
  shr ax, cl
  and al, 00001111b             ; 상위 니블을 지운다
 
  add bl, al                    ; 두번째 니블부터는 바이트 단위로 액세스한다
  mov ax, [bx]                  ; 아스키로 변환하여
  mov [di], al                  ; 하위 바이트만 저장한다
 
  mov bx, dx                    ; 복구
  mov ax, si
  add di, 1
 
  shr ax, cl                    ; 세번째 니블을 얻어온다
  xor ah, ah                    ; 상위 바이트와 상위 니블을 지운다
  and al, 00001111b
 
  add bl, al                    ; 저장
  mov ax, [bx]
  mov [di], al
 
  mov bx, dx                    ; 복구
  mov ax, si
  add di, 1
 
  and al, 00001111b             ; 마지막 니블을 얻어온다
 
  add bl, al                    ; 저장
  mov ax, [bx]
  mov [di], al
 
  mov bx, dx                    ; 원래의 값으로 복원한다
  mov ax, si
 
  mov ah, 40h                   ; 출력한다
  mov bx, 1
  mov cx, sizeof buffer
  lea dx, buffer
  int 21h
 
L2:
  mov ah, 08h                   ; 지연후 종료
  int 21h

  or al, al
  jz L3

L3:
  mov ax, 4C00h
  int 21h
main endp
end main


보다시피 xlat의 아이디어를 응용하려다보니 코드는 좀 길지만 그 흔한 루프도 하나 없는 반복 코드에 불과하다. debug.exe로 천천히 트레이싱해보면
작동방식이 훤히 눈에 들어올 것이다. 이번 코드는 대부분 앞에서 설명했으므로 분석은 생략하기로 하자. 매번 코딩하면서 느끼지만, 이론과 실제
코딩은 너무도 다르다.



우리는 xlat 명령과 xlat 명령의 작동 방식을 응용하여 간단히 16진수 변환을 수행했다. 앞에서 변환 방법을 여러가지로 해줄 수 있다고 했는데 두번째
여기서는 비교-분기를 잘 활용하여 16진 출력에 불필요한 것은 건너띄고 필요한 것만 변환하는 필터링 기법으로 해보자. xlat 명령을 쓰면 편리하긴하나
안좋게 표현하자면 쓸모없이 메모리가 룩업 테이블만큼 낭비된다고 할 수도 있다. 반면 여기서 사용할 필터링 방법은 논리와 연관되어 어쩌면 코딩이
어려울 수도 있다. 먼저 우리에게 필요한 것이 무엇인지를 보면, 아스키 십진수 30-39h, 아스키 알파벳 대문자 41h~46h까지의 영역이다. 이중 숫자와
대문자 사이에 출력과는 상관없는 7개의 아스키 조판 관련 문자가 있다. 이를 걸러내는게 핵심이다.

우리의 관심을 끄는 아스키 문자를 해부학적으로 나열하면 이렇다. 컴퓨터 해부학 + 창의력에 기반한 코딩 = 어셈블리 프로래밍이 된다.

hex       bin           char        comment

2F        0010 1111     /           너 이녀석 쓸모없는 존재구나
30        0011 0000     0           없어서는 안되는 존재 !
31        0011 0001     1          
32        0011 0010     2          
33        0011 0011     3
34        0011 0100     4
35        0011 0101     5
36        0011 0110     6
37        0011 0111     7
38        0011 1000     8
39        0011 1001     9           
3A        0011 1010     :            너 이녀석 왜 꼽사리 양다리 걸치는데
3B        0011 1011     ;           
3C        0011 1100     <
3D        0011 1101     =
3E        0011 1110     >
3F        0011 1111     ?
40        0100 0000     @            
41        0100 0001     A             왜 여기 숨어있니
42        0100 0010     B            
43        0100 0011     C
44        0100 0100     D
45        0100 0101     E
46        0100 0110     F
47        0100 0111     G             너는 뭐니

현실에서 우리를 혼란에 빠뜨리게 하는 것들이 있듯이, 아스키 테이블에도 이와 비슷한 3대 충치가 존재한다. 비유를 들자면, 밑지고 장사한다는
장사꾼, 갈대처럼 흔들리는 정치인, 세상에 자기만 있는 줄 아는 연예인 쯤에 해당한다고 할 수 있다. 이 3대 충치를 뽑아내지 않으면, 다른 멀쩡한
이빨마저 썩게되니 고통스럽지만 이를 뽑아서 태양 흑점에까지 던져버려야 한다. 비유가 좀 심했나 ? 여튼 단지 비유일 뿐이니 게의치말자.

이제 이를 필터링해야 하는데, 어떻게 할 것인가 ?



여기까지 따라온 관찰력이 있는 사람이라면, 대부분의 출력 인터럽트가 두 문자를 동시에 출력하지 못하는 것을 눈치챘을 것이다. 즉, 한 문자를
출력하던가 아니면 2개 이상의 블럭 (이를 스트링이라고 한다) 문자로 출력하던가 밖에 못한다. 마찬가지로 32비트 환경에서 32비트 레지스터는
4개의 아스키 문자를 담을 수 있지만, 4문자만 따로 출력해주는 API 함수는 존재치 않는다. 물론 직접 만드는 경우는 예외로 한다.

어떤 아스키 값을 al에 가져왔다고 하자. 그 아스키 값은 8비트이므로 8비트 레지스터인 al을 모두 점유한다. 반대로 생각하면, ax레지스터엔
기껏해야 아스키 두 문자밖에 저장이 안된다는 뜻이다. 8비트 레지스터 값중 우리에게 필요한 것은 아스키 레벨로 47h만도 못한 것들이다.
그 중에서 우리에게 불필요한 것들도 있으며 아스키 레벨로 2Fh만도 못한 것들이다. 그러므로 최소/최대 한계를 다음처럼 정해줄 수 있다:

cmp al, 2Fh
jbe GotoSun
...
cmp al, 47h
jae GotoSun

여기서 조건적 jmp 명령에 '-e'를 붙일 것인가 안 붙일 것인가는 개인의 취향이다. 나중에 흐름 제어 구조를 다룰때 자세히 다룰 것이다. 이는
기준을 어디에 잡냐에 따라 붙일수도 있고 안붙일 수도 있다. 예로,

cmp al, 30h
jb GotoSun
...
cmp al, 46h
ja GotoSun

이렇게 해줘도 논리상 같은 코드가 된다. 개인적으로 안붙인 걸 선호하지만, 상황에 따라 반드시 -e를 붙여야 되는 경우도 흔하다. 이걸로 한계는
정해줬는데 중간에 낀 7개를 어떻게 걸러낼 것인가 ? 다음 코드를 보자.

cmp al, 2Fh
jb GotoSun
...
cmp al, 3Ah
jae GotoSun
...
cmp al, 46h
ja GotoSun

이 경우 만약 AL이 3Ah라면 "GotoSun"에 가버리게 되므로, 41h~46h 뿐만 아니라 이 이상의 모든 아스키 코드마저 여기에 걸려 함께 "GotoSun"에
가버린다. 이는 큰 수부터 거꾸로 필터링해도 마찬가지 현상이 나올 수 있다. 아스키 십진수만 출력되거나 아스키 대문자만 출력 되길 원하는 것이
아니다. 우리가 원하는 것은 십진수 10개 (0도 포함)와 아스키 대문자 6개 (A-F)가 함께 들어있는 아스키 16진수이다. 이미 "GotoSun"에 가버린
것들을 구출하러 우리마저 무모하게 "GotoSun"에 가야할까 ? 여기에 대한 해답은 다음 코드를 분석해보면 알것이다. 




;============ word reg dump ==================

.model small
.stack 100h
.data
dump db "dump"

.code
main proc
  mov ax, @data
  mov ds, ax
 
  mov ax, 16F9h                 ; 테스트 값 (ax = 16F9)
  lea si, dump                  ; 저장 포인터
  push ax                       ; 스택에 백업

  mov cl, 4                     ; 쉬프트 카운터
 
  shr ax, cl                    ; 회전하여 (ax = 016F)

L1: 
  and ax, 0F0Fh                 ; ax의 상위 니블 마스킹 (ax = 010F)
  add ax, 3030h

L2:
  cmp ah, 39h                   ; 39 ?
  ja L3                         ; 양다리 걸치면 어쩔건대 ?

  mov [si], ah                  ; 저장
  jmp L4

L3:
  add ah, 07h                   ; 세븐 맞을 보여줄게
  mov [si], ah

L4:
  cmp al, 39h                   ; 다음 타자
  ja L5                         ; 나도 양다리 좀...
  mov [si+2], al                ; 난 정직한 넘이니 덕아웃으로 나갈게요
  jmp L6

L5:
  add al, 07h                   ; 너도 세븐 맞이 그리웠지 ?
  mov [si+2], al

L6:
  pop ax                        ; 다음 타자
  inc si                        ; 몽둥이 들고 나오세요

L7:
  and ax, 0F0Fh                 ; 난 shr 안 시켜줘 ?
  add ax, 3030h                 ; 넌 깨끗하니 그냥 가도 멋져

L8:
  cmp ah, 39h                   ; 3번 타자
  ja L9                         ; 양다리 아니지 ?

  mov [si], ah                  ; 그럼 나가봐
  jmp L10
 
L9:
  add ah, 07                    ; 아직도 정신 못차렸어 ?
  mov [si], ah

L10:
  cmp al, 39h                   ; 4번 타자
  ja L11
  mov [si+2], al
  jmp L12

L11:
  add al, 07h                   ; 세븐 맛
  mov [si+2], al

L12:
  mov ah, 40h                   ; write to file handle
  mov bx, 1                     ; bx = 파일 핸들 (1 = 모니터)
  mov cx, sizeof dump           ; cx = 길이
  lea dx, dump                  ; dx = 버퍼 주소
  int 21h

L13:
  mov ah, 0
  int 16h                       ; 키보드 인터럽트 호출 - 잠시 지연 (wait for key-press)

  mov ax, 4C00h
  int 21h

main endp
end main


앞 예제처럼 단순 반복 구문이 대부분이라 쉽게 이해될 것이다. 코드를 분석해보자.

dump db "dump"

우리를 위해 희생해줄 4바이트 메모리이다. "dump"를 우리는 ax 레지스터 값으로 덮어쓸 것이다. 단지 메모리 공간만 4바이트 할당하는 역할이다.

  mov ax, 16F9h                 ; 테스트 값 (ax = 16F9)
  lea si, dump                  ; 저장 포인터
  push ax                       ; 스택에 백업
 
주석에 설명한대로 아무 테스트 값을 ax에 정해주고, 메모리 포인터를 정해주고, 나중에 다시 한번 불러내야 하니 스택에 백업해두었다. 물론 다른
여유있는 레지스터에 해두는 것이 속도가 빠르지만, 맨날 레지스터만 가지고 놀려니 좀 심심해서 장난쳐본 것에 불과하다.

L1:
  and ax, 0F0Fh                 ; ax의 상위 니블 마스킹 (ax = 010F)
  add ax, 3030h
 
L1은 보다시피 ax를 통째로 마스킹해서 상하위 레지스터에 모두 30h씩 더해줬다.

L2:
  cmp ah, 39h                   ; 39 ?
  ja L3                         ; 양다리 걸치면 어쩔건대 ?
 
L2는 여기서 가장 핵심이 되는 충치를 제거하는 필터를 설치한 부분이다. 39h보다 크면 L3로 가서 07을 더해주고, 아니면 그냥 저장한다. 여기서
왜 07을 더해주는가 ? 7개의 출력과 상관없는 아스키 문자를 걸러내기 위한 것이다. 단순히 덧셈으로 쉽게 필터링해주고 있다. 걸러낼 것이나
걸러내고 남을 것이나 둘다 아스키 문자라는 공통 속성을 가지므로 이런 필터링이 가능하다. 만약 전혀 관련없는 것들을 서로 구분한다면, 서로
공통인자 (수학에서 최대 공약수 정도)에 해당하는 것들을 기준으로 삼아야 할 것이다. 그 공통인자를 잘 찾아내는 것이 코딩 실력에 도움이 될
것이다. 가끔 동양이나 서양에서 공통적으로 통용되는 것을 표현하는 말로 "동서양을 막론하고"라는 말이 이를 증명해준다.

L3:
  add ah, 07h                   ; 세븐 맞을 보여줄게
  mov [si], ah
 
여기서 07h을 더해줬다. 그리고 더해준 값을 메모리 포인터 내용에 저장했다. 보다시피 39h 이상의 아스키 코드는 키값 + 07h가 추가되어 알파벳
대문자 영역을 차지한다. 왜 대문자만 필터링 되는가? 우리가 처음 연산에 사용한 숫자는 0Fh로 마스킹 되어 여기에 30h가 더해져 기껏해야 30~3F
까지의 영역 만을 차지하게 되기 때문이다. 그 중에서 3A를 예로 들면, 원래는 아스키 콜론 ":"이지만 16진수와 관련이 없으므로, 여기에 7을
더해 46h로 만들어 A를 출력하게 해준 것이다. 만약 소문자로 출력한다면, 27h을 더해주면 된다. 왜냐하면, 아스키 대문자 영역이 소문자보다 20h가
작기 때문이다.

  mov [si+2], al                ; 난 정직한 넘이니 덕아웃으로 나갈게요
 
L4에 있는 이는 메모리 포인터 현재 주소에서 상대 옵셋 2만큼 더 떨어진 메모리의 내용을 al 값으로 채운다. "dump"라는 스트링에서 'm'에 해당한다.


L6:
  pop ax                        ; 다음 타자
  inc si                        ; 몽둥이 들고 나오세요
 
이제 한 페이즈가 끝나 ax의 상/하위 값을 완전히 새로 얻어와야 할 시간이다. 스택에서 끄집어냈다. inc si는 si를 1진행시켜 "dump" 스트링의 'u'를
가르킨다. si+2는 'p'를 가르킨다. L7부분은 shr를 해줄 필요가 없다. 왜냐하면 하위 니블만 계산하면 되기 때문이다. 2 페이즈의 코드는 앞에서 섰던 

것을 재사용했다. 괜히 논리 블럭으로 묶어봤자 머리만 아프고 시간만 허비할 것 같아 이 시점에서는 패스했다. 최적화는 독자 몫이다.

  mov ah, 0
  int 16h                       ; 키보드 인터럽트 호출 - 잠시 지연 (wait for key-press)
 
우리는 처음으로 BIOS 인터럽트인 키보드 인터럽트를 호출했다. 이는 랄프 브라운이 정리한 인터럽트 목록에서 보면 00h를 ah에 정해주고 호출하면
결과를 ah에 스캔 코드를 al에 아스키 코드를 돌려준다고 나왔을 것이다. 자세한 내용은 인터럽트 목록을 참조하라.

코드는 길어보여도 분석해보니 정말 단순하다는 것을 알았을 것이다. 필터링의 기본적인 원리를 아는 것이 중요하다고 할 수 있다. 



16진수로 변환하는 한가지를 더 들어보자. 지금 우리의 목표는 어떤 바이트 레지스터 (예로, al)에 들어있는 하위 니블인 16진수 한자리 숫자를
해당 아스키 문자로 변환하는 것이다:

변환전: 0,  1,   2,  3,  4,  5,  6,  7,  8,  9,  A,  B,  C,  D,  E,  F
-------------------------------------------------------------------------
변환후: 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 41, 42, 43, 44, 45, 46h

그리고 39h와 41h사이엔 3A, 3B, 3C, 3D, 3E, 3F, 40 의 7문자 때문에, A에 해당하는 니블 값을 강제로 7을 더해 3A + 7 = 41h로 변환해 주는
것이다. 그러므로 우리의 관심을 가장 끄는 것은 A라고 할 수 있다. A를 변환할 줄 알면 B~F까지도 쉽게 변환이 되기 때문이다. 상점에서 무언가를
팔 때, 잘 팔리는 물건을 눈에 잘 띄는 부분에 배치하듯이, 우리 또한 관심을 끄는 A를 먼저 해결해주는 것이 자연스런 coding (또는 co-ordinating)
이라고 할 수 있다. 물론 16진수중에 A가 가장 많이 사용되기 때문이 아니라는 것은 두말할 필요도 없다. 그렇다면 이 아이디어를 코딩에 적용하자.

L1:
  cmp al, 0Ah     ; 또는 cmp al, 10
  jae L1
  add al, 30h
  jmp L3

L2:
  add al, 7
  add al, 30h
 
L3:
 
그런데, al이 A보다 적건 크건 간에 L1 이나 L2 둘다 30h를 더해주는 것은 마찬가지므로, 이 비교-분기를 다음처럼 수정해보자.

L1:
  cmp al, 0Ah
  jb L2
  add al, 7
 
L2:
  add al, 30h
 
L3:
 
보다시피 L1이나 L2 둘다 30h를 더해주는 것은 마찬가지며 큰 수에 초점을 맞춰 코드가 더 간결해짐을 알 수 있다. 또한, 30h를 더해주는 부분을
이진 덧셈인 or (or을 "논리합"이라고 표현하는데, "이진합"이라고 생각하면 어셈블리 코딩이 더 수월해질 것이다)로 연산해주면 add 명령보다 훨씬
빠른 덧셈을 하게 된다.

L1:
  cmp al, 0Ah
  jb L2
  add al, 7
 
L2:
  or al, 30h
 
L3:

반면 7을 더하는 부분마저 or 연산하면 엉뚱한 결과가 나오는데, 그 이유는 30h를 더하는 4,5번 비트는 한 니블내에 직접 연산을 하지 않는 반면, 7
이라는 비트 0111은 0~3번 비트 내에 직접 연산이 일어나기 때문이다. 그러므로 결과가 서로 다르기 때문에 7은 add로 더해줘야 한다.

A or 30 + 7 = 46
A or 30 or 7 = 3F

이제 이 아이디어로 16진수 4개를 메모리에 갱신하여 이를 화면에 덤프시켜보자. 




;============ word reg dump 2 ==================

.model small
.stack 100h
.data
dump db "dump"

.code
main proc
  mov ax, @data
  mov ds, ax
   
  mov ax, 16F9h                 ; 테스트 값 (ax = 16F9)
  lea si, dump                  ; 저장 포인터
  push ax                       ; 스택에 저장

  mov cl, 4                     ; 쉬프트 카운터
 
  shr ax, cl                    ; 회전하여 (ax = 016F)

L1:
  and ax, 0F0Fh                 ; 상위 니블 마스킹 (ax = 010F)
 
L2:
  cmp ah, 0Ah                   ; ah = 0A ?
  jb L3
  add ah, 7                     ; no, + 7
L3:
  add ah, 30h
  mov [si], ah
 
L4:
  cmp al, 0Ah                   ; al = 0A ?
  jb L5
  add al, 7
L5:
  add al, 30h
  mov [si+2], al
 
L6:
  pop ax                        ; 스택 값 복원
  inc si                        ; 저장 포인터 한 칸 전진
 
L11:
  and ax, 0F0Fh                 ; 하위 니블 마스킹 (ax = 0F09)
 
L21:
  cmp ah, 0Ah                   ; ah = 0A ?
  jb L31
  add ah, 7                     ; no, + 7
L31:
  add ah, 30h
  mov [si], ah
 
L41:
  cmp al, 0Ah                   ; al = 0A ?
  jb L51
  add al, 7                     ; no, + 7
L51:
  add al, 30h
  mov [si+2], al
 
 
L52:
  mov ah, 40h                   ; write to file handle
  mov bx, 1                     ; bx = 파일 핸들 (1 = 모니터)
  mov cx, sizeof dump           ; cx = 길이
  lea dx, dump                  ; dx = 버퍼 주소
  int 21h
 
L53:
  mov ah, 0
  int 16h                       ; 키보드 인터럽트 호출하여 잠시 지연 (wait for key-press)
 
  mov ax, 4C00h
  int 21h

main endp
end main


이미 다 설명했으므로 분석은 각자 몫이다. 이미 장문이 되어버렸으므로 못다한 얘기는 다음 장에서 하기로 하자.

2009년 12월 25일 금요일

3장 - 수 체계와 진수 변환



3장 - 수 체계  (number system)와 진수 변환 (radix conversion)



우리는 이번 장에서 진수 변환을 조금 다뤄보자. 다소 고리따분한 내용이므로 될 수 있음 머리를 굴려가며 이해하길 바란다.

왜 진수 변환을 하는가 ?

대답은 간단하다 사람과 컴퓨터가 서로 다른 수 체계를 쓰기 때문이다. 우리가 일상에서 사용하는 숫자 체계 (number system)를 10진수라고 한다.
10진수는 0, 1, 2, ... 8, 9로 구성되며 보통 d (decimal)를 붙여준다. 즉, 10진수에는 10이 없다. 다만 십진수 1과 십진수 0이 합쳐졌을 뿐이다.
간혹 컴퓨팅 관련 서적을 보면 array - 1이라는 1을 빼주는 표현식이 흔하다. 왜인가 ? 컴퓨터는 0부터 세기 때문이다. 당신이 만약 1을 안빼서
버그로 고생해본적 있다면 0부터 세는 습관을 들일 필요가 있음을 실감했을 것이다. 1234d는 4자리 십진수이다. 이는 천단위 하나, 백단위 하나,
십단위 하나, 일단위 하나의 총 4개의 숫자 (digit)로 구성된다. 단위가 일단위에서 증가할수록 거듭제곱 (power)을 한 번 더 해줘 한꺼번에 더한다.
수식을 안쓰려다보니 이렇게라도 표현하는 나를 이해해주길 바란다.

2진수

컴퓨터는 2진수를 사용한다. 이진수는 0과 1로 구성되며 2가 없으며 비트 (bit, binary digit의 약어)라고 한다. 왜 2가 없는지는 십진수와
마찬가지이며, 끝에 b (binary)를 붙여주고, 0, 1, 10, 11, 100 식으로 1씩 증가한다. 2진수는 쓰는 숫자 (digit)가 0 또는 1의 두개 뿐이므로
큰수를 표현한다면 엄청나게 많은 숫자 (digit)가 필요하다. 예로, 10,000d를 이진수로 변환하면, 10011100010000b이다. 컴퓨터는 이런 방식으로
숫자를 인식한다. 이진수는 숫자가 커질수록 다소 도배적인 (verbose)경향이 강하다. 그러므로 사람이 읽고 수량을 짐작할 수 있는 무언가가
필요하게 되었으며, 이진수 4자리를 묶어서 표현하자는 방식이 16진수이다.

16진수

2진수 4개를 묶어서 16진수 한자리로 표현한다. 2의 4번 거듭제곱 (power)한 것이 16이기 때문이다. 16진수는 h (hexadecimal)를 접미어로 붙여준다.
간혹 다른 어셈블러나 컴파일러는 0x를 접두어로 붙여주기도 한다. 16진수는 0, 1, 2,...8, 9, A, B, C, D, E, F로 구성된다. 숫자와 알파벳이
결합하므로, 알파벳있는 16진수를 표현할 땐 0 (leading zero)을 앞에 붙여준다. 예로, 8h, 102Dh, 0FFh. 어셈블리 프로그래밍에서 16진수에 들어있는
알파벳은 소문자로 표현해도 상관없다. 즉, 대소 구분하지 않는다 (case-insensitive). 이러한 16진수 한자리 숫자를 니블 (nibble)이라고 하며, 니블
두개가 모여 한 바이트가 된다. 즉, 0FFh는 1바이트고, 8h도 1바이트이며, 102Dh는 2바이트이다. 16진수 최대 옵셋 주소인 0FFFFh는 2바이트 주소인
셈이다. 16진수를 쓰게되어 0과 1의 도배에서 벗어날 수 있게 되었다. 하지만, 어셈블리 프로그래머는 메모리 주소를 16진수로 표현하며, 너무 빈번히
사용하다보니 16진수의 도배에 빠지게된다. 그만큼 16진수는 어셈블리 프로그래밍에 없어서는 안된다.

우리는 10진수를 그냥 쓰면 되지만, 컴퓨터가 알아듣게 하려면 2진수나 16진수로 변환해서 알려줘야 한다. 그 반대로, 컴퓨터가 내뱉어내는 2진수나
16진수는 10진수로 변환하지 않고서는 그 수량이나 크기를 쉽게 짐작하기 어려울 것이다. 컴퓨터가 생명체라면 그만큼 인간은 10진수에 세뇌되어
왔다고 주장할 것이다. 프로그래머인 우리는 인간과 컴퓨터 사이에 징검다리를 놔야할 필요가 있다.

2진수를 10진수로 변환

1진수는 존재치 않는다. 왜냐하면 모두가 1이면 참/거짓, 또는 on/off에 해당하는 논리를 구성할 수 없기 때문이다. 지구상에 남자만 있다고 하는
격이랄수 있다. 지구상엔 남녀라는 인간이 분류 기준이 되듯이, 0과 1로 구성된 이진수가 컴퓨터에서는 가장 작은 구분 단위가 된다. 이진수는 4개씩
묶으면 16진수가 되므로 큰 문제는 안되지만, 이 16진수를 화면에 출력할 땐 문제가 된다. 이는 조금있다 다시 살펴보기로 하고, 이진수를 10진수로
만드는 방법부터 알아보자. 그러기에 앞서 먼저 두 이진수를 더한다고 해보자.

 0    01    1000010       101010101011
 1    10    1100101       000010000001
---  ---   --------     --------------
 1    11   10100111       101100101100

이를 10진수로 계산하면 이렇다.

0   1   066   2731
1   2   101   0129
-------------------
1   3   167   2860


이를 16진수로 계산하면 이렇다.

0   1   42    AAB
1   2   65    081
--------------------
1   3   A7    B2C 



마지막 결과물만 살펴보면 이진수 12자리 결과 값이 10진수로는 4자리로, 16진수로는 3자리로 줄어듬을 알 수 있다. 이는 2진수가 가장 작은
구분 단위이고, 진수 (base, radix)가 커질수록 표현할 수 있는 숫자 (digit)의 갯수는 줄어들기 때문이다. 그 말은 모든 2진수는 2이상의
다른 진수로 변환할 때, 그 진수만큼 묶어주면 된다는 뜻이다. 묶는다는 것은 더 이상 나눌 수 없을 때까지 반복으로 나눠준다는 뜻이다.

예로, 101100101100b를 10개 단위로 묶는다고 하자.

101100101100b / 10d = 100011110b = 286d,  R = 0
000100011110b / 10d = 000011100b = 28d, R = 6
000000011100b / 10d = 000000010b = 2d, R = 8


이제 나머지(R)만 밑에서부터 거꾸로 거슬러 읽으면 2 (10으로 나누어지지 않으므로) 8 6 0이 된다. 반대로 위에서부터 뒷 자리 (LSD, Least
Significant Digit)를 채우면서 읽어도 2860이 된다. 수학적인 마인드가 갖춰진 사람이라면 스쳐지나가는 뭔가가 느껴질 것이다. 프로그래밍 마인드가
갖춰진 사람이라면 코딩하고 싶어서 손이 근질근질할 지도 모른다. 하지만 조금 더 살펴보자.

위의 단순반복적인 나눗셈은 예로, 7 = 2 * 3 + 1이라는 "A = BQ + R" 이라는 수식 표현식에 들어맞는다. 여기서 Q는 몫 (Quotient), R은 나머지
(Remainder)를 의미한다. 또한 이 단순 나눗셈은 몫보다는 나머지에 관심을 가지는 연산이다. 이를 수학에서 모듈화 연산 (Modular Arithmetic)이라고
한다. 모듈화 연산은 굉장히 쓸모가 많지만 여긴 수학을 다루는 곳이 아니므로 넘어가기로 하자.

이제 우리에게 남겨진 것은 어떤 수를 십진수로 출력해내는 것이다. 무슨 소린지 알겠는가 ? 컴퓨터에 저장되는 숫자는 2진수이다. 이는 누군가
십진수로 변환하지 않으면 출력이 안된다. 예로, AX라는 16비트 레지스터에 1000d를 입력했다고 하자. 이 숫자는 프로그래머의 손을 떠나는 순간
비트로 변환되어 즉, 이진수가되어 1111101000b가 된다. 그러므로 이를 바로 출력하면 화면에 외계인의 글자로 찍힌다. 고급 언어인 C를 예로 들면,
printf 라는 함수를 누군가 (C 제작자나 라이브러리 제공자)가 십진수로 이미 변환하는 라이브러리를 제공한다. 그러므로 C 컴파일러를 쓰는 C 코더는
이 함수의 인자만 살짝 변경하여 출력해주면 그만이다. 하지만, 어셈블리 프로그래머는 당연 이런 함수는 커녕 기본적인 다른 루틴도 또한 지원하지
않으므로 직접 작성해야한다. 하지만 요즘엔 어셈블리 유저들 간에 코드가 상당히 공유되는 인터넷 지배 사회에 살고 있으므로 유저들이 만든 라이브러리
함수가 대부분 masm 패키지에 들어있다. 그만큼 C 코더는 어셈블리 프로그래머에 비해 빠르고 쉽게 코딩하는 셈이고, 반대로 이는 C 코더가 논리나
알고리즘 측면에서 어셈블리 프로그래머에 비해 빈약하게 될 수 밖에 없는 이유이다. 함수만 잘 불러쓰다보면 코딩이 끝나지 않은가 ? 그 말은 역설로
어셈블리로 코딩한다고 해도 C 컴파일러 제작자만큼 똑똑하지 않으면, 안하는 것만 못할 수도 있다는 뜻이다. 그런 이유에서 어셈블리 프로그래머는
항상 최적화 (optimization)에 민감해야하고, 어셈블리로 작성하면 무조건 작고 빠르다는 맹목적인 종교에서 벗어나야한다.

지난 장과는 다르게 이번에는 레고 블럭을 조립하듯이 필요한 것에 살을 붙여가는 pop-up 방식으로 프로그래밍하자. 사실, 지난 장에서 코딩했던 것은
내가 이미 코드를 알고 있다는 가정하에 drop-down 방식으로 작성한 것이다. 하지만, 누구라도 그러하듯이 처음부터 코드를 알고 태어나는 사람은
없다. 그런 이유로 코딩은 밑바닥에서 점점 고차원적인 단계로 나아가는 pop-up 방식으로 코딩하는 것이 자연스러운 것이다. 물론 고급 언어는 이미
테스트된 라이브러리의 내장 함수를 쓰므로 그럴 일도 적겠지만, 어셈블리 프로그래머라면 당연 그런 습관이 도움이 안될 것이다.

10진 변환의 기본 원리는 더는 못 나눌 때까지 계속 10으로 나눠가며, 매번 나눌때마다 나머지만 저장하라는 식이다. 당연한 얘기지만 10으로 나누면
나머지는 0~9까지이다. 가장 큰 나머지라도 몫보다는 1이 적다. 또한, 정수 레벨 나눗셈이므로 나누려는 수 (dividend)는 항상 나눌 수 (divisor)보다
크거나 같다고 가정한다. 16비트 레지스터를 예로 들면 나누려는 수는 16비트이고 나누려는 수는 8비트이다. 즉, 8비트 * 8비트 = 16비트이므로, 하나의
16비트 숫자가 두개의 8비트로 쪼개지는 것이다. 여기서 두배 (double)가 아니고 제곱 (power)가 됨을 다시 한번 인식하자. 물론, 16비트를 16비트로
나눈다면 몫이 0 또는 1이다.

어셈블리 프로그래머는 레지스터를 통해 주로 나눗셈을 할 것이므로, 레지스터가 담을 수 있는 값의 범위 (range)를 미리 알아야 한다. 32비트 레지스터를
대충 그림으로 그려보면 이렇다.



                                       <그림1>

각 비트는 0 또는 1을 담을 수 있고, 우측에서부터 0번 비트라고하며 각 비트에 번호가 할당되어있고, 비트 8개는 바이트, 16개는 워드, 32개는 더블워드가
된다. 우리는 계산된 결과물의 최대값이 어느정도 될 것인지를 미리 예측하면서 코딩해야한다. 즉, 16비트를 8비트로 나누면 8비트 범위를 넘지 안아야 한다.
64비트를 32비트로 나누어도 32비트가 넘어서는 안된다. 이처럼 레지스터에 담긴 값을 제어하는 역할을 어셈블리 프로그래머가 한다. 항상 유념할 것은 이진수
1자리가 큰 수일수록 제곱의 크기가 된다는 사실이다. 레지스터를 다룰 때 또 하나 알아야 할 것은 통째 (whole)로 쓰는 것과 부분적으로 (partial) 나눠서
쓸 수 있다는 것이다. 예로, 16비트가 담긴 16비트 AX 레지스터는 상/하위 바이트로 나눠서 ah, al레지스터로 쓸 수 있다. (h = high, l = low).

사람 머리와는 다르게 레지스터는 무한정 큰 양을 처리할 수 없다. 그렇다고해서 컴퓨터가 깡통이라고 얕잡아보다가 체스 챔피언이 컴퓨터에 무릎을 꿇은
사실은 누구나 알지 않은가. 체스 챔피언이 진 이유는 인간이기 때문이겠지만, 그보다는 컴퓨터가 거의 무한정의 보조 기억 장치인 메모리를 거의 사람 뇌의
수준에 가까울 정도로 빠르게 액세스 가능하기 때문이다.

이제 대표적인 나눗셈 CPU 명령어 div을 CPU 매뉴얼에서 16비트 버전 내용을 찾아보면, "div r/m8"과 유사한 포맷으로 나왔을 것이다. 이는 div 명령이
8비트 레지스터나 8비트 메모리 값을 인자로 필요한다는 뜻이다. 이 r/m8을 operand라고 하며, 엄밀한 의미에서 피연산자라고 칭해야 옳지만, 우리는
어셈블리 프로그래밍에서 명령 자체를 사용하므로 명령 자체에 요구되는 연산자라고 해도 의미상 피연산자와 혼동하지는 않을 것이다. 피-가 들어가는
단어를 개인적으로 싫어하기 때문이기도 하고, 괜히 말머리만 길어지기 때문이기도 하며, 연산자라고 표현한다고 독자가 혼동할 정도로 단순하진 않을
것이다.

div 명령은 여러 버전이 있지만, 레지스터가 얼마나 크냐에 따라 다를 뿐이지 그 형식은 위의 div r/m8과 별반 차이가 없다. 이제 우리가 나누려는
대상의 크기를 예측해야 한다. 우리는 8비트 데이터 00~FFh까지의 영역 중 한 수를 피젯수로 사용하여 (여기서는 가장 큰 0FFh), 그 8비트를 8비트
데이터인 0Ah를 젯수로하여 (이는 10d이며, 16비트일 경우 000Ah, 32비트일 경우 0000000Ah이다.), 8비트 몫과 8비트 나머지를 구해, 몫은 계속
더는 0Ah로 나눠지지 않을 때까지 피드백시키고, 나머지는 일시적으로 저장하여 출력 인터럽트의 인자로 넘겨줄 것이다. 출력 인터럽트에 넘겨질
인자는 0~9이므로 이는 바로 출력하면 아스키 제어문자로 출력되므로 여기에 30h를 더해 아스키 십진수 (ASCII decimal)로 변환해 출력할 것이다.

앞서 얘기했듯이 레고블럭으로 로보트 조립하듯이 머리 부분만 일단 조립한다고 하면 다음처럼 유사 코드가 나올 수 있다.

    mov ax, 나누려는 값
    div bl
    mov 저장하려는 r/m8, ah


나누려는 값 (dividend)은 ax에 16진수로 입력하고, bl은 나눗셈을 수행할 8비트 젯수 (divisor)이고, 그 결과는 ah:al에 나머지:몫이 각각 8비트로
리턴된다. 리턴된 ah는 r/m8에 저장한다. 여기까지는 별 어려움이 없을 것이다. 이제 로보트 몸통에 해당하는 루프를 하나 넣어주자. 루프를 넣는
이유가 반복 나눗셈을 하기 때문이다. 그러므로 다음과 같은 유사 코드가 나올 수 있다.


    mov ax, 0FFh      ; dividend (테스트 값 = 가장 큰 8비트 레지스터 값)
    mov bl, 10           ; divisor (bx의 하위 8비트 레지스터에 상수 10 = 0Ah로 세팅)

L1:

    div bl                  ; 나눠라 (AX = bl * al + ah)
 
    mov r/m8, ah      ; 나머지를 r/m8 연산자에 저장하라

    cmp al, 0            ; 몫이 0인가 ?
    jnz L1                ; 아니오, 몫이 0이 될 때까지 계속 나눠 그 나머지를 r/m8에 저장하라
 
L2:                        ; 예, 몫이 0이 되면 루프를 나와 이어지는 명령을 수행하라
    mov ax, 01h       ; 잠시 키 입력을 받아 종료되기 전까지 지연시켜라
    int 21h
 
    mov ax, 4C00h
    int 21h

여기까지 과정을 거치면, 값에 따라 al은 0~9중 한 값으로 계속 갱신되는 몫을 가지며, ah는 10으로 나눈 나머지로 계속 갱신된다. 나눗셈을
한번씩 수행할 때마다 그 몫과 나머지를 레지스터나 메모리에 저장할 수 있다. 이중 우리의 관심을 끄는 것은 나머지이다. 우리는 이 나머지를
임시로 다른 여유있는 8비트 레지스터나 메모리에 저장할 수도 있다. 어디에 저장할 것인가 ?

여유있는 레지스터에 저장하면 좋을 것이지만, 그러려면 먼저 출력 인터럽트가 어떤 레지스터를 고정적으로 사용하는지 알아야 하며, 출력 인자로
보내야 하므로 그 크기를 알아야한다. 1바이트는 00~FFh 까지의 영역이므로 이 변환을 거치면 총 3바이트 1자리 십진수가 된다. 3바이트를 어디에
저장할까 ?  각각 장단점이 있다. 먼저 메모리에 저장할 경우, 레지스터가 가지고 (hold) 있어야 할 필요가 없으므로 융통성이 있다. 다음으로
스택에 저장할 경우, 함수 입출력 인자로 보내주기가 수월하다. 하지만 메모리나 스택 (또한 메모리의 일부)에 저장할 경우 매번 메모리를 액세스해야
하므로 속도가 느리다. 마지막으로 레지스터에 직접 담을 경우는 속도가 빠른 반면, 레지스터가 분주해지며, 다음 인터럽트와 입출력을 고려해야한다.

이중에서 나는 3번째 레지스터에 직접 담는 방법을 선택하기로 했다. 디지털 세상에서 속도는 미덕이기 때문이며, 반면 융통성은 떨어진다고 하겠다.
먼저 이 데이터를 출력 인자로 넘겨줘야 하므로 1바이트 출력 인터럽트인 Int 21h/Ah=02h를 랄프 브라운의 인터럽트 리스트에서 살펴보자.

Input : DL = character to write
Return : AL - last character output

요약하면 이렇다 이 인터럽트를 쓰려면 DL에 바이트를 넣어주고 인터럽트 호출후 AL에 그 바이트가 기록된다. 출력하고 모니터에 글자를 찍어내면
그만이지 왜 AL에 리턴하는가 ? 그거야 인터럽트를 구현한 도스 프로그래머 마음이겠지만, 아마도 되쓰는 경우가 있어서일 듯 싶다. 이 인터럽트는
AL과 DL을 고정해쓰므로 우리는 다른 3레지스터를 골라야한다. 위에서 우리는 BL에 젯수 (divisor)를 고정하기로 했으므로, 나는 그나마 여유있는
BH, CH, CL을 3 바이트 임시 저장용 레지스터로 사용하려 했으나, 2개만 저장하면 됨을 알 수 있다. 왜냐하면 int 21h / ah = 02h에 dl로 바로
한 바이트를 넘겨줄 수 있기 때문이다. 그러므로 CH, CL 두 레지스터만 고정하면 되니 더 쉬워졌다.

    cmp al, 0            ; 몫이 0인가 ?
    jnz L1                ; 아니오, 몫이 0이 될 때까지 계속 나눠 그 나머지를 r/m8에 저장하라
  
우리는 급조된 몫 검사 루틴을 살짝 뜯어 고쳐야 할 필요가 있다. 어떤 수를 다른 어떤 수로 계속 나눠가면 언젠가는 몫이 0이 되지만, 이는
여차하면 divide error라는 인텔 CPU의 치명적인 예외 (exception)에 걸리기 쉽상이다. 그러므로 비교 검사 루틴을 다음처럼 수정하기로 했다.

    cmp al, 10
    jae L1
  
이는 피젯수가 10이상일 경우에만 나눗셈을 반복하므로 훨씬 방어적인 코딩이라고 할 수 있다. 그런데 우리는 BL이 이미 젯수이므로 다음처럼 살짝
바꿔도 된다. 이렇게 하면 매직 넘버 (magic number) 사용을 줄여 버그의 소지를 줄여준다.

    test al, bl
    jb L3

그리고 나눗셈을 하자마자 바로 나머지를 출력하면 숫자가 거꾸로 차근차근 찍히므로, 일단 3레지스터에 담아두고 나중에 앞자리부터 출력하기로 하자.
보다시피 우리가 하려는 것은 반복 나눗셈이므로 되물림 (recursion)루틴으로 해결할 수도 있지만, 괜히 스택 액세스만 더 많이 하여 속도만 느려지므로
여기서는 되물림 방식으로 하지 않을 것이다. 관심있으면 직접 해보는것도 좋을 것이다. 되물림 코딩이 세간의 화제가 된건 잘 알것이다. 이제 다 된듯
싶으니 여기까지 코딩하기로 하자.


.model small
.stack 100h
.data
;==================
; 비워둠
;==================
.code
main proc
    mov ax, @data
    mov ds, ax

    mov ax, 0000000011111111b   ; 가장 큰 바이트 값을 테스트로 삼았다. (255d)
    xor cx, cx                  ; CX는 임시 저장값을 담을 것이므로 0으로 초기화시켰다
    mov bl, 10                  ; 젯수 (divisor)

L1:
    test al, bl                 ; 시작하자마자 검사해버리자
    jb L3

    div bl                      ; 나누면 몫 = al, 나머지 = ah

L2:
    mov ch, ah                  ; 첫 나머지 저장
    xor ah, ah                  ; 이전 나머지 소거하여 새로운 나눗셈 준비

    div bl                      ; 또 나눠서 몫 = al, 나머지 = ah

    mov cl, ah                  ; 두번째 나머지 저장
    xor ah, ah

    div bl                      ; 마지막 나눠서 몫 = al, 나머지 = ah

    mov dl, ah

L3:
    mov ah, 02h
    add dl, 30h                 ; 아스키 십진수로 변환

L4:
    int 21h                     ; 출력하고

    mov dl, cl                  ; 두번째 출력 변수로 갱신
    add dl, 30h                 ; 또 십진 변환
    int 21h                     ; 두번째 출력 변수 찍고

    mov dl, ch                  ; 마지막 출력 변수 찍고
    add dl, 30h
    int 21h

L5:
    mov ah, 01h                 ; 잠시 브레이크 걸고
    int 21h

    or al, al
    jz L6

L6:
    mov ax, 4C00h               ; 종료하고 도스로 점프
    int 21h

main endp
end main


debug test.exe로 디버그 세션에서 한 명령씩 -t 스위치로 트레이싱 해나가다가, call 명령이 나오면 -p 스위치로 진행시키면서 끝까지 추적해가면
작동방식이 명확하게 머리속에 그려질 것이다. 또한, AX를 다른 값으로 변경하여 재컴파일하여 결과를 비교해보는 것도 좋다. 물론, 기능이 많이
부족하고 32비트 코드도 아니며 최적화를 거친것도 아니다.

이 코드를 실행시켜보면 속도가 굉장히 느림을 알 수 있을 것이다. 주범은 인터럽트이다. 이 출력 인터럽트는 내부적으로 컨텍스트 (context,
레지스터의 내용을 일부 또는 전부 저장해두는 것)를 보존하고, ISR (interrupt service routine)로 주소 전환을 하며, 비디오 모드 (video mode)를
변경하여 해당 글자를 찍어내고 다시 비디오 모드를 복원하고 스택을 조절하고 원래 호출자의 주소로 파 콜 (RETF)을 수행한다. 그러므로 속도가
현저히 떨어진다. 이는 나중에 다룰 DMA (direct memory access)로 어느 정도 해결할 수 있다. 또한, div 명령 자체도 사칙 연산 중에 가장
속도가 느린 명령이다. 최적화는 개인의 몫이므로 여기서는 원리만 알아보는 것으로 만족하고 다음 주제를 이어나가려한다.

비트는 0 또는 1이지만, 어쩔땐 상수를 표현할 수도 있다. 대표적인 상수로 windows.inc 파일을 살펴보면, FALSE/NULL은 0으로 TRUE는 1로 선언됨을
알 수 있을 것이다. 비트는 이처럼 불리언 타입 (boolean type)으로 표현 가능한 것이면 무엇이든 적용할 수 있다. 예로 흑백 컬러를 상수로 정의해
줄 수도 있다. 또한 비트는 모이면 힘을 발휘하는데 4개가 모여 니블이 되면, BCD (binary coded decimal)에 사용되기도 하며, 8비트가 모여서
바이트가 되면 최소 메모리 어드레싱 주소가 되기도 한다.

1바이트는 2니블이고 8비트이며 상위와 하위로 나눠 상위를 H.O. (high-order) Nibble, 하위를 L.O. (low-order) Nibble이라고 한다. 마찬가지로
바이트가 두개 모여 1워드를 이루면 상위와 하위로 H.O. Byte, L.O. Byte로 나타내기도 한다. 여기서 가장 왼쪽에 있는 것을 가장 영향력이 큰
역할을 행사한다는 뜻으로 MSB (most signficant byte)라고 하며, 32비트 DWORD의 왼쪽 워드를 MSW, 64비트 QWORD의 MSD라고 표현할 수 있다.

우리는 지금까지 양수(positive number)에 관해서만 다뤘다. 반면 컴퓨터는 음수도 잘 인식한다. 컴퓨터가 음수를 인식하는 방법에는 흔히들
2의 보수 (2's complement)식으로 표현한다고 한다. 비트 8개를 양수라는 가정하에 나열한다면, 0, 1, 2, 3,...은 다음처럼 표현된다.

0000 0001, 0000 0010, 0000 0011,...

그러다가 0111 1111보다 1큰 수를 표현하려면, 1000 0000이 되며, 이는 양수일 경우 128 십진수이지만, 이를 음수로 인정할 수 있다. 이를 부호있는
바이트 (signed byte)라하며, 256 을 반토막내서 0~127까지는 양수로 128~255까지는 음수로 표현하는 방법이 2의 보수 방법이다. 부호있는 바이트일
경우 1000 0000는 음수로 간주하고 -128의 의미를 가진다. 이것보다 1큰 수는 1000 0001이며 이는 -127이되고, -128, -127, ... -1까지는 다음처럼
비트 패턴을 이룬다.

1000 0000, 1000 0001, ... 1111 1111

보다시피 음수로 인정할 경우 제일 왼쪽 비트 MSb (most significant bit)가 1이며, 양수일 경우 제일 왼쪽 비트가 0이다. 0은 보통 양수로 인정한다.
그러므로 1바이트를 부호있는 바이트로 사용할 경우, -128, -127, ... -1, 0, 1, ...127 까지가 1바이트의 영역이 된다. 잘 보면 양수가 절대치가
1 적다. 이유는 앞서 설명했듯이 0을 양수로 인정하기 때문이다. 마찬가지로 워드, 더블워드, 쿼드워드 또한 이런 식으로 범위 (range)가 정해진다.
참고로 MSB = Most Significant Byte, MSb = Most Significatnt bit를 의미한다.

이를 암기하려는 사람들이 있는데 원리를 알면 그럴 필요가 없다. 자 머리속에 255~0이 순서대로 적힌 종이를 한장 그려보자. 이는 양수 테이블이라고
하기로 하자. 그런데 우리는 음수를 쓸 필요가 생겨 이 한장속에 음수도 넣으려고 한다. 그러면 우리는 이 종이를 절반으로 접어서 왼쪽은 음수를
순서대로 적고, 오른쪽은 양수를 순서대로 적는다. 쓸 수 있는 종이는 한장 뿐이지만, 반토막으로 접어서 우리는 양수와 음수가 모두 적힌 테이블을
구성할 수 있다. 이를 signed (즉, 음수를 인정하는) 수라고 한다. 반으로 접기 전의 수를 unsigned (즉, 부호와는 상관없이 모두 양수로 인정) 수라고
한다. 최대값을 생각해보면 접기전엔 255이지만, 접은 후엔 127이 되어 즉, 2의 역제곱 - 1 (0 때문)이 됨을 알 수 있을 것이다.


음수와 양수를 상호 변환하는 방법은 쉽다. 모든 비트를 뒤집어서 1을 더하라는 방식이다. 예로, 7이라는 바이트를 음수로 변환한다면,

0000 0111         ; 양수 7
1111 1000         ; 뒤집어서
1111 1001         ; 1을 더한다.

이는 -7이며 반대로 이를 양수로 변환하면,

1111 1001         ; 음수 7
0000 0110         ; 뒤집어서
0000 0111         ; 1을 더한다.

8000h = -32,768을 변환해보자.

1000 0000 0000 0000         ; -32,768
0111 1111 1111 1111         ; 뒤집어서
1000 0000 0000 0000         ; 1을 더한다

양수 <-> 음수 간의 전환은 neg 명령 하나면 해결된다. 물론 더 빨리 하는 방법이 있지만, 여기서는 다루지 않겠다.


이 시점에서 코딩을 하고 싶어 손이 근질근질할 것이다. 좋다. 어셈블리 프로그래머라면 추상적인 (abstract) 논쟁보다는 작은 정보라도
코딩을 통해서 확인하는 자세가 더 요구된다. 먼저 우리는 글자를 입력받아 이를 화면에 덤프하는 에코 프로그램을 만들자. 에코의 원리는
표준 입력 (stdin - 키보드)을 표준 출력 (stdout - 모니터)으로 그대로 화면에 덤프하는 것이다. 더 궁금하면 명령 프롬프트에서 > echo hello를
입력해보면 알 것이다. 무엇이 필요할까 ? 입력 인터럽트와 출력 인터럽트 하나씩 있으면 된다.

Int 21h/ah=01h는 디폴트로 에코 기능이 있는 인터럽트 이므로, 우리는 에코 기능이 없는 Int21h/ah=08h를 사용하기로 하자. 둘다 한문자를
입력받는 인터럽트이다. 그리고 한 문자를 출력하는 인터럽트는 int 21h/ah=02h로 이전에 써본적이 있을 것이다. int21h/ah=08h는 al에
입력한 문자를 저장한다. 그리고 int 21h/ah=02h는 dl에 있는 문자를 출력한다. 그러므로 al의 문자를 dl로 리다이렉션시켜줘야 한다.


Title echotest.exe : stdin >> stdout

.model small
.stack 100h
.data
;=============
; 비워둠
;=============

.code
main proc
  mov ax, @data
  mov ds, ax
 
L1:
 
  mov ah, 08h     ; stdin
  int 21h
 
  cmp al, 1Bh      ; if ESC ?
  jz exit              ; no, dump
 
  mov ah, 02h     ; stdout
  mov dl, al         ; redirect
  int 21h
  jmp L1
 
exit:
  .exit
main endp
end main



간단해서 별 것 업지만, 그래도 이 코드를 분석해보자. 먼저 Title은 프로그램에 간략 설명을 달때 쓰는 매크로 어셈블러 예약어이다.
앞으로는 이를 쓰지 않을 것이다. .exit 역시 매크로 어셈블러 예약어이며, mov ax, 4C00h, int 21h 두 명령을 하나의 매크로로 만들어둔
매크로 어셈블러 5.x 이상 예약어이다. 이 또한 다음부터는 쓰지 않을 것이다.

  cmp al, 1Bh      ; if ESC ?
  jz exit              ; no, dump
 
이는 입력 받은 문자가 아스키 ESC 키 (1Bh)인지를 체크하여 만약 그렇다면, 종료한다.

  mov ah, 02h     ; stdout
  mov dl, al         ; redirect
  int 21h
  jmp L1
 
만약 ESC 키가 아니면 위의 코드를 실행하고, 이는 L1으로 무한 루프를 돈다. al에 입력된 키를 mov 명령으로 dl로 리다이렉션시켜줬다.
간단하므로 다른 설명은 없어도 될 것이다.

표준 입/출력인 stdin/stdout은 파일 핸들 (file handle)로 쓸 수 있다. 그렇다면 이제 파일 핸들로 에코해보자.

.model small
.stack 100h
.data
buffer db 15 dup (' ')    ; 15 바이트 에코 버퍼

.code
main proc
  mov ax, @data
  mov ds, ax
 
  mov ah, 3Fh             ; read from file handle
  mov bx, 0               ; bx = 파일 핸들 (0 = 키보드, 1 = 모니터, 2 = 에러 모니터, 3 = AUX, 4 = PRN)
  mov cx, 15              ; cx = 읽어들일 바이트 단위 길이
  lea dx, buffer          ; 입력 버퍼
  int 21h
 
  mov ah, 40h             ; write to file handle
  mov bx, 1               ; 파일 핸들
  mov cx, 15              ; 출력할 버퍼의 바이트 단위 크기
  lea dx, buffer          ; 출력 버퍼 주소
  int 21h
 
  mov ax, 4C00h
  int 21h
main endp
end main


int 21h/ah=3Fh는 파일 핸들로 입력하는 함수이고, int21h/ah=40h는 파일 핸들로 출력하는 함수이다. 두 함수는 dx에 버퍼 주소를 정해주는데, 여기서는
같은 버퍼로 해줬으므로 결국 파일 핸들 (키보드)로 입력한 내용이 그대로 입력 버퍼로 들어가 출력 버퍼로 리다이렉션되어 파일 핸들 (모니터)로 출력된다.
인터럽트만 바꿔주고, 인자만 바꿔준 것 외에 별 볼일 없을 것이다. dup은 메모리에 데이터를 반복 선언해주는 강력한 예약어이며, ()안의 내용을 15번
복사한다. 이 예제의 경우 괄호 안의 내용은 공백 (space)이다.

만약 버퍼에 정해준 15바이트 이상을 입력하면 버퍼 오버플로가 일어나며, 255이상의 문자를 입력하면 OS 레벨에서 더 이상 입력을 거부하는 것을
알 수 있었다. 그리고 다량의 글자를 입력하면 15바이트 단위로 여러번 덤프도해줬다. 직접 테스트해보라.

의도와는 다르게 우리는 기본적인 입/출력 인터럽트를 구렁이 담 넘어가듯이 거의 다 다뤘다.

이제 우리는 5바이트 버퍼를 만들어주고 여기에 AX 레지스터의 값을 백업하여 십진수로 변환한 후 이를 화면에 출력할 차례다. 먼저
양수라고 가정할 경우를 그대로 화면에 덤프해보자.


;=========== decimal conversion test proggy ==========
;
.model small
.stack 100h
.data

result db 5 dup (30h), 0Dh, 0Ah, '$'
prompt db "Unsigned decimal print", 0Dh, 0Ah, '$'

;==============================
.code
main proc
  mov ax, @data
  mov ds, ax

L1:
  mov ah, 09h         ; 프롬프트 스트링 출력
  lea dx, prompt
  int 21h

@@:   
  lea si, result      ; si = 출력 변수 포인터
  add si, 4           ; 메모리 뒷자리 (si+4)부터 변수를 채운다
  xor dx, dx          ; dx는 나눗셈에 사용되므로 0으로 초기화시킨다

  mov ax, 0FFFFh      ; 테스트 값 (0FFFFh = 65535d)
  mov bx, 10          ; 젯수 (divisor, 이진수를 10개씩 묶어주는 역할)
 
L2:
  xor dx, dx          ; 나눗셈을 위한 준비 DX:AX / BX = AX (Q) + DX (R)
  div bx              ; 10으로 묶어라 (결과 ax = 몫, dx = 나머지)

L3:
  add dl, 30h         ; 마지막 바이트만 ASCII decimal로 변환
  mov [si], dl        ; 메모리에 저장
  dec si              ; 메모리 포인터 이동
 
  cmp ax, 0           ; 몫이 더는 묶어지지 않을만큼 다 나눴는가 ?
  jnz L2              ; 아니오, 반복하라
 
L4:
  mov ah, 09h         ; 예, 출력하고 종료하라
  lea dx, result
  int 21h
 
L5:
  mov ah, 08h         ; 잠시 지연
  int 21h
 
  or al, al
  jz L6
 
L6:
  mov ax, 4C00h
  int 21h
main endp
end main



다소 코드가 길어져서 당황할지 모르겠다. 이를 분석해보자.

result db 5 dup (30h), 0Dh, 0Ah, '$'

먼저 데이터 섹션은 5개의 30h를 정해주었다. 30h는 십진수 0을 의미하며, 이 메모리를 연산 결과 한자리씩 덮어쓴다.

@@:   
  lea si, result      ; si = 출력 변수 포인터
  add si, 4           ; 메모리 뒷자리 (si+4)부터 변수를 채운다
  xor dx, dx          ; dx는 나눗셈에 사용되므로 0으로 초기화시킨다
 
메모리를 액세스하려면 si나 di를 포인터로 사용한다. lea 명령은 result라는 변수의 첫 옵셋을 구해 si로 저장한다. 이후, si는 이 변수를
계속 포인트하여 반복으로 기록한다. si는 result의 첫 옵셋을 가지므로 4를 더해주면, 마지막 옵셋이 된다. 이전에 int 21h/ah=09h로 dx를
한번 사용했으므로 xor로 소거시켰다.

  mov ax, 0FFFFh      ; 테스트 값 (0FFFFh = 65535d)
  mov bx, 10          ; 젯수 (divisor, 이진수를 10개씩 묶어주는 역할)
 
이 코드는 익숙할 것이다. 10으로 나누기위해 피젯수 (dividend)와 젯수 (divisor)를 각각 최대 16진수와 10으로 초기화시켰다. 만약 테스트
값을 바꾸려면 ax에 정해주면 되고 진수 (base)를 바꾸려면 bx에 정해주면 된다. 대신 진수를 바꿀 경우 데이터 세그먼트의 출력 인자 또한
다르게 잡아줘야한다. 왜냐하면 우리는 10진수를 디폴트로 변환했지만, divisor를 10보다 작은 수로 할 경우 몫은 더 큰 수가 될 수 있어서이다.

이제 핵심 루틴인 L2와 L3가 나온다. L2는 위에서 언급한 단순 나눗셈 루틴이다.

L3:
  add dl, 30h         ; 마지막 바이트만 ASCII decimal로 변환
  mov [si], dl        ; 메모리에 저장
  dec si              ; 메모리 포인터 이동
 
이는 10으로 나누고 나면 실행되는데, 그 나머지가 dx에 저장되므로 그 중 가장 뒷자릿수만 취해서 30h를 더해주었다. 더해준 값으로 메모리를
갱신하고 포인터를 한자리 앞으로 이동했다.

  cmp ax, 0           ; 몫이 더는 묶어지지 않을만큼 다 나눴는가 ?
  jnz L2   
 
이는 몫이 0인지 검사하여 아니면 계속 나누는 역할이다. 이전에 방어적 코딩을 언급했는데 적용해보라.

  mov ah, 08h         ; 잠시 지연
  int 21h
 
이는 int 21h/ah=01h처럼 stdin 입력 인터럽트이다. 다만 에코를 하지 않는 점이 다르다. 이 인터럽트의 리턴 값은 ah = 01 인터럽트처럼 al에
반환된다. 분석해보니 별것 아님을 알았을 것이다. 다만 메모리 액세스 부분이 다소 생소할 것이다. 이는 나중에 다시 다룰 것이다.


이제 음수를 출력해보자.

;=========== signed decimal conversion ==========
;
.model small
.stack 100h
.data

result db 5 dup (30h), 0Dh, 0Ah, '$'
prompt db "Signed decimal print", 0Dh, 0Ah, '$'

;==============================
.code
main proc
  mov ax, @data
  mov ds, ax

L1:
  mov ah, 09h         ; 프롬프트 스트링 출력
  lea dx, prompt
  int 21h

@@:   
  lea si, result      ; si = 출력 변수 포인터
  add si, 4           ; 메모리 뒷자리 (si+4)부터 변수를 채운다
  xor dx, dx          ; dx는 나눗셈에 사용되므로 0으로 초기화시킨다

  mov ax, -1234       ; 테스트 값 (음수 -1234)
  mov bx, 10          ; 젯수 (divisor, 이진수를 10개씩 묶어주는 역할)
 
@@: 
  cwd
 
  test dx, dx         ; 부호를 테스트한다
  jz L2
   
  xor ax, dx          ; 절대치를 구한다
  sub ax, dx          ; 캐리가 발생하면 음수
  pushf               ; 캐리 플랙을 저장한다

L2:
  xor dx, dx          ; 나눗셈을 위한 준비 DX:AX / BX = AX (Q) + DX (R)
  div bx              ; 10으로 묶어라 (결과 ax = 몫, dx = 나머지)

L3:
  add dl, 30h         ; 마지막 바이트만 ASCII decimal로 변환
  mov [si], dl        ; 메모리에 저장
  dec si              ; 메모리 포인터 이동
 
  cmp ax, 0           ; 몫이 더는 묶어지지 않을만큼 다 나눴는가 ?
  jnz L2              ; 아니오, 반복하라
 
  popf                ; 저장된 캐리 플랙을 불러낸다
  jnc L5              ; 캐리가 발생하지 않으면 양수로 인정한다
 
L4:
  mov ah, 06h         ; '-'기호 출력
  mov dl, '-'
  int 21h
   
L5:
  mov ah, 09h         ; 출력
  lea dx, result
  int 21h
 
L6:
  mov ah, 08h         ; 잠시 지연
  int 21h
 
  or al, al
  jz L7
 
L7:
  mov ax, 4C00h
  int 21h
main endp
end main


보다시피 앞의 코드랑 별 차이가 없다. 어떤 이진수가 양수인지 음수인지를 결정하는 것은 유저의 몫이라고 할 수 있다. 특히 signed/unsigned
최대값인 8000h와 0FFFFh는 양수도 될 수 있고 음수도 될 수 있다. 양수로 인정할 경우 32768, 65535이지만, 음수로 인정할 경우 -32768, -1이다.
두 경우 강제로 양수 또는 음수로 세팅해줘야 한다. FFFFh는 문제가 될게 없지만, 특히 8000h는 남성도 아닌 여성도 아닌 중성의 성격을 띄고 있다고
할 수 있는 이진수이다. 여기서는 강제로 세팅해주지 않고 디폴트 signed decimal로 인정하여 출력하였다. 만약 강제로 세팅해준다면 8000h는 0으로
간주할 것이므로 test ax, 8000h로 테스트하여 이에 해당하면 shl ax, 1 해주면 강제로 0으로 세팅해준 셈이 된다.

CPU 명령인 NEG는 캐리 플랙을 0또는 1로 세팅하고 0/1 두 수에서 대상 연산자를 빼서 갱신한다. CPU 매뉴얼에서 이 명령은 다음처럼 작동한다. 



                               <그림>

보다시피 NEG 명령의 유사코드는 별다른 옵션이 없다. 그러므로 대부분의 음수/양수 전환은 절대치를 구하는 방법으로 다음처럼 마술같은 코드를 

주로 쓴다.

cwd
xor ax, dx
sub ax, dx

이 코드 조합은 강제로 절대치를 구해내는 코드이다. VC++ 컴파일러에서 절대치를 구할 때 이와 비슷한 명령 조합을 사용한다. 물론 더
원시적으로 해줄 수 있지만 여기서는 이 코드를 그대로 쓰기로한다. 이 코드는 dx에 사인비트를 복사하여 역으로 빼주는 방법이다. 이전에
뒤집어서 더하는 것을 언급했다. 이는 이를 다시 뒤집는 방법이라고 할 수 있다. 이 코드의 작동방식을 더 설명하는 것보다 debug로 트레이싱
해보면 쉽게 파악될 것이다. 이 3 명령을 거치고 나면 캐리 플랙이 0 (양수) 또는 1 (음수)로 토글된다. 이 캐리 플랙을 테스트하기 위해
pushf로 일단 백업해두었다. 


  popf                ; 저장된 캐리 플랙을 불러낸다
  jnc L5              ; 캐리가 발생하지 않으면 양수로 인정한다
 
이는 저장된 캐리 플랙으로 음수/양수인가에 따라 다르게 분기하며, 양수일 경우 '-' 기호 출력 루틴을 건너뛴다. 음수일 경우는 다음 코드가 실행된다.

L4:
  mov ah, 06h         ; '-'기호 출력
  mov dl, '-'
  int 21h
 
보다시피 단순한 인터럽트 호출로 '-'기호를 출력했다. 여기서 사용한 인터럽트는 int 21h/ah=06h이며, 이는 에코없는 한 문자 출력 함수이다.
나머지는 이미 설명했으므로 생략한다. 이제 테스트 값을 예로, -1이나 65535, 8000h, 등으로 바꿔가면서 재컴파일하여 테스트해보라.

주제가 본의 아니게 진수 변환으로 넘어가서 미안스럽게 생각한다. 하지만, 많은 어셈블리 초보가 십진수를 제대로 출력 못해서 고생하는 경우를
나는 많이 접했다. 노파심에서 조금 자세히 설명했을 뿐이다. 지금도 어셈블리를 배우는 초보 프로그래머 중 누군가는 irvine.lib이라는 라이브러리
코드를 불러쓰고 있을 것이다. 심지어 대학에서조차 이런 쌩뚱맞은 라이브러리 함수를 불러쓰는 것을 가르치고 있으니...우리는 다음 장에서 수 

(number)를 조금더 다뤄보기로하자.

2009년 12월 24일 목요일

2장 - 환경 구축

2장 - 환경 구축

우리는 지난 시간에 what is assembly ? 라는 질문에 어떻게 답변할 지를 배웠다. 또한 "단순한 마인드"의 필요성을 실감했다. 이외에도 어셈블리
언어를 배우려면 툴이 있어야겠다. 가장 좋은 툴은 역시 자신의 "머리"일 것이다. 부수적으로 필요한 툴을 몇개 나열하면 이렇다:

1. 컴퓨터 & 운영체제 (따로 언급할 필요가 없겠다.)
2. CPU 매뉴얼 (인텔 홈피 참조)
3. 텍스트 편집기 (메모장이나 텍스트 에디터)
4. 어셈블러 & 링커 (www.movsd.com 참조)
5. 리소스 (책, 인터넷 사이트)는 많을수록 좋고, 절판된 책에 나온 코드도 살짝 뜯어고쳐 쓸 수 있다. 우측 링크 목록에 나름대로 중요하다싶은
   링크를 걸어뒀으니 참조하라.

masm32 홈피에서 다운받아 설치하면 C:\masm32 폴더가 아래 <그림 1>처럼 생길 것이다. 




                                    <그림1>

이제 약간의 환경 변수를 만들어주자. 물론 배치 파일로 만들어도 되지만, 요즘 배치 파일 명령어 기억하는 사람도 없으니 패스하고 바탕화면에
폴더를 단축아이콘 만들어주면 편할 것이다. 이외에도 약간의 더 옵션을 손봐야한다. 고급 언어와는 다르게 어셈블리어에서는 프로젝트라는
개념이 없다. 만약 프로젝트 마인드에 익숙해있으면, 따로 IDE (Integrated Development Envirinment)를 구해보는 것도 좋다. 대표적인 IDE로

    * Visual Studio
    * RadAsm
    * WinAsm Studio
    * EasyCode

등이 있으며 이중 EasyCode를 IDE로 사용한 스샷은 <그림2>에 나와있다. 





                                  <그림2>

나는 IDE를 아예 쓰지 않을 것이므로, 빠른 세팅을 위해 아래에 <동영상>으로 보여주려한다. 화면이 작아서 잘 안보이면 링크를 클릭하라.


  <동영상> http://www.youtube.com/watch?v=BJA99a9bzro
 



                        

우리가 주로 작업할 폴더는 C:\masm32\bin 폴더이다. 이 폴더에 .asm/.obj/.exe 파일이 모두 저장된다. 그러므로 다른 어셈블러 보조 실행 파일과
섞일 수 있으므로 이 폴더 안에 작업용 하위 폴더를 하나 만들어두면 좋을 것이다 (예로, C:\masm32\bin\MyProg). 그러면 나중에 보관할 파일만
따로 여기에 담아두면 된다. 프로그래머라면 소스 파일을 보관하고 유저라면 실행 파일을 필요할 것이다. 우리는 이를 모두 작업 폴더에 저장하면
된다. 참고로 텍스트 에디터로 만들어진 소스 파일은 보관하기 전엔 C:\masm32\bin에서 어셈블 & 링크를 해야한다.

추가로 이 폴더에 작업 폴더 (나는 MyProg으로 해뒀다)를 만들어두고, 메모장과, 명령 프롬프트의 바로가기 아이콘을 만들어두면 좋을 것이다.
이 폴더에 빈곳을 우클릭하여 팝업 > 새로 만들기 > 폴더 / 바로가기로 생성. 바로가기가 만들어지면, 이제 이 폴더의 디폴트 시작 위치를 지정해준다.
팝업 > 속성 > 시작 위치 : C:\masm32\bin. 이제 우리는 텍스트 에디터 (메모장)로 소스를 작성한다. 메모장을 더블클릭하여 다음처럼 소스를 입력한다.

.model small
.stack 100h
.data
hello db "Hello, World!", 0Dh, 0Ah, '$'
prompt db "CopyLeft (CL) by E. T. from the outer-space", 0Dh, 0Ah, '$'

.code
main proc
    mov ax, @data
    mov ds, ax

    mov ah, 09h
    lea dx, hello
    int 21h

L1:
    mov ah, 01h
    int 21h

    or al, al
    jz L1

L2:
    mov ah, 09h
    lea dx, prompt
    int 21h

    mov ax, 4C00h
    int 21h
main endp
end main

이제 소스를 "test.asm" 이라는 이름으로 C:\masm32\bin 폴더에 파일로 저장한다. 파일로 저장되었으면 명령 프롬프트에서 어셈블러로 어셈블한다.
어셈블러 스위치는 "ml test.asm"이다 (masm test.asm이 아니다). 어셈블러는 성공적으로 "test.obj" 파일을 생성할 것이다. 이제 링커로 이를 실행
파일로 만든다. 링커 스위치는 "link16 test.obj"이다 (link test.obj가 아니다). 링커는 성공적으로 "test.exe" 파일을 생성할 것이다. 이 파일을
실행시키면 된다. 컴파일 스위치에 대한 자세한 설명은 "ml /?" "link /?"로 참조하라. 결국 텍스트 에디터 > 어셈블러 > 링커의 경로로 완성되는
셈이다. 만약, 32비트 코드를 작성한다면, 어셈블러와 링커 스위치를 아래처럼 바꿔줘야한다:

32비트 어셈블링 : ml  /c  /coff  /Cp 소스파일.asm
32비트 링킹 : link /SUBSYSTEM:WINDOWS  /LIBPATH:c:\masm32\lib  옵젝트파일.obj

중간에 생성된 *.obj 파일은 라이브러리로 만들때 사용하며 그 외의 경우는 삭제시켜 하드디스크의 용량을 확보하자. 우리는 당분간은 16비트 코딩을
할 것이다. 세상이 온통 64비트로 떠들석하지만, 우리는 이를 무시할 것이다. 64비트는 32비트에 토대를 두고, 32비트는 16비트에 토대를 둔다. 즉,
16비트 코딩으로 작동 방식을 빠르게 배우기위함이지 상용 제품을 만들려는 것은 아니다. 16비트 코딩으로 배운 어셈블리 배경 지식은 32비트 코딩에도
대부분 적용된다. 32비트 코드란 단순히 16비트 코드의 레지스터 크기와 어드레스 공간을 넓혀준 것에 불과하다. 64비트 코딩도 마찬가지다.

분석

이제 우리는 조금전 작성한 코드를 분석해볼 차례다. 이 코드는 사실 대부분이 뼈대 (template) 코드에 해당하는 수준이다. 이중 형광색으로
표시한 부분은 16비트 코딩 (앞으로는 특별히 16비트 코딩이라고 언급하지 않겠다.)시 거의 매번 사용하는 외워쓰는 코드다. 공개석상에서
"신사숙녀 여러분"하듯이 상투적으로 붙여준다고 생각하면 된다.

메모리 모델 (memory model)

.model 지시어는 메모리 모델을 정의하는 어셈블리 지시어이며, 다른 언어와 결합시킬 때를 위해서 존재하며, 코드와 데이터를 어떻게 조직화할
것인지를 결정한다. 대부분의 .exe 실행 파일은 도스에서는 small 모델이며, 이를 윈도우즈 버전으로 확장한 것이 flat 모델이다. exe의 선임이라고
할 수 있는 .com 실행 파일은 대부분 .tiny 모델이었다. small은 독립된 64K 세그먼트내에서 코드 섹션과 데이터 섹션이 존재한다. 반면 tiny 모델은
64K 세그먼트 안에 코드와 데이터가 공존한다. 이외에도 compact/huge/large/medium 등의 모델이 있으나 쓸 일이 거의 없으므로 무시하기로하자.

섹션

모든 어셈블리 프로그램은 코드와 데이터로 구성되며 이를 섹션이라 한다. 코드는 실행 코드이며 .code라는 섹션 아래에 위치한다. 데이터는 그 실행
코드가 참조하는 데이터 (예로, 값이나 스트링)이며, .data 섹션 아래에 위치한다. 이는 디버거로 열어보면 .code 섹션은 .text라는 이름으로 바뀌고,
.data 섹션은 그대로 .data섹션으로 보일 것이다. 이 두개의 섹션은 반드시 필요하며 없으면 실행조차 되지 않는다. 모든 .exe 파일은 스택이 필수로
요구된다. .stack 명령이 스택 섹션의 크기를 정해주며, 우리는 100h (256 바이트)를 정해주었다. 각 섹션은 ".섹션명"이 나오면 그 섹션이 끝난다.
앞으로 .model, .stack, .data, .code가 무엇인지는 다시는 언급하지 않겠다. 자세한 정보는 Masm Programmer's Guide를 참조하라.

변수

고급 언어에서 변수 (variable)라는 것은 어셈블리 레벨로 살펴보면 메모리의 특정 영역에 붙여준 상징적 이름에 불과하다. 변수의 타입 (type)은
그 변수의 크기를 정해주는 것이며, db (define byte)라는 지시어를 사용하여 바이트 타입의 변수를 정해줄 수 있다. 즉, 위의 hello/prompt 라는
두 변수는 바이트 단위의 배열을 이룬 변수이다. 이를 고급 언어에서는 스트링이라고 한다. 변수의 이름은 적을수록 좋다. 변수의 타입을 지정하는
지시어는 db (Byte), dw (Word), dd (DWord), dq (Qword), real4 (Single FP), real8 (Double FP), real10 (Extended Double FP)등이 있다.
스트링 변수 뒤에 0Dh, 0Ah는 엔터 키의 아스키 코드이다. 캐리지 리턴 + 라인 피드 두 16진수가 합쳐서 엔터 키를 출력하는 방식이다. 눈에 보이지
않는 이런 특수한 아스키 문자를 제어 문자 (Control character)라고 한다. 스트링의 끝에 붙은 '$'는 해당 인터럽트 함수 (int 21h, ah = 09h)가
요구하는 인자 (argument)이다. 이는 스트링 하나를 끝맺는 역할이다. 여기서는 각자가 독립된 스트링이므로 hello, prompt 뒤에 각자 붙여줬다.
만약, 이를 붙여서 하나의 변수로 출력한다면 앞에 있는 '$'는 빼줘야하고 변수명인 prompt를 삭제해줘야 한다. db 지시어는 여러 바이트 데이터를
컴마 (,)로 구분해서 뭉쳐줄 수 있기 때문이다. 나중에 다시 다루겠다.

프로시저

모든 코드는 .code 섹션에서 시작되며, 여기서는 main proc이 첫 코드이다. 이는 역시 CPU 명령이 아닌 어셈블러 지시어이다. 즉, 기계어를 생성하지
않고,그냥 이 메모리 영역을 어셈블러가 프로시저의 시작점이라고 인식하는 상징적인 심벌이다. 그리고 end main은 그 심벌로 시작되는 코드를 더 이상
어셈블하지 말라고 어셈블러에게 알려주는 지시어이다. 그 안의 proc / endp는 프로시저 (또는 함수, 서브루틴)의 시작과 끝을 정하는 지시어이다.

    mov ax, @data
    mov ds, ax

디폴트 세그먼트

이 명령은 디폴트 데이터 세그먼트를 정해주는 명령이다. 특별한 이유가 없는한 모든 .exe 실행 파일은 디폴트 데이터 세그먼트를 ds에 정해줘야 한다.
이는 masm의 귀찮은 것 중 하나이며, ds에 mov 명령으로 직접 상수 주소를 정해줄 수 없으므로, 범용 레지스터인 ax에 임시로 data라는 심벌의 주소
(@은 심벌의 주소를 의미한다)를 복사하여 이를 다시 ds 세그먼트 레지스터에 복사해주는 방식이다. 인텔 CPU의 mov 명령은 직접 세그먼트 레지스터
(여기서는 ds)에 상수를 할당할 수 없다. 자세한 내용은 CPU 매뉴얼에서 mov 명령을 참조하라.

    mov ah, 09h
    lea dx, hello
    int 21h

인터럽트 호출

이는 단순한 도스 인터럽트 호출문이다. 인터럽트는 도스 시절 대부분의 입출력 등의 보조 루틴을 OS 레벨에서 정의한 함수와 유사한 것이다.
인터럽트의 모든 단위는 16진수이다 (16진수는 어셈블리 프로그래밍에서 자연스런 단위이다.) 인터럽트는 인자를 mov, lead 등의 명령으로 레지스터에
정해주고, int 명령을 써서 인터럽트를 호출한다. 간단히 "인자를 주고 호출하면 무언가를 리턴한다"는 정도로 이해하면 된다. 인터럽트에 대해서는
나중에 다시 다루겠다. 당장 궁금하면 랄프 브라운의 인터럽트 리스트를 참조하라.

int 21h / ah = 09h 인터럽트는 스트링 출력 함수이다. 이는 ds:dx (세그먼트:옵셋)에 해당 스트링의 주소를 정해준다. 우리는 @data로 ds를 세팅
해줬으므로 dx에 해당 스트링의 주소를 lea (load effective address)로 로드해주면 된다. 이는 옛날 masm 프로그래밍에선 "mov dx, offset hello"
라고 해준 것과 같은 명령이다. 둘다 같은 역할이며 특정 심벌의 옵셋 주소를 얻어낸다. 그럼에도 불구하고 lea 명령이 훨씬 간략하고 우아한
명령이다. 하지만 둘 다 특정 심벌의 옵셋 (offset) 주소를 얻어냄을 잊진말자.

16 진수

16진수는 0,1,2,3,...9,A,B,C,D,E,F로 구성되며 9보다 1큰 수는 A이고, 2+E = 10이다. 16진수는 2진수 4개를 묶어서 사람이 읽기 쉽게 추상화시킨
것이다. 16진수를 표현할 때, 9h, 0Ah를 예로 들면, 알파벳으로 시작하는 16진수는 0 (leading zero)을 붙여주어 10진수와 혼동을 피하게끔한다.
만약 알파벳으로 시작하는 16진수에 0을 빼고 작성하여 컴파일하면 에러를 일으킨다. 관습적으로 b = 2진수, d = 10진수, h = 16진수 접미어를
사용한다 (10진수는 진수 단위를 생략하기도 하며, t를 붙여주기도 한다).

L1:
    mov ah, 01h
    int 21h

    or al, al
    jz L1

루프

이는 일종의 루프 (loop)이다. 루프가 무엇인지는 잘 알 것이다. 루프를 사용한 대표적인 예가 윈도우즈 프로그램이다. 이들은 모두 메시지 루프라는
것을 사용한다. 이 메시지 루프는 무한 루프이다. 루프를 구성할 때 보통 CPU loop 명령보다 jz/jne/jmp 등의 점프 명령을 더 많이 사용한다. 왜인가 ?
loop 명령은 (E)CX를 꼭 함께 제어해야 하는 귀찮은 명령이기 때문이다. loop 명령이 한번 실행될 때마다 (E)CX는 자동으로 1감소한다. 이 자동화 기능
때문에 대부분의 어셈블리 프로그래머는 점프 명령을 더 많이 사용하여 루프를 구현한다.

int 21h / ah = 01h는 한 문자를 입력받는 인터럽트이다. 이는 al에 문자의 아스키 값을 저장한다. 그러므로 or 명령은 이 키 값을 검사하는 역할을 하고,
이어지는 jz 명령은 그 검사 결과에 따라 분기하는 명령이다. 그런데 jz의 분기 타겟 (jump target)이 바로 앞의 L1이므로 루프가 되는 것이다. L1:은
콜론 (:)을 뒤에 붙여 레이블 (label)을 구성한다. 레이블은 분기 타겟 (jump target)과 위치 마커 (location marker) 역할을 한다. 대부분의 코드는
비교-분기 코드가 잇으며, 분기하려는 대상 주소를 분기 타겟이라고 한다. GW-BASIC 같은 옛 언어에서 라인 넘버와 비슷한 역할이다. 나중에 코드에서
분기 타겟을 참조하려면, jz L1처럼 콜론을 붙이지 않고 그 주소로 점프한다. 간혹 코딩하다보면 주의를 요하는 부분이 있기 마련이다. 주의를 요하는
코드에도 위치 마커 역할을 하는 레이블을 만들어 줄 수 있다. L2가 바로 그런 역할이다.

    mov ax, 4C00h
    int 21h

종료 코드
 
int 21h / ah = 4Ch는 도스 표준 종료 함수이다. 이 함수의 인자로는 al에는 종료 코드를 넣어준다. 여기서는 정상 종료를 의미하는 00을 al에 넣어줘
ah + al = ax가 되고 결국 ax = 4C00h이 되었다. 이 함수가 호출되면 프로그램은 메모리를 반납하고 도스로 제어를 넘긴다. 어셈에서 세미콜론 (;)으로
적어주면 문장 끝까지 모두 주석처리된다.

메모리 주소


메모리 주소는 레지스터의 어드레싱이 가능한 주소이다. 이 주소는 레지스터의 크기와 같은 말이다. ah와 al은 ax의 각각 상/하위 반토막 8비트
레지스터이고, ax는 16비트 레지스터이다. 32비트 레지스터인 EAX의 하위 반토막은 AX이고, 64비트 레지스터인 RAX의 하위 반토막은 EAX이다. 다른
범용 레지스터도 마찬가지다. 다만 64비트 일지라도 세그먼트 레지스터는 여전히 16비트이다. 왜 16비트 세그먼트를 고수하는지는 그저 인텔의
전통이라고 표현해야겠다.


세그먼트와 옵셋이 결합하여 논리상의 주소가 만들어진다. 그런데 16비트 세그먼트는 단지 한자리 숫자를 더한 주소에 불과하다. 그러므로, 옵셋
주소만 언급할 땐 0 ~ FFFFh까지의 16비트 영역이며, 세그먼트까지 고려한다면 0 ~ FFFFFh 영역의 20비트 주소가 된다. 인텔 초창기에 세그먼트
모델이 구현되었으며, 그래서 FFFF라는 옵셋을 모두 쓸 능력이 안돼, 단지 F라는 옵셋 한자리만 더 더해주었다. 만약 초기 설계시에 4:16이 아닌
16:16으로 주소를 쓸 수 있었으면 좋았겠지만, 그 당시 기술력이 걸림돌이 되었다.

그 이후로 계속 메모리 주소가 확장되었지만, 여전히 세그먼트 레지스터의 크기는 16비트를 유지했으며, 32비트로 넘어오더라도 32:32 주소가 아닌
0:32 플랫 메모리 모델이 되었다. 만약 32:32 주소로 되었다면 64비트 (18,446,744,073,709,551,615)라는 어마어마한 주소를 가질 수 있었을 것이다.
하지만, 32비트 메모리는 0:32 (0~FFFF FFFFh)까지의 4GB로 한정되었다. 또한 요즘 화두가 된 64비트 프로그래밍도 현재의 기술력으로 0:64의 주소를
모두 액세스하지 못한다.

이제 우리는 16비트 .exe 파일의 뼈대 (skeleton, shell)를 구성해보면 이렇다.

;================== skeleton ====================
.model small
.stack 100h

;================== include =====================
;================== EQU =========================
.data
;================== define data =================
;================== Extern ======================
.code
main proc
  mov ax, @data
  mov ds, ax
 
;================== instructions ================

  mov ax, 4C00h
  int 21h
main endp
end main
;================================================

hello, world를 16비트 .com 버전으로 구성해보면 이렇다.

;=================================================
.model tiny
;================== include =====================
;================== EQU =========================
;================== define data =================
.data
hello db "hello, world!", 0Dh, 0Ah, '$'

.code
org 100h        ; com entry point

main proc
;================= instructions =================
  mov ah, 09h
  lea dx, hello
  int 21h
 
  mov ah, 01h
  int 21h
 
  or al, al
  jz @F
 
@@:
  mov ax, 4C00h
  int 21h
main endp
end main
;================================================

이를 .com 실행 파일로 만드는 방법은 이렇다. 먼저 소스를 "test.asm"으로 저장한 후, "ml /AT test.asm"으로 tiny 모델로 컴파일하여
링커로 "link16 test.obj"라고 .exe 파일을 생성한다. 이제 C:\windows\system32 폴더에 있는 exe2bin을 masm 폴더로 복사하거나 test.exe
파일을 C:\windows\system 폴더로 복사하여 두 파일 (exe2bin/test.exe)이 같은 폴더에 있게 해준 후 "exe2bin test.exe test.com"을
입력하면 test.com 파일이 생성된다.

결론

시작부터 너무 어렵게 설명한 것 같은 기분이 든다. 하지만 형광색 표시를 제외하면 별것 없음을 알 것이다. 더구나 두 출력 인터럽트가 중복되니
더더욱 별것 없다. CPU 명령 모두를 예로 들 수 없고, 랄프 브라운의 인터럽트 또한 모두 사용하는 프로그램은 없다. 여기서는 필수(?)라고 생각되는
명령 5개 (mov, lea, int, or, jz)와 인터럽트 3개 (int 21h, ah = 09h, 01h, 4ch)만 살짝 언급했을 뿐이다. 이 명령과 인터럽트는 여기서 설명해봤자
시간 낭비일 뿐이니 궁금하면 CPU 매뉴얼과 랄프 브라운이 정리한 인터럽트 리스트를 참조하라. 세상 모든 것이 그렇듯이 알기 전엔 어렵지만,
알고나면 왜 그걸 어려워했는지 이해를 못하게 될지도 모른다. 어셈블리 프로그래밍도 마찬가지다. 레지스터가 도대체 뭔가 ? 어쩌면 우리는 CPU를
직접 만든 사람이 아니기에 평생 모를지도 모른다. 하지만, 그럼에도 불구하고 우리는 레지스터는 무엇이다고 "아는척"하며 사람들에게 쇼를 할 뿐이다.
우리는 이제 발을 담궜으니 한 걸음 더 나아가자.

블로그 보관함