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)를 조금더 다뤄보기로하자.

댓글 없음:

댓글 쓰기

블로그 보관함