gcc STL map iterator의 버그(?)

이전 글에서 VC++/gcc 두 플랫폼 STL의 map iterator 작동이 달라서 불만을 토로 했었다. 문제는 iterator가 맨 첫번째 원소를 가리키고 있을 때, -- 연산을 줄 때의 행동이었다. VC++은 싸이클을 그리면서 일단 null 노드, 즉 end()로 얻어지는 노드로 가는 반면, gcc는 그대로 머무르는 행동을 보였다. 그래서 VC++의 경우 -- 을 연속해서 주면 싸이클을 그리면서 움직인다.

직접 두 STL 구현의 소스를 까봤다. STL의 map은 Red-Black Tree라는 이진트리로 구성이 되어있다. 따라서 -- 연산은 Binary Search Tree에서 predecessor를 찾는 코드가 된다. 알고리즘 책에 보면 있는 그런 일반적인 알고리즘이다. 두 구현에서 -- 연산은 당연히 거의 비슷하다 . 먼저 gcc STL의 구현이다. libcstd++ 소스가 없어서 인터넷에서 겨우 찾았다. stl_tree.cc에 구현이 되어 있는 내용.

_Rb_tree_node_base*
_Rb_tree_decrement(_Rb_tree_node_base* __x)
{
if (__x->_M_color == _S_red && __x->_M_parent->_M_parent == __x)
__x = __x->_M_right;
else if (__x->_M_left != 0)
{
_Rb_tree_node_base* __y = __x->_M_left;
while (__y->_M_right != 0)
__y = __y->_M_right;
__x = __y;
}
else
{
_Rb_tree_node_base* __y = __x->_M_parent;
while (__x == __y->_M_left)
{
__x = __y;
__y = __y->_M_parent;
}
__x = __y;
}
return __x;
}

그리고 VC++ STL의 구현 내용. 실제로는 각종 디버깅 코드가 있는데 다 삭제하고 핵심만 남겼다.

static _Nodeptr _Max(_Nodeptr _Pnode)
{ // return rightmost node in subtree at _Pnode
while (!_Isnil(_Right(_Pnode)))
_Pnode = _Right(_Pnode);
return (_Pnode);
}

void _Dec()
{ // move to node with next smaller value
if (_Isnil(_Ptr))
{
_Ptr = _Right(_Ptr); // end() ==> rightmost
if (_Isnil(_Ptr))
return; // begin() shouldn't be incremented, don't move
}
else if (!_Isnil(_Left(_Ptr)))
_Ptr = _Max(_Left(_Ptr)); // ==> largest of left subtree
else
{ // climb looking for left subtree
_Nodeptr _Pnode;
while (!_Isnil(_Pnode = _Parent(_Ptr)) && _Ptr == _Left(_Pnode))
_Ptr = _Pnode; // ==> parent while left subtree
if (_Isnil(_Ptr))
return; // begin() shouldn't be incremented, don't move
else
_Ptr = _Pnode; // ==> parent if not head
}
}

보다시피 거의 동일하다. 딱 하나 다른 경우가 첫 번째 if 문에서 VC++은 _Isnil로 체크하는데, gcc는 좀 다른 방식으로 체크를 하고 있다. 참고로 RB-Tree의 구현은 바운더리 체크를 쉽게 하기 위해 root의 부모로 널로 채워진 sentinel node를 두고 있다.

그러면 이제 아래의 코드를 돌려보자.

map<int, int> m;
m[2]=2;
m[4]=4;
m[6]=6;

map<int, int>::iterator i = m.begin();
--i;
fprintf(stderr, "%d\n", i == m.end() ? 0:i->second);
--i;
fprintf(stderr, "%d\n", i == m.end() ? 0:i->second);
--i;
fprintf(stderr, "%d\n", i == m.end() ? 0:i->second);
--i;
fprintf(stderr, "%d\n", i == m.end() ? 0:i->second);
--i;
fprintf(stderr, "%d\n", i == m.end() ? 0:i->second);

출력문에 지저분한 비교문이 있는 것은 VC++ STL은 end 노드를 출력하고자 접근하면 assertion을 띄어주기 때문에 저렇게 넣었다. gcc는 assertion을 위한 코드는 없다. 디버깅 glibc 버전이면 있을 수도.

VC++의 행동은 싸이클을 그리면서 도는 것이었기 때문에 당연히 아래처럼 결과가 나온다.

0 6 4 2 0

맨 처음 i는 2를 가리킬 것이고 여기서 한 칸 내려가면 맨 끝 원소로 가기 때문에 0을 출력할 것이다. 그리고 다시 거기서 한 번 내려가면 최대 원소인 6이 나온다.

그런데 gcc는 바닥에 있을 때는 늘 머물렀기 때문에 0 0 0 0 0으로 나와야만 한다. 그런데???

결과는 VC++과 똑같이 0 6 4 2 0으로 나온다!!! 즉, gcc map STL에서도 iterator는 바닥을 지나 싸이클을 돌면서 순회한다. 그러면 결국 원소가 하나만 있을 때, iterator 연산에 문제가 있다는 것을 말한다. 실제로 위의 테스트 코드에서 원소 4, 6의 삽입을 막아 버리고 map에 원소 하나만 있도록 한 뒤 같은 코드를 돌리면 결과:

2 2 2 2 2 2

로 나오게 된다.

좀 더 실험을 해보자. 이번에는 원소를 m[6]=6 코드를 막고, 즉, map에다 삽입을 2, 4 순서로 삽입한 뒤 돌리면? 여전히 위와 같이 2만 출력된다. 마지막으로!! 원소 삽입을 큰 것 부터 해보자. 즉, map에다가 4, 2로 두 개만 딱 넣어보자. 그러면?? 싸이클을 그리면서 순회한다 -_-;

아무래도 맨 최상단의 if 문에서 비교하는 nil 노드 때문에 이상하게 되는 것 같은데 더 이상 자세한 디버깅은 귀찮고 짜증나서 포기한다.

(잠정적인) 결론: gcc STL map iterator 구현에 버그가 있다. 만약, 이터레이터 자체가 순회하는 것이 잘못된 행동일 수 있다. 그러나 적어도 행동의 일관성은 보여야할 것 아닌가? 어떤 경우에는 바닥에 머물고 어떤 경우에는 싸이클을 그리면 어떡 하라는거야! 완전 지금 기분은 --;

여러분들의 추가적인 테스팅 대환영 합니다.

by object | 2008/05/18 06:02 | 컴퓨터 | 트랙백 | 핑백(1) | 덧글(14)
트랙백 주소 : http://minjang.egloos.com/tb/1893698
☞ 내 이글루에 이 글과 관련된 글 쓰기 (트랙백 보내기) [도움말]
Linked at art.oriented : S.. at 2008/07/09 18:22

... 도 헷갈리기 딱 좋다. string 객체를 부득이하게 printf로 찍어야 하는 경우, .c_str()을 부르지 않아 삽질을 계속한다. 플랫폼마다 map의 이터레이터 구현이 달라 삽질한 경우도 있다. 그러나 쭉 써보면 이런 설계에 고개가 끄덕여진다. algorithm이나 functional에 있는 것들을 쓰면서 "내가 참 뭘 몰라도 한참 모르고 S ... more

Commented by 진상 at 2008/05/18 08:31
역시 유닉스계열에서 cpp가 중요도가 떨어지기 때문일까요? STL에서 버그가 있다면 이를 바탕으로 구축한 소프트웨어 모두에서 잠정적인 버그를 갖게 되는건데..
Commented by 백승우 at 2008/05/18 09:51
관련내용인지 몰라도...
유닉스나 리눅스에서 그런지 몰라도 i+=1 과 i++는 다르게 행동하더군요. 첫번째 경우는 스택 메모리를 사용해서 증가시키는 반면, 후자는 레지스터에서 처리하더군요.(이것때문에 고생했습니다. ㅡ.ㅡ)
Commented by object at 2008/05/18 10:34
흠... i += 1, i++은 전혀 프로그래머에게 가시적인 차이가 없는데 어떤 점 때문에 고생하셨어요??

스택 메모리를 사용한다는 건 굳이 해석한다면
add [esp+10h], 1
과 같이 번역되어있는 경우를 뜻할 것이고 레지스터를 쓴단 말은
mov eax [esp+10h]
add eax 1
mov [esp+10h] eax
뭐 이런 걸 말씀하시는 것 같은데요.

사실 저 둘은 프로그래머의 관점에서는 전혀 차이가 없는 셈이고 컴파일러가 최적화를 하면 대부분 반복적인 메모리 로드/스토어는 제거가 되지요. 그나저나 i += 1, i++은 최적화된 코드에서는 전혀 차이가 없습니다.
Commented by 백승우 at 2008/05/18 11:29
인덱스 i를 선언하고 getchar로 문자를 하나씩 입력받아 저장하는 배열을 선언했습니다.
while((c=getchar())!=EOF)
{
arr[i++] = c;
// arr[i] = c;
// i += 1;
}
주석처리 하지 않은 부분과 주석처리한부분을 살펴보시면 버그가 있습니다. ^^
++, +=1 차이라기 보다는 C언어에 미숙한 제가 만든 버그죠 ^^ 자바만 하다 C를하니 메모리 개념이 없었던게 이유입니다. ㅋㅋㅋ
Commented by object at 2008/05/18 11:42
어.. 주석처리한 것과 똑같아 보이는데.. 버그가 어딨죠 -_-;
Commented by 백승우 at 2008/05/18 11:55
array 사이즈가 10인데 20개 입력받았다고 가정해보세요. 아마 어느 정도는 문제 없이 돌다가 값자기 i 값이 10, 11,12 , 38 <-- 이렇게 변해있습니다.
이는 arr의 주소값이 증가하다가 상대적으로 높은 주소값이 위치한 (스택) i를 덮어써버리게된 경우인데, 위 두가지 경우가 다른걸 확인했습니다.
(확실한 소스를 보여드려야 하는데, 소스가 회사 서버에 있으니..ㅡㅡ)
Commented by object at 2008/05/18 12:50
아.. 하하하.. 네 전형적인 stack-based buffer overflow의 예이네요. 그걸 이용해서 예전에 권한탈취 많이 했죠. 스택 어딘가에 보통 return address가 저장되고 거기에 악성 코드가 있는 주소를 집어 넣어 그 주소로 점프하게 하는 것이 고전적이 수법이었죠.

흠. 이건 버그라고 말하기는 굉장히 그렇구요. 일단 오버플로우 자체가 프로그램 버그이기 때문에 그 여파로 생긴 것은 부가적인 문제라고 봅니다. 그런데 컴파일 최적화 옵션 주셨나요? 말씀대로 i가 파괴가 되려면 매 루프에서 i를 메모리에서 읽어와야하는데요. 거의 컴파일러 옵션을 주면 i는 루프 밖에서 오직 한 번만 메모리에서 읽고 그 다음에는 계속 레지스터에서만 읽고 쓰고 합니다. 물론, 루프가 굉장히 길어 레지스터의 압박이 심하면 루프 안에서도 i의 메모리 주소에서 읽어와서 말씀대로 망가진 i 값이 읽어질 수 있는데요, 그렇다고 해서 i += 1과 i++이 다르다고 판단할 수는 절대 없습니다. 이건 순전히 컴파일러가 어떤 방식으로 코드를 만들어내고 최적화가 어떻게 되느냐에 따른 문제네요. 만약 i++ 이라도 레지스터가 부족하면 메모리에서 i를 읽어와야하고 그렇다면 망가진 i값을 가져올 수도 있죠. i += 1역시 마찬가지입니다.
Commented by 백승우 at 2008/05/18 13:39
++, += 1 문제라기보다는 일단 제가 만든 버그 여파로 디버깅하다가 발견한겁니다.^^ 데니스리치 할아버지 책에 ++가 속도가 쬐끔 빠르다고 나와있는게 머리를 스치더라고요..^^
옵션은 안줬습니다. 연습문제에 옵션이라면 -g 밖에 ㅋㅋㅋ
Commented by object at 2008/05/18 13:45
흠, ++과 += 1은 최적화된 컴파일러에서는 차이가 없어요 :) 직접 비교해보셔도.. 20년 전 같으면 ++ 이 더 빠를 수도 있지만 요즘 컴파일러 무척 똑똑합니다. 그나저나 다시한번 += 1은 스택을 사용하고 ++은 스택을 사용하지 않는다는 틀린 생각입니다. 그건 절대 아닙니다. 어떻게 코드가 만들어질지는 컴파일러 마음입니다.
Commented by 백승우 at 2008/05/18 14:41
우선 gcc만가지고 해본결과이니 다른환경은 모르겠습니다. 제가 테스트해본 환경(레드햇9.0)에서 gcc는 어셈블리 코드가 다르게 나왔습니다.
Commented by object at 2008/05/18 15:01
네, 다르게 나올 겁니다. 요즘 제가 gcc, VC++, icc 세 개 컴파일러가 만들어 놓은 x86 어셈을 자주 보기 때문에 비교적 정확히 말씀드릴 수 있는데요. 보통 i++, i += 1은 최적화 하지 않았을 때 아래 처럼 번역이 될 수 있습니다.

1. 가장 단순 무식한 방법
mov eax [주소]
add eax 1
(또는 inc eax)
mov [주소] eax

2. 약간 이상한 방법 (x86-64 gcc가 이렇게 만들더군요)
lea offset(%rbp), %rax
incl (%rax)
뭐 이런 식인데, 해석하면 i가 있는 주소를 rax에다 올린 뒤, 그 메모리 주소를 오퍼랜드로 넘기는 방식

3. 젤 간단한 코드
inc [메모리]

보기에는 사뭇 다르나 위 코드는 당연히 똑같은 일을 합니다. 그래서 프로그래머가 i++, i += 1이 다르다고 가정하고 무언가를 하는 것은 매우 위험합니다. 위 경우 모두 공히 "load - computation - store"의 세 부분으로 쪼개져서 결국엔 실행이 되니까 3번 방식이 명령어 하나라고 해서 다른 것이 아닙니다.

그러나 이게 최적화가 되면 가용 레지스터 여부에 따라 반복적인 memory load가 사라질 수 있습니다. 그러면 말씀대로 스택 버퍼 오버플로우가 나서 i가 있는 곳의 메모리가 망가져도 문제가 없죠. 거의 장담하지만 저 위의 세코드는 실행하는데 속도 차이가 없습니다. 3번이 눈에 보이지는 않지만 내부적으로 레지스터로 옮기는 작업을 하는 마이크로 명령이 더 만들어집니다.

최적화가 되면 그냥.. i는 루프 위에서 한번만 로딩이 되고 내부에서는 그냥 inc ecx 처럼 쓰입니다.

암튼 스택버퍼오버플로우가 날 때 i++, i+=1에 따라 행동이 달라질 수 있지만 이것을 유의미한 것으로 받아들이면 곤란합니다. 물론 그 메커니즘을 이해하는 건 매우 유익하지만... 잘못된 선입관을 가질까봐 걱정되서 말이 길어졌군요.
Commented by 백승우 at 2008/05/18 15:14
하하.. 오늘 제대로 하나 배우네요 ^^
제 나름대로는 결론은 'arr[i++]'식의 코드 <-- 는 쓰지 말자.. 뭐 대단한 코드 같이 보이지만 가독성도 떨어지고..
Commented by 몽몽이 at 2008/05/18 23:11
아 이거 저도 느꼈던건데... 그때는 당하고도 뭐가 뭔지 몰라서 걍 코드를 다 뒤집어서 다르게 만들고 말았죠...
Map은 그냥 Map일뿐 다른걸 기대하지 말자... 뭐 그랬던가...
버그를 버그라고 말 할 수 있는 것도 용기와 지혜가 필요한 것을 절감하게 되는군요.
Commented by 이홍석 at 2008/05/19 22:57
억울한게 undefined 자체가 표준에서 '이건 실행시키면 어떻게 될지 저도 잘 모르겠어요 하하하'
뭐 이런 컨셉이니..; 웬지 따지면 손해인 것 같아요"..." 차라리 표준 디버그 만들고 예외라도 던져죠 어헝ㅠㅠ
gcc 에서 reverse_iterator 는 어떨지 쪼꼼 궁금한데..; 다시 깔아볼까 고민 중입네다...;

:         :

:

비공개 덧글

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





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

한RSS 구독자수 website counter

한RSS에 추가

Add to Google

rss

skin by 이글루스