{&&, ||, !} vs. {&, |, ~}

C/C++ 고수분들에겐 뭐 이런 다 아는 얘기하냐라고 반문하실 수 있겠지만 생각보다 저 두 연산자들의 집합의 차이를 조금씩 헷갈려하는 분들이 있는 것 같아서 정리 차원에서 쓴다.

&&, ||, !는 이른바 Logical operator로 Boolean으로 결과가 나온다. 참 아니면 거짓, 이 두 가지 밖에 없다. 반면, &, |, ~는 그냥 일반 +, - 처럼 산술연산이다. 특히 ~는 0과 1을 뒤집는 1's complement를 구해주는 연산으로 !과 결코 혼동해서는 안된다.

또 (a && b) 에서 expression a가 0이면 b는 아예 실행도 되지 않고 바로 전체 값을 false로 반환한다는 것도 잘 알 것이다. || 역시 비슷하게 작동한다. C++ 표준 (5.15) 에는 이것을 명확하게 보장하고 있다.

The && operator groups left-to-right. The operands are both implicitly converted to type bool (clause 4). The result is true if both operands are true and false otherwise. Unlike &, && guarantees left-to-right evaluation: the second operand is not evaluated if the first operand is false.

 

심심한 관계로 실제 C/C++ 컴파일러가 위 6가지 연산을 어떻게 만들어내는지 살펴보았다.

    i = j & k;
008D1174 mov eax,dword ptr [k]   ; eax = k
008D1177 and eax,dword ptr [j]   ; eax = eax and j
008D117A mov dword ptr [i],eax   ; i = eax
i = j | k;
008D117D mov eax,dword ptr [k]   ; eax = k
008D1180 or eax,dword ptr [j]   ; eax = eax or j
008D1183 mov dword ptr [i],eax   ; i = eax
i = ~j;
008D1186 mov eax,dword ptr [j]   ; eax = j
008D1189 not eax                 ; eax = not eax
008D118B mov dword ptr [i],eax   ; i = eax

별 내용없다. 이들 연산자는 덧셈과 같은 산술 연산자이므로 그야말로 거기에 대응되는 인스트럭션으로 대치되었음을 알 수 있다. 따라서 if (a & b)가 있을 때, a가 아무리 0을 반환한다해도 그냥 끝나는 것이 아니라 b도 반드시 값을 구하게 된다.

그렇다면 Boolean 연산자들의 결과는? 위와 같이 대응되는 연산자가 없다. &&와 ||는 0과 같은지를 비교하는 분기문들로 이루어져있다. ! 역시 이에 해당하는 연산자가 있을 것 같다는 기대와 달리 다르게 표현되고 있다. 옆에다 주석으로 어떤 의미인지 적어봤다.

    i = j && k;
008D118E mov eax,dword ptr [j]    ; eax = j
008D1191 test eax,eax              ; {flags} = eax & eax
008D1193 je main+0F2h (8D119Ch)  ; if (ZeroFlag == 1) jump 008D119C
008D1195 mov eax,dword ptr [k]    ; eax = k
008D1198 test eax,eax              ; {flags} = eax & eax
008D119A jne main+0FBh (8D11A5h)  ; if (ZF == 0) jump 008D11A5
008D119C mov dword ptr [i],0      ; i = 0
008D11A3 jmp main+102h (8D11ACh)  ; jump 008D11AC
008D11A5 mov dword ptr [i],1      ; i = 1
i = j || k;
008D11AC mov eax,dword ptr [j]    ; eax = j;
008D11AF test eax,eax              ; {flags} = eax & eax
008D11B1 jne main+119h (8D11C3h)  ; if (ZF == 0) jump 008D11C3
008D11B3 mov eax,dword ptr [k]    ; eax = k;
008D11B6 test eax,eax              ; {flags} = eax & eax
008D11B8 jne main+119h (8D11C3h)  ; if (ZF == 0) jump 008D11C3
008D11BA mov dword ptr [i],0      ; i = 0
008D11C1 jmp main+120h (8D11CAh)  ; jump 008D11CA
008D11C3 mov dword ptr [i],1      ; i = 1
i = !j;
008D11CA mov eax,dword ptr [j]    ; j = 0
008D11CD xor edx,edx              ; edx = 0
008D11CF test eax,eax              ; {flags} = eax & eax
008D11D1 sete dl                  ; if (ZF == 1) dl = 1
008D11D4 mov dword ptr [i],edx    ; i = edx

이걸 이해하려면 x86 Flags register에 대한 지식이 좀 필요하다. x86은 어떤 연산을 수행하고 나면 결과값 외의 여러 상태 변화를 Flags register에 담는다. 예를 들어, parity 값을 저장한다거나, 연산 결과가 표현 범위를 벗어났음을 뜻 하는 overflow 여부를 레지스터에 담는다. 비슷하게 ZF라는 Zero flag는 이전 연산의 결과가 0인지를 기억한다. ZF = 1이라면 이전 연산 결과가 0이라는 이야기다. x86 TEST 명령어는 두 피연산자를 & 연산하고 결과 값은 버리고 각종 플래그들을 채워준다.

je는 "Jump if equal" 이라는 뜻이다. 구현은 ZF == 1일 때 주어진 주소로 점프하도록 되어있다. 왜 같을 때 점프하라는데 ZF 플래그 값을 보는지 궁금해할 것이다. 보통 if 문은 CMP과 같은 두 피연산자를 비교하는 명령어와 je, jne와 같은 조건분기명령어 세트로 이루어진다. 여기서 x86 CMP 연산은 두 인자를 빼는 SUB와 같은 역할을 한다. 그러나 TEST처럼 결과 값은 버리고 플래그 상태만 바꾼다. 따라서 두 피연산자가 같다면 그것들의 뺄셈은 0이 될 것이고 ZF는 1이 된다. 따라서 je는 "같을 때 점프하라"는 뜻이 성립된다. jne는 "Jump if not equal".

이 지식만 있으면 저 x86 코드는 이해할 수 있을 것이다. !, Logical Not 연산 역시 비슷하고 dl은 edx 32비트 레지스터 중 하위 8비트 부분만 가리킨다. Boolean이 1바이트라는 정의를 나름대로 충실히 따르고 있는 셈.

 

p.s. 008D11CD 주소에 있는 "xor edx, edx"는 edx에 0을 대입하는 코드이다. 자기 자신을 xor 하면 당연히 0이 나오기 때문에 저렇게 쓴 것인데 왜 명시적으로 "mov edx, 0"을 쓰지 않고 저렇게 표현했을까? x86 코드를 보면 0으로 세팅하는 것을 "xor, eax eax"와 같은 코드로 주로 대체한다. 왜 이렇게 했냐면 x86에서 골치아프게 존재하는 (다른 명령어셋도 비슷할 듯) partial register stall을 막기 위해서다. 펜티엄 프로부터 CPU가 xor eax, eax 같은 형태가 오면 특별하게 처리해서 의존성이 안 걸리게 해서 시간 낭비를 막아주도록 하고 있다.

by object | 2008/07/08 15:07 | 컴퓨터 | 트랙백 | 덧글(12)
트랙백 주소 : http://minjang.egloos.com/tb/1968466
☞ 내 이글루에 이 글과 관련된 글 쓰기 (트랙백 보내기) [도움말]
Commented by 눈팅중 at 2008/07/08 16:23
아직 학부생으로 대학다니고 있는 초짜 학생인데, 요즘 학교에서 배운 내용과 비슷한 내용이 나와서 재밌네요. (기계어쪽으로는 MIPS 몇줄밖에 모르네요.;;;) 가끔 와서 보는데 오늘도 좋은글 감사합니다.
Commented by 쌍부라 at 2008/07/08 23:08
아 왜 xor eax, eax 로 표현하는지 궁금했는데 (더 찾을거리가 늘어났지만) 이유가 있었군요 ㅠㅠ!!

C, C++, 잡아, C#등 모두 &&, || 연산이 숏서킷으로 땡인데, vb.net에서 그거랑 비슷한거 만든답시고 AndAlso와 OrElse 연산이 생긴 것 같더라고요. (둘다 숏서킷)
Commented by ㄹㄹㄹ at 2008/07/09 02:00
xor eax, eax를 쓰는 이유가 stall을 막기 위해서였나요? 음.. 인스트럭션의 바이트수가 작아서 그런 건줄 알았는데 다른 이유도 있었군요
Commented by object at 2008/07/09 02:07
어느 문서에서 xor eax eax를 쓰는 내용을 읽었는데 어디서 봤는지 도통 기억이 안나네요.
Commented by 클랴 at 2008/07/09 10:35
xor eax,eax 는 한번의 인스트럭션 사이클(?)에 실행 되지만
mov eax, 0 은 0 이라는 값을 메모리에서 읽어 오는 시간이 걸리기 때문에 xor 를 쓴다고 배운 기억이 납니다.
그걸 제가 배운 때는 80년대말 Z80 였으니 세상이 두번 바뀌었지만요 ^^
Commented by object at 2008/07/09 10:57
xor eax eax는 분명히 한 사이클에 실행이되구요. 그런데 mov eax 0는 메모리와 상관이 없습니다. 비록 mov가 메모리로부터도 데이터를 가져올 수 있는 명령어지만 mov eax 0에서는 0은 immediate 상수이기 때문에 메모리로 가는 일은 없습니다 :) 따라서 저것도 한 사이클에 되는 겁니다. 다만 제가 읽기로는 mov, xor 등으로 플래그 레지스터들이 변화하는데 여기에서 의존성이 발생한다는 걸 분명 읽었는데... 어디서 봤는지 기억이 안나네요.
Commented by 클랴 at 2008/07/09 18:49
제가 말씀드리려 햇던 것은... 0 이라는 상수 자체도 code 영역의 메모리에 들어 있기 때문에 그 메모리를 읽기 위한 시간이 걸린다는 뜻이 였습니다.

예를 들어, Z80 의 경우 (x86 계열은 니모닉이 기억안나네요.. OTL)
8비트 A 레지스터에 0 값을 넣는 명령을 어셈블하면
LD A, 00H : 3E 00
과 같이 2 바이트 코드가 생성 되는 데 뒤의 한 바이트 00 을 읽는 데 시간이 걸린다는 의미이지요. ^^
Commented by 클랴 at 2008/07/09 18:52
아참, xor eax eax 한 경우, 결과값은 무조건 0 이 나오므로
zero flag가 세트 되는 side-effect에 주의해야 한다는 내용이 있었군요.
우와.. 어셈블리를 안쓴지 몇년 되었더니 완전히 잊어먹었었네요.
Commented by object at 2008/07/09 19:07
말씀대로 상수, immediate가 code 영역에 있는데요. 그런데 별도의 메모리 로드가 일어나지 않습니다. 왜냐면 말그대로 'immediate', 즉 바로 instruction을 가져옴으로써 그 상수값을 가져올 수 있기 때문이죠. mov eax 0 이라는 인스트럭션을 가져올 때, 같이 가져오기 때문에 메모리를 읽기 위한 추가적인 시간은 들어가지 않습니다. 예를 들어,

int a = 0x123456;

이건 mov로 표현이 될 것이구요.

mov dword ptr [ebp-14h],123456h

이걸 코드로 직접 보면

C7 45 EC 56 34 12 00

보다시피 인스트럭션 자체에 0x123456이 인코딩이 되어있어서, I$에서 저 명령을 가져오고 디코더가 저걸 판독할 때 바로 0x123456은 준비가 되게 됩니다. 그래서 imm이라고 부르죠. 레지스터 파일이나 메모리를 읽을 필요가 없는 경우입니다.
Commented by object at 2008/07/09 19:12
아, 물론 xor eax eax는 코드 표현이 짧을테니 읽어들이는 양이 적겠네요. x86 같은 가변 길이에서는 차이가 나겠지요. 그런데 또 보통 캐쉬 라인 크기가 64바이트라서 차이는 거의 없을 것 같습니다. I$는 비교적 프리펫치 하기가 쉬운 점도 있고요.
Commented by loveiris at 2008/07/10 00:44
나는 왜 90년대말인데도 z80 ... ;;;;
mov a, 0 에서 zf=1 이 되는 z80의 비극이 시초... ㄱ- 로 기억하는데...
지금은 그냥 효율성 때문일듯.?
Commented by noname at 2008/07/15 09:51
xor reg,reg 형태는 꽤 고전적인 어셈블리 최적화 기법이었습니다. 그 기원은 8086에서 시작했고요. 8086/88에서 mov reg,0 형태의 경우에는 immediate 값의 loading에 clock cycle이 더 소모되었기 때문에 사용했던 최적화입니다. 현재의 CPU에서 봤을 때는 의미가 없습니다만 여전히 mov reg,0에 비해 코드 크기가 짧아지기 때문에 아직도 사용하고 있다고 보시면 됩니다.

:         :

:

비공개 덧글

<< 이전 페이지 다음 페이지 >>





by object 여기는 공사중....
최근 등록된 덧글
최근 등록된 트랙백
VisualStudio 2005에서 Gui..
by 셈말짓기
SSD와 WD의 벨로시랩터
by 정보와 휴식...그리고 미래

한RSS 구독자수 website counter

한RSS에 추가

Add to Google

rss

skin by 이글루스