2010년 1월 13일 수요일

8장 - 이진 세상으로 - 곱하기

지난 시간에 이어 이번 장은 곱셈에 관한 것이다. "곱셈" 우리에겐 어렵지만, 컴퓨터는 너무도 쉽게 해결한다. 다만 CPU 곱셈 명령이 속도가 조금
느린게 흠이긴 하다. CPU 명령어 레퍼런스 (요즘엔 매뉴얼에 클럭 속도를 표시하지 않아 옛날 클럭을 정리한 AoA 기준으로)를 보면, 16비트
레지스터 (reg16)로 연산할 경우 add/sub 명령은 1클럭 짜리인 반면, mul/imu은 대략 10클럭이며, div/idiv은 대략 20클럭 많게는 40클럭까지
소모된다. 물론 팬티엄 초창기 이후 클럭 속도가 10배 가량 증가해서 클럭 속도로만 측정하는 것은 다소 무리가 있지만, 상대적으로 그만큼
곱셈과 나눗셈, 특히 나눗셈은 굉장히 클럭 소모적인 사치스런 명령이라고 할 수 있다.

더 진행하기에 앞서 우리는 먼저 2진 곱셈을 할 줄 알아야겠다. 어렵게 느껴지지만 사실 2진 곱셈은 2진 뺄셈보다 훨씬 쉽다. 어찌보면 십진
곱셈보다 더 쉽다. 왜냐하면 0 x 0 = 0, 0 x 1 = 0, 1 x 0 = 0, 1 x 1 = 1만 알면되기 때문이다. 즉 1끼리 곱할때만 1이되는 것을 기억하면 된다.
다만, 곱을 더하는 점이 십진 계산과 흡사하기 때문에 비트가 급격히 많아지는 점만 주의하면 된다.

4자리로 확장해서 뒷자리부터 곱해서 더하자. 예로, 1010 * 0101

원래값          1자리  10자리  100자리 1000자리

(10)  1010      1010    1010    1010    1010
(5)   0101      0001    0000    0100    0000
------------------------------------------
                   1010    0000    1010    0000 ---> 1010 + 0000 (0) + 1010(00) + 0000 (000)
                 0000
               1010
             0000

         ------------
10x5 =  00110010    = 50d

이제 8자리로 확장해보자. 자리가 급속히 성장하므로 미리 답을 보고 직접 맞춰보라.

(172)   10101100
(202)   11001010
-------------------------
(34744) 1000011110111000   

샘플에 있는 비트 패턴이 별 차이가 없어서 그렇지 만약 비트에 중복되는 1이 많으면 많을수록 나중에 합산하는 과정에서 오류를 일으키기 쉽다.
종이와 연필로 계산할 땐 사람들은 왼쪽부터 써나가는 경향이 있으니 왼쪽부터 곱셈해서 나중에 한번에 자리를 맞춰주면 수월할 것이다. 또한,
2진 곱셈에서는 자리가 급격히 늘어남을 유념해야한다. 즉, 더블워드 * 더블워드는 쿼드워드이다 (32bits * 32bits = 64bits). 이는 32번의 2진
곱셈과 32번의 2진 덧셈을 해야한다는 뜻이고, 0또는 1이 될 비트가 64개가 된다는 얘기다. 더블워드는 대략 40억 정도이며, 32자리 이진수이지만,
둘을 십진 레벨로 곱할땐 4,000,000,000 * 4,000,000,000 = 16,000,000,000,000,000,000 이 되어 십진 레벨로 10자리 * 10자릿수가 결과는
20자리가 된다.

16진수 곱셈표:

얼핏 생각해봐도 곱셈은 반복 연산이 많이 들어간다. 인간은 반복 연산에 너무 약한 모습을 보인다. 심지어 같은 말을 두번만 해도 "두말하면
잔소리지"가 된다. 언젠가 TV 광고에서 "백만 스물 하나, 백만 스물 둘,..." 이렇게 세어나가는 배터리 광고를 본적있다. 하지만, 백만까지 셀 수
있는 사람이 몇이나 되겠는가 ? 1초에 1씩 틀리지않고 세어나간다해도, 하루에 60*60*24 = 43200 밖에 못센다. 백만까지 셀려면 23일 걸린다는
소리다. 물론 은행에서 지폐를 100개씩 묶듯이 100단위로 끊어서 세더라도 중간에 "몇까지 세었는가?"라고 물으면 바로 그 백단위 묶음을 참조하느라
1초에 하나 이상을 세기도 힘들 뿐더러, 1단위 수는 그나마 빨리 셀 수 있겠지만, 710389 등의 자릿수가 늘어나면 1초에 하나씩 세기도 힘들것이다.
더구나 세는 중간 중간 휴식이나 수면을 취해야 하니, 결국 백만이라는 숫자는 인간이 셀 수 없는 (?) 숫자에 불과할 지 모른다. 어찌보면 "백만"
이라는 수가 그리 큰 수가 아닌데도, 이를 센다는 것은 기네스북에 도전한다는 것처럼 느껴지기도 한다. 반복 연산에 이리 취약한 게 인간이며
반대로 그만큼 곱셈과 나눗셈의 지겨운 단순 연산을 벗어나려 노력하다보니 컴퓨터가 지금에까지 발전하였다고 할  수 있다.

곱셈 명령인 mul/imul의 8비트 버전 작동 코드를 보자.

mul: (기본 포맷 - "mul r/m8")

AX <- AL * Src

imul: (기본 포맷 "imul r/m8", "imul r16, r/m16", "imul r16, r/m16, imm16")

AX <- AL * Src
if (AH = 00h) or (AH = FFh))
  then CF = 0; OF = 0;
  else CF = 1; OF = 1;
fi

보다시피 mul/imul은 Dest 연산자를 AL로 사용하여 거기에 Src 연산자를 곱하여 결과를 AX로 리턴한다. mul은 unsigned에 imul은 signed에 사용되며,
mul은 단항 (unary) 명령이며, imul은 단항, 또는 16비트 이상에서 이항 (binary), 삼항 (ternary) 명령이 가능하다. 단항 명령으로 쓰이면, 이 둘은
AL (byte), AX (Word), EAX (DWord), RAX (QWord)에 곱하려는 원래의 값 (multiplicand)을 정해주고, 곱하려는 값 (multiplier)을 r/m8에 정해준다.
그 결과는 AX (byte), DX:AX (Word), EDX:EAX (Dword), RDX:RAX (QWord)에 곱 (product)을 비트 단위 2배 (즉, 제곱) 확장된 상태로 리턴한다.


(피)연산자 크기     Src1      Src2      Destination

바이트              AL        r/m8      AX
워드                AX        r/m16     DX:AX
더블워드            EAX       r/m32     EDX:EAX
쿼드워드            RAX       r/m64     RDX:RAX


플랙 세팅:

mul 명령은 결과 (곱)의 상위가 0 이면 CF/OF를 0으로 클리어하고, 그렇지 않으면 (즉, 곱이 자리 올림이 일어난 경우) CF/OF를 1로 세트하며, 다른
플랙은 정의되지 않는다. imul은 MSb (사인 비트 포함)가 결과의 상위 절반 (upper half)으로 캐리가 일어나면 CF/OF 가 1로 세트되고, 하위 절반에
딱 들어맞으면 CF/OF가 클리어된다. 이 시점에서 코드로 이를 확인하자.

;=========== mul/imul test ==========
;

.model small
.stack 100h
.data

result db 5 dup (30h), 0Dh, 0Ah
;==============================
.code
main proc
  mov ax, @data
  mov ds, ax

  xor ax, ax
  xor bx, bx
  xor cx, cx
 
L1:
  mov al, 12          ; AL = Multiplicand
  mov bl, 34          ; BL = Multiplier
  mul bl              ; AX = Product (AH = High part, AL = Low part)
 
  call decdump
 
  xor ax, ax
 
L2:
  mov al, 12          ; AL = Multiplicand
  mov cl, 34          ; CL = Multiplier
  imul cl             ; AX = Product (Ah = High part, AL = Low part)
 
  call decdump

exit:
  mov ah, 08h           ; 잠시 지연
  int 21h

  mov ax, 4C00h
  int 21h
main endp

decdump proc            ; 메모리에 result 변수를 하나 선언해서 호출
  push bp               ; 호출자 bp 백업
  mov bp, sp            ; sp 복사

  push ax               ; 변경하게될 레지스터 백업
  push bx
  push cx
  push dx
  push si
  push di               ; 안쓰지만, 형식상 푸시
  pushf                 ; 플랙도 백업

L1:
  lea si, result        ; 메모리 주소 액세스
  add si, 4             ; 제일 뒷 자리부터
  mov bx, 10            ; div factor

L2:
  xor dx, dx            ; 나눗셈 연산에 사용할 것이므로 초기화
  div bx

L3:
  add dl, 30h           ; + 30h
  mov [si], dl          ; dl 저장
  dec si                ; 메모리 포인터 이동

  cmp ax, 0             ; 몫 = 0 ?
  jnz L2

  mov ah, 40h
  mov bx, 1
  mov cx, sizeof result
  lea dx, result
  int 21h

  popf                  ; 플랙 복원
  pop di
  pop si
  pop dx
  pop cx
  pop bx
  pop ax                ; 레지스터 복원

  mov sp, bp            ; sp 복원
  pop bp                ; bp 복원
  ret                   ; 호출자로 리턴
decdump endp
end main

L1은 mul 연산을 사용했고, L2는 imul 연산을 사용했다.

  mov al, 12          ; AL = Multiplicand
  mov bl, 34          ; BL = Multiplier
  mul bl              ; AX = Product (AH = High part, AL = Low part)
 
이 경우 AL (= 0C) * BL (= 22h) = AX에 0198h (408)을 리턴하고, AX의 상위 파트 (AH)가 01로 세트되므로, CF/OF가 동시에 1로 토글된다. 즉,
두 수를 곱하면 바이트 레벨로는 오버플로가 되었다는 뜻이다. imul 또한 같은 결과가 되며, CF/OF가 동시에 1로 토글되었다. 여기서 우리는 자리가
두배로 늘어남을 알 수 있으며, 바이트 레벨 가장 큰 수라고 할 수 있는 FFh (255) * FF을 곱하더라도 FE01h (65025)가 되는 것을 유념할 필요가 있다.
바이트 * 바이트 = 워드가 되며 "워드 레벨" 오버플로가 발생한 것이 아니다. 곱셈에서 CF/OF의 역할은 "소스" 연산자의 크기 초과를 의미하지
대상 연산자의 크기 초과를 의미하는 것은 아니다.

지난 시간에 뺄셈을 다루면서 음수를 출력하는 서브 루틴이 없어서 마치 등대없이 항해하는 기분이 들었을 지 모르겠다. 더 진행하기에앞서 지난
시간에 배운 것과 3장에서 배운 십진수 변환을 응용하여 signed 수를 출력해보자. 코딩할 때마다 새로운 아이디어가 떠오를지 모르므로, 아예 십진
변환의 원리만 간략히 점검하고, 백지 상태에서 새로 코딩해보자.

음수를 음수로 출력하려면 무엇이 필요할까 ?

"음수를 정해주고 출력 함수를 호출하면 된다". 이런 말은 누구나 할 수 있으며, 그 정도 생각은 아무나 다 할 수 있다. 이런 말뿐인 생각을
"잡념" 또는 "헛된 망상"이라고 한다. 죽은 생각에 숨을 불어넣는 마술이 바로 코딩이다. 사람들은 그런 생명체도 아닌 그래픽 덩어리를 보고
멋지다고 감탄하지 않는가 ?

음수를 출력하려면, '-' 기호를 출력 변수와는 상관없이 따로 출력해야 한다. '-' 기호는 출력 변수에 아무 관련없다. '-'를 화면에 출력하고
 이어서 출력 변수가 뒤이어 출력되어야 함은 당연한 얘기다. 우리는 이를 따로 매크로로 만들 필요없이 서브 루틴에 임베딩시킬 것이다.
이전에 한 문자를 출력하기 위해선 바이오스 teletype 출력 인터럽트나 도스 인터럽트로 해결했다. 여기서는 도스 인터럽트로 하기로 하자.

mov ah, 06h
mov dl, '-'
int 21h

하지만, 이는 우리가 음수라고 결정내렸을 때만 출력해야지 양수일 때도 출력하면 안되는것 또한 당연하다. 그렇다면 어떤 수가 음수인지 아닌지를
판정내려야 하지만 서브 루틴이 결정할 일은 아니다. 그 외에도 그 수가 얼마나 큰 수인지를 덤으로 알아야하며, 고려할 게 많다. 몇가지만 집어보자.

우리는 이번엔 서브 루틴을 다음처럼 호출할 것이다.

mov ax, 1234h
push ax
call mysub
add sp, 2

보다시피 이는 표준 C 스타일 서브 루틴 호출 코드이다. 이를 메인 (caller)에 정해주고 호출하면 우리의 호출 루틴 (callee)은 이를 받아서 어떤
연산을 한다. 그 연산 결과를 예전처럼 전역 변수인 result에 리턴하기로 하자. 이제 서브 루틴은 push된 ax 값을 프레임으로 다룰려고 하니,
에필로그 코드를 다음처럼 수정하자.

  push bp               ; 호출자 bp 백업
  mov bp, sp            ; sp 복사
  mov [bp-2], ax        ; 지역 변수에 ax 복사
 
즉, push 된 ax가 [bp - 2] 주소 (즉, 스택 첫 프레임)에 복사된다.

이제 우리는 어떤 수가 음수인지를 알아야한다. 그 수를 바이트 레벨 레지스터인 al로 입력받았다고 하자. 참고로 더 큰 레지스터를 써도 무관하지만
어셈블리에서 대부분의 기본 원리를 알기엔 바이트 단위로 연산하는게 더 효과적일 것이다. 인텔 계열 CPU를 바이트 단위 어드레싱 기계 (byte
addressable machine)이라고도 하지 않은가 ? 또한 큰 수에서 작은 수를 빼는 것은 쉽지만, 작은 수에서 큰 수를 빼는 것은 어렵듯이 바이트 레벨
연산을 할 줄 알면 쿼드워드 레벨 연산 또한 자리만 늘려진 것에 불과할 정도로 쉽게 받아들여지지만, 애초에 프로그래밍을 쿼드워드 레벨로 배우기
시작하면 반대로 작은 수를 연산할 때 취약해지는 약점을 보이기도 한다. 이는 장난감을 다룰 줄 모르는 아이에게 칼자루를 쥐어주는 것과 같다.

이외에도 더 필요한 것을 몇개 나열해보자.

1. 십진 변환
2. 2의 보수 연산
3. 메모리 포인터 이동

대략 핵심적인 것만 나열하면 이렇다. 만약, al로 받은 값이 signed 수라고 가정하면, 그 수는 이진수이므로 십진 변환이 필요하며, signed 수이므로
2의 보수 연산이 필요하다. 또한 4자리 16진수는 5자리 십진수가 되므로 이를 5번 반복 변환해줘야 하며, 이외에도 메모리 포인터를 이동시켜야 한다.
즉, 생각보다 고려할게 많아진다는 얘기다. 하나씩 하나씩 점검해보자.

사인 비트인 왼쪽 최상위 비트 (MSb)는 사용하지 않고 음/양 구분 기호로 쓴다는 것은 상식이며, 이미 다뤘다. 복습 차원에서 예로, 81h를 입력했다면,
양수로는 129이지만, 음수로는 얼마이겠는가 ?

81h = 1111 0001b = 0000 1110b (뒤집어서) + 1 (1을 더한다) = 0000 1111 = |15| = -15

이 또한 알 것이다. 그럼 이를 음수로 출력하려면 무엇이 더 필요하겠는가 ?

잘 보면 80h가 양수일 때는 129이며, 음수일 땐 -15이므로 절대치로만 생각해보면 자릿수 (3자리:2자리)가 일치하지 않음을 알 수 있을 것이다.
그 말은 singed 수를 다룰 경우 애초에 양수로 입력받은 모든 수는 음수로 모두 바꿔서 저장하던가, 반대로 애초에 음수로 입력받은 수는 모두 양수로
바꿔서 저장해야 한다는 뜻이다. 이는 unsigned로 입력된 것을 signed로 저장할 때도 마찬가지다. 어떤 데이터가 signed/unsigned로의 의미를 가질지는
유저 레벨에서 결정되어야 한다. 그걸 감안하지 않으면 윈도우즈 계산기처럼 81h를 Hex 모드로 입력하고 Dec 모드로 백날 변환해도 ungigned로 129만
보여줄 뿐이다. 다른 말로 바이트로 저장되었더라도 우리는 unsigned/signed 양쪽을 모두 감안해야 멍청하다는 소리를 면할수 있다.

unsgined <-> signed로의 상호 변환을 바로 위에서 계산했던 것 처럼 2의 보수 방법으로 절대치로 해주는 것이다. 그 말은 입력받은 데이터를 모두
절대치로 변환을 바꾸고 포인터를 이동해 저장해야 한다는 뜻이다. 그런 이유로 스택에 지역 변수를 하나 선언해주고 거기에 ax 값을 복사한 것이다.
여기서 문제가 발생하는데 우리가 지금까지 배웠던 2의 보수는 바이트 레벨이었다. 하지만 우리가 변환해야할 데이터는 워드 레벨 데이터이다.
예로, 8F80h 라는 워드 데이터를 당신은 십진 음수로 변환할 수 있는가 ? 여기에 대한 답을 얻지 못했다면 결코 변환을 할 수 없다는 얘기다.

십진 변환은 이미 다뤘으며 결과에 30h만 더해주면 된다. 다만 그 결과를 산출하기 전에 10으로 나눠주면 된다. 이제 2의 보수 연산시 고려할 것은
부호 확장만 남은 셈이다. 하지만, 이 또한 한번이 아닌 바이트 갯수만큼 변환을 해줘야 하며, neg 명령의 자동화 기능을 바이트 레벨로 약간 수정할
필요가 있다. 예로, al에 받은 unsigned 수는 다음처럼 signed 수로 변환할 수 있다.

xor ax, ax        ; 미리 소거
mov al, 81h       ; 테스트 값
sbb ah, al        ; 사인 비트를 복사하여 ah에 마스킹 값 생성
xor al, ah        ; 원래 값 xor 마스킹 값
sub al, ah        ; 원래 값 - 마스킹 값

이게 전분가 ? 대략 준비가 다 된듯 싶으나 이외에도 한가지 빠진게 있다. 특정 바이트가 음수인지 양수인지를 검사하는 것이다. 그건 서브루틴이 할
일이 아니며 유저 레벨에서 메인에서 해줘야 한다. 메인에서 이를 검사한다면 다음처럼 체크해줄 수 있다.

test ah, ah
jz Location

이제 대략적인 윤곽은 살펴봤으니 죽은 생각에 숨을 불어넣어보자. 테스트 값을 정해 이 값을 음수라는 가정하에 변환해서 출력해 주는 것이 목표다.

;=========== signed word ASCII decimal dump ==========
;
.model small
.stack 100h
.data

result db 5 dup (30h), 0Dh, 0Ah      ; 5바이트 signed 음수 -1이라고 가정
;==============================
.code
main proc
  mov ax, @data
  mov ds, ax

  lea si, result

  mov ax, 8F80h        ; 테스트 값 (음수로 인정할 경우 7080h = 십진 절대치 |28800| = -28800)
  push ax
  call sdecdump
  add sp, 2

exit:
  mov ah, 08h           ; 잠시 지연
  int 21h

  mov ax, 4C00h
  int 21h
main endp

sdecdump proc           ; 메모리에 result 변수를 하나 선언해서 호출
  push bp               ; 호출자 bp 백업
  mov bp, sp            ; sp 복사
  mov [bp-2], ax        ; 지역 변수에 ax 복사

  push ax               ; 변경하게될 레지스터 백업
  push bx
  push cx
  push dx
  push si
  push di               ; 형식상 푸시
  pushf                 ; 플랙도 백업

  mov bx, [bp-2]        ; 지역 변수를 다시 백업한다 (변환을 위함)

L1:

  xor ah, ah            ; 하위 바이트를 2의 보수로 변환하기 위해 상위 바이트를 지운다
  sbb ah, al            ; 상위 바이트에 al의 2의 보수를 생성

  mov bl, ah            ; 임시로 변환된 ah를 bl에 저장한다
  mov al, bh            ; 백업된 상위 바이트를 얻어온다

  mov ah, 0             ; ax 상위를 0으로 소거한다 (CF를 클리어하는 xor 대용)
  sbb ah, al            ; 하위 바이트의 2의 보수를 구한다
  mov bh, ah            ; bx = 변환된 ax의 2의 보수
  mov [bp-2], bx        ; 변환된 보수 스택에 저장

  xor ax, bx            ; ax <-> bx 스왑 (ax는 나눗셈에 이용, bx는 버린다)
  xor bx, ax
  xor ax, bx

L2:
  lea si, result
  add si, 4             ; 메모리 제일 뒷자리 포인트
  mov cx, 4             ; 4번 나눈다
  mov bx, 10            ; div factor

L3:
  cwd                   ; divide error를 방지하기 위해 워드 레벨 나눗셈
  div bx                ; 나눈 결과 ax = 몫: dx = 나머지

  add dl, 30h           ; 아스키 십진수로
  mov [si], dl          ; 나머지의 하위 바이트만 메모리에 저장

  cmp ax, 0             ; divide error 발생시 종료
  jz L4

  dec si
  loop L3
 
  add al, 30h
  mov [si], al          ; 마지막 몫은 나누지 않고 그냥 저장한다
 
L4:
  mov ah, 06h           ; '-' 기호 출력
  mov dl, '-'
  int 21h
 
Last:
  mov ah, 40h           ; 변환된 값 덤프
  mov bx, 1
  mov cx, sizeof result
  lea dx, result
  int 21h

  popf                  ; 플랙 복원
  pop di                ; 레지스터 복원
  pop si
  pop dx
  pop cx
  pop bx
  pop ax

  mov sp, bp            ; sp 복원
  pop bp                ; bp 복원
  ret                   ; 호출자로 리턴
sdecdump endp
end main

이미 충분히 설명하였고, 주석 착실히 달아뒀으니 천천히 분석해보기 바란다. 이 프로그램을 실행시켜 8000h 미만의 수를 입력하면 음수로 변환을
하지 않음을 알 것이다. 8000h (-32768)은 절대치가 가장 큰 음수이지만, neg 명령은 이를 8000h로 변환할 뿐이다. 가장 큰 음수인 8000h는 보통
2의 보수 연산으로 음수로 변환할 수 없는 가상적인 수라고 할 수 있다. 이 값을 변화하는 것은 이 서브 루틴의 역할이 아니니 당분간은 무시하기로
한다. 이진수의 양면과도 같은 음수를 출력하는 방법도 알았으니 이제 본론으로 돌아가 곱셈에 대해서 더 알아보자.

사실 곱셈은 별로 다룰 내용도 없다. AL/AX/EAX/RAX에 피승수 (multiplicand)를 기타 다른 여분 레지스터 (spare register)에 승수 (multiplier)를
넣고 "mul/imul 승수"로 해주면 그 결과가 두배로 확장된 비트로 리턴되며 AX/DX:AX/EDX:EAX/RDX:RAX로 리턴된다. 리턴된 값의 상위 영역 (AH/AX/
EAX/RAX)가 0이냐 1이냐에 따라 각각 플랙 두개 (OF/CF)를 0또는 1로 세팅한다. 즉, 곱셈에서 오버플로는 곱셈을 당하는 레지스터의 영역보다 큰가를
의미할 뿐이다. 이는 FF * FF = FE01이 되어 2자리 * 2자리 곱셈이라도 4자리를 넘지 못하는 것으로 생각하면 된다. 십진수로 99 * 99 = 9801이 되는
것처럼 같은 n자릿수끼리 곱하더라도 2n자리는 될지언정 2n+1 자리는 되지 않기 때문이다. 즉, 곱셈에서 오버플로는 n자릿수 초과를 의미할 뿐이다.
또한 유사코드를 잘 보면 OF/CF가 동시에 세팅됨을 알 수 있다. 즉, 곱셈에서 캐리는 오버플로와 같은 역할이라고 생각할 수 있다.

그러므로 만약 다음과 같은 코드를 입력하고 앞에서 작성한 음수 출력 루틴을 호출하면 에러가 된다.

  mov ax, 0FEh        ; multiplicand 테스트 값 (al = -2)
  mov bx, 0FEh        ; multiplier 테스트 값 (bl = -2)
  imul bl


곱셈 결과가 양수로 오버플로가 일어나기 때문이다. 그러므로 양수일 경우는 양수 출력 루틴까지 달아주고 다음처럼 호출해보자. 8000h를 강제로
양수로 인정해주고 출력해주자. 물론 8000h는 shl 1 로 쉬프트시켜 강제로 0으로 세팅하거나 강제로 양수에 -만 붙여서 출력하는 방법도 있으나
여기선 생략하기로 하자.

;=========== unsigned/signed word ASCII decimal dump ==========
;
.model small
.stack 100h
.data

result db 5 dup (30h), 0Dh, 0Ah
;==============================
.code
main proc
  mov ax, @data
  mov ds, ax

  lea si, result

  mov ax, 0FEh        ; multiplicand 테스트 값 (al = -2)
  mov bx, 0FEh        ; multiplier 테스트 값 (bl = -2)
  imul bl             ; AX는 양수 결과
 
  cmp ax, 8000h
  ja L1               ; 양수 (unsigned)를 테스트할 땐 ja

  push ax
  call decdump
  add sp, 2
  jmp L2

L1: 
  push ax
  call sdecdump 
  add sp, 2
 
L2:
  mov ax, 8000h       ; ax 리셋
 
  cmp ax, 8000h
  jg L3               ; 음수 (signed)를 테스트할 땐 jg
 
  push ax
  call decdump
  add sp, 2
  jmp L4
 
L3:
  push ax
  call sdecdump
  add sp, 2
 
L4:

exit:
  mov ah, 08h           ; 잠시 지연
  int 21h

  mov ax, 4C00h
  int 21h
main endp

decdump proc            ; 메모리에 result 변수를 하나 선언해서 호출
  push bp               ; 호출자 bp 백업
  mov bp, sp            ; sp 복사
  mov [bp-2], ax

  push ax               ; 변경하게될 레지스터 백업
  push bx
  push cx
  push dx
  push si
  push di               ; 안쓰지만, 형식상 푸시
  pushf                 ; 플랙도 백업

L1:
  lea si, result        ; 메모리 주소 액세스
  add si, 4             ; 제일 뒷 자리부터
  mov bx, 10            ; div factor

L2:
  xor dx, dx            ; 나눗셈 연산에 사용할 것이므로 초기화
  div bx

L3:
  add dl, 30h           ; + 30h
  mov [si], dl          ; dl 저장
  dec si                ; 메모리 포인터 이동

  cmp ax, 0             ; 몫 = 0 ?
  jnz L2

  mov ah, 40h
  mov bx, 1
  mov cx, sizeof result
  lea dx, result
  int 21h

  popf                  ; 플랙 복원
  pop di
  pop si
  pop dx
  pop cx
  pop bx
  pop ax                ; 레지스터 복원

  mov sp, bp            ; sp 복원
  pop bp                ; bp 복원
  ret                   ; 호출자로 리턴
decdump endp

sdecdump proc           ; 메모리에 result 변수를 하나 선언해서 호출
  push bp               ; 호출자 bp 백업
  mov bp, sp            ; sp 복사
  mov [bp-2], ax        ; 지역 변수에 ax 복사

  push ax               ; 변경하게될 레지스터 백업
  push bx
  push cx
  push dx
  push si
  push di               ; 형식상 푸시
  pushf                 ; 플랙도 백업

  mov bx, [bp-2]        ; 지역 변수를 다시 백업한다 (변환을 위함)

L1:

  xor ah, ah            ; 하위 바이트를 2의 보수로 변환하기 위해 상위 바이트를 지운다 
  sbb ah, al            ; 상위 바이트에 al의 2의 보수를 생성

  mov bl, ah            ; 임시로 변환된 ah를 bl에 저장한다
  mov al, bh            ; 백업된 상위 바이트를 얻어온다

  mov ah, 0             ; ax 상위를 0으로 소거한다 (CF를 클리어하는 xor 대용)
  sbb ah, al            ; 하위 바이트의 2의 보수를 구한다
  mov bh, ah            ; bx = 변환된 ax의 2의 보수
  mov [bp-2], bx        ; 변환된 보수 스택에 저장

  xor ax, bx            ; ax <-> bx 스왑 (ax는 나눗셈에 이용, bx는 버린다)
  xor bx, ax
  xor ax, bx

L2:
  lea si, result
  add si, 4             ; 메모리 제일 뒷자리 포인트
  mov cx, 4             ; 4번 나눈다
  mov bx, 10            ; div factor

L3:
  cwd                   ; divide error를 방지하기 위해 워드 레벨 나눗셈
  div bx                ; 나눈 결과 ax = 몫: dx = 나머지

  add dl, 30h           ; 아스키 십진수로
  mov [si], dl          ; 나머지의 하위 바이트만 메모리에 저장

  cmp ax, 0             ; divide error 발생시 종료
  jz L4

  dec si
  loop L3
 
  add al, 30h
  mov [si], al          ; 마지막 몫은 나누지 않고 그냥 저장한다
 
L4:
  mov ah, 06h           ; '-' 기호 출력
  mov dl, '-'
  int 21h
 
Last: 
  mov ah, 40h           ; 변환된 값 덤프
  mov bx, 1
  mov cx, sizeof result
  lea dx, result
  int 21h

  popf                  ; 플랙 복원
  pop di                ; 레지스터 복원
  pop si
  pop dx
  pop cx
  pop bx
  pop ax

  mov sp, bp            ; sp 복원
  pop bp                ; bp 복원
  ret                   ; 호출자로 리턴
sdecdump endp
end main

보다시피 출력 루틴 때문에 그렇지 메인만 살펴보면 간단히 분기 테스트해준 것 외엔 별 것 없다.


imul의 2항 3항 연산을 살펴보자. 사람뿐만 아니라 CPU에게도 곱셈/나눗셈은 상당히 부담스런 연산이다. 그런 부담스런 요소는 고스란히 어셈블러에게
넘겨지고 어셈블러는 자연스럽게 어셈블리 프로그래머에게 넘긴다. 그래서 two-pass 어셈블러라고 칭하는지 모르겠다. 그런 것 중에 하나가 mul/imul
명령을 쓸때마다 ax 레지스터를 암시적 multiplicand/product 연산자로 쓴다는 점이다. 그런 불합리한 요소를 조금은 덜어주기 위해서 나온 명령 포맷이
"imul reg, imm" 형식이다. 하지만 이 명령은 286 프로세서 명령으로 추가되었으므로, 어셈블리 소스에 .286 지시어를 넣어줘야한다. 사용 예로,


.286
...

  mov bl, 12h         ; multiplicand
  imul bx, 10h        ; multiplicand (12h)* multiplier (10h) = product (bx)

...


이 경우 al 레지스터가 아닌 bl 레지스터를 multiplicand (피승수) 연산자로 사용하였으며, 상수 10h를 multiplier로 취해 그 product를 bx에 저장한다.
당연히 결과가 저장되는 연산자 (bx)는 상위 워드를 정해줘야 하므로, "imul bl, 10h"라는 명령은 존재치 않는다. .286 지시어를 추가하면, 어셈블러
자체적으로 강화 규칙을 적용하여 코드 중간 중간 우리가 작성하지 않은 코드를 끼워넣는다. 이는 .386 이상의 지시어에도 마찬가지다. 테스트해본
결과 imul 연산시 bl을 shl 4해주는 것을 확인하였다. 그 결과 100h가 곱해져 엉뚱한 결과가 나왔다. 그러므로, .286을 쓰더라도 곱해진 결과를 ax로
복사하여 "mov ax, bx" 처럼 해주고 서브 루틴을 호출하니 에러가 해결되었다. 메인 부분만 보면 이렇다.

;=========== imul variants test ==========
;
.286                          ; for imul variants
.model small
.stack 100h
.data

result db 5 dup (30h), 0Dh, 0Ah
;==============================
.code
main proc
  mov ax, @data
  mov ds, ax

  lea si, result

  mov bl, 12h         ; multiplicand
  imul bx, 10h        ; multiplicand (12h)* multiplier (10h) = product (bx)
 
  mov ax, bx
 
  cmp ax, 8000h
  ja L1               ; 양수 (unsigned)를 테스트할 땐 ja

  push ax
  call decdump
  add sp, 2
  jmp L2

L1: 
  push ax
  call sdecdump 
  add sp, 2
 
L2:


exit:
  mov ah, 08h           ; 잠시 지연
  int 21h

  mov ax, 4C00h
  int 21h
main endp

이 경우 multiplicand/product를 ax가 아닌 다른 레지스터에 정해준 것을 제외하고는 별반 다를 바 없다. 물론 32비트 이상의 레지스터로도 이런
변형형 명령을 쓸 수 있는데, 그러려면 최소 .386이상의 지시어를 넣어줘야 한다. 3항 명령인 "imul reg, src, imm" 포맷은 생략한다. 변형형이
쓰기는 편리할 수 있지만, 역시 원래의 포맷에 비해 클럭이나 기계어 코드 크기가 약간씩 증가함을 기억해야 할 것이다.


또한 2항 연산자는 상수를 주로 쓰는 경우가 많다. 예로, "imul ax, 2" 명령은 더 빠른 덧셈 명령인 "add ax, ax"로 대체할 수 있다. 어떤 수를
2배 한다는 것은 그 수를 한번 더 더해주면 되기 때문이다. 곱셈을 상대적으로 빠른 덧셈으로 대체하는 방법으로 예로, 5를 곱한다고 하자.

imul ax, 5

=

mov ba, ax    ; 복사하여
add ax, ax    ; 2 * ax
add ax, ax    ; 4 * ax
add ax, bx    ; 5 * ax

또한 상수 곱셈의 일부는 쉬프트 연산으로 대체가능한 경우도 많다. 예로, 5 * 320

5 * 320 = 1600 = (5 * 256 = 5 * 2^8 = 1280) + (5 * 64 = 2 * 2^6 = 320)

  mov ax, 5
  mov bx, 320
  mul bx
 
=
 
  mov ax, 5
  mov bx, ax          ; 복사하여
  mov cl, 4           ; 쉬프트 카운트 = 4
 
  shl bx, cl          ; bx * 2^4
  shl bx, 1           ; bx * 2^5
  shl bx, 1           ; bx * 2^6
 
  shl ax, cl          ; ax * 2^4
  shl ax, cl          ; ax * 2^8
 
  add ax, bx          ; ax * 2^8 + bx * 2^6 = ax * 320


이장의 마지막에서 복정밀 곱셈을 다루려했으나 이는 너무 지루하고 시간소모적이므로 생략하기로 한다. 대부분의 어셈블리 책에서 복정밀 곱셈은
"곱해서 더하라"고 간략히 언급하지만, 막상 이론과는 너무도 다르다. 예로, 4바이트 * 4바이트 두 데이터를 바이트 레벨로 복정밀 연산한다고하자.
그리고 그 데이터가 FF FF FF FFh라고 가정하자. 그러면 다음과 같은 반복적인 곱셈을 해야한다.

FF FF FF FF
FF FF FF FF
-----------
          E1 E1 E1 E1 E1 E1 E1 E1
         E1 E1 E1 E1 E1 E1 E1 E1
       E1 E1 E1 E1 E1 E1 E1 E1
      E1 E1 E1 E1 E1 E1 E1 E1
    E1 E1 E1 E1 E1 E1 E1 E1
   E1 E1 E1 E1 E1 E1 E1 E1
 E1 E1 E1 E1 E1 E1 E1 E1
E1 E1 E1 E1 E1 E1 E1 E1

또한 각 줄마다 하위 바이트의 상위 니블을 상위 바이트의 하위 니블과 더해야 하며, 또한 각 컬럼마다 캐리를 감안한 덧셈을 해야한다. 그거외에도
엔디언 (endianess)까지 작용하여 빅 엔디언으로 계산할 것인지 리틀 엔디언으로 저장할 것인지를 감안해야하고, 또한 데이터를 더하는 과정에서
부수적으로 압축 (packing)이라는 것까지 고려해야 할 것이며, 바이트 단위로 계산한다고 하더라도 색인 레지스터 (bx/si/di)가 최소 16비트이거나
32비트 레지스터이다보니 바이트 레벨로 계산한다면 바이트 단위 스와핑을 수십번을 해줘야 한다. 나 또한 도전해봤으나 지루하고 눈이 아파서
중간에 포기해야했다. 반복적인 데이터를 계속 쳐다보고 자릿수를 감안해가면서 코딩하는 것 자체가 곤혹스러웠다. 복정밀 연산을 구현하면 분명
프로그램의 가치가 높아지지만, 사실 이를 구현하기란 데이터의 도배를 참아내고 마라톤하는 것과 유사하며 인내와 집중력이 필요하다.

여기서는 AoA에 있는 두 더블워드 곱셈을 워드 레지스터로 하는 방법을 들어본다. 받아적은 거라서 맞는지 결과는 모르겠다.

;=========== multiple precision multiplication example ==========
;

.model small
.stack 100h
.data

var1 dd 12345678h                   ; multiplicand (X1X2) - 78563412
var2 dd 89ABCDEFh                   ; multiplier (Y1Y2) - EFCDAB89
var3 dq ?                           ; product

result db 5 dup (30h), 0D, 0Ah
;==============================
.code
main proc
  mov ax, @data
  mov ds, ax

L1:
 
  xor ax, ax                        ; lower result
  xor bx, bx                        ; lower result backup
  xor cx, cx                        ; upper result backup
  xor dx, dx                        ; upper result
 
L2:
  mov ax, word ptr [var2]           ; Y2
  mov bx, ax                        ; backup for later use
  mul word ptr [var1]               ; Y2 * X2 = DX:AX
  mov cx, dx                        ; backup upper result
  mov word ptr [var3], ax           ; save lower result
 
  mov ax, bx                        ; restore Y2
  mul word ptr [var1+2]             ; Y2 * X1 = DX:AX
  add ax, cx                        ; add partial product
  adc dx, 0                         ; DX + carry
  mov bx, ax                        ; backup
  mov cx, dx                        ; save partial product for later use
 
L3:
  mov ax, word ptr [var2+2]         ; Y1
  mul word ptr [var1]               ; Y1 * X2 = DX:AX
  add ax, bx                        ; add previous partial product
  adc cx, dx                        ; add previous partial product + carry
  mov word ptr [var3+2], ax         ; save partial product
 
  mov ax, word ptr [var2+2]         ; Y1 (reload)
  mul word ptr [var1+2]             ; Y1 * X1 = DX:AX
  add ax, cx                        ; add partial product
  adc dx, 0                         ; add upper result + carry
 
  mov word ptr [var3+4], ax         ; save
  mov word ptr [var3+6], dx
 
exit:
  mov ah, 08h           ; 잠시 지연
  int 21h

  mov ax, 4C00h
  int 21h
main endp



 

  

 

댓글 없음:

댓글 쓰기

블로그 보관함