2010년 1월 8일 금요일

6장 - 이진 세상으로 - 더하기

6장 - 이진 세상으로 (Welcome to the binary world!) - 더하기

우리는 비트가 추상적인 단위이므로 이를 머리에 그림을 그려 해석하자. 

<그림>





각 네모는 0 또는 1이 들어갈 수 있으며 이 네모를 비트(bit, 보통 소문자 b로 축약한다)라고 칭하기로 하자. 위의 그림은 64비트 데이터이다. 각 비트는
번호를 메겨 "0번 비트" 또는 "비트 0" 등으로 표현하기로 하자. 그림에서 까만 4비트를 니블 (nibble)이라고 하기로 하고, 1니블은 16가지의 경우의
비트를 담을 수 있다. 이 4개의 비트는 16진수 한자리로 표현할 수 있으며 64비트는 16개의 니블로 구성되고, 0~ FFFF FFFF FFFF FFFFh 까지의 영역에
해당한다. 이 영역을 64비트 메모리 주소라고 한다. 니블은 은어적인 경향이 강하므로 자주 언급하지 않는다. 하지만 어셈블리 세상에서 니블의 역할은
아주 중요하다 메모리 주소를 표현할 때 자주 사용하기 때문이다. 그렇다면 Dword는 몇 니블인가 ?

사용하는 비트가 2배 많아지면 표현할 수 있는 "경우의 비트"는 2 제곱이된다. 즉, 32비트 데이터는 64비트 데이터의 1/2 비트 수를 필요하고 표현할 수
있는 범위가 0 ~ FFFF FFFFh 까지이다. 표현할 수 있는 범위를 고상한 표현으로 "해상도" (resolution) 라고 한다. 바이트 (Byte, 보통 대문자 B로
축약한다)는 8개의 비트로 표현되며 이중 0번 비트를 LSb / 7번 비트를 MSb라고 한다. 워드는 바이트보다 점유하는 비트 갯수가 두배 많으며 표현할
수 있는 데이터 영역은 바이트의 2 제곱이다. 그렇다면 워드는 몇 니블인가 라고 물어봐도 또 유치한가 ?

워드는 데이터를 표현할 때 단위를 의미하는 용어로 자주 사용되며 32비트 데이터를 Dword (double-word), 64비트 데이터를 Qword (Quad-word), 128비트
데이터 부터는 공식적인 표현이 없다. 나는 128비트 데이터를 워드가 8개 모여서 OWord (Octa-word)라고 부르지만, 인텔 CPU 매뉴얼은 Double Qword로
표현한다. 그 이상은 부르기 나름이다.

비트 표현에서 제일 왼쪽 비트는 on/off 상태에 따라 값이 크게 변경되는 무게가 실리는 비트 (MSb)이다. 대부분의 데이터 타입에서 MSb는 sign bit로
인정된다. MSB는 워드 이상에서 왼쪽 바이트를 의미한다 (MSb/MSB 두 단어를 혼동하지는 않을 것이다.) DWord는 MSW/LSW로 구성되고, QWord는 MSD/LSD로
구성된다. 그렇다면 QWord는 몇 워드인가라고 또 유치한 질문을 해도 될까 ?

바이트 레벨로 본다면 Word는 2바이트, Dword는 4바이트, Qword는 8바이트이다. Dword는 32비트 운영체제의 디폴트 데이터 타입이다. 즉, 같은 데이터라도
32비트 레벨로 저장/로드하면 속도가 빨라진다는 소문도 있다. 왜냐하면 32비트가 못되는 작은 데이터는 32비트 데이터로 변환해서 연산을 취하게끔 OS가
"미친짓"을 하기 때문이다. 이런 미친짓은 64비트로 운영체제가 업그레이드하더라도 유저 허락없이 "지맘대로" 또 변환할게 뻔하다. 한두번 그래왔는가 ?
왜 시키지도 않는 짓을 하느라 쓸데없이 CPU 클럭을 낭비하는가 ? 어떤 OS가 이런 미친짓을 하면 군중심리 반영하듯이 왜 덩달아 다른 OS도 따라하는가 ?

OS는 우리의 코드를 메모리로 로드한다. 어떤 copyright 문구에 이렇게 씌어져있다. "한 컴퓨터당 한 카피본"... 우리는 이 말이 얼마나 허무맹랑한 지
잘 알고 있다. 왜냐하면 어떤 자료를 인터넷으로 다운받아 파일로 저장해두면, 하드 디스크엔 이미 한 카피본이 생성되며, 이는 로우 레벨로 보면 단순한
이미지에 불과하므로 또 다른 카피본이 로우 레벨로 디스크에 존재하며, 또 메모리에 로드된다는 말이 카피본을 만든다는 말과 같다. 또한 메모리에
로드된 코드는 CPU로 전성되므로 카피라이트 문구는 절대 이루어질 수 없는 문구이다. 즉 그 말은 메모리에 로드되지 않고 CPU로 로드되지도 않으며,
디스크에서 자체 실행되는 유령 프로그램으로 쓰라는 말이다.

프로그램 코드가 실행되기 까지는 3단 토스를 거치는데, 이를 fetch-decode-execute라고 한다. OS가 메모리에 프로그램을 넘겨주면 CPU는 메모리에서
실행 코드를 fetch하여 이를 기계어로 decode하여 의미를 해석하고 디코딩된 명령이 존재하면 그 명령을 execute한다. 메모리에서 전송된 실행 코드가
담기는 곳이 레지스터이며 AX, BX,... 등의 이름을 CPU 제조사가 만들어 부르며, 각 레지스터는 담을 수 있는 비트 갯수가 제한되어있다. AL은 AX의
바이트 단위 레지스터이고, EAX의 하위 워드는 AX에 담기며, RAX의 32비트 하위 비트들은 EAX에 담긴다. 이들 레지스터는 부분적으로 나눠서 쓸 수 있다.

우리가 mov ax, 12345678h라고 하면 담을 수 없는 데이터를 어거지로 담으려고 하기에 어셈블러는 에러를 통보한다. mov al, bx라고 해도 작은 데이터에
큰 데이터를 어거지로 넣는다고 투정하며, 반면 mov bx, al이라고 해주면 큰 데이터 공간에 작은 데이터를 저장하기에 별 불평하지 않는다. 저장된 데이터에 어떤 연산을 해줄 것인지는 어셈블리 프로그래머에 달렸다. 


유치한 작업은 계속 되어야하므로 우리는 정수 레벨의 초등 산수를 어셈블리로 구현해보자. 땅짚고 헤엄을 칠 수 있어야 태평양도 헤엄쳐 횡단할 것
아닌가 ? 우리가 쓸 수 있는 정수는 16비트 레벨로 제한한다. 그렇다고 머리를 16비트 레벨에 제한할 필요는 없다. 또한 이번 장에서 다룰 산수는
사칙 연산이다.

모든 고급 수학 연산은 사칙 연산의 응용물이라고 할 수 있다. 또한 사칙 연산중 나머지는 곱해서 빼주는, 즉, 곱셈과 뺄셈의 응용물이라고 할 수 있다.
또한 곱셈은 반복적으로 더해주는 덧셈의 응용물이라고 할 수 있으며, 뺄셈은 역수를 더해줄 수 있으므로 덧셈의 응용물이라고 할 수 있다. 즉, 덧셈은
모든 산술 연산에서 가장 기본적인 연산이라고 할 수 있다.

이진 덧셈은 쉽다. 1 + 1 = (C)0 만 기억하면 된다. 여기서 C는 자리 올림 (carry)이며 10에 해당한다. 뒷자리부터 차근 차근 더해가되, 16비트 이상이면
4개씩 끊어서 계산하면 수월하다. 1자리 끼리 덧셈부터 4:4, 8:8, 16:16 까지 그림으로 그려보면 이렇다.




이쯤에서 어셈블리 프로그래머에게 바이블과도 같은 CPU 매뉴얼을 끄집어내려는 당신은 기질이 있다고 할 수 있다. CPU 매뉴얼은 소장하라고 있는 것이
아니다. 시간이 지나 더 나은 CPU가 나오면 이전의 CPU 매뉴얼은 쓸모없어지기 때문이다. CPU 명령어 레퍼런스에서 ADD 명령 찾기란 땅짚고 헤엄치기다.
알파벳 순으로 명령이 정리되어서 금방 찾을 것이다. 이 명령을 찾아보면 포맷이 "ADD D, S" 형식일 것이다 (D: destination operand, S: source operand).

이 명령은 D에 S를 더한다. 이 명령은 여러 포맷이 있지만, "ADD r/m16, imm16" 등은 있어도 "ADD m16, m16" 포맷은 없음을 알 것이다. 즉, 메모리와
메모리 간의 직접적인 덧셈은 할 수 없다. (r: register, m: memory, imm: immediate)
이 명령을 수행하고나면 앞장에서 다룬 6개 플랙 레지스터가 영향을 받으며, 작동 코드는 "D <- D + S"이다. 힌두교인이 코란에 씌어진 문구를 실천하듯이
어셈블리 프로그래머는 CPU 매뉴얼에 적힌 내용에 기반해서 코딩한다.

대표적인 덧셈 연산으로 1부터 10까지 누적합 구하는 예를 들 수 있다. 하지만 이는 설명을 위한 것이지 실제로 1~10까지 차근 차근 더할 일은 일상에서
자주 발생하지는 않는다. 차라리 10까지 1씩 더하는 연산은 더 많이 발생할 것이다. 어찌됐건간에 우리는 1~10까지를 누적합 구한다고 하자.

이진수만 쓰는 손가락 두개인 E.T. 세상에서 기발한 아이디어를 제안하는 사람에게 상금을 주는 논쟁이 벌어졌다.

1. 나는 1~10까지 add 명령 하나로 더할께요
2. 나는 10~1까지 add 명령을 거꾸로 더할께요
3. 나는 가운데 5에서 양쪽으로 퍼져가면서 더할께요
4. 나는 루프로 더하는 코드량을 줄여볼게요
5. 나는 가우스의 공식 "n(n+1) / 2" 으로 누적합을 공식으로 구해볼게요
6. 나는 함수나 라이브러리를 만들어 이를 호출하는 방식으로 더할게요


이외에도 다양한 제안이 있었지만, 당장 생각나는대로 코드 부스러기 (code snippets)만 살펴보자.

mov ax, 0         ; ax에 합을 담기로 하자. 0은 덧셈에서 항등원이라서 0으로 초기화시키고 ax에 더해주자.
add ax, 1         ; 1 더하고
add ax, 2         ; 2 더하고
add ax, 3         ; 3 더하고
...
add ax, 10        ; 10 더하고


중간에 4~9까지 더하는 코드는 생략했다. 이렇게라도 코딩하면 작동은 하지만 아무래도 같은 코드에 더할 상수만 바꿔주니 눈이 별로 즐겁지 않을
것이다. 그럼에도 불구하고 이 코드는 분기 처리를 하지 않으므로 상당히 빠른 코드에 해당한다. 코드에서 분기는 속도를 떨어뜨리는 주범이다. 왜냐하면
분기 명령 (jxx)이 나오는 순간 CPU는 하던 연산을 포기하고 분기 주소를 찾아내려 메모리를 탐색하고 해당 분기 주소를 찾으면 그 주소로 점프할
준비를 하기 때문이다. 이런 흐름 제어 구조의 대표가 루프이다. 그러면 루프로 누적합 한다면,

mov ax, 0         ; 누적합 저장
mov cx, 10        ; 루프 카운터
L1:
  add ax, cx      ; 누적합에 루프 카운터 값을 더한 후 루프 카운터 값을 1씩 떨어뜨린다
  loop L1

 
보다시피 루프를 구성하기 위해 카운터를 어딘가에 정해줘야하는 부수작업도 더 필요하며, 루프 카운터가 어느 한계값에 도달했는지 CPU는 내부적으로
계속 추적해야 한다. 그럼에도 단순 반복 코드가 적어 보기는 좋다. 즉, 속도를 희생하여 가독성 (readability)을 높이는 격이라할 수 있다. 사실 CPU
loop 명령은 cmp/jmp 명령을 압축한 명령이라고 할 수 있으므로 이는 cmp/jmp 명령으로 재구성할 수 있다.


mov ax, 0
mov cx, 10        ; 루프 카운터
L1:
  add ax, cx
  dec cx          ; 카운터를 감소시켜
 
  cmp cx, 10
  jbe L1


또한 이 루프는 카운터 값을 뒤에서 체크했지만, 루프를 시작하자마자 먼저 체크할 수 있다. 또한 1부터 더하나 0부터 더하나 결국 같은 결과이다.


mov ax, 0
mov cx, 10
L1:
  cmp cx, 10      ; maximum count ?
  ja L2
  add ax, cx      ; No, add
  dec cx          ; count down
  jmp L1
L2:               ; Yes, do next


이 루프는 루프 카운터를 0을 주고 1씩 증가시켜 루프를 만들 수도 있다. 또한 어떤 수에 1을 더한다는 뜻은 어떤 수에 1을 증가시킨다는 말과 같고,
어떤 수를 0으로 세팅한다는 것은 그 수를 xor 시킨 것과 같은 결과이므로 다음처럼 가독성과는 거리가 멀지만 속도를 조금 더 올릴 수도 있다.


xor ax, ax
xor cx, cx
L1:
  cmp cx, 10
  ja L2
  add ax, cx
  inc cx          ; count up
  jmp L1
L2:


이번엔 부분합 두개로 더해보자.


mov ax, 1         ; 부분합 S1
mov bx, 6         ; 부분합 S2
mov cx, 5         ; S1 말항
mov dx, 10        ; S2 말항

L1:
  add ax, cx
  add bx, dx
  dec cx          ; 5, 4, 3, 2, 1
  dec dx          ; 10, 9, 8, 7, 6
 
  cmp dx, 6
  je L2
  jmp L1
 
L2:
add ax, bx        ; S1 + S2


코드는 좀 길어졌지만 간혹 굉장히 큰 프로그램을 분할정복 (divide & couquer) 방식으로 작성할 경우 이 아이디어는 효과를 발휘할 수 있다. 다음으로
가우스의 공식 s = (초항 + 말항)*항수+1 / 2를 적용해보자.


mov ax, 0           ; 초항
mov bx, 10          ; 말항
add ax, bx          ; 초항 + 말항
add bx, 1           ; 항수 + 1 (mul factor)
mul bx              ; 곱해서
shr ax, 1           ; 2로 나눈다


이는 루프 자체가 없기에 빠르고 간단하다. 다만 누적합하라는 규칙을 어겼을 뿐이다. 이외에도 여러 방법이 있을 것이고, 명령어를 살짝 바꿔서 하는
방법과 조합하면 더욱 많을 것이다. 또한 특정 반복 코드를 매크로나 서브-프로시저로 작성해두고 매크로나 서브-프로시저의 코드를 복사해서 쓰는
방법이 있지만 여기서는 다뤄봤자 머리만 아프므로 패스하기로하자. 형식상 예로 누적합 연산을 들었지만, 사실 일상에서는 단순 덧셈보다 쓸 일이
적을 것이다. 미적분이 아무리 중요하다고 사칙 연산만큼 많이 쓰이겠는가 ?

나는 가끔 고급 언어 책을 보는데, 피보나치 수열을 계산하는 부분을 보면 대부분이 어떤 똑똑한 코더가 작성한 재귀 호출 (recursive call)로 이를
설명하고 있다. 왜 그런지 모르겠다. 피보나치가 재귀 호출 아니면 구할 수 없는 수가 아닌데도 왜 한결같이 코드를 베껴서 자신의 아이디어인양
그리 설명하는지 모르겠다. 피보나치 계산 루틴을 설명하면서 F2 = F0 + F1 이런 허무맹랑한 공식 들먹이면서 그리 설명한 책 본적 없는가 ?

피보나치 수는 앞 수에 뒷 수를 더해 다음수로 되먹이는 반복적으로 더하는 대표적인 누적합 연산 중 하나이다. 만약 앞수를 ax에 담는다고 하자.
그리고 뒷수를 bx에 담는다고 가정하고 다음 코드를 보자.

;============ fibonacci without recursive call ==================

.model small
.stack 100h
.data

.code
main proc
  mov ax, @data
  mov ds, ax
 
  mov ax, 0
  mov bx, 1
  mov cx, 7
L1:
  add ax, bx
  add bx, ax
  loop L1
 
  xor ax, bx
  xor bx, ax
  xor ax, bx

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

  mov ax, 4C00h
  int 21h

main endp
end main



ax = 0, bx = 1로 시작했다. 대부분 피보나치 수열은 F(0) = 0, F(1) = 1 이라는 가정으로 시작한다. 이후 L1에서 ax에 bx를 더하면, 그 합이 ax에
리턴된다. 리턴된 합을 bx에 다시 더해주면 ax와 bx는 피보나치 수열을 차례대로 가지게 된다. xor 3콤보 명령은 단지 ax와 bx를 바꾸는 역할이며,
짝수번 연산할 때 (cx가 짝수일 경우)는 생략해도 되는 코드이다. cx에 7을 정해주고 7번의 루프를 돌았다. 피보나치 수열은 자릿수가 급속 성장할
수 있으므로 브레이크를 걸어주지 않으면 오버플로에 걸릴 수 있다.

피보나치 수는 상당히 흥미로운 무한 수열인데, 흡사 두 연인이 사랑놀이하는 것 처럼 생각할 수도 있다. 너가 나에게 키스를 한번 보내면, 나는 너에게
키스를 두번 보내고, 너가 나에게 키스를 세번 보내면 나는 너에게 키스를 다섯번 보낸다. 두 사람이 싸우는 것으로 비교하자면, 너가 날 한 대 때렸으니
나는 널 두대 때리고, 그러면 너는 나를 세대 때리겠지, 그러면 나는 너를 다섯대 때리겠다. 사랑놀이로 이해하든 싸움으로 이해하든 각자 몫이다.

어셈블리 책에서 ADD 명령과 맞물려서 나오는 명령이 INC 명령이다. 이 명령은 특정 연산자의 값을 1만큼 증가시킨다 (즉, 더한다). 우리는 보통 연산을
할때 1을 더하는 경우가 너무도 많기 때문에 add ax, 1 형식으로 더해주는 것보다 inc ax를 쓰면 더욱 간략하고 빠른 코드가 된다. inc 명령은 다른
연산자와 다르게 단항 연산자 (unary operand)를 취한다. 즉, inc r/m8 형식은 있어도 inc a, b 포맷은 없다.

add 명령으로 1을 더한 것과 inc로 1을 더한 것의 차이를 들자면 add는 6개의 플랙을 잠재적으로 세팅할 수 있지만, inc는 CF 플랙을 무시한다. CF 플랙은
자리 올림 또는 자리 내림 (carry/borrow)을 반영한다. 즉, add 명령은 자리 올림을 감안한 덧셈이며, inc는 자리 올림과는 무관한 덧셈이다. 예로 AX에
FFFF라는 무지막지하게 거대한 (16비트 레벨에선 가장 큰 수) 수를 입력하고 1을 add로 더한다면 더 이상 담을 수 없을 만큼 큰 수라는 신호로 CF 플랙을
토글하고 0을 리턴한다. 하지만, inc로 더하면 담을 수 있건 말건 신경쓰지 않는다. 이 아이디어는 어디에 적용할 수 있을까 ? 일단 테스트 코드로 이륵
확인하자.  

;============ add vs inc tester ==================

.model small
.stack 100h
.data
prompt db "Carry Generated"
error db "No Carry"
.code
main proc
  mov ax, @data
  mov ds, ax
 
L1: 
  mov ax, 0FFFFh               ; 테스트 값
  add ax, 1
  jc L3
 
L2: 
  mov bx, 0FFFFh               ; 테스트 값
  inc bx
  jc L4
 
  jmp Exit
 
L3:
  mov ah, 40h
  mov bx, 1
  mov cx, sizeof prompt
  lea dx, prompt
  int 21h
  jmp L2
 
L4:
  mov ah, 40h
  mov bx, 1
  mov cx, sizeof error
  lea dx, error
  int 21h
 

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

  mov ax, 4C00h
  int 21h

main endp
end main


L1은 add로 테스트했고 L2는 inc로 테스트했다. L1에서 캐리가 발생하면 L3로 점프하여 "Carry Generated" 메시지를 출력하고 L2로 돌아간다. L2에서
캐리가 발생하면 "No Carry"를 출력하고 종료한다. 하지만, 앞에서 얘기했듯이 L2는 inc 명령을 써서 캐리를 무시하므로 jc L4 명령이 무시되고,
이어지는 jmp Exit에 걸려 "No Carry"를 출력하지 않고 종료 루틴에 이른다. 즉, L4는 실행되지 않는다.

예로, AX = 0FFFFh (1111 1111 1111 1111b)일 경우 여기에 1을 더하면 -1 (또는 65535)에서 0으로 토글됨을 알려준다. 이는 ADD 명령일 경우 적용되지만,
INC 명령일 경우 CF에 반영하지 않는다. 즉, inc 명령은 부호 무시 (unsigned) 연산이라고 가정하고 덧셈을 하는 셈이된다. 7FFFh에 1을 더하는 경우도
마찬가지이다. 둘다 8000h로 만들지만, ADD는 CF를 토글하고 INC는 CF를 무시한다. AL이 7F/FFh 일때도 마찬가지다. 이쯤에서 유치한 질문을 해보자.
우리는 80h번 루프를 돌려고 한다. cl에 루프 카운터를 정해주고 7Fh까지 도달한 상태에서 한번 더 루프를 돈다면 add를 써야할까 inc를 써야할까 ?
둘다 80h로 증가시키기에 아무거나 써도 상관없다. 그렇다면 어느 경우 add를 쓰고 어느 경우 inc를 쓸까 ? 보통 add 명령보다 inc 명령이 인코딩되는
크기는 적고 속도는 빠르다고 알려졌다. 즉, 캐리와 아무 상관없다면 될 수 있음 inc를 쓰는게 좋지만, 캐리에 민감해야 할 경우라면 add를 쓰면된다.
그런 이유로 대부분의 루프 카운터에 inc를 쓴다. 이는 sub과 dec에도 비슷하게 적용된다.

캐리를 완전히 무시하는 덧셈 명령이 inc인 반면 캐리를 완전히 인정하는 덧셈 명령은 adc이다. adc는 add 명령과 포맷이 거의 흡사하며 mem + mem을
허용치 않는 점 까지 유사하다. "adc d, s" 포맷이며, d + s + CF이다. 여기서 CF는 0 또는 1이다. 다음중 캐리를 발생하는 것을 골라라고 유치한
질문을 한다면 몇번을 고를 것인가 ?


;============ Who's the Carrier ? ==================

.model small
.stack 100h
.data

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

L1: 
  mov al, 7Fh
  add al, 1                 ; 1
  clc
  mov al, 7Fh
  adc al, 1                 ; 2
  clc
  mov al, 7Fh
  inc al                    ; 3
  clc
  mov al, 0FFh
  add al, 1                 ; 4
  clc
  mov al, 0FFh
  adc al, 1                 ; 5
  clc
  mov al, 0FFh
  inc al                    ; 6
  clc

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

  mov ax, 4C00h
  int 21h

main endp
end main


7Fh -> 80h로의 증가는 add/adc/inc 모두 캐리를 발생치 않지만, FFh -> (1)00h로의 증가에서는 add/adc 두개가 캐리를 발생한다. 즉, 4번 5번이다.

덧셈 명령의 플랙 표는 이렇다:



곱셈은 덧셈의 반복 연산이라는 점을 생각하면, 우리는 상대적으로 느린 곱셈 명령 (Pentium 기준 10 클럭 소요)을 상대적으로 빠른 덧셈 명령 (1~3 클럭
소요)으로 일부 대체할 수 있다. 예로, ax = 3이라고 하자.

mov ax, 3                 ; ax = bx = cx = dx = 3
mov bx, ax
mov cx, ax
mov dx, cx

add ax, ax                ; ax = ax + ax = 2 * ax

add bx, bx                ; bx = 2 * bx
add bx, bx                ; bx = 4 * bx
add bx, bx                ; bx = 8 * bx

add cx, cx                ; cx = 2 * cx
add cx, cx                ; cx = 4 * cx
add cx, cx                ; cx = 8 * cx
add cx, dx                ; cx = 9 * cx

또한 2를 곱한다는 것 (mul reg8/16/32는 대략 10-11클럭 소요)보다 shl로 1비트 쉬프트 연산해주면 (펜티엄 기준 shl/shr reg8/16/32, 1 명령은 1클럭
소요) 더 빠르게 연산할 수 있다.

mov si, 3
mov di, si                ; 곱셈 인자 (mul factor)를 백업해두고

shl si, 1
shl si, 1
shl si, 1                 ; si = 8 * si
add si, di                ; si = 9 * si

하지만 이는 add reg8/16/32, reg8/16/32 포맷 명령이 덧셈의 속도가 향상되어 펜티엄 기준으로 전부 1 클럭을 소요하여 별 차이가 없을 것이다. 그럼에도
불구하고 mul reg8/16/32 포맷은 대략 10~11 클럭 소요되므로 mov bx, 9, mul bx 보다는 대략 두배 정도 (1 + 11 = 12 클럭) 빠를 것이다.


캐리는 지난 시간에서 다뤘듯이 제일 왼쪽 비트가 자리 올림 또는 자리 내림이 일어날 때 발생하므로, unsigned 연산일 경우 오버플로가 된다. 만약,

AX    + BX

9999h + 8888h = ?

이 두 수를 unsgined 라고 가정하고 더한다면,

  1001 1001 1001 1001
  1000 1000 1000 1000
--------------------
1 0010 0010 0010 0001

캐리가 발생하고, 16비트 레지스터는 16비트 밖에 담지 못하므로, 가장 왼쪽 1은 CF에 반영되고, 나머지 비트 (2221h)만 ax에 담기게 되고, 오버플로가
발생함을 의미한다. 만약 이를 signed라고 인정하고 연산한다고 해도 오버플로가 발생하긴 마찬가지다. 비유를 들자면, 밥을 너무 많이 먹어 배탈난 셈이다.
그렇다면 배탈나기 전에 우리는 그만 먹어야 할 것이므로 이를 CF 플랙으로 테스트하여 분기 (JC)할 수 있다.

;============ unsgined addition overflow tester ==================

.model small
.stack 100h
.data
X dw 9999h
Y dw 8888h
Z DW ?
prompt db "Overflow generated"

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

L1:
  mov ax, X                     ; X 로드
 
L2:
  mov bx, Y                     ; Y 로드
  add ax, bx                    ; 배에 밥을 더한다
  jc L3                         ; 그러기에 작작 먹으랬지
  jmp Exit
 
L3:
  mov ah, 40h
  mov bx, 1
  mov cx, sizeof prompt
  lea dx, prompt
  int 21h
  xor ax, ax                    ; 리턴 값 클리어
  jmp L2
 

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

  mov ax, 4C00h
  int 21h

main endp
end main

보다시피 L2에서 JC로 CF를 테스트하여 오버플로가 발생하면 출력 루틴으로 가되 오버플로가 발생치 않으면 종료 루틴으로 이르게 해줬다. 출력 루틴에서는
AX를 리셋해주고 다시 L2로 덧셈을 하게 해주었다. 만약 sigend 오버플로를 검출하려면, JC/JNC를 쓰지말고, JO/JNO를 쓰면된다. L2의 JC를 JO로 바꾸면,
이 두 수의 덧셈은 signed 수일지라도 오버플로를 발생한다 (CF = 자리 올림/내림, OF = 레지스터 리미트 초과). 여기서는 단순히 에러를 출력했지만,
오버플로가 발생한다면 출력 루틴 대신, "sub ax, bx" 등으로 했던 연산을 이전으로 되돌릴 수 있을 것이다.

CPU 명령 중 2 연산자를 필요로 하는 명령은 둘 중 하나는 레지스터여야한다. 즉, "명령 mem, mem" 형식은 오류이다. 대부분의 사칙 연산이나 데이터
이동 명령인 mov 등에서도 이 규칙은 적용된다. 반면 연산자가 하나만 있어도 되는 inc/dec 명령은 "명령 mem" 형식을 써도 오류가 아니다.

;============ mem & mem ==================

.model small
.stack 100h
.data
X dw 9999h
Y dw 8888h
Z DW ?

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

L1:
  mov ax, X                     ; X 로드
  mov bx, Y                     ; Y 로드
  add X, Y                      ; mem + mem 오류
  mov X, Y                      ; mem <- mem 오류
  inc X                         ; X = 9A 99 99 99 메모리 상태 -> 리틀 엔디언 99 99 99 9A
 

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

  mov ax, 4C00h
  int 21h

main endp
end main

보다시피 X, Y, Z는 모두 mem 연산자이다. 그러므로 add/mov 명령은 MASM 오류 (invalid instruction operands)이고 inc 명령은 오류가 아니다. 다른
어셈블러는 이를 어셈블러가 자체적으로 체크하여 "명령 reg, mem" 등의 형식으로 바꿔주어 오류를 피하기도 한다. 이런 오류를 피하려면 메모리 연산자를
레지스터로 로드하여 연산해야 한다. 예로, "add ax, Y". 이는 아주 상식적인 내용이지만, 나는 한때 이 오류의 원인을 몰라서 몇일을 고민한 적도 있다.

우리는 16비트 레지스터로 기껏해야 16비트 덧셈 밖에 못한다. 하지만, adc를 사용하여 더 큰 자릿수도 더할 수 있다. 나는 이를 복정밀 연산 (multiple
precision arithmetic)이라고 부른다. 이는 FPU를 사용한 단정밀 (Single precision), 배정밀 (Double precision), 배확장 (Extended double precision)
과는 전혀 별개의 연산이다.

X dword 12345678h
Y dowrd 87654321h

더블워드 X, Y 두 수를 16비트 레지스터로 더한다면 어떻게 해야할까 ? 포기할까 ? 몇몇 공학용 소프트웨어에서는 어마어마하게 큰 자릿수도 간단히
계산하던데, 그 원리가 뭘까 ? 바로 복정밀 연산에 해답이 있고, 복정밀 덧셈 연산의 키워드는 adc이다. 이를 해결하기 전에 먼저 바이트 레지스터로
워드 데이터를 더하는 연습먼저 해야할 것 같다. 메모리는 워드 데이터를 리틀 엔디언 방식으로 저장한다. 즉, 바이트 단위로 뒤집어서 저장한다.
예로, 1234h라고 워드를 선언하면 실제 메모리 상엔 34 12 식으로 보인다. 그러므로 12h를 액세스하려면, 메모리 주소 + 1을 해주고, 34h를 액세스
하려면 그냥 메모리 주소로 정해준다. 코드로 복정밀 연산을 해보자.

;============ add word vals using byte regs ==================

.model small
.stack 100h
.data
AA dw 1234h                   ; 34 12    
BB dw 5678h                   ; 78 56
CC dw ?                       ; 00 00  -> AC 68

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

L1:
  mov al, byte ptr AA         ; al = 34h
  add al, byte ptr BB         ; al = 34h + 78h = ACh
  mov byte ptr CC, al         ; CC = ?? ACh
 
  mov al, byte ptr AA+1       ; al = 12h
  adc al, byte ptr BB+1       ; al = 12h + 56h = 68h + (C)
  mov byte ptr CC+1, al       ; CC = 68ACh

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

  mov ax, 4C00h
  int 21h

main endp
end main


보다시피 워드 값 두개 (AA, BB)를 바이트 레지스터인 al로 액세스하여 더한 후 결과를 다른 워드 값 (CC)에 저장했다. 하위 바이트인 34h와 78h를
더할땐 add 명령으로 더했지만, 이 두수를 더하면 캐리 (자리 올림)가 발생할 지 모르므로 상위 바이트 덧셈에 이를 반영하여 12h에 56h를 더할 때
만약 캐리가 있으면 캐리마저 함께 더하라고 해줬다. 캐리가 없으면 adc 명령은 상위 바이트에 0을 더할 뿐이다. 데이터 섹션에 있는 주석은 메모리에
저장될 때를 의미한다. 결과물로 메모리엔 AC 68로 저장되지만 실제로는 68AC이다. 메모리에 바이트 역순 (reverse byte)으로 저장되는 것을 리틀
엔디언 (little endian) 방식이라고 하며 아마 익히 들어봤을 것이다.

이를 응용하여 더블워드 데이터를 워드 ax, 아니 바이트 레지스터인 al로 더해보자.

;============ add dword vals using byte regs ==================

.model small
.stack 100h
.data
AA dd 12345678h                   ; 78 56 34 12    
BB dd 87654321h                   ; 21 43 65 87
CC dd ?                           ; 00 00 00 00
                                  ; 99 99 99 99

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

L1:
  mov al, byte ptr AA         ; al = 78
  add al, byte ptr BB         ; al = 78 + 21
  mov byte ptr CC, al         ; CC = 99
 
  mov al, byte ptr AA+1       ; al = 56
  adc al, byte ptr BB+1       ; al = 56 + 43 + (C)
  mov byte ptr CC+1, al       ; CC = 99
 
  mov al, byte ptr AA+2       ; al = 34
  adc al, byte ptr BB+2       ; al = 34 + 65 + (C)
  mov byte ptr CC+2, al       ; CC = 99
 
  mov al, byte ptr AA+3       ; al = 12
  adc al, byte ptr BB+3       ; al = 12 + 87 + (C)
  mov byte ptr CC+3, al       ; CC = 99

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

  mov ax, 4C00h
  int 21h

main endp
end main


더하다보니 전부 99가 되어버려 캐리가 반영되지 않았지만, 테스트 값은 중요치 않다. 다만 두 덧셈 결과를 CC에 저장할 때 오버플로만 조심하면 된다.
여튼 성공적으로 더블워드 값을 바이트 레지스터로 더했음은 분명하다. 이 테스트 값을 바꿔서 캐리가 일어날 때도 적용되는지 확인하라. 룰은 이렇다.
가장 하위 바이트 (LSB)는 그냥 add로 더하고 나머지 바이트는 adc로 더해주면 된다. 연습 차원에서 Double Quadword (128비트) 덧셈에 도전해보는 것은
어떤가 ? 이 연산은 16/32비트 레지스터를 사용할때도 적용된다. 다만 연산자 뒤에 더해주는 숫자가 16비트 레지스터를 사용할 경우 2의 배수로 (0, 2,
4, 8,..)  액세스하고, 32비트 레지스터이면 4의 배수로 (0, 4, 8, 12) 메모리를 액세스하는 점이 다를 뿐이다.

댓글 없음:

댓글 쓰기

블로그 보관함