2010년 1월 31일 일요일

14장. 루트를 찾아라!


14장. 루트를 찾아라!


언젠가 현장에서 일하면서 제곱근 문제를 풀어야 할 경우가 생겼다. 평소에 수학하고는 담쌓고 지낸터라 막막하기만 했다. 나에게 주어진 임무는
줄자, 산소 절단기, 막연하게 생긴 철판때기 뿐이었다. 이를 이용하여 가로와 세로는 1m이되 가로:세로가 직각이 되는 삼각형 (직각 이등변 삼각형,
equilateral right triangle 인가 ?) 모양으로 철판을 잘라내는 것이었다.

줄자로 대략 1m를 표시하여 쭉 잘라냈다. 앗, 여기서 정확히 90도로 가로:세로를 맞춰줄 수 없었다. 나에게 주어진 것이라곤 줄자와 철판, 절단기
뿐이지 각도기는 없었으며, 더구나 그 철판도 한번 정도 잘라낼 크기 밖에 못되었다. 오래되서 어떻게 해결했는지 기억이 잘 나지 않지만, 여튼
아주 단순한 그걸 해결하려 애먹었던 기억이 있다. 아 각도기를 주고 90도를 맞추라고 하던가...

그 이후로 프로그래밍 하면서 수학, 특히 제곱근이 아주 골칫거리가 될 때가 많았다. 근런데 이건 나만의 문제가 아니라는 걸 수학책 뒤져보면서
알게되었다. 제곱근 (square root), 이 녀석 pi 보다 더 자주 사용되면서 수학에서 막강한 위세를 가진 괴물 정도에 해당한다. 이 녀석이 없으면
삼각함수 (trigonometric function) 계산은 어림도 없다. 그럼에도 불구하고 그리 만만치가 않다.

나는 제곱근을 어떻게 어림잡을 수 (estimate) 있는지 인터넷을 찾아봤다. 그러다가 문득, 예전에 도서관에서 심심풀이로 읽었던 과학 동아
(http://www.donga.com/pdf/donga/200703/02/2007030240A23230107.pdf)의 홍길주식 제곱근 풀이법이 떠올랐다. 홍길주는 "바보가 아니면 얘들도
푼다"고 했듯이, 놀라울 정도로 간단 명료했다. 과학 동아 내용을 인용하면 이렇다:

"홍길주의 풀이법은 간단하다. 먼저 수를 반으로 나누고 나눈값을 1부터 오름차순으로 뺀다. 9의 경우
반으로 나눈 값 4.5에서 1을 빼고, 남은값 3.5에서 2를 빼는 식이다. 그렇게 더는 뺄 수 없을 때 남은
수를 2배한 뒤 그 수가 뺄 수와 같으면 제곱근이라는 것. 3.5에서 2를 빼고 남은수 1.5는 3으로 더는
뺄 수 없고 이를 2배한 3이 빼려는 수 3과 같기 때문에 9의 제곱근은 3이 된다는 것이다. "

하지만, 과학 동아에서 이 글을 잘 읽어보면 몇가지 함정으로 인해 과학이 아닌 소설이 되버렸다.

1. 빼서 1.5가 되었을 때 1.5라는 실수를 자연수로 어떻게 처리할 것인가 ?
2. 더는 뺄 수 없어서 2배했을 때, 만약 빼려는 수와 같지 않으면 어떻게 될 것인가 ?
3. 최종적으로 2배한 값을 빼려는 수와 어떻게 비교할 것인가 ?

비록 엉터리 해석이지만, 이 훌륭한 아이디어를 우리는 코딩해보자.

;================== Hong Kil Ju's Natural Roots ============

.model small
.stack 100h
.data
result dw ?
.code
main proc
  mov ax, @data
  mov ds, ax

  mov ax, 25                ; test value
  mov bx, 1                 ; initial guess
  shr ax, 1                 ; temp = test / 2

L1:
  sub ax, bx                ; temp = temp - guess
  inc bx                    ; guess + 1

  cmp ax, bx                ; if (temp <= guess)
  jbe L2                    ;   result = guess
  jmp L1                    ; else goto L1

L2:
  mov result, bx

exit:
  mov ax, 4C00h
  int 21h
main endp
end main


워낙에 아이디어가 간단해서인지 코드 자체도 별것없다. 다만, 한가지 의심이 갈 만한 부분이라면 왜 guess라는 현재까지 추정된 카운터를 루트라고
단정했는가 ? 이는 자연적인 현상이라고 해야하겠다. 신기하게도 홍길주의 알고리즘에서 카운터가 바로 루트로 인정해도 되는 현상이 일어났다. 즉,
최후에 2를 곱할 필요가 없다는 뜻이며, 단지 확인하려는 수준에 불과하다고 할 수 있다.

홍길주의 알고리즘은 분명 획기적이랄 수 있다. 지금 대부분의 전산에서 루트를 구하는 표준 방식이 뉴턴 방식 (Newton method)인데 이는 많은
나눗셈이 들어가므로 굉장히 속도가 느리다. 그래서 예전에 언급한적 있는 상수를 곱하는 방법과 쉬프트해주는 방법으로 어느 정도 느린 속도를
만회하는 방식을 쓰고 있다. 반면, 홍길주의 알고리즘은 루프 안에서 단지 뺄셈/비교-분기 만으로도 해결이 가능하니 디지털로 잘 융합되면
세상을 놀라게할 만한 획기적인 루트 찾기 알고리즘이랄 수 있다.

근본적으로 루트에서 우리가 필요한 것은 소수점 이하의 값이 아니다. 설령 소수점 이하의 값이라하더라도 정작 더 중요한 것은 정수 자리를 차지한
값이라고 할 수 있다. 3.7 이라는 수에서 3이 중요한가 .7이 중요한가만 생각해봐도 충분히 납득할 것이다. 그런 이유로 디지털에서 가장 왼쪽
비트를 "most significant" bit라고 부르는 것이다.


홍길주의 알고리즘은 아이디어 자체는 분명 획기적이지만 문제는 숫자가 커질 때이다. 어머어마한 큰 수를 다른 어마어마한 큰 수로 나눈다면 여러번
빼는 방식이 어쩔 땐 한번의 나눗셈만 못할 수 있기 때문이다. 그런 이유로 그는 세상을 잘 타고태어났는지 모른다. 이 시점에서 홍길주의 풀이는
뒤로 하고 다른 사람들은 루트를 어떻게 찾으려 했는지 알아보자.

나는 루트의 소숫점 이하를 어떻게 계산하는지 인터넷을 검색하여 몇가지 동영상을 찾을 수 있었다.

http://www.youtube.com/watch?v=XTwx6q1Yspg
http://www.youtube.com/watch?v=TayKw7jaMi8
http://www.youtube.com/watch?v=3i94NWF39nU
http://www.youtube.com/watch?v=GvTFfLGCMV4

이들 동영상을 자세히 보면 반복 나눗셈 (즉, 두 수를 곱하여 뺌)을 하되, 넘지 않는 가장 근접한 곱수 (multiples)를 계속 구해나가는 방식이며,
16비트 레벨에서는 소숫점 4자리가 한계였다. 일명 "박스 테크닉"이라고 부르며, 자릿수가 늘어나면 뺄셈하다가 지치기 딱 좋은 방법임을 알 수
있다. 계산을 해나가는 동안 찾으려는 소숫점 값이 박스에 들어갈 숫자와 같은 점은 주목할 만하다. 다만, 이해가 안가는 점은 십진 나눗셈에선
바로우로 한자리 0 만 내려주면 되는데, 이 계산에선 왜 두자리 00 을 내려주냐는 것이다.

나는 예전에 주판으로 루트를 계산해보려 한 적 있다. 인터넷을 검색하여 주판으로 루트를 계산하는 방법을 찾았지만, 주판을 배운적이 없으면,
오히려 낯설기만 할 것이다 (http://webhome.idirect.com/~totton/soroban/SqRt2/). 주판에서는 나눗셈을 하듯이 곱을 빼가는 방법이며, 위에서
처럼 머리로 (mental) 근접한 곱을 빼가는 방법과 아주 흡사했다.

주판에서 사용한 방법도 위와 아주 근사하며 Root(2)를 구할 경우 02. 00 쌍 (pairs)으로 계산하는 것은 별반 차이가 없다. 주판이 소숫점 이하
자릿수를 더 빠르게 많이 얻어낼 수 있지만, 역시 소수점 이하 자릿수를 많이 구해야할 경우 복잡해지는 것은 마찬가지다. 주판이 뭔지 모르는
사람도 있을 것이므로 주판은 단지 진화를 거부한 과거의 컴퓨팅 방식이라는 것만 기억해두고 원점으로 돌아와 위의 동영상식 해석 방법을 다시
살펴봐야겠다.


이 계산법은 위키 (http://en.wikipedia.org/wiki/Methods_of_computing_square_roots)에 자세히 나와있으며, 예로 Root (2)를 계산한 그림을
인용하면 다음과 같다:

동영상에서 설명한 것과 같은 방식이며, 제곱근 위에 적을 숫자와 계산 중간에 오른쪽 옆에 임시로 구한 결과가 일치함을 알 수 있다. 이상하게도
홍길주 방법에서 카운터를 루트의 값으로 인정하는 것이나 중간에 한 자리씩 답이 나오는 서양식 방법이나 말로 설명하기 힘든 묘한 숫자의 농간이
느껴졌다. 아직까지 루트는 미확인 괴물에 불과하다. 하지만 이 계산법은 알고리즘이랄 수 없다. 왜냐하면 머리로 계산하는 과정 (mental process)
으로 대소를 판정하기 때문이다. 이 시점에서 루트를 찾기 위한 노력은 잠시 중단해야겠다. 실수에 대한 정리가 없는 과정에서 다뤄봤자 부정확한
알고리즘으로 끝나기 때문이다. 실수 (float)에 대해 한바퀴 삥 둘러보고 다시 루트를 찾아 떠나기로 한다.

루트 찾기 링크 : http://en.wikipedia.org/wiki/Category:Root-finding_algorithms

2010년 1월 25일 월요일

13장 - 어셈블리 초등 산수

13장 - 어셈블리 초등 산수

이번 장은 앞에서 배운 것들을 떠올려 아주 흔하게 일어날 수 있는 기초적인 산술 연산 (특히 대수, algebra)을 다룬다. 수학 공식을 코딩하고
싶을 때가 있으며, 알고리즘을 구현하거나 분석할 경우가 있기 마련이니, 여기서 다룰 내용은 기초적이면서도 귀중한 배경지식으로 작용할
유사 코드이다.

* 변수 선언

변수는 데이터를 할당 (assignment)하는 용도로 사용되며, "name type value"형식으로 선언한다. 이렇게 선언된 변수는 운영체제가
메모리를 할당하며, 원한다면 이 변수에 특정 값을 지정해줄 수 있다.

- 예,

  var db 30                               ; var 라는 바이트 변수를 선언하여 십진수 30으로 초기화했다.
  var dw ?                                ; var 라는 워드형 변수를 선언하여 초기화를 하지 않았다. var는 00 00이 된다.
  var dd 5 dup (0)                        ; var 라는 더블워드 원소가 5개인 배열의 각 원소를 0 으로 초기화한다.
  var db "Hello, World!", 0Dh, 0Ah, '$'   ; var 라는 문자 배열 (스트링)이 H ~ !, 0Dh, 0Ah, '$'가 낮은 주소(옵셋)부터 초기화된다.
 
* 할당된 값 로드

변수는 CPU에 로드되기 전까지는 단지 공간을 확보하는 용도에 불과하며, 연산을 위해서는 CPU 레지스터에 로드되어야 한다. 사용되지 않는 변수는
선언할 필요가 없다. 선언된 변수는 mov 명령으로 로드한다.

  var db 12h                  ; byte 변수가 하나 선언되었으며, 값이 12h로 초기화되었다.

  mov al, var                 ; byte 변수를 byte 레지스터로 로드한다. al = 12h
  mov bx, word ptr [var]      ; byte로 선언된 변수를 word 레지스터로 로드한다. 값이 다를 때는 type ptr 지시어를 써주고, []는 내용을
                              ; 의미하며, bl = 12h, bh = garbage를 가진다. 작은 레지스터에 큰 type의 메모리를 로드할 순 없다.
 
* 할당된 값 리셋

  var db 12h

  mov cl, 34h                         ; cl에 정해준 값 (34h)으로 var는 리셋되며, var = 34h가 된다.
  mov var, cl                         ; 이 값은 프로그램이 실행되는 동안 유지되며, 종료하고 재 실행하면 다시 12h로 리셋된다.
 

위에서 다룬 것들은 값 (value)을 할당하는 것이지만, 주소 (address)를 할당할 수 있다.

* A = address of mem

mov a, offset [mem]
= lea a, mem 

* 로드 - 갱신 - 저장 (load-op-save)

mov 명령은 "mem-to-mem" 연산을 허용치 않는다. 반면 movs 명령은 "mem-to-mem"연산이 가능하다. 그러므로 만약 mov로 어떤 메모리 값을 로드해서
갱신하여 새로 저장한다면, 갱신 따로 저장 따로 해줘야한다. 즉, 다른 값이 메모리에 복사되는 것이다.

  mov al, val
  mov mem, val                 ; mem = val 할당 (mem-to-mem)
 
하지만, mov 명령은 "imm-to-mem" 형식이 가능하므로 메모리 값에 상수를 직접할당할 수 있다.

  mov mem, imm                 ; mem = imm
 
* A = B + C                                     * A = B - C

  mov ax, B                     ; 로드            mov bx, B
  add ax, C                     ; 연산            sub bx, C
  mov A, ax                     ; 저장            mov A, bx
 
* A = B * C                                     * A = B / C

  mov ax, B               ; 로드                  mov ax, B
  xor dx, dx              ; 상위 소거             cwd
  mov bx, C               ; 로드                  mov bx, C
  mul/imul bx             ; 연산                  div/idiv bx
  mov [mem], dx           ; 상위 저장             mov [mem], ax           ; 몫 저장
  mov [mem+2], ax         ; 하위 저장             mov [mem+2], dx         ; 나머지 저장
 
* A = - A

  neg ax
  =
  not ax
  add ax, 1

* X op1 Y op2 형태: 이 연산을 하려면 우선 순위를 고려해 머릿속에 괄호를 그려두고 연산하면 된다. 

 
* A = A - B - C                                   * A = A - (B + C)

  mov ax, A                                         mov ax, B
  sub ax, B                                         add ax, C
  sub ax, C                                         sub A, ax
  mov A, ax
 
* A = A - (B - C) = A - B + C

  mov ax, B
  sub ax, C
  sub A, ax
 
* A = A + B * C

  mov bx, C
  mov ax, B
  mul/imul bx
  mov ax, A
  add ax, bx                      ; 하위 무시, 선 곱셈 후 덧셈
  mov [memA], ax

  또는,
 
  mov ax, B
  mul C
  add ax, A
  mov [memA], ax
 
* A = A / B - C                                    * A = A / B * C

  mov ax, X                                          mov ax, A
  cwd                                                cwd
  div/idiv B                                         mul/imul C
  sub ax, C                                          div/idiv C
  mov [memA], ax                                     mov [memA], ax
 
* A = 2N * 8 (mul대신 shl로)

  mov al, 5
  mov bl, al        ; 복사

  add al, al        ; N * 2
  mov cl, 3         ; N * 8
  shl bl, cl        ;
  add al, bl        ; 2N + 8N

* A = B * (3/4)

  mov al, 55
  mov bl, al        ; 복사

  mov cl, 2
  shr bl, cl        ; 1/4

  sub al, bl        ; 55 * (1 - 1/4)
 
 
* A = (B + C) * (D + E)

  mov ax, B
  add ax, C
  mov cx, ax         ; cx = B + C
 
  mov ax, D
  add ax, E          ; ax = D + E
  imul cx
  mov [memA], ax
 

* (x + y) / (x - y)


  mov al, x                   ; al = x
  mov bl, y                   ; bl = y

  mov cl, al                  ; cl = al = x, copy
  add al, bl                  ; al = x + y

  sub cl, bl                  ; cl = x - y

  mov ah, 0                   ; sign extension
  div al, cl                  ; al = (x + y) / (x - y)
 
* C = A logical B :

  mov ax, A
  and/or/xor ax, B
  mov C, ax
 
* B = (NOT A) and 1

  mov ax, A
  not ax
  and ax, 1
  mov B, ax


 * 2차 방정식 : ax ^ 2 + bx + c

수학에서 2차 방정식은 대입 값이 무제한이지만, 전산에서는 값을 제한해야하며 항상 오버플로를 감안해야하며, 어떤 항 (coeffient)이 제일 많이
쓰이는지를 고려하여 이를 활용한다. 위의 식에서는 x가 가장 많이 사용되고, 여기서는 오버플로, 상위워드는 무시하기로 한다.
a = 1, b = 2, c = 3, x = 4, y = ax^2 + bx + C

.model small
.stack 100h
.data
vara dw 1
varb dw 2
varc dw 3
varx dw 4
.code
main proc
  mov ax, @data
  mov ds, ax
 
  mov bx, varx
  mov ax, bx
  cwd
  mul bx            ; x ^ 2
  mov dx, vara      ; ignore hiword
  mul dx
  mov cx, ax        ; cx = partial product
 
  mov ax, varb
  mul bx
  add cx, ax        ; ax ^ 2 + bx
 
  mov ax, varc
  add cx, ax        ; ax ^ 2 + bx + c
 
 
exit:
  mov ax, 4C00h
  int 21h
main endp
end main

* max & min : mov - cmp - jmp 조합으로 쉽게 구할 수 있다.

.model small
.stack 100h
.data
vara dw 1
varb dw 2
varc dw 3
vard dw 4
.code
main proc
  mov ax, @data
  mov ds, ax
 
  mov ax, vara
  mov bx, varb
 
  cmp ax, bx                ; if (a > b)
  jbe L1
  jmp L2
 
L1:
  xchg ax, bx               ; ax = max
 
L2:

  mov cx, varc
  mov dx, vard
 
  cmp cx, dx                ; if (a < b)
  jae L3
  jmp L4
 
L3:
  xchg cx, dx               ; cx = min
 
L4:
 
 
exit:
  mov ax, 4C00h
  int 21h
main endp
end main

이는 분기 타겟 버퍼 (BTB)를 활성화하므로 다음처럼 분기 처리를 하지 않고 강제로 세팅해줄 수 있다. (source from : assembly gems)

;Summary:       eax = min (eax, ecx) (both eax and ecx unsigned)
;Compatibility: 386+
;Notes:     8 bytes, 4 clocks (P5), destroys ecx and edx
    sub    ecx, eax    ; ecx = n2 - n1
    sbb    edx, edx    ; edx = (n1 > n2) ? -1 : 0
    and    ecx, edx    ; ecx = (n1 > n2) ? (n2 - n1) : 0
    add    eax, ecx    ; eax += (n1 > n2) ? (n2 - n1) : 0
; Standard cmp/jbe/mov takes 2-8 clocks on P5 and 1-17 on P6

;Summary:       eax = max (eax, ecx) (both eax and ecx unsigned)
;Compatibility: 386+
;Notes:     9 bytes, 5 clocks (P5), destroys ecx and edx
    sub    ecx, eax    ; ecx = n2 - n1
    cmc            ; cf = n1 <= n2
    sbb    edx, edx    ; edx = (n1 > n2) ? 0 : -1
    and    ecx, edx    ; ecx = (n1 > n2) ? 0 : (n2 - n1)
    add    eax, ecx    ; eax += (n1 > n2) ? 0 : (n2 - n1)
; Standard cmp/jae/mov takes 2-8 clocks on P5 and 1-17 on P6

* 평균 구하기: (A + B) / 2


  mov ax, a           ; unsigned average            mov ax, a             ; signed average
  mov bx, b                                         mov bx, b
 
  add ax, bx                                        xor ax, bx
  rcr ax, 1                                         sar ax, 1
                                                    and ax, bx
                                                    add ax, bx



.model small
.stack 100h
.data
vara dw 1
varb dw 2
varc dw 3
vard dw 4
.code
main proc
  mov ax, @data
  mov ds, ax
 
  mov ax, vara
  mov bx, varb
 
  sub bx, ax              ; min
  sbb dx, dx
  and bx, dx
  add ax, bx
 
  mov ax, varc
  mov bx, vard
 
  sub bx, ax               ; max
  cmc
  sbb dx, dx
  and bx, dx
  add ax, bx
 
  mov ax, vara
  mov bx, varb
  add ax, bx                ; unsigned average
  rcr ax, 1
 
  mov ax, varc
  mov bx, vard
 
  xor ax, bx                ; signed average
  sar ax, 1
  and ax, bx
  add ax, bx
 
exit:
  mov ax, 4C00h
  int 21h
main endp
end main

12장 - 부호 확장과 마스킹 & 기타 잡동사니들

12장 - 부호 확장과 마스킹 & 기타 잡동사니들


알다시피 컴퓨터는 무한정의 데이터를 저장할 수 없다. 한상 한정된 양 (magnitude)으로 연산을 수행하는 기계이다. 주요 연산인 산술 연산을 어떤
데이터에 수행하면 자리 올림/내림 (carry) 문제가 발생할 수 밖에 없다. 십진수로 2 자릿수 (digit) + 2 자릿수 덧셈을 하다보면 그 결과는
4 ~ 5 유효 자릿수 (significant digit)가 될 수 있다. 반면 뺄셈을 하다보면 2 자릿수 - 2 자릿수 = -1 유효 자릿수가 될 수 있다. 곱셈은
2 자릿수 * 3 자릿수이면 두 자릿수를 더해서 유효 자릿수 (5)를 계산하고, 나눗셈은 큰 자릿수에서 작은 자릿수를 빼서 유효 자릿수를 구한다.

컴퓨터는 또한 음수/양수를 모두 저장할 수 있으며, 8비트 양수 (unsigned)일 경우 0, 1, 2, ...255를, 8비트 음/양 모두 포함수 (signed)일 경우
-128, -127,...-1, 0, 1,...126, 127 까지를 표현할 수 있다. 양수의 경우 255를 넘는 순간 캐리 플랙 (CF)을 토글하고, 음수일 경우 -1 (FFh)을
넘어갈 경우 CF를 토글한다. CF를 토글하면 자리 올림수는 CF에 저장되고 무시되며, 담을 수 있는 수만 담는다. 255 + 1 = 0, -1 + 1 = 0

음수/양수를 모두 다루는 수 (signed)는 중간에 7Fh (양수 127)와 80h (음수 -128)로 2진 레벨 1이 증가할 때 OF를 토글하여 부호가 바뀌었는지를
체크하고, 양수/음수 간의 전환 (wrap)이 일어난다. 즉, 127 + 1 = -128 = 80h. 하지만, 16진수 80h는 사인 비트 (MSb, 최상위/최좌측 비트)가
1이므로 엄밀히 음수이다. 음수의 크기 (magnitude)를 구하려면 절대치로 구하며 절대치는 2의 보수로 계산된다. 그러므로 80h의 크기를 알고
싶으면 1000 0000 (2진수로 사인 비트가 on이므로 음수다) -> 0111 1111 (뒤집어서) -> 1000 0000 (1을 더한다). 즉, 80h 는 2의 보수로도 80h라서
크기를 알수 없는 수라고 할 수 있다. 이 수를 십진수로 -128 (가장 큰 음수)라고 부르기로 했다.

음수이건 양수이건 간에 1 바이트 두수를 더한다고 2바이트가 되지는 않지만, 곱셈을 하면 그 결과물의 유효 자릿수가 배 (double)가 될 수 있으며,
나눗셈을 하면 그 절반 (half) 자릿수가 될 수 있다. 이런 이유로 부호 확장이 필요하다. 덧셈/뺄셈의 경우 캐리에 자리 올림/내림을 반영하면
되므로 크게 문제되지 않지만, 현대 컴퓨팅에서 단순히 덧셈/뺄셈만 하는 경우가 드물기에 또한 부호 확장을 고려해야한다.

n- 바이트 데이터에서 최상위 비트는 사인 비트로 사용되며, +/- 의 부호를 결정한다. unsigned는 암시적인 +가 붙은 데이터라고 할 수 있고 이는
0으로 상위 비트를 채워 부호를 확장한다. 예로, 8비트 80h: 1000 0000 -> 16비트 0080h: 0000 0000 1000 0000 처럼 앞에다 0을 붙여 부호를 확장
하며 이를 제로 확장 (0 확장, zero-extension)이라고 한다. 반면, 음수는 8비트 -80h: 1000 0000  -> 16비트 FF80h: 1111 1111 10000 0000 처럼
앞에 -1을 의미하는 1을 붙여 확장한다. 부호 확장은 원래의 데이터에 있는 최상위 비트를 확장될 상위 데이터 영역에 가득히 복사해주는 것이다.

부호 확장의 반댓말이 부호 축소(sign contraction)인데 위처럼 앞에 가상의 00 또는 FF가 붙은 데이터 만이 음/양을 결정할 수 있으므로, 0CAD,
F001 같은 데이터는 unsigned 레벨에서는 부호 축소를 할 수 없고, signed일 경우 축소할 경우 오버플로 (여기서는 언더플로)에 이르므로 역시
부호 축소할 수 없다. 우리는 부호 축소는 무시할 것이다.

어셈블리 코딩시 부호 확장은 명령으로 해결할 수 있으며, CBW/CWD/CWDE/CDQ 등의 명령이 우리 대신 부호 확장을 해주는 명령이다. 이는 데이터의
사인 비트를 체크하여 자동으로 사인 비트를 상위 데이터에 복사해넣는다. 하지만, CBW는 바이트 레지스터인 AL의 사인 비트 (bit 7)를 취해 워드
레지스터인 AX의 상위 바이트 (AH)를 가득 채워 워드 데이터로 변환한다. 즉, 1000 0000 -> 1111 1111 1000 0000. CWD/CWDE는 AX의 사인 비트
(bit 15)를 취해 각각 DX/EAX에 상위 워드를 채워 결국 더블워드 데이터로 만든다. CDQ는 EAX의 사인 비트 (bit 31)을 취해 EDX를 가득 채워 결국
쿼드워드로 둔갑시킨다.

부호 확장 자체가 그렇지만, 이들 명령은 덧셈/뺄셈에서는 별 효력이 없으나, 나눗셈에서 만약 이를 빠뜨리고 div/idiv으로 연산할 경우 #DE에 이를
수 있으므로 나눗셈에서는 선택이 아니라 필수다. 곱셈도 마찬가지로 연산하기 전에 먼저 부호를 확장해줘야 한다. (선 확장 후 연산)

이들 명령은 Accumulator를 암시적 레지스터로 사용하는 단점이 있다. 이 단점을 개선하여 나온 스트링 명령이 MOVSX/MOVZX이며 각각 0이나 1로
특정 연산자 (보통 16비트 이상의 레지스터나 8비트 이상의 메모리 주소)에 채운다. 하지만 이 명령은 masm에서 .386 이상의 프로세서 지시어를
써줘야 사용할 수 있는 단점이 있다. 이 명령의 64비트 버전이 MOVSX/MOVSXD 이며, MOVSX는 32비트에서도 사용된다.

부호 확장 명령이 각각 장단점이 있으므로 이를 우리는 에뮬레이트 할 수 있다. CBW를 에뮬레이트하면 대략 이렇다:

  test al, 10000000b
  <양수일 경우 연산>
  jz L1
  jmp Last
 
L1:
  mov ah, -1
  <음수일 경우 연산>
 
Last:

이 경우 L1 부분에 "mov ah, -1" 대신 "mov bl, -1" 식으로 다른 아무 레지스터라도 부호 확장이 가능하다. 다른 부호확장 명령도 이와 유사한 유사
코드로 흉내낼 수 있다.

또한 산술 연산을 하다보면 캐리가 발생할 경우 adc/sbb로 확장할 수 있다.

  <산술 연산>
  jc L1
  <캐리가 없을 경우 연산>
  jmp Last
 
L1:
  adc/sbb 확장할 레지스터, 0/1
 
Last:

논리 연산일 경우도 adc/sbb로 확장할 수 있으며 rcl/rcr로 캐리가 발생하면 확장할 수 있다.

  <논리 연산>
  jc L1
  <캐리가 없을 경우 연산>
  jmp Last
 
L1:
  adc/sbb 확장할 레지스터, 0/1
  rcl/rcr 확장할 레지스터, 연산자 크기
  loop L1

Last:

물론 논리 연산시 "mov X, -1"로 해줘도 무관하다. 이 방법은 특정 레지스터에 구속되지 않고 원하는 레지스터에 확장할 수 있는 장점이 있다.



위에서 다룬 명령과 수동 방식을 혼합하여 다양한 방법으로 부호 확장할 수 있다. 상수 0이나 -1로 채울 경우:

  mov al, 10110000b               =     mov al, 10110000b
  movsx ax, al                          cbw
 
  xor ax, ax          ; 양수로          mov ax, 0FFFFh          ; 음수로 = mov ax, -1
  mov al, 01010000b                     mov al, 10110000b

비트 복사해서 쉬프트:

  mov al, 양수        ; 양수로          mov al, 음수      ; 음수로
  mov ah, al          ; 복사            mov ah, al        ; 복사
  shr ax, 8           ; 논리 쉬프트     sar ax, 8         ; 산술 쉬프트

비트 복사해서 마스킹 값 쉬프트:

  mov al, 양수/음수 무관
  mov ah, al                            ; 복사
  and ah, 80h                           ; 마스킹 (and x, 1000 0000)
  sar ah, 8                             ; 쉬프트

32비트 부호 확장:

  xor eax, eax                          mov al, mem
  mov al, mem/imm8                      cwd/cwde
 
  mov edx, eax
  shr edx, 31

메모리 데이터 확장:

  movsx cx, Byte          ; Byte -> Word
  movzx Word, Byte        ; Byte -> Word
  movsx ebx, Word         ; Word -> Dword
  movzx Dword, cx         ; Word -> Dword

메모리 -> 메모리 확장: rep movs

간혹 서로 크기가 다른 연산자로 연산을 할 경우가 생긴다. 이럴 경우는 작은 연산자를 큰 연산자로 부호 확장하여 연산한다. 작은 연산자를 큰
연산자로 부호 확장할 때, signed 연산인 경우 부호 확장, unsigned 연산인 경우 제로 확장을 해준다.

  mov al, Unsigned        ; unsigned 덧셈           mov al, Signed        ; signed 덧셈
  mov ah, 0                                         cbw
  add ax, Extended                                  add ax, Extended

어쩔땐 부호를 먼저 확장해야할 지 아니면 연산을 먼저 해야할 지 결정하기 힘들 때도 있다. 이럴 경우 연산을 수행후 (후) 확장을 해준다.

 
  add al, myVar
  adc ah, 0
 
만약 특정 레지스터가 어떤 값을 지녀야 할 경우 보조 레지스터를 사용하여 부호 확장한다.

  mov bx, ax            ; 복사
  mov al, myVar
  cbw
  add ax, bx
 
만약 보조 레지스터마저 여의치 않으면, 직접 확인하는 수 밖에 없다.

  add al, myVar           ; 로드
 
  cmp myVar, 0            ; 0 ?
  jge ZeroExtend          ; no
 
  adc ah, 0FFh            ; yes
  jmp Next
 
ZeroExtend:
Next:

물론 임시로 스택에 확장할 보조 레지스터를 저장해도 좋다.

  push bx                   ; 백업
  mov bx, ax                ; 복사
  mov al, myVar             ; 로드
  cbw                       ; 확장
  add ax, bx                ; 연산
  pop ax                    ; 복원
 
물론 메모리에 임시로 저장해두고 확장해도 된다. 이 방법은 주로 고급 언어에서 사용하는 방법이다.

  mov mem, ax               ; 백업
  mov al, myVar
  cbw
  add ax, mem               ; 로드해서 연산

만약 signed 데이터일 경우 변수를 두개 선언하고 필요에 따라 확장한다.

  myByte db ?
  myWord dw ?
 
  mov al, myByte
  cbw                         ; byte -> word
  cwd                         ; word -> Dword
  add ax, word ptr [myWord]
  adc dx, word ptr [myWord+2]
 
물론 386 이상이라면 다음 코드를 고려해보자.

  movsx eax, Var1
  add eax, Var2
 
386 이상에서 쿼드워드에 연산한다면 다음을 고려해보자.

  myByte  db -1
  myQWord dq 1
 
  movsx eax, myByte
  cdq                             ; Dword -> Qword
  add eax, dowrd ptr myQword
  adc edx, dword ptr myQword+4
 
그래도 안된다면 쉬프트와 로테이트로 연산하여 스택에 저장해가면서 연산하자. 이외에도 다양한 방법이 있으며 몇가지 흔히 볼 수 있는 것만 예로
들었을 뿐이다. 특정 연산과 결합하는 경우를 살펴보자. 먼저 산술 덧셈 연산의 예,

L1:
  movsx ecx, byte ptr [edx]             ; edx의 바이트 데이터를 ecx로 확장
  add ecx, eax                          ; (s) ecx + eax
  dec edx                               ; next
  loop L1

뺄셈:

  movsx ecx, bl                          movzx eax, byte ptr [eax]      ; (z) eax
  sub ecx, 10h                           movzx edx, byte ptr [edx]      ; (z) edx
                                         sub eax, edx                   ; (z) (eax - edx)
                                        
곱셈:

  movsx edx, dl         ; (s) edx <- dl
  mov ebx, ecx          ; multiplier
  imul ebx, edx         ; (s) ebx (high) * (s)edx (low)
 
나눗셈:

  movzx eax, ax         ; (z) eax (dividend low)
  mov ecx, 10h          ; divisor
  xor edx, edx          ; (z) edx (high)
  div ecx               ; edx (R): eax (Q)
  mov ebx, edx          ; 나머지 (R)만 저장
 
스택 액세스:

  movzx cx, 63h
  push ecx               ; (z) ecx, (z) eax
  movzx eax, ax
  push eax
 
덧셈 & 나눗셈:

  movzx eax, byte ptr [edx+eax]     ; (z) eax (low)
  movzx edx, byte ptr [edx]         ; (z) edx (high)
  add eax, edx                      ; (z) eax + edx
  mov ecx, 0Ah                      ; divisor
  cdq                               ; reset edx
  idiv ecx                          ; R:Q = edx:eax
  add edx, 30h                      ; R + 30h
  mov eax, edi                      ; next dword
 
곱셈 & 나눗셈

  mov ecx, 7                        ; divisor
  cdq                               ; -> edx:eax
  idiv ecx                          ; R:Q = edx:eax
  imul edi                          ; eax * edi = edx:eax
  add eax, 0FFFFFFFDFh              ; eax - 21
  mov edi, eax                      ; save to edi
  add eax, edi                      ; double edi
 
논리 연산의 예를 살펴보자.

  movzx ecx, cx                      movzx si, al                           mov eax, edx
  shl ecx, 08                        mov al, dl                             cdq
  or ebp, ecx                        and eax, 01                            sub eax, edx
                                     xor esi, eax                           mov edx, dword ptr [edi * 4]
                                                                            sar eax, 1
                                                                           
몇가지 역어셈 코드에서 인용했지만, 보다시피 부호 확장은 선택이 아니라 거의 필수다. 이외에도 지난 번에 다룬적 있는 니블 스와핑이나 bswap,
xchg, xchgadd 등으로도 해줄 수 있으며, 아예 룩업 테이블을 만들어두고 xlat으로 부호를 확장하거나 bt/btr/bts 등의 비트 세팅 명령을 부호
확장에 사용하기도 한다.  



* 마스킹

다른 값에 있는 비트를 이용하여 특정 값을 0이나 1로 강제로 만드는 것을 masking이라고 한다. 강제로 세팅할 비트를 제외하고는 건드리지 말아야
한다. 그러한 이유로 마스킹에 쓸 특정 비트를 꺼내서 (extract) 집어넣는 (insert) 과정이 필요하다. and 연산자는 마스킹 비트를 0으로 많이
산출하므로 마스킹 되기전의 소스가 1이라면 1로 마스킹하는 경우만 1을 유지한다. 그 결과 1이 아닌 비트는 0으로 변하며 변한 비트를 마스킹되어
떨어져 나간 비트 (masking out bits)라고 한다.

우리는 이전에 아스키 변환을 다뤘으므로 '0' ~ '9'가 ASCII 30~39h 에 각각 매칭되는 것을 알고 있다. 여기서 30h를 빼면 2진 값이 되므로 이를
0Fh로 and 마스킹하면 하위 니블만 얻어지고 그 값을 사용하면 된다. 물론 sub으로 30h를 빼줘도 되지만 사람들은 and를 더 선호한다. 특정 비트를
0으로 만들어 버리는 and와는 반대로 특정 비트를 1로 만들면 그 비트는 마스킹되어 세팅된 비트 (masking in bits)가 된다. 마찬가지로 0~9의
이진값을 30h로 or시키면 이 아스키 코드가 산출된다. 대문자/소문자 변환도 마찬가지 원리가 적용된다. 특정 데이터가 확연히 블럭으로 구분된다면
이 또한 마스킹을 적용할 수 있다. 그런 예로 압축 데이터가 해당한다.

이미 마스킹은 앞장에서 여러번 해봤으므로 따로 예를 들지 않아도 이해될 것이다.

* 복정밀 NOT 과 NEG

not 명령은 모든 비트를 뒤집는다. 이 명령은 아무 플랙에 영향주지 않으므로 조건 분기는 의미가 없다. 복정밀 not 연산은 간단하다. 변환하려는
대상 연산자를 모두 not 명령으로 뒤집어주면 된다.

  not ax                          not dx
  not dx                          not ax
 
not의 성격상 이 명령을 반복하면 다시 되돌리는 격이 된다. 이는 "xor X, 0FFh", "xor X, 0FFFFh" 등으로 해줄 수도 있다. not과 비슷하면서도
조금 복잡한 것이 음수로 만드는 복정밀 neg이다. 이는 neg + sbb 조합으로 해결할 수 있다. 0에서 특정 연산자를 빼주는 원리릴 이용한 것이다.
빼준다면 sub과 역할이 비슷하므로 다음처럼 복정밀 neg를 수행할 수 있다.

  neg dx                    ; 하위 워드를 음수로 변환해서
  neg ax
  sbb dx, 0                 ; 바로우가 일어나면 dx를 1 추가로 감소
 
문제는 자릿수가 많아져 반복 코드가 늘어날 때 오타가 자주 발생하는 점이 문제이다. AoA에서 인용한 386이상의 128비트 복정밀 neg코드는 이렇다.

myVar dd 0, 0, 0, 0         ; 128 비트 정수
 
  neg myVar+12              ; 최상위 dword
  neg myVar+8               ; 다음
  sbb myVar+12, 0           ; 상위 dword로 조절
 
  neg myVar+4               ; 2번째 dword
  sbb myVar+8, 0            ; 3번째 dword로 조절
  sbb myVar+12, 0           ; 최상위 워드에 바로우 뺄셈
 
  neg myVar                 ; 하위 워드
  sbb myVar+4, 0            ; 2번째 dword로 조절
  sbb myVar+8, 0            ; 3번째 dword로 조절
  sbb myVar+12, 0           ; 하위 워드로 바로우 뺄셈
 
이럴 경우 매번 neg 연산할 때마다 캐리를 전송해야 하므로 속도가 느려질 수 있으므로, 0에서 값을 빼는 방법으로 neg를 수행할 수 있다.

myVar dd 0, 0, 0, 0, 0      ; 160 비트 정수

  mov eax, 0
  sub eax, myVar            ; 0 - myVar 하위
  mov myVar, eax            ; 저장
 
  mov eax, 0
  sbb eax, myVar+4          ; 다음
  mov myVar+8, ax           ; ax
 
  mov eax, 0
  sbb eax, myVar+8
  mov myVar+8, ax
 
  mov eax, 0
  sbb eax, myVar+12
  mov myVar+12, ax
 
  mov eax, 0
  sbb eax, myVar+16
  mov myVar+16, ax

* xor로 부호 테스트

특정 값의 부호를 절대치로 구할 수 있으나 또한 사인 비트를 xor하여 음/양을 결정할 수 있다. 물론 xor이 절대치를 구해주진 않는다.

  mov al, byte ptr [op1+3]
  xor al, byte ptr [op2+3]
  mov cl, al                ; 부호 저장
 
  <기타 연산>
 
  cmp cl, 0                 ; 0 ?
  jns L1
 
  <음수 변환>
 
L1:
  <양수 처리>
 
* 플랙 테스팅

test 명령은 암시적 and 연산이며 결과를 버리는 점이 and와 다르다. 그러므로 and 연산으로 하던 것을 test 명령으로 미리 알아볼 수 있다. 그러므로
특정 데이터의 여러 비트를 여러번 테스트 할 수 있다. 

  test ax, 1
  jnz Bit0
  test ax, 2
  jnz Bit1
  test ax, 4
  jnz Bit3
  ...
 
하지만 test 명령이 결과를 갱신하지 않으므로 test/cmp를 조합해서 여러번 플랙을 테스트할 순 없다. 그런 이유로 비트 그룹에서 특정 한 비트를
측정하는 용도로 사용된다. 하지만, .386 이상에선 bt 명령으로 멀티비트를 개별적으로 테스트할 수 있다. test 명령은 제로 플랙을 테스트하는데
특히 유용하게 사용되며, "test X, X" 처럼 같은 연산자를 테스트해주면 된다. 이는 "cmp X, 0" 대체 명령이다. 물론 and/or 등의 논리 연산이랑
test를 조합해서 사용하면 의미가 없다. 486 이상의 파이프라인 실행이 가능한 프로세서에서는 결과를 반영하지 않으므로 위험을 미리 방지해 주는
역할도 수행한다.

복정밀 메모리 데이터라면 레지스터에 로드하여 test보다는 and로 테스트할 수 있다.

  mov ax, word ptr [var]
  and ax, word ptr [var+2]
  and ax, word ptr [var+4]
  ...
  cmp ax, word ptr [var+N]
  cmp ax, 0FFFFh
  je Is0FFFFh

and 명령은 2의 n승 -1 까지의 변수 카운터를 구현할 수 있다.

  inc myCounter
  and nBits
 
여기서 nBits는 우측에서 부터 비트를 누적하면 된다. 예로,

  inc myCounter
  and myCounter, 00001111b        ; 0~15까지 카운트
 
또한 and로 Mod 2^n 나머지를 구하는 것은 이전에 다뤘다.
 
* 복정밀 비교

애석하게 캐리를 감안한 비교 명령은 없다. 더구나 cmp, sub 두 명령이 같은 플랙으로 테스트를 하므로 sbb 명령과 혼합해 쓰기도 어렵다. 이 문제는
AoA를 직접 참고하기 바란다.

2010년 1월 22일 금요일

11장 - 디지털 훌라후프 - 쉬프트와 로테이트

11장 - 디지털 훌라후프 - 쉬프트와 로테이트

사람이 인공적으로 만들어낸 사물은 대부분 네모나거나 직선형이다. 하지만 자연이 만들어낸 사물은 대부분 동그랗거나 원형에 가깝다. 우리가 여기서
다룰 쉬프트 (shift)와 로테이트 (rotate) 명령은 원형적 사고방식을 적용하면 이해하기가 쉽다. 이 둘은 사전상 "회전하다"는 뜻을 가진다. 둘간의
차이가 있지만 비트를 회전하는 점에서는 같다. 여기 8인치 줄자가 있다.

<그림>


각 비트 번호는 오른쪽에서 왼쪽으로 0번부터 메겼다. 우리는 이를 바이트라고 부르며, 8비트 속에 비트8은 없다. 이중 제일 왼쪽 끝 비트를 MSb, 
제일 오른쪽 끝 비트를 LSb라고 부른다. 이 네모난 비트 박스에 0 이 들어갈지 1 이 들어갈지는 정의하기 나름이다. 또한 이 바이트가 무엇을
담을지는 상황에 따라 다르며, 만약 산술 연산에 사용될 숫자를 의미한다면, MSb = sign bit가 된다. 익히 알고 있는 내용일 것이다.

이제 이 비트 덩어리에서 왼쪽 끝 / 오른쪽 끝 비트에 접착제로 붙여서 띠를 만들어 허리에 두를 수 있다고 하자. 물론 8인치가 너무 작으면
16인치나 32/64인치 허리띠도 있으니 상황에 따라 필요한 것을 취하면 되니 길이는 무시하기로 하자. 이제 우리는 원형으로 만들었으니 이 비트
덩어리를 회전시킬 수 있다.

왼쪽으로 회전시키는 명령이 shl/sal/rol/rcl 이고, 반대로 오른쪽으로 회전시키는 명령이 shr/sar/ror/rcr이다. 좌우 어느 방향으로 회전하든간에
비트 위치는 고정되며, 데이터가 들어있는 네모난 비트 박스만 이동된다고 생각하자. 플랙표는 이렇다:

<그림>


똑똑한 사람들은 이를 다 외우지만, 혹사시킬 뇌세포가 부족한 사람은 이를 이해한다. 최소 8비트 이상의 비트를 회전할 것이므로 AF의 의미는 없다.
또한 회전을 할 때마다 비트 레벨 자리 올림이 일어날 수 있으므로 CF를 항상 리셋한다. 로테이트 명령은 비트를 유지하면서 회전하는 것이므로
SF/ZF/PF에 영향을 줘서는 안된다. 반면 쉬프트 명령은 회전할 때마다 비트를 리셋하므로 SF/ZF/PF는 결과에 따라 리셋된다. 1비트만 회전할 경우
OF 마저 리셋하며 그 이상을 회전할 경우 OF는 어찌될지 알 수 없다.

shl/sal 명령은 "shl/sal d, c" (d: dest, c: count) 일반 포맷으로, c에 정해준 수만큼 왼쪽으로 비트를 회전한다. 두 명령은 같은 명령이며, 두
명령의 기계어까지 같다. debug.exe는 sal을 허용치 않으며, 만약 signed 데이터일 경우 형식적으로 sal (shift arithmetic left)을 사용한다.
떨어져나가는 제일 왼쪽 끝 비트는 CF에 복사되고, 채워져 들어오는 제일 오른쪽 끝 비트는 0 으로 채워진다.

<그림>




위의 플랙표를 참조하면, ZF 플랙도 갱신한다고 되어있다. 어떤 수를 왼쪽 쉬프트하여 0으로 만드는 경우는 오버플로를 일으키는 경우이다. 이는 80h
이상의 수를 쉬프트할 때 발생한다. 예로, 80h/8000h/8000000h에 해당하는 수를 1비트 쉬프트하면 바이트/워드/더블워드 레벨에서 담을 수 없으므로
OF가 토글되고, 쉬프트 됨과 동시에 0 이 되므로 ZF가 토글되며, 또한 최상위 비트인 1이 떨어져 나가니 CF까지 함께 토글하며, MSB에서 패러티가
짝수 (0)로 변하므로 PE 플랙까지 함께 토글하며, 또한 최상위 비트인 사인 비트가 떨어져 나가므로 SF 까지 토글한다. 즉, 80h를 쉬프트하는 것은
CPU 입장에서 일종의 혁명 (?)이 일어난 셈이다. 

  mov al, 80h         ; al = 1000 0000
  shl al, 1

80h는 음수도 아닌 것이 양수도 아닌 것이 음수도 되고 양수도 되는 중성적인 존재이다. 결국 어떠한 수이건간에 여러번 shl하다보면, 언젠가는 0이
될 것이다.

  mov al, 01h         ; al = 01
  mov cl, 80h         ; 80h (128)번 루프도세요 (cl = 루프 카운터)
L1:
  shl al, 1
  loop L1

이 경우 결국 어느 시점에서 0이 될 것이며, 그 이상은 공회전하는 역할이다. 보통 mov 명령으로 cl에 쉬프트 카운터를 정해주는데 cl이 8비트
레지스터라고해서 8비트 값을 모두 정해봤자 의미가 없다. 왜냐하면 FFh (255)로 정해준다면, 쉬프트 중간에 이미 0이 되므로 더 이상 쉬프트할
필요가 없기 때문이다. 즉, 데이터가 8/16/32비트이면, 그 데이터에 따라 7/15/31이 최대 유효 쉬프트 카운트이다.

이 시점에서 CPU 매뉴얼을 인용하자.

IA-32 Architecture Compatibility

The 8086 does not mask the shift count. However, all other IA-32 processors (starting with the Intel 286 processor) do mask
the shift count to 5 bits, resulting in a maximum count of 31. This masking is done in all operating modes (including the
virtual-8086 mode) to reduce the maximum execution time of the instructions.

32비트 이하 모드 (이를 호환 모드라고 한다)에서 8086 CPU는 쉬프트 카운트를 마스킹하지 않는다. 하지만, 286 이후부터의 모든 IA-32 프로세서는
쉬프트 카운트를 5 bits로 마스킹하여 최대 쉬프트 카운트를 31까지만 허용한다. 결과적으로 모든 운영 모드 (V86 모드도 포함)에서 shl/sal 명령의
실행에 걸리는 시간을 단축시켰다.

이 시점에서 코드로 이를 확인하자.

  mov al, 1
  mov cl, 32            ; 쉬프트 카운터
  shl al, cl            ; al = 01
 
  mov bl, 1
  mov cl, 31
  shl al, cl            ; al = 00
 
  mov bh, 0FFh
  mov cl, 32
  shl al, cl            ; al = 0FFh

보다시피 쉬프트 카운터를 32 이상으로 정해주면 쉬프트가 일어나지 않으며 값이 그대로 유지된다. 그렇다면 64비트 모드에서는 마스킹하지 않는다는
소린가 ? 애석하게도 내 컴이 아직 32비트라서 테스트해볼 수가 없었다. 솔직히 말해서 32비트 이상을 쉬프트할 일도 별로 없다. 하지만, 만약 32비트
이상을 쉬프트 한다면, 중간에 끊어서 다시 쉬프트해주거나 위처럼 루프로 돌리면 된다. 참고로 "shl al, 2"처럼 쉬프트나 로테이트 명령에 2이상의
imm 값을 정해주려면 masm에서 .286 이상의 프로세서 지시어를 써야한다. 그 이전의 프로세서에서 2이상의 쉬프트를 하려면, cl에 정해주던가
아니면 다음처럼 "shl X, 1" 명령의 반복 코드를 써왔었다.

  mov al, 1
  shl al, 1
  shl al, 1
  shl al, 1
  shl al, 1

비록 코딩량은 좀 늘어나지만, "shl X, 1" 명령은 상대적으로 빠른 명령 (386 3클럭, 펜티엄 이상 1클럭)이라서 문제될 것이 없다.

"shl X, 1" 명령은 왼쪽으로 비트를 격상 (promotion)시키므로, 오버플로가 발생하지 않는 범위 내에서 결국 2의 배수를 곱한 값이 된다. 그런
이유로 2의 배수를 곱한다 싶으면 상대적으로 느린 mul/imul을 대신하여 "shl X, 1"을 써왔다. 반면 "shl X, 0"을 하면 어떨까 ?

  mov al, 12
  shl al, 0                ; 인밸리드래...ㅡ,.ㅡ

이렇게 해주면, masm은 친절히 invalid instruction operands 라고 알려주며, debug.exe는 친절히 거부해버린다. shl "X, Y"의 8비트 레벨 매직
넘버를 나열하면 이렇다.

Y:01 - X * 2
Y:02 - X * 4
Y:03 - X * 8
Y:04 - X * 16
Y:05 - X * 32
Y:06 - X * 64
Y:07 - X * 128
Y:08 - X * 256

위에서 8비트 레벨의 맥시멈 쉬트프 카운터는 7이라고 했으므로, 이 카운터를 8로 정해주는 것은 결국 1 0000 0000 으로 만드는 것과 같다. 즉,
"mov al, 0" 다만 플랙을 OF/CF/PF/ZF을 세팅하는 점이 다를 뿐이다. 이는 쉬프트하다가 중간에 플랙을 테스트하여 브레이크를 걸 수 있다는 뜻이다.

  mov al, 01h         ; al = 01
  mov cl, 50h         ; 대충 넉넉히 잡아주고
 
L1:
  shl al, 1
  jo L2               ; 오버플로가 일어나면 (al = 80h) 브레이크
  loop L1
 
L2:
  shl al, 1
  jo L3               ; 오버플로가 일어나면 (al = 00) 다시 브레이크
  loop L2
 
L3:

이 경우 오버플로 플랙을 테스트하여 브레이크를 걸었다. 즉, 바이트 레벨 최상위 비트가 세팅되어서 브레이크가 걸린 셈이다.


shl로 상수 곱셈을 대신하는 경우는 이전에도 설명했다. 예로, al = al * 9를 한다고 하자.

  mov al, 8
  mov bl, al            ; 복사해서
  shl al, 1             ; al = al * 2
  shl al, 1             ; al = al * 4
  shl al, 1             ; al = al * 8
  add al, bl            ; 붙여넣기

shl을 쓰는 이유가 "mul al, 9" 보다 빠르기 때문에 이렇게 해왔다. 산술 쉬프트인 sal은 쉬프트를 하는 점은 유사하지만, 사인 비트를 유지한다.
여기서 사인 비트라 하면 음수일 경우 -를 의미하려 채워지는 모든 사인 비트를 의미한다. 코드로 확인하자.

  mov al, 0FFh
  mov cl, 80h
 
L1:
  sal al, 1
  loop L1

초기에 al = 0FFh (-1)을 정해주고 루프에서 "sal al, 1"로 한번씩 쉬프트했다. 그 결과,

al = 0FFh                 ; 1111 1111 (-1)
al = 0FEh                 ; 1111 1110 (-2)
al = 0FCh                 ; 1111 1100 (-4)
al = 0F8h                 ; 1111 1000 (-8)
al = 0F0h                 ; 1111 0000 (-16)
al = 0E0h                 ; 1110 0000 (-32)
al = 0C0h                 ; 1100 0000 (-64)
al = 080h                 ; 1000 0000 (-128)
al = 000h                 ; 0000 0000 (-256 = 0)

보다시피 sal은 shl과 똑같이 작동한다. 실제로 디버그로 sal을 트레이싱하면 shl로 변환해서 보여준다. 



and/or 등의 비트 연산 명령으로 특정 비트를 복사해서 붙여넣기 방식을 배웠다. 이들 명령이 쉬프트와 혼합하면 더욱 놀라운 일을 하기도 한다.

  xor ax, ax                  ; ah = al = 0
  mov al, 16                  ; al = 0001 0000
  shl al, 1                   ; al = 0010 0000
  and al, 0F0h                ; al = 0010 0000
  or ah, al                   ; ax = 0010 0000 0010 0000
 
이 경우 al의 값을 shl로 1비트 쉬프트하여 ah에 그대로 복사했다. 또한 다른 레지스터와 결합하여 더욱 놀라운 일을 하기도 한다.

  mov ax, 1234h               ; ax = 1234h
  xor bx, bx                  ; bx = 0000h

  mov bl, 0AAh                ; bl = 1010 1010
  shl bl, 1                   ; bl = 0101 0100
  and bl, 0F0h                ; bl = 0101 0000
  mov bh, bl                  ; bx = 0101 0000 0101 0000
  or ax, bx                   ; ax = 5274h
 
이 경우 bl로 비트 교차 패턴 (AAh, 55h)으로 마스킹해주고 하위 니블을 지우고 bh에 마스킹 패턴을 복사하여 ax를 or로 비트합산해줬다. 



사회적으로 높은 지위로 오르기 위해 왼쪽만 보고 달렸다면, 이제 과거를 돌이켜보려 오른쪽도 살펴봐야 공평할 것이다. shr/sar은 비트를 오른쪽으로
쉬프트하며, shl/sal과 반대 방향이다. 쉬프트하여 떨어져나가는 오른쪽 비트 (LSb)는 캐리 플랙 (CF)에 저장되고, 최상위 왼쪽 비트 (MSb)는 0으로
채워진다. shr/sar로 비트를 쉬프트하다보면 특정 비트가 다른 비트 위치로 이동하거나 숫자로 의미를 가지는 데이터는 2의 배수로 나눠지는 역할을
한다. 즉, X = X / power of 2 이며, 이를 그림으로 그려보면 이렇다:

<그림>



보다시피 떨어져나간 마지막 비트는 CF에 저장된다. shr/sar의 차이는 사인 비트 (MSb)를 어떻게 세팅하냐로 판가름난다. shr은 가장 왼쪽 끝 비트를
0 으로 지운다. sar은 양수일 경우는 0으로 지우지만, 음수일 경우는 1로 채운다. sar가 1로 채우는 경우는 최상위 비트가 1로 세팅되어 음수로
인정하기 때문이다.

  mov al, 0FFh              ; al = 1111 1111
  sar al, 1                 ; al = 1111 1111, CF = 1, SF = 1, PF = 1
  shr al, 1                 ; al = 0111 1111, CF = 1, OF = 1, PF = 1, OF = 1
 
이 경우 모든 비트가 1로 세트된 상태이므로 sar로 1비트 오른 쉬프트를 하더라도 사인 비트가 1로 채워지므로 결과적으로 값이 변하지 않지만,
shr는 부호와는 상관없이 무조건 0으로 채우므로 결국 7Fh로 반감된다. 7Fh는 양수이며, 127이다. 결국 -1 / 2 가 shr 일땐 + 127 이, sar 일땐
-1이 되었다는 얘기다. 255 / 2 = 127.5인데 나머지를 무시한 (chopped, truncated) 나눗셈이 되었다. 만약 div/idiv으로 FFh를 다음처럼 나누면,

  mov al, 0FFh
  mov bl, 2
  div/idiv bl               ; ax = 017F (ah = 나머지, al = 몫)
 
같은 결과가 될 것이다. shr는 어느 정도 수긍이 가지만, sar는 -1을 2로 나눠 -1이 되었다는게 조금 이상할 것이다. 0이 되어야 하지 않나 ?
이 시점에서 CPU 매뉴얼을 꺼내보자. 조금 장문이니 긴장하고 읽어보자.

Using the SAR instruction to perform a division operation does not produce the same result as the IDIV instruction.
The quotient from the IDIV instruction is rounded toward zero, whereas the “quotient” of the SAR instruction is
rounded toward negative infinity. This difference is apparent only for negative numbers. For example, when the IDIV
instruction is used to divide -9 by 4, the result is -2 with a remainder of -1. If the SAR instruction is used to
shift -9 right by two bits, the result is -3 and the “remainder” is +3; however, the SAR instruction stores only
the most significant bit of the remainder (in the CF flag).

- sar로 2씩 나눌 때는 idiv으로 나눌 때와 결과가 다를 수 있다. idiv의 몫은 0으로 라운드되지만, sar의 "몫"은 음의
무한대로 라운드된다. 이 둘의 미묘한 차이는 음수 나눗셈을 할 때 확연히 드러나는데, 예로 들면, -9를 4로 idiv으로
나누면 결과는 몫이 -2, 나머지가 -1이 되는 반면에, sar로 -9를 2비트 오른 쉬프트하면, 결과는 -3이 되고, 나머지는 +3이
된다. 하지만, sar 명령은 나머지의 가장 중요한 비트만 CF에 저장한다.

쉽게 해석하려고 노력했는데 무슨 소린지 이해가 가는가 ? 해석한 나 또한 모르겠으니 코드로 직접 나눠보자.

  xor ax, ax
  mov bx, ax
  mov dx, ax
 
  mov al, -9        ; F7
  cbw               ; FFF7
  mov bl, 4         ; FFF7 / 4
  idiv bl           ; ah = 나머지 (FF = -1), al = 몫 (FE = 1111 1110 -> 2의 보수로 0000 0001 + 1 = 0000 0010 = [2])
 
  mov dl, -9        ; dl = F7 = 1111 0111 -> 2의 보수로 0000 1000 + 1 = 0000 1001 = [9]
  sar dl, 1         ; dl = FB = 1111 1011 -> 2의 보수로 0000 0100 + 1 = 0000 0101 = [5]
  sar dl, 1         ; dl = FD = 1111 1101 -> 2의 보수로 0000 0010 + 1 = 0000 0011 = [3]
 
idiv 의 경우 -9 / 4 = -2 (몫), -1 (나머지) 이고, sar의 경우 -9 / 4 = -3 (몫), +3 (?) 몫은 이해가 가는데 나머지는 어떻게 측정했는지
도무지 감이 안온다. sar 회전시 두번 다 SF/ZF/PF/CF를 토글했음은 알 수 있었다. 여튼 한가지 분명한 것은 음수중 절대치가 홀수인 수를
여러번 sar 하는 것은 라운드 문제로 인해 오해의 소지가 있음을 알 수 있다. 반면, 다음처럼

  mov al, -16h      ; F0
  sar al, 1         ; F8 = -8
  sar al, 1         ; FC = -4
 
절대치가 짝수인 음수는 아무 문제가 없음을 알 수 있다. 그런데 -9/4의 sar 했을 때 나머지가 무슨 근거로 +3이라고 했는지는 여전히 미지수다.
피젯수 (dividend)가 양수일 경우에 사람들은 익숙하므로, -1 을 2로 정수 레벨 나눗셈을 한다면 0이 될 것이라고 생각한다. 

  mov ax, 1
  mov bx, -1
  sar ax, 1                   ; 0000h, 1 / 2 = 0
  sar bx, 1                   ; FFFFh, -1 / 2 = -1
 
그렇다면, 피젯수 (dividend)가 음수이며 절대치가 홀수일 땐 특별한 처리를 해줘야 한다는 얘기다. Michael Abrash는 다음처럼 음수일 경우 홀/짝에
관계없이 1을 더해서 해결하는 방법을 알려준다.

  mov ax, -1                  ; 음수라면
  and ax, ax                  ; 사인 플랙을 체크하고
  jns L1                      ; 양수일 땐 정상적인 나눗셈
  add ax, 2 - 1               ; 절대치를 컴파일 타임시 - 연산자로 빼준 값을 더해준다. (즉, 1을 더해준다)
 
L1:
  sar ax, 1                   ; 이제 음수든 양수든 2로 나눈다.
 
이 아이디어와 앞장에서 배운 "cmp X, 1" 방법을 응용하여 우리는 다음처럼 해결할 수도 있다.

  mov ax, -1                  ; 음수라고 하자
  cmp ax, 1                   ; 마지막 비트를 테스트하여
  jz L1                       ; 짝수면 L1으로
  add ax, 1                   ; 홀수면 1을 더하고
 
L1:
  sar ax, 1                   ; 음수든 양수든 2로 나눈다.
 
sar 명령은 부호를 유지하면서 쉬프트하므로 사인 확장에 요긴하게 쓰인다. 1비트씩 sar 할 때마다 사인 비트가 차곡 차곡 쌓여가니 8번 sar하면
결국 모든 비트가 부호 비트로만 채워진다. "sar ax, 8"이라고 하면 al의 사인 비트가 16비트 ax로 확장되며, ah는 지워진다.

  mov al, 81h        ; -127
  mov cl, 8          ; 쉬프트 카운터
  sar ax, cl         ; ax = 0, OF = 0, SF = 0, ZF = 0, CF = 1
 
하지만 부호 확장을 하려면 상위 데이터의 MSb만 남겨둬야하므로 바이트/워드/더블워드로 확장하는 명령인 CBW/CWD/CDQ는 다음처럼 -1을 빼고 쉬프트
해줘야 한다.

  mov ah, al         ; 복사             mov dx, ax      ; 복사        mov edx, eax      ; 복사
  sar ah, 7          ; = CBW            sar dx, 15      ; CWD         sar edx, 31       ; CDQ

물론 .286 이상의 프로세서 지시어를 쓰지 않으면 cl에 쉬프트 카운터를 정해줘야 하고, 다음처럼 다른 레지스터에 부호를 확장할 수 있다.

  mov cx, bx         ; 복사
  sar cx, 15         ; CWD (CX:BX)
 
이외에도 특정 비트를 복사하여 붙여넣기 방법과 .286 이상부터 .movsx/movzx 등으로 확장하는 방법은 나중에 더 다루겠다.



sar를 이해했다면 shr는 더 쉽다 오른 쉬프트를 하되, 제일 왼쪽 비트를 0으로 채운다. 그 결과 2로 나눈 효과가 된다.

  mov al, 0FFh                ; al = 1111 1111 (FFh, 255)
  shr al, 1                   ; al = 0111 1111 (7Fh, 127)
  shr al, 1                   ; al = 0011 1111 (3Fh, 63)
 
보다시피 한번씩 쉬프트할 때마다 왼쪽에 추가되는 비트는 0이다. shift 연산시 매번 캐리 플랙 (CF)을 리셋하므로 만약 CF를 캐리로 더해주거나
빼주면 어떨까 ?

  mov ax, 0FFFFh              ; FFFFh, 양수 65535라고 하자
  mov cl, 4
  shr ax, cl                  ; 0FFFh, = 4095, 65535/16 = 4095.9375
  adc ax, 0                   ; 마지막 캐리를 더하자. (ax = 4096)
 
보다시피 간단히 반올림 (round up) 처리를 했다. 간혹 and와 or은 쉬프트/로테이트 연산에 날개를 달아준다.
 
  mov al, 0AAh                ; al = 1010 1010
  mov cl, 4                   ; 쉬프트 카운터
  shr al, cl                  ; al = 0000 1010
  or al, 30h                  ; al = 0011 1010 = 3A

이번엔 니블 단위로 뒤집어보자. 물론 rol 명령으로 할 수 있지만, 여기서는 shr/shl을 혼합해서 해보자.

  mov al, 3Fh                 ; 테스트 값
  mov bl, al                  ; 복사하여
  mov cl, 4
  shr al, cl                  ; al은 상위 니블 소거
  shl bl, cl                  ; bl은 하위 니블 소거
  or al, bl                   ; 둘을 합친다
 
간단히 니블 병합을 해줬다. rol 명령도 좋긴 한데 간혹 다른 비트는 손대지 않고 싶을 때가 있고, 노는 레지스터가 있으면 해볼만 할 것이다.
또한 shr/shl 대상을 서로 바꿔도 잘 통하며, 16비트나 32비트로도 확장할 수 있다. shr/shl/or을 한번씩 더 적어주면 뒤집기가 된다. 만약
32비트에서 작업한다면 워드만 and 등으로 마스킹해서 합쳐도 좋을 것이다.


rol 명령은 shl 명령처럼 비트를 왼쪽으로 회전시킨다.

<그림>


shl과 마찬가지로 매번 떨어져 나가는 비트는 캐리 플랙 (CF)에 복사되지만, shl과는 다르게 떨어져나간 비트 (MSb)로 채워질 비트 (LSb)를 채운다.
마치 열쇠 고리에서 열쇠를 들고 자물쇠에 맞춰 보는 것으로 비유하면, CF가 자물쇠, 회전되는 비트가 열쇠 정도에 해당한다. CF에 채워지는 비트는
LSb에도 채워진다.

  mov al, 55h                 ; al = 0101 0101
  rol al, 1                   ; al = 1010 1010
  rol al, 1                   ; al = 0101 0101, CF = 1
  rol al, 1                   ; al = 1010 1010
  rol al, 1                   ; al = 0101 0101, CF = 1

이 경우 55h (0101 0101)는 비트가 서로 교차되는 패턴이므로 한번씩 회전시킬 때마다 CF가 번갈아 토글된다. 8비트 레지스터에서 8 비트를 회전하면
결국 제자리로 돌아온다. "rol al, 4"처럼 8비트 레지스터를 4비트 회전하면 니블이 서로 바뀐다. 물론 복수 비트를 회전시킬 경우 .286이하에서는
cl에 로테이트 카운터를 정해준다. 로테이트 명령은 쉬프트 명령과 명령 포맷이 같다.

ror 명령은 shr 명령처럼 비트를 오른쪽으로 회전시킨다.

<그림>

매번 떨어져 나가는 비트는 캐리 플랙 (CF)에 복사되고, 그 비트는 또한 LSb에도 채워진다. 만약 8비트 레지스터를 8비트 로테이트하면 원래로
돌아오고 4비트 로테이트하면 두 니블이 바꿔진다.

  mov ax, 1234h
  mov cl, 4
  ror ax, cl                  ; ax = 4123
  ror ax, cl                  ; ax = 3412
  ror ax, cl                  ; ax = 2341
  ror ax, cl                  ; ax = 1234

만약 ax = 1234h 일 경우 ax의 모든 니블을 4321h로 스와핑하라면 ? 바이트/워드/더블워드/쿼드워드의 공통점은 니블의 갯수 (또는 비트의 갯수)가
짝수라는 점이므로, 상/하위 반토막씩 쪼개서 쓸 수 있다. 만약 AX에 니블 4개 값이 있다면 다음처럼 4개를 역순으로 스와핑할 수 있다. 

  mov ax, 1234h               ; 1234
  mov cl, 4
  ror ax, cl                  ; 2341
  ror ax, cl                  ; 3412
  ror ah, cl                  ; 4312
  ror al, cl                  ; 4321

모든 쉬프트/로테이트 연산은 캐리 플랙을 갱신한다. 그러므로 쉬프트나 로테이트할 때마다 떨어져나가는 CF를 조사하여 비트가 몇개인지 셀수 있다.
여기서는 캐리를 이용하여 입력 키 값을 2진수로 출력해보자.

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

.model small
.stack 100h
.data

temp db 8 dup (?)

.code
main proc
  mov ax, @data
  mov ds, ax
 
  mov ah, 00h                   ; Get Keystroke (al = ASCII)
  int 16h
 
  cmp al, 1Bh                   ; if ESC
  jz Exit

  lea si, temp
  mov cl, 8                     ; 8 bits
 
L1:
  mov bl, 30h                   ; mask
  rol al, 1                     ; rotate
  adc bl, 0                     ; mask + carry
  mov [si], bl
  inc si
  loop L1

  mStdout temp
 
Exit:

  mov ax, 4C00h
  int 21h

main endp
end main

보다시피 출력 변수 포인터를 옵셋 0으로 잡아줬으니 rol로 각 비트를 로테이트하여 미리 매직 넘버로 30h를 세팅한 값에 adc로 캐리를 더해줬다.

rol/ror 명령은 쉬프트 명령과 마찬가지로 캐리 플랙 (CF)을 매번 갱신하지만, 오버플로 플랙 (OF)은 다소 이상하게 갱신한다. 쉬프트에서 OF는
"초과"의 의미가 있지만, 로테이트에서 OF는 쉬프트 되는 비트가 0 인가 1 인가를 측정하는 용도로 사용된다. 쉬프트 연산과는 다르게 로테이트
연산은 비트 자체를 유지하므로 소스 데이터의 내용에 의존하므로 로테이트 이후 SF/PF/ZF/AF를 세팅해서는 안된다. 논리적으로 따져보면 당연한
결과이다. 반면 CF는 매번 리셋하고, OF는 작동 방식이 다소 애매하므로 CPU 매뉴얼을 인용해보자:

The OF flag is defined only for the 1-bit rotates; it is undefined in all other cases (except that a zero-bit rotate does nothing,
that is affects no flags). For left rotates, the OF flag is set to the exclusive OR of the CF bit (after the rotate) and the
most-significant bit of the result. For right rotates, the OF flag is set to the exclusive OR of the two most-significant bits
of the result.

- OF 플랙은 1비트 로테이트에서만 정의되며 멀티 비트일 경우 의미가 없다. (0 비트 로테이트는 아무것도 하지 않으며, 아무 플랙에 영향주지
않는다). 왼쪽 쉬프트일때, OF는 소스 데이터의 MSb와 CF를 xor하여 갱신하고, 오른쪽 쉬프트일때, OF는 연산 결과의 소스 데이터의 두 MSb를
xor하여 세팅된다.

다소 설명이 애매하므로 유사코드로 살펴보자.



이 코드를 조금 살펴보자. 먼저 연산자의 크기를 mod 연산으로 구해두고, 루프로 들어가 dest 연산자의 MSb를 CF에 복사해두고, dest 연산자는
2를 곱하거나 (rol), 2로 나누고 (ror), 카운트를 감소시켜 루프를 돈다. rol/ror 모두 로테이트 카운터가 1일 경우만 OF를 세팅하는데, rol의
경우 OF <- MSb (dest) xor CF, ror의 경우 OF <- MSb (dest) xor MSb-1 (dest) 라고 위의 설명한 내용이 나온다. 코드로 보자.

  mov al, 0                 ; al = 00xx xxx0
  ror al, 1                 ; al = 00xx xxx0, CF = 0, OF = 0 xor 0 = 0
  
  mov al, 80h               ; al = 10xx xxx0
  ror al, 1                 ; al = 010x xxx0, CF = 0, OF = 0 xor 1 = 1

  mov al, 0FFh              ; al = 11xx xxx1
  ror al, 1                 ; al = 111x xxx1, CF = 1, OF = 1 xor 1 = 0

00 이나 FF는 아무리 로테이트해도 값이 변경안되므로 OF는 항상 0이며, 비트 1이 교차된 데이터를 로테이트하면 반드시 OF = 1이다. 위의 유사
코드에서 FF가 "rol X, 1" 했을 때 왜 FF가 되는지는 다소 흥미롭지 않은가 ? 또한 쉬프트/로테이트 카운터가 0이 아닐때를 보면, 멀티 비트
쉬프트/로테이트를 루프로 해결하는 것을 알 수 있다. 즉, 쉬프트 카운터가 루프 카운터로 작동하니, 반대로 쉬프트 카운터를 적은 값으로
정해줄수록 루프를 적게 돈다는 얘기다.

rcl/rcr 명령도 각각 좌/우로 로테이트하는 명령이다. 이들 명령에 사용되는 CF은 1비트 레지스터 역할을 한다. 즉, 대상 (피)연산자가 8비트라면,
rcl/rcr 명령은 9비트를 로테이트 하는 셈이다. 이들 명령 또한 쉬프트 명령과 포맷은 같다. rcl 명령은 왼쪽으로 로테이트하고 떨어져 나가는 MSb는
CF에 임시로 저장되고, rcr 명령은 오른쪽으로 로테이트하고 떨어져나가는 LSb를 CF에 임시로 저장한다. 위의 rol/ror 그림을 그대로 참조하여
CF를 로테이트에 끼워넣는다고 이해하면 된다.

모든 쉬프트/로테이트 연산은 CF를 수정하므로 rcl/rcr 명령은 다른 쉬프트/로테이트 명령과 자주 결합하여 사용된다. 특히 복정밀 쉬프트/로테이트
연산이나 압축된 데이터 연산에서는 없어서는 안되는 존재이다. 마치 덧셈에서 adc, 뺄셈에서 sbb와 비슷한 역할을 한다.

  mov al, 12h                 ; al = 0001 0010
  shr al, 1                   ; al = 0000 1001, CF = 0
  shr al, 1                   ; al = 0000 0100, CF = 1
  rcr al, 1                   ; al = 1000 0010, OF = 1, CF = 0
  rcr al, 1                   ; al = 0100 0001, OF = 1, CF = 0

첫번째 shr는 보다시피 0000 1001로 만들고 캐리를 일으키지 않지만, 두번째 shr는 캐리를 일으키므로 이 캐리는 CF에 들어간다. 이어서 rcr을 써주면
마지막 캐리된 비트를 로테이트에 재사용한다. 그 결과 캐리가 MSb에 들어가 1000 0010이 되고, 떨어져나가는 비트가 없으므로 CF는 0이 되었다.
두번재 rcr은 첫번째 rcr의 떨어져나간 0을 그대로 MSb에 넣고, 마지막 떨어져나가는 비트는 0이므로 CF에 영향주지 않는다.

rcl/rcr 명령에서 OF는 OF <- MSb (dest) xor CF로 같다. 이는 CF가 0이면 OF = 1, CF가 1이면 OF는 0으로 반대로 작동한다는 뜻이다. 하지만, rcl은
선 로테이트 후 OF 갱신, RCR은 선 OF 갱신 후 로테이트를 하는 점이 다르다. rcl은 연산 후 대상 연산자에 2를 곱해서 CF를 더한 것이며, rcr은
대상 연산자를 2로 나눠 (CF * 2의 연산자크기 만큼 거듭제곱)을 더한 것이다.

0이 아닌 (non-zero) 어떤 수를 쉬프트/로테이트 명령만으로 0으로 만들어라고한다면, 단순히 좌/우 어느 한 방향으로 0이 될때까지 쉬프트시키면
된다. 만약, 8비트라면 8번 쉬프트하면 결국 원래 값이 된다. 캐리된 비트를 다른 레지스터로 옮겨주면 비트 레벨 복사가 된다. 물론 mov 명령으로
해결할 수 있지만, 여기서는 로테이트로 해보자.

  xor ax, ax
  mov bx, ax
  mov cx, bx

  mov ax, 1234h
  mov cl, 16
 
L1:
  ror ax, 1               ; ax = 1234h
  rcr bx, 1               ; bx = 1234h
  loop L1

ror을 한번씩 실행할 때마다 ax에서 CF로 떨어져나간 비트를 rcr을 사용하여 bx로 보내주었다. 이는 rol/rcl 조합으로도 가능하다. 이처럼 rcl/rcr
명령은 다른 쉬프트/로테이트 연산 뒤에서 쓰여지면 캐리를 복사한 셈이된다. 그러면 몇개의 1을 가지고 있는지도 쉽게 셀 수 있다.

L1:
  ror ax, 1               ; ax = 0001 0010 0011 0100
  adc bx, 0               ; bx = 1의 갯수
  loop L1

그렇다면 0인 비트를 모두 지우고 1인 비트만 복사할 수도 있을 것이다.

  xor ax, ax
  mov bx, ax
  mov cx, bx

  mov ax, 1234h
  mov cl, 16
 
L1:
  ror ax, 1               ; ax = 0001 0010 0011 0100
  jc L3
 
L2:
  dec cl
  jcxz Last
  jmp L1
 
L3:
  rcl bx, 1                ; bx = 1Fh = 11111b
  jmp L2

Last:

rol/ror을 거치고 이어서 rcl/rcl을 쓰면 rcl/rcr 명령 또한 CF를 토글하므로 우리는 이전 rol/ror의 떨어져나간 비트를 얻어낼 방법이 없으므로
분기처리로 해줬다. 마치 워드 프로세서에서 공백을 제거하는 것과 비슷한 연산을 해줬다.

어떤 바이트에 있는 모든 비트를 역순으로 재배치할 수는 없을까 ? 사실 현실 세계에서 바이트가 최소 의미 레벨이므로 이를 뒤집는 것은 현실과는
거리가 멀지만 몇몇 프로그램에서 암호화 루틴이나 압축 루틴에서 이는 자주 사용된다. 이는 bsf/bsr 등의 비트 연산 명령으로 할 수 있지만, 이는
.386 이상의 프로세서 지시어를 써야한다. 그렇다면 이전 사람들은 어떻게 해결했을까 ? 이를 쉬프트와 로테이트 명령으로 에뮬레이트해보자.

예로, 76543210 8개의 비트가 있다고 하자. 이를 절반으로 나눠서 7654 3210으로 일단 살펴보면, 7 <-> 0, 6 <-> 1, 5 <-> 2, 4 <-> 3으로 바꿔야
0123 4567 이 됨을 알 수 있다. 비록 1바이트 레지스터에 모두 담을 수는 있지만, 그 레지스터를 쉬프트/로테이트해봤자 07654321 식으로 밖에
안된다. 이는 다소 정렬 알고리즘에 가까우므로 여기서 다루기엔 조금 무리가 있지만, 배운 것을 실제 적용하는 차원에서 직접 코딩해보자.

먼저 알고리즘을 조금 살펴봐야겠다. 알고리즘 책 먼지 쌓이게 덮어둔지 오래되서 이런 정렬 방식을 뭐라하는지는 모르겠으나, 우리는 8개의
비트를 거꾸로 정렬하되, 이 데이터를 16비트로 확장하여 (즉, 노는 레지스터에 복사하여) 이를 반복적으로 캐리를 포함한 로테이트하기로 하자.
8비트 데이터를 1비트씩 rcl/rcr 하다보면, 언젠가는 중복되는 패턴이 나올 수 있다. 76543210이라는 데이터는 여기서는 비트 위치를 의미한다.
그리고 그 데이터를 al에 정해주고 이를 ah에 복사하면 다음처럼 패턴이 그려질 것이다. ah = 7654 3210, al = 7654 3210.

이제 캐리를 포함하여 반복으로 ah는 왼쪽으로 로테이트하고, al은 오른쪽으로 로테이트하면, 다음처럼 패턴이 그려질 것이다.

CF  ah            al          CF      루프 횟수

    7654 3210     7654 3210
7 - 6543 2107     7765 4321 - 0         1
6 - 5432 1070     6776 5432 - 1         2
5 - 4321 0701     5677 6543 - 2         3
4 - 3210 7012     4567 7654 - 3         4
3 - 2107 0123     3456 7765 - 4         5
...

우리는 0123 과 4567 이라는 패턴을 찾았으므로 더는 루프를 돌 필요가 없다. 왜냐하면 76543210은 데이터가 아니고 비트 위치를 의미하기 때문이다.
그런데 문제는 서로 한칸씩 밀려서 일치하는 패턴이 형성되므로 루프를 네번만 돌고 ah만 따로 한번 더 로테이트해주면 된다. 이 과정을 마치면,
ax = 2107 0123 4567 7654 의 패턴이 나오며, 이를 오른쪽으로 4번 쉬프트하고 ah만 따로 4번 더 쉬프트하면 결국 ah는 0이 되고, 바뀌어진 비트
위치는 al에 0123 4567로 리턴된다. 이를 코딩하면 이렇다:

  xor ax, ax
  mov al, 0AEh                  ; al = 7654 3210 (bit #, 테스트 값 무시)
 
  mov ah, al                    ; ax = 7654 3210 7654 3210
  mov cl, 4                     ; loop counter
 
  rol ah, 1                     ; ax = 6543 2107 7654 3210, CF = 7
 
L1:
  rcr al, 1                     ; ax = 6543 2107 7765 4321, CF = 0
  rcl ah, 1                     ; ax = 5432 1070 7765 4321, CF = 6
  loop L1                       ; ax = 5432 1070 6776 5432, CF = 1,...
                                ; ax = 2107 0123 4567 7654
  mov cl, 4
  shr ax, cl                    ; ax = 2107 0000 0123 4567
  shr ah, cl                    ; ax = 0000 0000 0123 4567
 
L2:

숫자가 도배되다보니 제법 복잡해보이지만, 코딩 자체는 단순하기 그지 없다. 이 원리를 나중에 알고리즘 구현할 때 써먹는 것은 어떤가 ? 그냥
bsf/bsr 명령에 의존하더라도 한번쯤 연구해볼만 하지 않은가 ? 알고리즘은 원리만 알아두면 두고 두고 써먹을 수 있다. 이제 우리에게 남은 것은
복정밀 쉬프트/로테이트 연산이다.

rcl/rcr로 캐리를 이용한 쉬프트/로테이트 연산의 작동 방식을 이해하면 복정밀 쉬프트/로테이트 또한 쉽다. 복정밀 연산에 대해서는 귀찮은 관계로
AoA 코드를 부분 인용하기로 한다. rcl/rcr 명령은 adc 명령 처럼 없어서는 안되는 존재이다. 왜냐하면 쉬프트/로테이트 연산이 그 자체가 자리
옮김을 의미하기 때문이다. 우선 더블워드 데이터를 워드 레지스터로 쉬프트/로테이트 연산해보자. ax가 상위 값을 담는 레지스터이고 dx가 하위
값을 담는 레지스터라면 다음처럼 shl/rcl 조합으로 간단히 32비트 데이터를 1비트 왼쪽 쉬프트할 수 있으며 x2를 한 값이 된다.

  xor ax, ax
  xor dx, dx
 
  mov ax, 1234h
  mov dx, 5678h
 
  shl ax, 1
  rcl dx, 1
 
만약 48비트 데이터라면 여분 레지스터를 하나 더 이용하여 다음처럼 1비트 오른 쉬프트를 할 수 있다:

  xor ax, ax
  mov bx, ax
  mov dx, ax
 
  mov ax, 1234h
  mov bx, 5678h
  mov dx, 9ABCh
 
  shr ax, 1
  rcr bx, 1
  rcr dx, 1
 
결과는 2로 나눈 값이 ax:bx:dx 3 레지스터에 저장된다. 만약 64비트 데이터라면 여분 레지스터를 하나 더 이용하여 다음처럼 1비트 쉬프트할
수 있다:

  xor ax, ax
  mov bx, ax
  mov cx, bx
  mov dx, cx
 
  mov ax, 1234h
  mov bx, 5678h
  mov cx, 9ABCh
  mov dx, 0DEF0h
 
  shr ax, 1
  rcr bx, 1
  rcr cx, 1
  rcr dx, 1
 
이처럼 방향이 어느쪽인지 결정하여 shr이면 rcr을 shl이면 rcl을 추가로 하위 레지스터에 값을 로테이트해주면 된다. 여기서 주의할 점은 쉬프트
/로테이트 명령은 카운터를 복수 비트로 정해줄 경우 캐리를 무시하고 연산하므로 우리는 1비트 단위로 쉬프트해야 한다. 이 문제를 더블 쉬프트
명령 (shld/shrd)으로 어느 정도 해결할 수 있으나 애석하게도 더블 로테이트 명령은 아직 없다. shl/shr처럼 rol/ror도 해주면 된다. 하지만
로테이트는 자리 이동이 중요하지 그 결과값이 얼마냐가 중요한 것이 아니다.

메모리에 저장된 복정밀 데이터는 리틀 엔디언이다. 그러므로 만약 48비트 메모리 쉬프트를 한다면 다음처럼 해준다.

  shl word ptr [myVar], 1           ; 상위 워드 시작 옵셋
  rcl word ptr [myVar+2], 1         ; 다음 워드
  rcl word ptr [myVar+4], 1         ; 최하위 워드
 
만약 복수 비트 쉬프트할 경우 이 블럭을 루프로 잡아주고 cl에 카운터를 정해주면 된다. 만약 데이터가 메모리 연산자라면 위의 레지스터 연산을
상위 워드부터 해야하므로 다음처럼 거꾸로 정해줘야 한다:

L1:
  sar word ptr [myVar+4], 1
  rcr word ptr [myVar+2], 1
  rcr word ptr [myVar], 1
 
L2:
  shr word ptr [foo+4], 1
  rcr word ptr [foo+2], 1
  rcr word ptr [foo], 1
 
L1은 sar 버전이고 L2는 shr 버전이다. 만약 .386 지시어로 확장 쉬프트 명령으로 한다면 다음처럼 코딩한다:

myVar dw 1234h, 5678h, 9012h
...

  mov eax, myVar+4
  shld  myVar+8, eax, 6
 
  mov eax, myVar
  shld myVar+4, eax, 6
  shl myVar, 6
 
"shld s1, s2, imm" 명령은 s2에서 비트를 취해 s1으로 imm 만큼 쉬프트해서 넣는다. 첫번째 shld는 myVar+4에서 myVar+8로 쉬프트해서 넣고,
두번째 shld는 myVar에서 myVar+4로 쉬프트해서 넣으며 마지막 shl은 최하위 더블워드를 적절히 쉬프트한다. 기억할 것으로 최상위 dword를 최하위
dword로 쉬프트하는 점이고, 복수 비트 연산이므로 캐리 플랙이 영향을 못 발휘하는 점이다. 확장 로테이트에 대해서는 직접 AoA를 참조하라.

2010년 1월 20일 수요일

10장 - 비트 단위 연산 - AND/OR/XOR/NOT

10장 - 비트 단위 연산 - AND/OR/XOR/NOT

이 연산을 불리언 연산 (boolean operation)이라고도 하고 논리 연산 (logical operation)이라고도 한다. 작동 방식이 비트 단위로 이루어져 비트단위
연산 (bitwise, bit-by-bit operation)이라고도 한다. 여기서는 비트 단위 (bitwise) 연산이라고 하기로하겠다. AND/OR/XOR은 두 비트를 취해서 그
결과를 갱신한다. NOT은 한 비트를 취해 그 결과를 갱신한다. 즉, A op B 또는 NOT A/NOT B 형태가 될 수 있다.

AND: 0 op 0 = 0, 0 op 1 = 0, 1 op 0 = 0, 1 op 1 = 1
OR:  0 op 0 = 0, 0 op 1 = 1, 1 op 0 = 1, 1 op 1 = 1
XOR: 0 op 0 = 0, 0 op 1 = 1, 1 op 0 = 1, 1 op 0 = 0

NOT: not 0 = 1, not 1 = 0



수학적으로 해석하면, and는 교집합, or은 합집합, xor은 합집합 - 교집합, not은 여집합 쯤에 해당한다.

<그림>

하지만 창피하게도 이땅은 수학 문맹국이므로 이런 수학적 논리 따위는 통하지 않는다. 논리 회로를 공부한 사람이라면 여러 불 연산중 단지 4개뿐임을 눈치챌 것이다. 이유는 이들 4개만 있어도 불 연산을 모두 흉내낼수 있어 (예로, NAND = AND -> NOT, NOR = OR -> NOT),  원자적인 불 대수 역할을 하기 때문이라고 할 수 있다.



똑똑한 사람들은 이를 외우지만, 더 중요한 것은 이를 이해하는 것이다. 이 진리표는 언제라도 필요하면 찾아볼 수 있지 않은가 ? 비트 단위 명령을
어셈블리 코딩시 주의해야 할 한가지를 꼽자면, "결과를 갱신한다"는 것이다. 즉, NOT 을 제외하고 두개의 입력 (input) 값으로 하나의 출력 (output)
값을 산출한다. 지금도 외워쓰는 수준에 불과하지만, 이것을 이해 못해 처음 어셈블리를 배울 때 무진장 고생했던 기억이 있다. 또한, 추가로 중요한
것은 같은 연산자 끼리도 연산이 가능한 것이다.

mov ax, 1
and ax, 0       ; ax = 1 and 0의 결과를 반영한다.

mov bx, 1
or bx, bx       ; bx = 1 or 1의 결과를 반영한다.

mov cx, 1
xor cx, myflag  ; cx = 1 xor myflag의 결과를 반영한다.

mov dx, 0FFh      ; dx = 1111 1111 1111 1111
not dx            ; dx = 0000 0000 0000 0000


여기서 and/or/xor 을 잘 살펴보면, 모두 op A, B의 형태를 취하며, A는 A op B가 되고 B는 그대로 유지된다 (소스 불변의 법칙). 반면, or의 경우,
뒤의 bx는 원래 변하지 않기로 했지만, or을 해줌과 동시에 갱신된다. 즉 같은 연산자일 경우 소스 불변의 법칙은 깨진다. not의 경우는 어려울게
없지만 문제는 1비트만 not 하는 경우가 별로 없다보니 어렵기는 마찬가지다. 플랙표는 다음과 같다:


똑똑한 사람은 이 플랙표를 외우지만, 메모리가 약간 부족한 사람은 이를 다음처럼 이해한다:

비트 연산 명령은 비트 단위 (bit-by-bit)로 연산하므로 자리 올림에 관여하는 OF/CF를 건드려서는 안된다. 비트 연산을 할 때 최소 8비트 이상의
데이터 (가장 작은 레지스터가 8비트 레지스터)를 가지고 연산하므로 1니블 캐리를 의미하는 AF은 무시해도 된다. 비트 연산을 하다보면 MSb가
토글될수 있으며, 모든 비트가 0이 될 수 있고, 최하위 8비트 (MSB)가 토글될 수 있다. 

이 플랙표는 jo/jc 명령을 쓰다가 버그에 이를 수 있음을 경고한다고 할 수 있다.

or ax, ax
jc Location           ; 이렇게 하면 뒷일은 책임 못진다.

또한, 다음처럼 무책임한 분기처리를 하지도 않을 것이다.

not ax
jz Location           ; 무슨 근거로 분기를 ?

하지만 우리는 다음처럼 cmp 명령 없이도 비트 단위 명령으로 분기 조건을 테스트할 수 있다는 것을 알 수 있다.

and ax, 1           or ax, ax           xor bx, bx
jnz L1              jz L1               js L1

논리 연산 명령으로 분기 처리할 때 xor은 강제 분기 (unconditional jump) 역할을 할 수 있으므로 거의 사용하지 않는다. 테스트되는 플랙중 0 이냐
1 이냐를 결정하는 ZF 플랙이 가장 중요하다고 할 수 있다.

and, or, xor 3 명령은 "op r/m8/16/32, r/m/i8/16/32" 형태로 같다. 특이한 점은 레지스터 없이 "and mem, imm" 형태는 허용되어도, 메모리 간의
연산인 "and mem, mem" 형태는 없다. MOV 명령과 마찬가지로 메모리 간의 직접 연산은 대부분의 인텔 명령에서 통하지 않는다. not은 "not r/m8/16/32"의 단항 (unary)
명령이며, 플랙을 완전히 무시하고, "not mem"은 허용되어도, "not imm" 형태는 없다.

and 명령은 "and d, s" 일반적인 형태로 s 연산자에서 소스를 취해 d 연산자에 bitwise AND 연산을 수행하여 결과를 d에 반영한다. 두 연산자의
같은 자리에 있는 각 비트가 1로 일치하면 d에 1로 세팅하고, 그렇지 않으면 0으로 세팅한다. (0으로 세팅 = 클리어) 연산 수행후 OF/CF가 클리어
(0)되고, SF/ZF/PF는 결과에 따라 세팅되고 AF는 정의되지 않는다.

or 명령은 and와 포맷과 플랙 세팅이 같으며 bitwise OR 연산을 수행하는 점이 다르다. 두 연산자의 같은 자리에 있는 각 비트가 0으로 일치하면
d에 0으로 세팅하고, 그렇지 않으면 1로 세팅한다. xor 명령도 포맷 플랙 세팅은 같지만, bitwise XOR 연산을 수행하며, 같은 자리에 있는 각 비트가
서로 다르면 1로 세팅하고, 그렇지 않으면 0으로 세팅한다. not은 아무런 플랙에 영향주지 않으며, "not d" 포맷으로 하나의 연산자인 d를 취해 모든
비트를 뒤집어 (이를 1의 보수라 한다) 그 결과를 d에 되돌린다.

그런 이유로 "and X, 1" 형태로 마스킹에 주로 사용되며, "or X, 0/1" 형태로 비트 세팅에 사용되며, "xor X, X" 형태로 비트 소거에 자주 사용된다.

고급 언어인 C와 표현 방식을 비교해보면 이렇다. (앞으로 어셈블리(어) 등의 약어를 "ASM, 어셈" 등으로 칭하기로 한다)

ASM         C
-------------------

AND X, Y    X & Y

OR X, Y     X | Y

XOR X, Y    X ^ Y

NOT X       ~ X

고급 언어는 직접적으로 CPU 명령을 쓸 수 없으니 특별한 기호라도 써서 표현해야 하니 이 얼마나 불편한가 ? 만약 비트 연산을 중복해서 한다면
C 코드는 외계인의 언어처럼 변해버린다. C 프로그래머에게 어셈블러는 "가난한 사람들의 컴파일러" 쯤에 해당하지만, ASM 프로그래머에게 C는
"게으른 사람들을 위한 어셈블러" 쯤에 해당한다. 물론 한물간 논쟁거리가 되버렸지만...

여기까지가 교과서적인 내용이고 잘 읽었다면, 다음 문제는 쉽게 풀어야 정상이다.


xor ax, ax             ; 0 xor 0 = 0, 1 xor 1 = 0
xor bx, bx

mov ax, 01010000b
mov bx, 01110000b
xor ax, bx              ; ax, bx = ?



답을 제시하고 싶은 맘은 없었으나 그래도 간략히 설명하면 이렇다. 같은 연산자를 "xor X, X"하면, X와 X의 합집합 - X와 X의 교집합 = 0 이 되듯이,
"xor ax, ax"는 0으로 ax 레지스터를 소거한다. "xor bx, bx"도 마찬가지다. mov 명령은 고급 언어에서 대입 (assignment) 정도에 해당한다. 16비트
레지스터 ax, bx에 8비트 값을 주면 하위 8비트만 채우고 상위 8비트는 손대지 않는다. 이를 xor하여 ax에만 저장하므로,

ax = 0000 0000 0101 0000
bx = 0000 0000 0111 0000
---------------------------
ax = 0000 0000 0010 0000

이 되고, bx는 소스 연산자로 사용되므로 그대로 값을 유지한다.

쉽게 풀리지 않았다면, 학교에서 "연산"만 배워서 일 것이다. "연산후 취하자"는 개념이 있을 줄을 그때는 몰랐던가, 아니면 비트가 많아질 것을
고려하지 않았기 때문일 것이다. 단순히 8비트 논리 연산에서 틀리면 32비트/64비트 프로그래밍을 어찌하겠는가 ? 어셈블리 프로그래밍에서 논리
연산은 가장 중요하다고 할 수 있다. 그만큼 숙달되어야 한다는 뜻이다. 앞으로 중간 중간 물음표를 던져줄테니 직접 풀어서 마침표로 리턴해주기
바란다. 내가 풀이한 것은 나만의 우물속에서 해석한 세상일 뿐이다. 나라는 인간이 갇혀사는 우물 속에 그대마저 가두어두고 싶지는 않다. 나를
밟고 뛰어 세상으로 나가라. 그리고 세상 밖이 어떻게 생겼는지 얘기라도 해주라.

xor은 희안하게 작동하면서도 쓸모가 굉장히 많으며, 이전에도 특정 레지스터 두개를 스와핑할 때 사용해봤다.

ASM               C
----------------------------

xor x, y          X = X ^ Y
xor y, x          Y = Y ^ X
xor x, y          X = X ^ Y



이는 말로 설명하기 힘드니 위의 밴 다이어그램을 떠올려서라도 이해하라. 이 코드는 temp라는 제3자를 사용하지 않고, 둘을 직접 스와핑하는 매직
코드 정도에 해당한다고 외우자.

불 대수 (boolean algebra) 연산에서 어떤 비트 x에 0을 더하면 x가 되고, 어떤 비트 y에 1을 더하면 1이된다. 이러한 비트 단위의 덧셈을 or로
표현한다. 즉,

x + 0 = x, y + 1 = 1
0 + x = x, 1 + y = 1

라는 수학적 공식 (axiom)이 성립한다. 예로,

or x, 0             ; x = 0
or x, 1             ; x = 1

하지만 이 또한 수학적인 정의로만 남는다. 돼지털 세상에서는 재정의해야 한다. 예로,

  xor ax, ax
  xor bx, bx
  xor cx, cx          ; ax = bx = cx = 0
 
  mov al, 0Fh         ; al = 0000 1111
  mov bl, 0           ; bl = 0000 0000
  mov cl, 1           ; cl = 0000 0001

  or al, 0            ; al = 0000 1111
  or al, 1            ; al = 0000 1111
  or al, 2            ; al = 0000 1111
  or al, 0FFh         ; al = 1111 1111

  or bl, 0            ; bl = 0000 0000
  or bl, 1            ; bl = 0000 0001
  or bl, 2            ; bl = 0000 0011
  or bl, 0FFh         ; bl = 1111 1111

  or cl, 0            ; cl = 0000 0001
  or cl, 1            ; cl = 0000 0001
  or cl, 2            ; cl = 0000 0011
  or cl, 0FFh         ; cl = 1111 1111

특정한 패턴이 감지되는가 ? 아마도 감지하지 못했다면, 다소 도배적인 코드여서이거나 아니면 자릿수가 늘어나서 소홀해졌기 때문일 것이다.
or은 특정 비트를 and나 xor처럼 세팅하는 것은 마찬가지이지만, 0을 1로 만드는 것을 주목할 필요가 있다. or을 "논리합"이라고 표현하는데
우리는 "비트합"으로 해석하면 문제가 쉽게 해결될 것이다. 위의 예에서, "1111"로 이미 세팅된 비트는 아무 비트나 더해도 1을 유지하므로,
0, 1, 2로 or해도 무의미한 효과가 된다. 반면, 0Fh는 하위 4비트가 1로 켜진 것에 해당하는데, 여기에 0FFh를 or하면 상위 비트마저 켜서,
0F -> FF 가 된다. 여기에 아무리 큰 수를 더해도 CF/OF를 0으로 만들기 때문에 (즉, 캐리/오버플로 무시) 자리 올림이 일어나지 않는 비트합
이라고 할 수 있다. "or bl, 2"는 현재 01인데 여기에 2 (10)을 or하므로 결국 11 (3)이 된다고 이게 십진수로 2를 더했다고 생각해서는 안된다.
그런 이유로 or은 1로 마스킹할 때 유용하게 사용된다. 예로,

mov al, 0010 0101
or al,  1000 0000
-------------------
        1010 0101

이렇게 하면 (물론 공백은 코딩할땐 붙여써야하고, 이진수는 -b를 붙여줘야한다), 최상위 비트 (즉, 사인비트)만 켜주는 역할을 한다. 만약 이
비트가 1로 켜져있는 상태라면 1로 or하므로 그대로 유지된다. 물론 오버플로와는 관련없다. or로 마스킹할 때 몇가지 중요한 매직 넘버는 이렇다:

00h : 비트를 유지 (jz 명령과 혼합하여 무조건 분기하는 역할)
80h : 8비트 최상위 비트만 세팅,
FFh ; 8비트 모든 비트 세팅
0Fh ; 하위 니블만 세팅
7Fh ; 사인비트만 무시하고 세팅 (0111 1111b)

물론 이 매직 넘버를 16비트나 32비트로 사용하려면 뒤에 F나 0을 붙여준다. 예로, or EAX, 80000000h / or AX, 0FFFFh.

반면, 이 코드의 or을 전부 and로 바꾸면 모두 0000 0000이 되며, and를 테스트하기엔 마땅치않다. and는 논리곱이라고 하는데 여기서는 "비트곱"
이라고 하자. 예로,

mov ax, 1234h       ; ax = 1001000110100
and ax, 0FFFFh      ; ax = 1001000110100 = or ax, 0

보다시피 모든 비트를 켠채로 (FFFF = 1111 1111 1111 1111) and 해주면 그 값을 그대로 유지한다. 이는 0으로 or 해준 것과 같은 효과다.
반면 "and ax, 0" 하면 ax는 모든 비트가 0으로 된다. 이는 xor ax, ax 또는 mov ax, 0과 같은 효과다. and로 마스킹 할때도 위의 or에서와
비슷한 매직 넘버를 사용할 수 있다.

00h : 모든 비트 off
FFh : 모든 비트 on
80h : 사인 비트만 on 나머지는 off
0Fh : 하위 니블만 on 상위 니블은 off
7Fh : 사인 비트만 off 나머지는 on (복수 바이트 일 경우 even-parity를 생성한다)

and는 비트단위의 곱셈에 해당하는 값만 취하기때문에 or보다 마스킹에 더욱 유용하게 사용된다. 물론 마스킹 비트를 확장하려면 or에서 처럼
0 이나 F를 추가해주고 0은 지워지고 F는 남겨진다고 생각하면 된다.

아스키 테이블을 보면, 30h~39h는 아스키 십진 숫자 (0~9)로 할당되어있다. 이를 unpacked BCD (4비트 숫자)로 표현하려면 상위 니블을 꺼주면 된다.

  mov al, '0'         ; al = 0011 0000 ('0', 30h)
  and al, 0Fh         ; al = 0000 0000

  mov al, '9'         ; al = 0011 1001 ('9', 39h)
  and al, 0Fh         ; al = 0000 1001

꺼진 비트를 다시 켜려면 "or al, 30h"로 해주면 된다. 즉, 4비트 BCD를 십진수로 변환하는 원리이다. 또한, 홀수와 짝수도 and 명령으로 테스트해볼
수 있다.

  mov al, 0Fh         ; 테스트 값

  mov bl, al          ; 복사
  and bl, 1           ; 끝 비트만 격리시켜 (isolate) 
  jz L1               ; ZF가 세트되면 짝수이므로 여기에 홀수 처리 루틴을 위치시킨다
  jmp next
 
L1:                   ; 여기에 짝수 루틴이 위치한다

next:                 ; 홀수/짝수 루틴 모두 여기에 이른다

물론 암시적 and 명령인 "test al, 1"도 같은 역할이다. 모든 홀수는 끝 비트가 1이므로, 만약 al이 홀수이면, ZF 플랙이 세트되지 않으며, 만약
짝수이면 ZF 플랙이 세트된다.

and와 or은 혼합해서 쓸 수 있고, 쉬프트와도 혼합해서 쓰기도 한다. 돼지털 세상에서 만능 키워드인 "copy & paste" 테크닉을 살짝 수정한 것이
"cut & paste"이고, 이는 비트 세상에도 적용할 수 있다.

  mov al, X             ; X 중에 불필요한 것이 있다
  mov bl, Y             ; Y 중에 불필요한 것이 있다
 
  and al, 0F0h          ; = 1111 0000 (X - 상위 니블)
  and bl, 0Fh           ; = 0000 1111 (Y - 하위 니블)
 
  or al, bl             ; = 1111 1111 (재구성)

물론 바이트에서 특정 비트를 0000 0101 또는 1010 1111 등으로 짤라내거나 LSb 또는 MSb만 짤라낼 수도 있을 것이다. 예로,

  and al, 80h           ; = 1000 0000
  and bl, 01h           ; = 0000 0001
 
  or al, bl             ; = 1000 0001
  sar al, 1             ; = 1100 0000
 
xor은 비트를 뒤집는다. 예로,

  mov al, 0AAh          ; = 1010 1010
  xor al, 0FFh          ; = 0101 0101

이를 1의 보수라하고 여기에 "add al, 1"하면 2의 보수로 변신한다. not과 같은 역할이다. 하지만 이를 다시 뒤집으면,

  mov al, 0AAh          ; = 1010 1010
  xor al, 0FFh          ; = 0101 0101   = not al
  xor al, 0FFh          ; = 1010 1010   = not al

같은 값으로 xor을 두번 해주면 원래의 값을 되찾는다. 이런 원리로 인해 대부분의 암호화 프로그램에서는 xor을 꼭 쓴다. 암호화 프로그램은 먼저
평문 (plain text)를 특정 키 (위에서는 0FFh가 키이다)를 사용하여 암호화된 문장 (encrypted text)로 만들고, 이를 해독할 때 다시 그 키를 xor로
재사용하여 평문으로 되돌린다. 그래픽 프로그램에서 xor은 특정 컬러 비트를 지웠다가 다시 복원하는 용도로 사용되기도 하며, 특정 포트나 하드웨어
리소스를 임시로 토글할 때도 사용된다.

간혹 우리는 어떤 수를 xor 했을 때 어떤 값인지 알아야 할 때가 있다. 예로, 3 xor 5 = ? 값을 구한다고 하자. 위의 밴 다이어그램을 비트 레벨로
해석하자. 3 = 011, 5 = 101 이므로 둘 간의 비트 합집합은 111이고, 둘 간의 비트 교집합은 001 이므로 111 - 001 = 110 = 6이다.

  mov ax, 3
  mov bx, 5
  mov cx, ax            ; 복사
  or ax, bx
  and cx, bx
  sub ax, cx


not은 특정 비트를 뒤집는다. 하지만 뒤집어진 비트가 논리적으로 반대의 뜻을 가진 수라고 가정해서는 안된다. 예로, 0000을 not하면 1111이 된다.
이는 특정 함수의 리턴 값으로 인정할 수 있지만, 0 의 반댓말이 F 라는 뜻은 아니다. 비트 세상에서 0의 논리적인 반댓값은 1이다. 하지만, 1의
반댓값은 0FE이다.

 - N = - |NOT (N) + 1|
  
-3 = NOT (2)
-128 = NOT (127)
80h = NOT (7Fh)

  mov al, 0FFh
  not al            ; al = 0
  mov bl, 0FEh
  not bl            ; bl = 1

그러므로 0 과 1을 번갈아 토글하려면, xor을 사용해야한다.

  mov al, 01
  xor al, 01        ; al = 00
  xor al, 01        ; al = 01

test 명령은 암시적인 and라고 할 수 있다. 즉, and 연산은 하되 결과에 따라 SF/ZF/PF를 세트하고, 그 결과 값을 버린다. 반영되는 플랙은 위의
and와 같으며 작동 유사 코드는 이렇다:

temp <- Src1 AND Src2;
SF <- MSB (temp);

if temp = 0
  then ZF <- 1;
  else ZE <- 0;
fi;

PF <- BitwiseXNOR (temp[0:7]);
CF <- 0;
OF <- 0;                          ; AF는 정의되지 않음

neg 명령은 "0 - X"를 수행하여 2의 보수를 구하며, 작동 코드는 이렇다:

if dest = 0
  then CF <- 0;
  else CF <- 1;
fi;
dest <- [0 - (dest)]

이 명령은 만약 소스 연산자가 0이면 CF를 0으로 세팅하고 그렇지 않으면 1로 세팅한다. CF를 제외한 5개 플랙은 결과에 따라 세팅한다.

복정밀 논리 연산은 다소 쉽다. 로드 - 연산 - 저장 3과정만 해주면된다. 여기서는 AoA 코드를 인용한다.

32비트 복정밀 and:

mov ax, word ptr [var]
and ax, wor ptr [mask]
mov word ptr [result], ax

mov ax, word ptr [var+2]
and ax, word ptr [mask+2]
mov word ptr [result+2], ax

48비트 복정밀 or:

mov ax, word ptr [var]
or ax, word ptr [mask]
mov wor ptr [result], ax

mov ax, word ptr [var+2]
or ax, word ptr [mask+2]
mov word ptr [result+2], ax

mov ax, word ptr [var+4]
or ax, word ptr [mask+4]
mov word ptr [result+4], ax

64비트 소스를 32비트 레지스터로 복정밀 xor:

mov eax, dword ptr [var]
xor eax, dword ptr [mask]
mov dword ptr [result], eax

mov eax, dword ptr [var+4]
xor eax, dword ptr [mask+4]
mov dword ptr [result+4], eax

32비트 (dx: 상위 16비트, ax: 하위 16비트) 복정밀 not:

not ax
not dx

또는,

not dx
not ax

복정밀 neg는 직접 AoA를 참조하라.

블로그 보관함