2010년 1월 10일 일요일

7장 - 이진 세상으로 - 빼기

7장 - 이진 세상으로 - 빼기


앞장에서 덧셈의 기본을 익혔다면 이번 장은 쉽게 느껴질 것이다. 덧셈의 양면과 같은 뺄셈이다. 뺄셈 관련 명령은 SBB/SUB/DEC이며 플랙표는 이렇다.

<그림>


앞의 덧셈 연산과 같다. 기본 포맷, 클럭, 작동 코드는 이렇다.

기본 포맷     클럭 (Pentium)              작동 유사 코드

"SBB D, S"    sbb r8/16/32, r8/16/32 : 1  D <- (D -(S + C))
"SUB D, S"    sub r8/16/32, r8/16/32 : 1  D <- (D - S)
"DEC D"       dec r8/16/32 : 1            D <- D - 1

(D: destination operand, S: source operand, r8/16/32 = 8-bits, 16-bits, 32-bits register)


이중 역시 SUB이 대표적으로 사용되는 명령이지만, 캐리를 신경쓰지 않는 루프 카운터로는 DEC가 무난하며, SBB는 복정밀 연산 등의 특수한 용도로
사용된다. SUB 명령부터 살펴보자. 이 명령은 앞의 D 값에서 뒤의 S 값을 빼서 그 결과를 다시 D에 되먹인다. 대부분의 사칙 연산은 R(E)AX(AL)을
대상 연산자로 사용하면 다른 레지스터보다 상대적으로 빠르게 수행되며 인코딩되는 코드의 크기도 절약된다. add와 마찬가지로 mem - mem 형식은
허용치 않는다. 또한 imm - r/m/i 형식도 허용치 않는다. 즉, 빼기 전에 레지스터를 사용하는게 무난하다.

만약 S < D 이면, 연산 결과 오버플로가 발생하며, 오버플로는 signed 연산일 경우 OF로, unsigned 연산일 경우 CF로 측정 가능하다. 두 연산 모두
signed 결과라 가정하고 SF에 반영한다.

이 내용은 CPU 매뉴얼에 나와있는 교과서적인 문구이다. 어떤 이는 "sub ax, ax" 형식으로 특정 레지스터를 초기화할 때 사용하기도 한다.
이진 뺄셈은 중간에 0이나 1이 많이 들어가면 계산이 어렵다. 앞자리를 계속 추적해야하기 때문이다.

<그림>




만약 이 계산이 어려우면, 자신만의 방법을 동원하라. 나는 덧셈은 왼손 엄지 손가락으로 캐리를 표시하고 뺄셈은 오른손 엄지 손가락으로 바로우를
표현하기도한다. 뺄셈의 경우 1000 - 0111의 경우 0001이 되듯이 반대로 0111 - 1000 = (B) 1111가 되듯이, 중간에 같은 비트가 여러개 중복되면
계산이 어려워지니 캐리/바로우가 토글되는 시점을 잘 감지하라.

간혹, 스택을 할당할 때도 add/sub 명령을 쓰기도 한다. 리버스 엔지니어링을 해본 사람이라면, 다음처럼 상투적인 함수 프롤로그/에필로그 코드를
본적 있을 것이다.


push bp         ; 호출자의 BP를 저장하고
mov bp, sp      ; 스택 프레임을 구성하여
sub sp, 10h     ; 10h (16 바이트, 4 * Dword 크기, 또는 8 * word 크기만큼 스택을 할당하고)
mov [bp-2], ax  ; ax 값을 스택에 지역 변수로 생성하고 (스택은 거꾸로 자라므로 bp-2 주소가 가장 먼저 생성한 지역 변수이다)
...
mov sp, bp      ; 스택 프레임을 해제하고
pop bp          ; 호출자의 BP를 복원하여
ret             ; 호출자로 리턴

애석하게도 외우지 마라고 해도 자주 쓰다보면 외워지기도 하지만 이 정도 코드는 암기해줄 필요가 있다. 왜냐하면 대부분의 고급 언어와의 인터페이싱을
위해 이 암기용 코드를 자주 사용하기 때문이다. 이왕 외우게 될 거라면 debug로 한번 따라가보자. 이 내용은 32비트/64비트 환경으로 넘어가더라도
중요하므로 꼭 한번쯤 따라해보는 것이 좋다. 이미 2장에서 디버그 기본 구성을 마쳤다면 바로 따라갈 수 있지만, 그렇지 않다면 2장을 다시 참조하라.
사실 이 시점에서 스택을 다루는 것은 다소 무리가 있으므로 약간 집중하고 트레이싱할 준비를 하자.

우리는 메인에서 ax 값을 정해주고 이를 함수가 아닌 레이블로 프롤로그 에필로그를 구성하여 이를 따라가 스택에 저장된 내용을 bx에 복사해 오는 것이
목표이다. 즉,

mov ax, 1234h
push ax
add bx, 1

이렇게 테스트 코드를 만들어 bx가 1235h가 되게 만드는 것이다. 즉, debug.exe를 사용하여 기본적인 스택 트레이싱을 배워보자는 것이다.

;============ prolog & epilog test code ==================

.model small
.stack 100h               ; sp = 100h
.data


.code
main proc
  mov ax, @data
  mov ds, ax
 
  mov ax, 1234h
  push ax                 ; sp = sp - 2 = 0FEh, 스택에 데이터 기록
  add bx, 1

L1:
  push bp                 ; sp = sp - 2 = 0FCh
  mov bp, sp              ; bp = sp
  sub bp, 2               ; bp = bp - 2 = 0FAh
 
  mov bx, [bp+4]          ;
 
  mov sp, bp
  pop bp
  ret

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

  mov ax, 4C00h
  int 21h

main endp
end main

대부분의 책에서 "스택은 거꾸로 자란다" 이런 문구 한번쯤은 봤을 것이다. 틀린 말은 아니지만, 코딩할땐 별로 도움 안되는 상투적 문구이다. 우리는
매번 .stack 100h라는 코드를 넣고 코딩해왔다. 이 지시어의 의미는 우리의 프로그램이 sp = 100h로 자동으로 세팅이 된 상태로 시작한다는 뜻이다.
그리고 0~100h (256 바이트)를 프로그램이 사용하는 스택으로 할당해주는 지시어이다. 이 말이 더 어려운가 ?

책에서 배운 "스택은 거꾸로 자란다"를 이제 이렇게 해석해보자. 우리는 글을 쓸 때 보통 왼쪽에서 오른쪽으로 (LTR, Left to Right) 써나간다. 보통
우리가 메모리에 변수를 a, b 두개를 선언하면, a, b 이런식으로 메모리 상에 써진다고 비유할 수 있다. 그리고 왼쪽이 낮은 주소이다. 이 말은
이해하겠는가 ? 그렇다면 스택은 반대로 오른쪽에서 왼쪽으로 데이터를 써나간다. 이렇게 비유하면 어려운가 ?

좋다. 디버그를 실행하였다면, 위의 소스 코드로 컴파일/링크를 거친 실행 파일을 명령 프롬프트로 로드하자. (예로, debug test.exe) 그러면 익숙한 (?)
- 디버그 프롬프트가 우리를 반겨줄 것이다. 디버그 세션으로 들어감과 동시에 프롬프트는 -로 바뀐다 (이미 알고 있을 것이다). -r 명령으로 (-는 빼고)
레지스터 내용을 확인하자. -r 명령은 debug.exe로 디버깅할 때 처음으로 쓰는 명령이라고 생각하면 된다.

디버그 세션에서 제일 먼저 시작되는 명령은 mov ax, @data이다. 이는 데이터 세그먼트의 주소를 얻어오는 지시어이며, 이번 장 주제와는 거리가 멀다.
그러므로 -t 명령으로 무시하고 넘겨버리자 (-t 명령은 무엇인지 알 것이다. 한 명령 진행을 의미한다. -는 빼고 입력하라.) 그러면 다음 명령인 mov ds,
ax에 이를 것이다. 이 또한 현 시점에서 의미없으므로 -t로 넘겨버리자.

그러면 XXXX:0005 mov ax, 1234h를 가르킬 것이다. 이를 -t로 진행시키고, 이어지는 push ax 도 -t로 진행시키자. push 명령은 스택 포인터를 2만큼
(32비트 스택 포인터는 4만큼, 64비트 스택 포인터는 8만큼) 감소시키고 그 감소된 주소에 값을 넣는 명령이다. 그러므로 스택 포인터는 100h - 2 =
00FEh를 가르킨다. 우리는 이 시점에서 스택 포인터 값을 읽을 수 있다. "-d ss:00FE" 명령으로 2가 감소된 스택 포인터 값을 덤프하면 그 내용이
34 12 (리틀 엔디언)로 보일 것이다. 즉, 초보적인 스택 덤프를 한 것이다. 이어지는 add bx, 1 명령은 샘플 코드이므로 별 의미없으므로 -t로
진행시키고, L1으로 넘어가자.

L1에 있는 현재 우리의 SP는 00FEh를 가르킬 것이며 이어지는 명령은 push bp로 스택 포인터 값을 2 떨어뜨린 주소에 BP를 복사한다. 보통 에러가 없는 한
BP 값은 유저가 맘대로 쓰게끔 (32비트 프로그래밍에선 그렇지 않지만) 00으로 설정되어있다. 이 시점에서 "-d ss:00FC"를 입력하여 스택을 다시 덤프하면,
00 00 34 12라고 보일 것이다. 즉, FC = 00, FD = 00, FE = 34, FF = 12h이다. 이처럼 BP 또한 스택에 푸시하는 이유는 BP로 스택을 액세스하기 위함이다.
왜인가 ? 스택은 전역으로 쓰이기 때문에, 특정 서브 루틴이 이를 액세스하면 SP 값이 수정될 수 있으므로 미리 안전하게 BP로 액세스하자는 아이디어를
적용하였기 때문이다. 즉, BP는 SP의 백업본쯤에 해당한다.

이제 mov bp, sp를 -t 명령으로 진행하면, sp값을 bp에 그대로 복사한다. 현 시점에서 BP = SP = 00FC가 되었다. 즉, 스택 포인터와 베이스 포인터가
같아졌으므로 이제 프레임 단위로 스택을 액세스할 수 있다. 즉, 다른 서브 루틴에서도 스택 프레임을 만들어서 SP를 가상으로 액세스할 수 있다는 뜻이다.

이어지는 sub bp, 2 명령도 -t로 진행시키면, sp는 변치 않지만, bp는 2가 감소하여 SP = 00FC, BP = 00FAh가 된다. 즉, BP가 2바이트, 즉, 한 프레임
앞서간다는 뜻이다. 이를 빨간펜을 하나 더 구해서 밑줄 긋는 것으로 생각해보라. SP는 검은펜, BP는 빨간펜. 그리고 그 밑줄쳐진 대상이 한 프레임, 즉,
2바이트 스택 데이터이고, 고급 언어에서 스택에 선언된 첫 지역 변수에 해당한다. 이 시점에서 "d ss:00F9"라고 하면, 다음과 같을 것이다:

F9 FA       FB FC         FD FE     FF 100h
1A C3       13 00         00 34     12 17
-----       ------      -------   -------
현재 BP     현재 SP     백업된 BP  처음 푸시한 값
sub bp, 2   mov bp, sp   push bp    push ax

이 그림을 잘 보면, 1 바이트씩 왼쪽으로 전진하였음을 알 것이다. 그리고 왜 "d ss:00FA"를 하지 않고 "d ss:00F9"으로 덤프했는지 의심스러울
것이다. 이는 push/pop 명령이 100h를 기준으로 16비트 스택이므로 2바이트씩 증감하기 때문이며, 데이터를 "쓰기전에 먼저 스택 포인터를 감소"하기
때문이다. 그러므로 100h는 SP가 가르키는 쓰레기 값이며, FF부터 실제로 스택 기록이 일어난다는 뜻이다. F9 또한 현 시점에서 쓰레기 값이다. 
즉, FA FB - FC FD - FE FF가 실제 내용에 관여하는 스택 주소이다. 아주 기본적인 내용이고, 스택이 워낙에 함수에 밀접하게 관여하다보니 이를
자세히 설명한 책이 시중에 많지만, 이를 이해 못하는 사람들이 의외로 많다. 이걸 기억하자. "쓰기전에 감소", "로드한 후에 증가", "쓰는 것은
push", "로드하는 것은 pop".

이제 우리는 BP가 SP보다 한 스텝 앞서가므로, FA에서 4를 더한 주소를 (즉, 오른쪽 주소)를 mov bx, [bp+4]로 얻어내면 FEh의 값이 BX로 복사된다.
왜 이 주소인지 계산되는가 ? 즉, 우리가 처음에 push ax로 푸시한 1234는 지역 변수 영역 내에 있지 않다는 소리다. 다른 말로, 전역 스택 변수를
서브 함수로 액세스하는 마술을 부린 셈이다. mov bx, [bp+4]를 -t 명령으로 트레이싱하면, 이 값이 BX에 복사될 것이다.

그리고 이어지는 mov sp, bp는 스택 포인터를 BP로 복사하고 (SP=BP= 00FAh), 이어지는 POP 명령은 우측으로 두칸 전진 (00FCh)하여, 이어지는 RET
명령 또한 두칸 전진하여 (00FEh)우리 프로그램의 첫 명령인,   mov ax, @data 를 다시 실행한다. -t 명령으로 트레이싱해보라. 그 결과 sp의 스택은
100h에서 2씩 계속 감소하여 결국 언젠가는 스택 언더런에 이르게 될 것이다. 이를 방지하려면 ret 명령 앞에 add sp, 2를 넣어줘야 스택 균형이
이그러지지 않지만, 여기서는 무시하고 위의 코드를 설명하는데 초점을 맞췄다. 스택은 프로그램이 관리하므로 운영체제는 스택이 망가지던 말던
아무 관여하지 않으니 이 얼마나 축복인가 ? 즉, 스택 터널링 (stack tunneling)이 통하니...

엉뚱한 주제로 말꼬리가 길어진건가 ? 아니면 아주 귀중한 정보를 제공한 것인가 ?

간략히 스택과 프롤로그 / 에필로그 코드의 작동 방식을 트레이싱해봤으니 이를 응용해보자. 지난 시간에 덧셈에 관해 공부하면서 십진 출력 루틴이
없어서 마치 긴 터널을 등불없이 걷는 기분이 들었을지 모르겠다. 이제 우리는 AX에 값을 넘겨주면 이를 십진수로 변환해서 메모리에 저장하는 간략한
함수를 만들 것이다. 이미 3장에서 십진 변환은 해봤으므로 여기서 변환 루틴 자체를 다시 설명할 필요는 없는것 같다.

;============ decimal dump subroutine call ==================

.model small
.stack 100h               ; sp = 100h
.data
result db 5 dup (30h)

.code
main proc
  mov ax, @data
  mov ds, ax
 
  mov ax, 0FFFFh        ; 테스트 값 (가장 큰 16비트 2진수)

  call decdump          ; ax를 변환해서 result에 저장한다

Exit:
  mov ah, 0
  int 16h

  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

대략 코드를 살펴보자. 먼저 main은 볼것도 없을 것이다. 단순히 ax에 테스트값을 주고 call로 서브루틴에 잡다한 변환 루틴은 떠넘겼다. 우리의 막강
십진 변환 루틴은 이미 3장에서 설명했으므로 별로 설명할 것도 없다. 프롤로그 코드와 에필로그 코드는 상투적인 코드고, 그 사이에 push/pop의 역할은
변경할 가능성이 있는 레지스터는 미리 푸시하고 역순으로 팝한다. 이때 pushf/popf로 플랙도 백업해도 된다. .286부터는 pusha/popa를 쓸 수 있지만,
여기서는 그냥 단순히 하나씩 백업/복원해주었다. 또한 우리는 전역 변수를 액세스하여 쓰는 방법이 편하므로 서브 루틴에서 add sp, 2 등의 지역 변수
저장 공간을 할당하지는 않았다.  


지난 시간에 누적 합인 피보나치 수열을 구해봤으니 이번엔 누적 뺄셈도 해보자. 우리는 ax에 초기값을 정해주고, 이를 0, 1, 2, 3, 4, 5로 차근차근 빼서
노는 레지스터 (spare registers)를 사용해 차근 차근 뺀 값을 되먹일 (feedback) 것이다. 즉, ax = 초기값, bx = ax - 1, cx = bx - 2, dx = cx - 3,
si = dx - 4, di = si - 5. 무엇을 해야할지 머리에 그려지는가 ?

사실 레지스터는 프로그램이 처음 시작되면 특정한 운영체제에서 정해준 값을 가진다. 이를 전문 용어로 컨텍스트 (context)라고 한다. 보통 범용
레지스터는 0으로 초기화된다. 하지만, 시스템에따라 이는 변할수 있다. 레지스터는 또한 언제라도 리프레시 (refresh)가 가능한 CPU 내장 메모리이다.
그러므로 어떤 값을 여러 레지스터에 저장할 필요도 없이 단 하나의 레지스터만 계속 저장/복원하면 된다. 프로그래밍하다보면 간혹 어떤 레지스터는
특정한 값을 유지해야할 경우가 생기기 마련이다. 그러니 6개의 레지스터가 계속 특정한 값을 가지게끔 되먹여줘 루프를 돌때마다 새로 갱신하게
만들어 주는 것도 의미가 있을 것이다. 물론 레지스터 값을 특정하게 유지하려면 스택에 push/pop해도 되지만, 스택처럼 작동하게끔 레지스터를
이용해보자는 것이다. "머리가 미련하면 손발이 고생한다" 이 말 또한 어셈블리 프로그래밍에서 적용된다. 레지스터를 맘껏 컨트롤하지 못하면
CPU에겐 외부 장치에 불과한 느려터진 메모리 스택이라도 이용해야하지 않겠는가 ?

;============ dump feedback subtraction ==================

.model small
.stack 100h

.data
result db 5 dup (30h), 09h

.code
main proc
  mov ax, @data
  mov ds, ax
 
  mov ax, 0FFFFh            ; 테스트 값 = 가장 큰 16비트 이진수
  call decdump
  jmp L7
 
L1:
  cmp ax, 0
  jc E1
  mov bx, ax                ; bx로 되먹임
  sub bx, 1
  xchg ax, bx               ; ax <-> bx 스왑 (bx를 출력)

  cmp bx, 5
  jbe exit
  call decdump

L2:
  cmp ax, 0
  jc E2
  mov cx, ax                ; cx로 되먹임
  sub cx, 2
  xchg ax, cx               ; ax <-> cx 스왑 (cx를 출력)

  cmp cx, 5
  jbe exit
  call decdump

L3:
  cmp ax, 0
  jc E3
  mov dx, ax                ; dx로 되먹임
  sub dx, 3
  xchg ax, dx               ; ax <-> dx 스왑 (dx를 출력)

  cmp dx, 5
  jbe exit
  call decdump

L4:
  cmp ax, 0
  jc E4
  mov si, ax                ; si로 되먹임
  sub si, 4
  xchg ax, si               ; ax <-> si 스왑 (si를 출력)

  cmp si, 5
  jbe exit
  call decdump

L5:
  cmp ax, 0
  jc E5
  mov di, ax                ; di로 되먹임
  sub di, 5
  xchg ax, di               ; ax <-> di 스왑 (di를 출력)

  cmp di, 5
  jbe exit
  call decdump

L6:
  cmp ax, 5
  jbe exit

L7:
  jmp L1

E1:
  mov ax, 0FFFFh           ; 형식적 에러처리 (리셋하고 종료)
  jmp exit
E2:
  add bx, 2
  jmp exit
E3:
  add cx, 3
  jmp exit
E4:
  add dx, 4
  jmp exit
E5:
  add si, 5
  jmp exit
E6:

exit:
  mov ah, 0
  int 16h                     ; 지연후 종료

  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

보다시피 코드는 길어도 중복 코드가 대부분이다. 이를 분석해보자.

result db 5 dup (30h), 09h

이는 탭 (09h) 문자로 출력에 사용될 스트링을 구분했다. 보통 엔터 (ODh, 0Ah)로 출력해도 무관하지만, 5개 문자마다 엔터로 구분하면 급속히 화면이
올라가므로 도저히 무슨 짓을 하는지 짐작조차 하기 어렵기 때문에 살짝 변형을 준것에 불과하다. 공백 (20h)로 구분하면 더 많은 내용을 볼 수 있을
것이다.

  mov ax, 0FFFFh            ; 테스트 값 = 가장 큰 16비트 이진수
  call decdump
  jmp L7
 
이는 초기값을 설정해주고 화면에 한번 덤프하고 L7로 분기하는 프롬프트 출력 루틴이다. L7는 L1으로 점프하는 한줄 코드 뿐이다.

L1:
  cmp ax, 0
  jc E1
  mov bx, ax                ; bx로 되먹임
  sub bx, 1
  xchg ax, bx               ; ax <-> bx 스왑 (bx를 출력)

  cmp bx, 5
  jbe exit
  call decdump
 
우리는 이 코드를 분석하면 다른 5개 블럭의 작동방식도 거져 얻게된다. 먼저 ax가 0인지 형식적으로 체크해주고 만약 0보다 작으면 에러 루틴으로
이르지만, 사실 우리가 지금 테스트에 사용한 코드는 unsigned라고 가정했기에 이는 무의미한 형식적 체크에 불과하다. ungigned 정수 (즉 양수)에서
계속 뺄셈을 하므로 물론 언젠가는 음수가 되겠지만, 음수가 되는 그 순간 더큰 양수로 변신한다. unsigned이기 때문이다.

되먹임은 단순히 mov로 복사해서 sub 명령으로 각 레이블마다 1 더 적은 값으로 빼줬다. 그리고 우리의 출력 함수가 ax를 기반으로 출력하므로 뺄셈의
결과를 출력 인자로 넘겨주려 xchg로 바꿔줬다. 그리고 5인지를 체크하여 작으면 종료루틴으로 분기하고 그렇지 않으면 화면에 덤프했다.

사실 65535에서 시작해서 1, 2, 3, 4, 5를 빼나가다보면 어느 시점에 브레이크를 걸어야할지 현재로는 모른다.

4 - 5 = -1 = 65535

unsigned라고 가정하고 연산했으므로, 형식상 캐리를 검출하면 되지만, 어느 시점에 캐리가 발생할 지 현재로는 알 수 없다. 그렇다면 무한 루프에
빠지게 된다는 소린데, 이를 오버플로로 검출한다고 해도 마찬가지다. 즉 우리는 언제 캐리나 오버플로가 발생할지 모르므로 know-when의 문제에
빠지게 된다. 그렇다면 한 명령씩 덤프해서 추적해야 하지만, 이 또한 기껏해야 15를 빼주는 것에 불과하므로 수천번을 트레이싱해야 한다는 얘기다.
65535에서 1, 2, 3, 4, 5를 차례로 빼가다보면 무엇에서 무엇을 뺐을 때 음수가 되는지 당신은 아는가 ?

나는 그걸 몰라서 억지로 cmp xx, 5 식으로 뺀 결과가 5보다 작거나 같을 때 종료하게 했다. 그 결과 5가 되는 순간 브레이크가 걸렸고, cmp ax, 0은
무시되었다. 왜 상수 5를 뺐는지는 그냥 1, 2, 3, 4, 5 를 차근차근 빼다보면 언젠가는 5 근처에서 뺄셈을 할 것이며, 단순히 그 시점에 브레이크를
걸었을 뿐이다. 괜히 엉뚱한 걸로 머리 혹사시키지 않고 대충작성했다고 날 원망하지 말라. 브레이크 걸 시점은 당신의 몫 아닌가 ? 출력 루틴은 이미
앞에서 설명했으니 중요치 않고 전반적으로 살펴보면 마치 고급 언어에서 switch-case 문처럼 작동한다는 것을 눈치챘는가 ?

이를 실행시키면 5를 표현할 때 11115식으로 표현하는 버그가 있다. 이는 엄밀히 말해서 버그가 아니라 기능상 미비한 점 (mis-feature)이다.
왜인지는 알 것이다. 자릿수가 변경되더라도 무시하고 앞에 덤프한 것을 되쎃기 때문이다. 이를 해결하려면 현시점에서 코드만 더 길이지므로
생략했다. 이미 충분히 길어졌기 때문이다. 컨셉 자체는 단순하기 그지없으니, 관심있으면 직접 수정해보라.


뺄셈을 가장 잘 활용한 예를 들라면 나는 유클리드의 알고리즘 (Euclid's Algorithm)을 들 수 있다. 2천년도 더된 이 그리스 수학자가 아직도 사람들의
머리에 남는 이유는 아마도 깊은 사고를 통하여 단순 명쾌한 이론을 제시해서일 것이다. 옛날이나 지금이나 작은 것이 아름답다는 미덕은 통하나보다.
유클리드의 알고리즘은 아마 고급 언어로 프로그래밍해본 사람은 한 두번쯤은 접했을 만한 두 수의 최대 공약수를 구하는 단순한 방법이다. 2천년이
지난 지금에도 유클리드가 언급되는 이유는 이 최대 공약수 (GCD)를 "일반" 사람들과는 다르게 뺄셈으로 구하자는 아이디어였기 때문이다.

가만히 생각해보면 나눗셈은 반복 뺄셈과 깊은 연관이 있다. 추상적인 개념이라서 우리는 비유로 이해하자. 길동이는 심심하면 놀다가 가끔 떡방앗집을
들락거렸다. 떡집 주인 아저씨가 일하느라 바쁘다보니 몰래 방앗간 구석에 숨어들어가 떡을 조금씩 떼어먹는 습관이 있었다. 길동이는 떡을 떼어먹을때
주인 아저씨에게 걸리면 안되니 모서리 부분만 살짝 떼어먹었다. 주인 아저씨는 얼핏보고 일하느라 누가 떼어먹는지도 모르고 있었다. 조금 더 떼어먹으니
원래의 네모난 정사각형 모양의 시루떡이 마름모꼴로 변했다. 길동이는 혹시 아저씨가 눈치챌까봐 마름모 모양의 시루떡판을 살짝 돌려서 네모난 척
속였다. 주인 아저씨는 별 의심하지 않고 바쁘게 일하고 있었다. 길동이는 이번엔 욕심을 더 부려 마름모 모양의 시루떡에서 절반을 확 떼어먹고 시루떡을
세모꼴로 만들어버렸다. 주인 아저씨는 평소와는 너무도 다른 시루떡의 모양에 이번에는 속지 않았다. 비유가 신통치 않으면 요즘 장안의 화제인 쥐가
치즈를 갉아먹는 걸로 알아서 비유하던지 해보라.

시루떡하고 유클리드의 알고리즘과 무슨 관련이 있나 ? 유클리드의 알고리즘은 두 수의 최대 공약수를 구하는 알고리즘인데 큰수에서 작은수를 반복으로
빼서 구하는 원리이다. 위의 비유에서 시루떡에서 조금 떼먹었다고 시루떡이 치즈가 되지는 않는다. 즉, 원래의 시루떡이나 조금 떼어먹어서 작아진
시루떡이나 시루떡이긴 매한가지다. 다른 비유를 들자면 사람이나 짐승이나 원자로 구성되긴 마찬가지다는 소리다.

1Km (1000미터) 짜리 줄자와 10m 짜리 줄자가 있으면 1000 - 10을 뺀 990m 또한 1Km 줄자와 10m 줄자에 모두 포함된 원자 레벨 단위라고 생각할 수 있다.
다른 말로 990 - 10을 뺀 980m 또한 10m 줄자로 잴수 있다는 소리다. 그런 식으로 계속 빼나가다보면 결국 두 수가 10, 10m로 같아지며 이 같아지는
수가 두 수의 최대 공약수가 된다.

그런데 이젠 3m 짜리 줄자와 55m 줄자가 있어 이 두 수의 최대 공약수를 구한다고 하자. 같아질 때까지 반복으로 큰 수에서 작은 수를 빼나가다보면,
55, 52, 49, 46, 43, 40, 37, 34, 31, 28, 25, 22, 19, 16, 13, 10, 7, 4에까지 이른다. 4에서 3을 빼면 1이 되고 큰 수와 작은 수가 바꿔지며, 결국
남겨지는 두 수가 1, 3이며 두 수가 같지 않으므로 작은 수를 취한다. 결국 큰 수에서 작은 수를 무한히 빼다보면 언젠가는 음수가 되니 (즉 시루떡을
모두 먹어치운 경우), 빼고 남은 수가 0이되면 더 이상 뺄셈을 하지 않는다 (즉, 0이 되기 전까지만 떼어먹고 가장 작은 부스러기 1은 남겨둬야 무늬라도
시루떡이라고 부를 수 있지 않겠는가 ?)

이를 유사코드로 비유하면 이렇다.

GCD (원래떡, 남은떡)
  if (원래떡 = 0)
    ret 남은떡
   
  loop (남은떡 not Zero)
    if (원래떡 > 남은떡)
      원래떡 = 원래떡 - 남은떡        ; 떼어먹는다
    else
      남은떡 = 남은떡 - 원래떡        ; 떼어먹어 내 뱃속으로 들어간다
  ret 원래떡
 
이 코드를 잘 보면, if 부분은 당연하게 받아들여지지만, else 부분이 다소 애매하다. 하지만 else는 if의 반대를 의미하므로 결국 둘다 원래떡과 떼어먹고
남은떡이 바꿔지는 점을 감안하면 같은 연산이라고 할 수 있다.


;============ Euclidean subtraction ==================

.model small
.stack 100h
.data
result db 5 dup (30h)

.code
main proc
  mov ax, @data
  mov ds, ax
 
  mov ax, 65535         ; 원래 떡
  mov bx, 255           ; 남은 떡
 
L1:
  cmp ax, 0             ; 원래 떡 ? 0
  jbe L3
 
  cmp bx, 0             ; 남은 떡 ? 0
  je L4
 
  cmp ax, bx            ; 아니오, 바꿔서 떼어먹기
  jbe L2
 
  sub ax, bx            ; 떼어먹고
  jmp L1
 
L2:
  sub bx, ax            ; 떼어먹고
 
L3:
  call decdump
  jmp exit
 
L4:                     ; 바꿔서 출력
  xor ax, bx
  xor bx, ax
  xor ax, bx
  call decdump
 
exit:
  mov ah, 0
  int 16h               ; 지연후 종료

  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


이미 비유까지 들어서 장황하다 싶을 정도로 설명했으니 알고리즘 자체는 더 언급하지 않아도 눈에 훤할 것이다. 다만 xor 3단 콤보의 역할을 간략히
살펴보면, 우리의 출력함수인 decdump는 디폴트 출력인자로 ax를 가정한다. 반면, 유클리드 알고리즘에서 디폴트 (즉, GCD 가 1이 되는 경우) 인자로는
bx를 가정하므로, 이를 서로 스와핑해서 출력해준 것만 주의하면 된다. 이 코드는 나름 최적화를 거쳐 재구성하는 것도 좋지만, 어쩔땐 최적화보다
가독성이 중요할 때도 많으므로 당분간 최적화는 무시하기로 하자. 별것 아니지만, GCD는 prime number (소수)와 더불어 현대 암호학의 핵심을 이루고
있으며, 다른 응용 분야가 넓으므로 이 기회에 GCD의 원리를 한번쯤은 연구해보는 것도 좋을 것이다. 


사칙 연산에 관여하는 6종 플랙이 뺄셈에서 어떤 의미를 가지는지 알아보자. 이 6종 플랙은 서로 반대되는 경향이 강하다. ZF는 마지막 오른쪽 비트
(L.S.b)가 0인지 1인지를 신경쓰고 SF는 반대로 마지막 왼쪽 비트 (M.S.b)가 0/1인지 신경쓴다. 즉, 이 두 플랙은 비트단위 최소 플랙이라고 할 수
있으며, 화학에서 원자쯤에 해당하는 플랙이다. 우리는 이 두 비트를 체크하려면 간단히 좌/우측 끝 비트만 체크하면된다.

;============ SF/ZF subtraction tester ==================

mStdout macro prompt
  mov ah, 40h
  mov bx, 1
  mov cx, sizeof prompt
  lea dx, prompt
  int 21h
endm

.model small
.stack 100h               ; sp = 100h
.data
result db 5 dup (30h)
prompt1 db 20h, "Sign flag set", 0Dh, 0Ah
prompt2 db 20h, "Sign flag not set", 0Dh, 0Ah
prompt3 db 20h, "Zero flag set", 0Dh, 0Ah
prompt4 db 20h, "Zero flag not set", 0Dh, 0Ah

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

  mov cx, 10              ; 루프 카운터

  mov ax, 0FFFFh          ; ax = 1111 1111 1111 1111b (테스트 값 -가장 큰 16비트 2진수)
  add ax, 1               ; + 1
  js L2                   ; sign flag on ?
 
  call decdump            ; no, 출력
  mStdout prompt1 
  jmp L3

L2:                       ; yes, 출력
  call decdump
  mStdout prompt2

L3:
  xor ax, ax              ; ax = 0000 0000 0000 0000b (가장 작은 16비트 2진수)
  sub ax, 1               ; - 1
  jz L4                   ; zero flag on ?
 
  call decdump            ; no, 출력
  mStdout prompt3
  jmp L5
 
L4:
  call decdump            ; yes, 출력
  mStdout prompt4
 
L5:

Exit:
  mov ah, 0
  int 16h

  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

코드를 간략히 살펴보자. 처음으로 매크로를 사용하였다. 가독성을 떨어뜨리는 주범이기 때문에 사실 나는 매크로를 잘 쓰지 않는다. 또한, Masm 이름
자체가 Microsoft Macro Assembler 이지만, 나는 masm의 가장 실패작이 매크로라 생각한다. 매크로는 코드를 복사해서 쓰기 때문에 프로그램의 덩치만
늘려주는 역할밖에 못한다고 나는 단정한다. 좋은 점이라면 단 하나, 중복 코드를 여러번 되쓰지 않아도 되는 점이다. 그런 이유로 이번 샘플 코드에서는
매크로를 마지못해 사용했다. 나 뿐만 아니라 대부분의 어셈블리 프로그래머가 매크로를 무시하거나 쓰더라도 최소한만 쓴다. 이유는 뻔하지 않은가 ?
스크립트 언어와 유사한 매크로를 배워서 제대로 쓸 시간에 CPU 명령어 하나라도 더 살펴보는 것이 낫지 않겠는가 ? 하지만 코딩하다보면 써야할 이유도
많이 생기므로 쓰긴 쓰되 마지노선을 정해두고 쓰는 것이 좋을 것이다. 또한 될 수 있음 프로시저로 해결하는게 나을 것이다.

매크로를 선언함과 동시에 형식 인자 (parameter)로 prompt를 요구했다. 이 파라미터를 메인에서 건네받아 이를 인터럽트의 출력 인자로 보냈다. 매크로는
선언함과 동시에 어셈블리 복사 코드 역할을 할 수 있으므로, 항상 프로그램의 선두에 선언해준다. 또는 라이브러리 파일로 모아두기도 하여 include
시키기도 하지만, 나는 매크로 라이브러리를 거의 쓰지 않을 것이다. 메인에서 prompt1,2,3,4를 번갈아가며 이 매크로에 인자로 건네주었다.


보다시피 사인 플랙과 제로 플랙은 극과 극이라고 할 수 있다. 이 테스트 값과 덧셈, 뺄셈 값을 바꿔가면서 어느 시점에 토글되는지 살펴보라.


이왕 언급한거 다른 플래도 함께 살펴보자. 별 관련 없는 오버플로 플랙 (OF)은 "초과"를 의미한다. 예로, 8비트 이진수이면, 1111 1111b가 최대
한계이지만, 여기에 1을 더하면 0이 되어 0 또한 8비트 한계로 담을 수 있기 때문에 오버플로를 발생하지 않는다. 반면 최소 8비트 이진수인 0000 000b
에서 1을 빼는 경우 1111 1111b가 되어 이 또한 8비트 레벨로 담을 수 있으므로 오버플로가 되지 않는다.

다음으로 AF (Adjust flag, 명칭이 언제 이렇게 바뀌었는지 모르겠지만, 앞으로는 바뀐 명칭을 무시하고 "보조 캐리 플랙"으로 부르겠다)은 니블 단위
캐리를 의미한다. 이는 비트 3 (우측 4번째 비트)에서 캐리가 발생한 경우에 해당하며 명시적으로 이를 테스트하는 명령은 없다. 그 말은 덧셈/뺄셈
연산은 AF 플랙에 영향을 주지 않는다는 얘기다. 그렇다면 이를 테스트 할 방법이 필요한데, 다른 연산을 이용하여 측정하면 된다. AF 측정 방법은
다양할 수 있지만, 예를 들자면, 비트를 여분 레지스터에 복사하여 이를 로테이트하여 CF으로 테스트할 수 있다.

반면, SUB 명령은 PF도 세팅하는데, PF는 LSB 바이트를 기준으로 1의 홀짝 갯수를 측정하므로, JP(JPE)/JPO로 측정할 수 있다. 다만 이를 측정할 땐
논리적인 홀/짝수와는 상관없이 최하위 바이트에 의존한다는 점과 JP는 짝수 패러티를 의미한다는 것을 기억해야 한다. 즉, 1111 0000b에서 1을 빼면,
1110 1111b이 되어 홀수 패러티가 되므로, 이에 합당한 분기를 하려면 JPO를 써야한다.

덧셈과 마찬가지로 뺄셈에서도 가장 중요한 플랙은 캐리 플랙이다. 캐리 플랙은 최상위 비트 (MSb, 즉, 사인 비트)의 자리 올림/자리 내림을 반영하므로,
덧셈과 뺄셈시 캐리가 발생하면, 값에 따라 SF/OF마저 함께 변경될 수 있다. 덧셈 뺄셈에서 캐리는 음수에서 양수로의 전환 또는 그 반대로의 전환을
의미하므로 값에 따라 SF/OF와 함께 측정하면 음수에서 양수로 또는 그 반대로 전환이 일어나는지 측정할 수 있다.

만약 0F00 + 0F00 두수를 더하면, 1E00이 되므로 MSb 캐리가 발생치 않지만, 0F00 - 0F00은 0이 되어 MSb 캐리가 발생한다. 즉, 최상위 비트란 레지스터가
이미 담고 있는, 즉, 세팅된 최상위 비트가 아니고, 데이터에 있는 최상위 비트를 말한다. 다음 코드를 보자.

mov ax, 0F00h
add ax, ax      ; 캐리를 발생하는가 ?

mov bx, 0F00h
sub bx, bx      ; 캐리를 발생하는가 ?


add의 경우 최상위 비트는 1이므로 여기에 어떤 수를 더해도 오버플로가 발생하지 않는한 최상위 비트는 1이라 캐리가 발생치 않으며, sub의 경우
최상위 비트가 1이지만, 뺄셈을 수행함과 동시에 최상위 비트가 0으로 클리어되어 캐리가 발생한다. 이제 각 플랙의 의미를 알았으므로, 위에서 쭉
설명한 대로 테스트 코드를 만들어보자. 참고로 한번에 다 체크할 것이므로 코드가 좀 길지 모르지만, 디버그는 예전에 말했듯이 첫 번째 세팅된
플랙만 표시하고 변경될 때만 표시하므로 플랙 변경을 제대로 감지하기 어려우므로, 각자 테스트 코드를 코딩해서 컴파일해보고 알아내야 할 것이다.

;============ subtraction result OF/AF/PF/CF tester ==================

mStdout macro prompt
  mov ah, 40h
  mov bx, 1
  mov cx, sizeof prompt
  lea dx, prompt
  int 21h
endm

.model small
.stack 100h               ; sp = 100h
.data
result db 5 dup (30h)
prompt1 db 20h, "Flag set", 0Dh, 0Ah
prompt2 db 20h, "Flag not set", 0Dh, 0Ah


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

  mov cx, 10              ; 루프 카운터

  mov ax, 0FFFFh          ; ax = 1111 1111 1111 1111b (테스트 값 -가장 큰 16비트 2진수)
  add ax, 1               ; + 1
  jo L2                   ; OF ?
 
  call decdump            ; no, 출력
  mStdout prompt2 
  jmp L3

L2:                       ; yes, 출력
  call decdump
  mStdout prompt1

L3:
  xor ax, ax              ; ax = 0000 0000 0000 0000b (가장 작은 16비트 2진수)
  sub ax, 1               ; - 1
  jo L4                   ; OF ?
 
  call decdump            ; no, 출력
  mStdout prompt2
  jmp L5
 
L4:
  call decdump            ; yes, 출력
  mStdout prompt1
 
L5:
  mov ax, 000Fh           ; ax = 0000 0000 0000 1111b (테스트 값)
  mov bx, ax              ; 비트 복사
  mov cl, 4               ; 로테이트 카운터
  ror bx, cl              ; 상위 니블로 세팅
  rol bx, 1               ; 1비트 왼쪽 쉬프트
  jc L6                   ; 캐리가 발생하면 ax는 AF가 세팅된 셈이다.
 
  mStdout prompt2
  jmp L7
 
L6:
  mStdout prompt1

L7:
  mov ax, 00F0h           ; ax 리셋 (= 0000 0000 1111 0000b)
  sub ax, 1               ; ax 리셋 (= 0000 0000 1110 1111b)
  jpo L8
 
  mStdout prompt2
  jmp L9
 
L8:
  mStdout prompt1
 
L9:
  mov ax, 0F000h
  mov bx, ax              ; ax = bx = 0000 1111 0000 0000b
  add ax, 0F00h           ; ax+0F00 = 0001 1110 0000 0000b = 1E00h
  jc L10                  ; ax Carry ?
  mStdout prompt2         ; no, sir
  jmp L11
 
L10:
  mStdout prompt1         ; yes, sir

L11:
  sub bx, 0F00h           ; bx = 0F00h - 0F00h = 0
  jc L12                  ; bx Carry ?
  mStdout prompt2         ; no, sir
  jmp L13
 
L12:
  mStdout prompt1         ; yes, sir
 
L13:   

Exit:
  mov ah, 0
  int 16h

  mov ax, 4C00h
  int 21h

main endp


코드가 길어져서 서브루틴 부분은 포스팅하지 않았다. 앞에서 사용한 것 그대로 사용했으니 돼지털 세상의 만능 테크닉인 copy&paste를 이용하라.
과잉 친절하지 않을테니 주석을 벗삼아 디버그로 트레이싱하여 직접 플랙의 작동 방식을 느껴보기 (?) 바란다.

뺄셈을 응용한 명령으로 neg/not/cmp를 들 수 있다. 이들 명령은 암시적으로 뺄셈을 수행하여 연산한다. neg는 이미 2의 보수를 다루면서 언급했으니
잘 알것이다 (뒤집어서 1더하기), not은 neg와 비슷하며, 뒤집기만 하고 1을 더하지 않는다 (뒤집어진 것을 1의 보수라 한다). 


2의 보수 (2's complement) : 사람들이 이 단어를 많이 어려워하는 것 같다. 컴퓨터 쓰면서 이 단어 한두번쯤 안들어본 사람 몇이나 될까 ? 그런데
애석하게도 나는 이 용어가 잘 못 되었다고 생각한다. complement 이 단어를 사전에서 찾아보면 분명 "부족한 무언가를 채워주는 무언가"쯤으로
해석될 것이다. 즉, 수학으로 표현하자면, x + y = z가 되는 x, y라고 할 수 있다. 그럼 위의 정의는 x + y = 2 라는 소린가 ? 많은 사람들이 이렇게
해석하니 그 많은 사람들이 이해를 못하는 것이다. 그렇다면 ? x + y = 0 이라고 해석하면 이해가 되는가 ? 즉, "0의 보수"라는 소리다. 이는 엄연히
국어사전과는 상관없이 내 나름대로 정한 용어니 참고하라. 지금부터는 0의 보수라 칭하기로 하겠다. 나는 이 단어가 어파스트로피 (')가 인쇄되면서
잉크가 잘 못 찍혀서 달라붙은 것 쯤으로 받아들인다. 즉, 0을 구성하는 2개의 보수이다. 어파스트로피를 빼고 해석하면 아주 자연스럽게 이해될
것이다. 비유로 들자면, 남녀가 만나서 결혼을 하듯이, 결혼에 관여하는 두 존재를 0의 보수 (사전상 2의 보수)라 할 수 있다.

합쳐서 0을 이루는 두 요소를 몇개 나열해보자. 0 + 0 = 0, 1 + (-1) = 0, 2 + (-2) = 0,... 필이 오는가 ? 1의 0의 보수는 -1 (0FFh)이다. 이중 0 + 0은
바이트 레벨에서 0h + 0h도 되고, 80h + 80h도 된다. 80h를 그래서 나는 중성적인 수 (중성수, neuter)라고 부른다. 우리대신 이 역할을 해주는 CPU
명령이 neg와 sbb이다. 합쳐서 0이 되는 수를 몇개 더 구해보자.

x     y         z = 0
----------------------
0     0         0
1     -1        0
0Fh   F1h       0
3Fh   C1h       0
69h   97h       0
7Fh   81h       0
80h   80h       0
FFh   01h       0

neg는 단항 (unary) 명령어이다. 반면 2항 neg에 해당하는 명령이 sbb이다. 즉, neg는 (피)연산자 한개를 주고 "neg x" 명령으로 사용하여 x를 정해주고
실행하면 x가 0의 보수로 갱신되고, sbb는 "sbb d, s" 형식으로 s에 값을 주면 d에 0의 보수가 리턴된다. 둘다 0을 가지고 암시적인 뺄셈을 한다고 할
수 있다. 이 간단한 것을 이해 못하고는 코딩 자체가 피곤해진다. 여기서는 0의 보수라는 새로운 개념을 도입했지만, 그래도 외부와의 통신이 중요한
세상이다보니 우리는 다시 "2의 보수"라 부르고 세상으로 돌아가자. 용어는 아무 소용없으며, 그 속의 숨은 뜻이 중요하다 (read between the lines).


cmp는 이미 수없이 써봤으니 익숙할 것이다. "cmp d, s" d에서 s를 암시적으로 빼서 결과는 무시한다. cmp 뒤엔 보통 분기 처리문 (jxx)이 따른다.
디지털 세상에서 CPU가 두 수를 어떻게 비교하겠는가 ? 사실 두수의 차이에 기반해서 비교를 한다. 다만, 그 뺄셈 결과를 버리는 점이 sub 명령과는
다르다. 즉, cmp 명령은 두 수의 비교 결과를 플랙에만 반영한다는 소리다. 그 말은 반대로 특정 플랙을 cmp 명령으로 갱신할 수 있다는 뜻이다.
예로,

mov ax, 10h   ; ax =    0000 0000 1111 0000
cmp ax, 1h    ; temp =  0000 0000 1110 1111
cmp ax, 1h    ; temp =  0000 0000 1110 1110

보다시피 AF, ZF, PF 3개의 플랙이 토글되었다. AF/PF는 값에 따라 다를 수 있지만, 암시적으로 1을 빼주는 CMP XX, 1은 한번씩 쓸때마다 ZF를 토글
한다. 또한 암시적으로 1을 뺄때마다 캐리 (즉, 바로우)가 발생하므로 CF를 토글한다. 다음을 보자.

mov ax, 0
cmp ax, 1
mov bx, 0FFFh
cmp bx, 1
mov cx, 0
cmp cx, 1
mov dx, 0FFFh
cmp dx, 1

이 명령을 트레이싱해보면, cmp 명령을 한번씩 할 때마다 ZF/CF가 토글되며 같은 비트로 토글된다. 즉, 연산을 하지 않고, ZF를 CF에 복사했다고
해석할 수도 있고 그 반대로 해석할 수도 있다. 다른 말로 CMP XX, 1로 별다른 연산을 하지 않고 ZF/CF에 따라 분기를 취할 수 있다.

여기서는 1이라는 "상수"로 뺄셈을 했지만, 다른 상수를 뺄셈에 적용하여 논리와는 전혀 무관한 프로그래밍을 하는 경우도 많다. 그 대표적인 예로,
대/소문자 변환이라 할 수 있다. 소문자는 아스키 코드 상에서 대문자보다 20h가 크다. 아마도 초창기 대문자만 쓰던 컴퓨터 시절에서 유래했을 듯
싶다. 그러니 소문자에서 20h를 빼면 대문자가 되고, 반대로 대문자에서 20h를 더하면 소문자가 된다. 아스키 맵을 참고하라. 또한, 대문자와
소문자 사이에는 특별히 끼어드는 특수 문자가 6개 있으므로 이를 필터링해줄 수 있어야겠다. 경계 값에 해당하는 문자만 이진수로 살펴보자. 

문자  hex     bin
----------------------
A     41    0100 0001
Z     5A    0101 1010

조판 문자 6개 (5Bh~60h)

a     61    0110 0001
z     7A    0111 1010

보다시피 이진수 레벨에서 대문자는 5번 비트가 0이고 소문자는 5번 비트가 1이다. 즉, 20h가 더해지거나 빼진 것이다. 이는 덧셈 뺄셈으로 변환할
수도 있지만, 상대적으로 빠른 마스킹 명령 (and, or, xor)으로도 변환할 수 있다. 

A or  0010 0000b        ; 소문자로
Z or  0010 0000b

a and 1101 1111b        ; 대문자로
z and 1101 1111b


;============ toLower/toUpper ==================

mStdout macro prompt
  mov ah, 40h
  mov bx, 1
  mov cx, sizeof prompt
  lea dx, prompt
  int 21h
endm

.model small
.stack 100h               ; sp = 100h
.data
hello db "Hello, World!"

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

  call toLower
  mStdout hello
 
B1:
  mov ah, 0                 ; break
  int 16h 

  call toUpper 
  mStdout hello
 
B2:                         ; break
  mov ah, 0
  int 16h

  mov ax, 4C00h
  int 21h

main endp

toLower proc
  push bp                   ; 플로로그
  mov bp, sp
 
  push ax
  push bx
  push cx
  push dx
  push si
  push di
  pushf

  lea si, hello             ; 스트링 주소 얻어
  mov cx, sizeof hello      ; 루프 카운터 설정하고

L1:
  mov al, [si]              ; 문자를 로드해서

  cmp al, 'A'               ; 필터
  jb L2

  cmp al, 'Z'
  ja L2

  or al, 00100000b          ; 소문자로 (= add al, 20h)

L2:
  mov [si], al              ; 저장하고
  inc si                    ; 포인터 이동
  loop L1
 
L3:
  popf
  pop di
  pop si
  pop dx
  pop cx
  pop bx
  pop ax
 
  mov sp, bp                ; 에필로그
  pop bp
  ret
toLower endp

toUpper proc
  push bp                   ; 플로로그
  mov bp, sp
 
  push ax
  push bx
  push cx
  push dx
  push si
  push di
  pushf

  lea si, hello           ; 스트링 주소 얻어
  mov cx, sizeof hello    ; 문자 갯수만큼 루프 카운터 설정
   
L1:
  mov al, [si]            ; 문자 복사하고

  cmp al, 'a'             ; 필터 달아주고
  jb L2

  cmp al, 'z'
  ja L2

  and al, 11011111b       ; 대문자로 (= sub al, 20h)

L2:
  mov [si], al            ; 저장하고
  inc si                  ; 포인터 이동하고
  loop L1
 
L3:
  popf
  pop di
  pop si
  pop dx
  pop cx
  pop bx
  pop ax
 
  mov sp, bp                ; 에필로그
  pop bp
  ret
toUpper endp

end main



덧셈에 비해 뺄셈은 교환 법칙 (commutative law)이 성립하지 않으므로 다소 제한적으로 사용될 수 밖에 없다. 다음 코드를 보자.

mov ax, 1
mov bx, 2

add ax, bx
add bx, ax

mov cx, 3
mov dx, 4

sub cx, dx
sub dx, cx

덧셈처럼 꺼리낌없이 뺄셈에서 쓰다가 예상치 못한 음수 결과를 보고 당황하지는 않을 것이다. 덧셈의 경우 ax, bx 둘다 양수이지만, 뺄셈의 경우
cx는 음수 (-1)가 되고 이를 dx에 되먹여 뺄셈하므로 (음수를 빼므로) dx는 5가 된다. CX의 경우 수학적으로 음수이지만, unsigned 이진수일 경우
양수이다. 즉, 교환 법칙은 unsigned 이진수일 경우 뺄셈에서도 통한다는 소리다.


INC와 마찬가지로 만약 특정 값에서 1을 빼려면 DEC가 유리하다. 이 명령은 CF 플랙을 보존하며, 루프 카운터로 유리하게 쓰일 수 있다.

이와는 반대로 SBB 명령은 뺄셈을 할 때 CF 값마저 함께 뺀다. 즉, 캐리 플랙을 소스 연산자에 더해서 한꺼번에 뺀다. 뺄셈에서 CF는 자리내림/바로우
(borrow)를 의미한다. SBB 명령도 mem - mem은 허용치 않는다. 만약 imm값을 뺄 경우는 D는 부호 확장 (sign-extension)이 일어난다. SBB는 S, D 연산자가
signed/unsigned인지를 구분하지 않는다. 대신, OF/CF를 검사하여 각각 signed이면 OF로, unsgined이면 CF로 알려준다. SF는 sigend라는 가정하에 SF를
세팅/클리어한다. SBB 명령은 특히 SUB 명령을 뒤따라 복정밀 연산 (multi-byte/word/dword)연산에서 사용할 수 있다.
 

이는 교과서적인 CPU 매뉴얼에서 번역한 내용이다. 이 명령의 작동 유사 코드를 IA32 매뉴얼과 IA64 매뉴얼로 보면 약간의 차이가 있을 것이다.

IA32: DEST <- DEST - (SRC + CF)
IA64: DEST <- (DEST - (SRC + CF))

DEST = 3, SRC = 2, CF = 1 이라고 가정하고 계산해보라. 둘간의 차이가 뭔지 알겠는가 ? 계산 결과물만 보자면 둘간의 차이는 없다. 다만 IA64매뉴얼이
우선 순위를 조금 더 강조했을 뿐이다. CPU 매뉴얼을 보면 이런 내용이 나온다:

The state of the CF flag represents a borrow from a previous subtraction.

- CF 플랙의 상태는 이전 뺄셈에서 바로우를 나타낸다.

이 명령은 SUB 명령 뒤를 이어서 사용되는 경우가 많다. 일단 한 번 SUB 되면 그 캐리를 이용하여 추가로 1을 더 빼던가 0을 더 빼던가 한다. 덧셈에서
ADC의 역할과 비슷한데, 문제는 뺄셈이므로 여차하면 사인 플랙에 영향을 줄 수 있다. CPU 매뉴얼에 보면 꼭 중요한 얘기는 뒤에 나오는 것 같다.
맛있는건 나중에 먹는 사람의 심리를 아는걸까 ?

The SF flag indicates the sign of the signed result.

- SF 플랙의 상태는 signed 결과물의 부호를 나타낸다.

다음 코드를 보자.


;============ SUB & SBB tester 1 ==================

mStdout macro prompt
  mov ah, 40h
  mov bx, 1
  mov cx, sizeof prompt
  lea dx, prompt
  int 21h
endm

.model small
.stack 100h               ; sp = 100h
.data

prompt1 db "Flag set", 0Dh, 0Ah
prompt2 db "Flag not set", 0Dh, 0Ah

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

L1:
  mov al, 03                ; al = 03
  sub al, 1                 ; al = 02, no carry
  jc L2
  sub al, 1                 ; al = 01, no carry
  jc L2
  sub al, 1                 ; al = 00, no carry
  jc L2
  sub al, 1                 ; al = FF, carry ON, sign ON
  jc L2
  sub al, 1                 ; skip
  jc L2
 
  mStdout prompt2           ; skip
  jmp L3
 
L2:
  mStdout prompt1           ; show
 
L3: 
  mov al, 03                ; al = 03
 
L4:
  sub al, 1                 ; al = 02, no carry
  jc L5
  sbb al, 1                 ; al = 01, no carry
  jc L5
  sbb al, 1                 ; al = 00, no carry
  jc L5
  sbb al, 1                 ; al = FF, carry ON, sign ON
  jc L5
  sbb al, 1                 ; skip
  jc L5

  mStdout prompt2           ; skip
  jmp exit
 
L5:
  mStdout prompt1           ; show
 
Exit:
  mov ah, 0
  int 16h

  mov ax, 4C00h
  int 21h

main endp
end main


얼핏봐서는 SUB/SBB 두 명령의 차이를 못 느낄 정도로 동일하게 작동한다. 하지만, 출력 루틴과 캐리 검출 때문에 그런것이지, 이를 다음처럼 수정하고
트레이싱하면 결과가 상당히 차이를 보인다.

;============ SUB & SBB tester 2 ==================

.model small
.stack 100h               ; sp = 100h
.data

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

L1:
  mov al, 03                ; al = 03 (set al)
 
  sub al, 1                 ; al = 02, no carry
  sub al, 1                 ; al = 01, no carry
  sub al, 1                 ; al = 00, no carry
  sub al, 1                 ; al = FF, carry ON, sign ON
  sub al, 1                 ; al = FE, no carry, sign OFF
 
L2: 
  mov al, 03                ; al = 03 (reset)

L3:
  sub al, 1                 ; al = 02, no carry
  sbb al, 1                 ; al = 01, no carry
  sbb al, 1                 ; al = 00, no carry
  sbb al, 1                 ; al = FF, carry ON, sign ON
  sbb al, 1                 ; al = FD, no carry, sign OFF
 
Exit:
  mov ah, 0
  int 16h

  mov ax, 4C00h
  int 21h

main endp
end main


마지막 결과만 보면, sub 버전: al = FE, sbb 버전: al = FD로 sbb가 추가로 1을 더 뺐음을 알 수 있다. 즉, 음수로 변경되는 그 순간 1을 더 빼므로,
ADC 명령처럼 복정밀 연산에 쓸 수 있는 것이다. 만약 sbb 버전에 sbb al, 1을 마지막에 하나 더 추가하면 al = FC가 된다. 즉, 음수에서 빼는 순간만
1이 추가로 빼진다는 것이다. ADC와는 다르게 SBB는 뺄셈이므로 크리티컬한 시점에서 다양하게 적용되기도 한다. 다음은 Agner Fog의 최적화 매뉴얼에서
16비트 버전으로 구현한 두 수의 최소값을 구하는 코드이다. if (a < b) b = a; 이런 유사코드 많이 접하고 빈번히 사용되는 min 값 구하는 루틴이다.


;============ bx = min (ax, bx) ==================

.model small
.stack 100h               ; sp = 100h
.data

.code
main proc
  mov ax, @data
  mov ds, ax
 
  xor ax, ax
  xor bx, bx
  xor cx, cx
 
  mov ax, 1             ; ax = 파괴되고, bx = min (ax, bx)
  mov bx, 2
 
  sub ax, bx            ; 사인 비트를 얻어
  sbb cx, cx            ; 사인 비트를 CX로 부호확장
  and cx, ax            ; 원래 값을 마스킹하여
  add bx, cx            ; 마스킹 값을 빼려는 값에 더한다
 
 
 
Exit:
  mov ah, 0
  int 16h

  mov ax, 4C00h
  int 21h

main endp
end main


주석만 읽어봐도 쉽게 알 수 있을 것이다. 보통 두 수의 값을 비교하거나 거기에 맞게 분기를 해야 최소값을 구할 수 있지만, 이 코드는 비교 분기
자체가 없으며 무조건 bx는 최소값을 가진다. 다만 ax가 파괴되는데 이는 중요치 않다. 코드의 관심은 빠르게 분기 처리 없이 최소값을 구하는 것이니.

이처럼 사인비트를 확장하여 마스킹하고 더하거나 빼주는 연산 예전에도 절대값 (absolute value)구할때 써봤다.

cwd
xor ax, dx
sub ax, dx

테스트 코드로 살펴보자.


;============ ax = abs (ax) ==================

.model small
.stack 100h               ; sp = 100h
.data

.code
main proc
  mov ax, @data
  mov ds, ax
 
  mov ax, -1            ; 테스트 값 (음수)
 
  cwd                   ; dx로 사인 확장하여
  xor ax, dx            ; 원래 값으로 마스킹하고
  sub ax, dx            ; 마스킹 값을 빼준다 
 
Exit:
  mov ah, 0
  int 16h

  mov ax, 4C00h
  int 21h

main endp
end main


sbb는 adc처럼 또한 복정밀 연산 (multiple precision arithmetic)에 사용할 수 있다. adc와 마찬가지로 처음만 sub 나머지는 sbb해주면 된다.
이론은 그러한데 실은 이를 출력하는 과정에서 십진수로 변환을 해야하므로 복정밀 나눗셈과 복정밀 부호확장을 추가로 해줘야 한다. 그러므로
여기서 다루기엔 무리가 있으므로 다음에 다루던지 하겠다. 이외에도 부호확장 + 마스킹 + 추가 연산으로 해줄 수 있는 연산이 많아 여기서 다
다룰 수 없는 점 이해해주길 바란다.


의도와는 다르게 뺄셈을 다루면서 내용이 너무 중구난방 길어진것 같다. 개인적으로 "자세한 정보는 무엇을 참고하시오"라는 리다이렉션 문구를 끔찍히
싫어하다보니 말 나온김에 계속 다루다가 꼬리에 꼬리를 물고 삼천포로 빠져들었다. "자세한 정보는..." 이 말을 싫어하는 이유는 마치 너무 소중한
정보라서 줄 수 없으니 돈내고 얻어라는 듯이 들리기도 하고, 그 정보를 찾아서 또 시간 낭비하게 만들며 결국 알고보면 별것 아니더라는... 그런
이유로 말 나온김에 계속 이어나갔을 뿐이다.

댓글 없음:

댓글 쓰기

블로그 보관함