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


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

댓글 1개:

  1. Golden Nugget - Casino & Hotel - Mapyro
    Golden Nugget Casino & 세종특별자치 출장마사지 Hotel. Mapyro 대전광역 출장샵 Hotels 천안 출장안마 - 2000 rooms. No ATM. 여주 출장안마 Free WiFi, free parking. No ATM. Free 파주 출장마사지 room service, free parking.

    답글삭제

블로그 보관함