C++26: 단순 무한 루프는 더 이상 미정의 동작이 아님
요약
본 기사는 C++ 표준에서 단순 무한 루프가 더 이상 미정의 동작(UB)이 아닐 것이라는 변화에 대해 논하며, 이로 인해 발생하는 잠재적 위험성을 경고합니다. 특히 임베디드 시스템이나 저수준 프로그래밍에서 흔히 사용되는 `while(1)` 구조가 컴파일러 최적화나 표준 변경으로 인해 예상치 못한 동작을 할 수 있다는 우려를 제기합니다.
핵심 포인트
- 단순 무한 루프의 UB 해소는 임베디드/시스템 프로그래밍에 위험할 수 있음.
- 컴파일러가 시스템 호출을 삽입하는 것은 예측 불가능하며, 전방 진행 보장 개념이 잘못됨.
- 무한 루프는 단순히 컴파일되어야 하며, 표준 변경은 신중해야 함.
- 관련 기능(std::vector, std::ranges 등)의 하드코딩 및 결합 심화가 문제점임.
루프 본문을 std::this_thread::yield() 호출로 바꾸는 건 Linus를 비롯한 많은 이들이 C++에서 싫어하는 숨겨진 코드의 전형임. 생성자와 소멸자는 어느 정도 불가피하고 덜 뜬금없지만, Rust는 적어도 소멸 처리에서 비지역적 코드의 영향 범위를 더 잘 제한함.
C11 규칙을 채택하기 싫었다면, 단순 루프에 진단이나 오류를 요구하고 프로그래머가 ::yield 등을 명시적으로 넣도록 하는 규칙을 검토했어야 함. 숨겨진 코드도 없고 컴파일러가 뜻밖의 동작을 할 여지도 줄어듦.
C 위원회는 표준의 정의되지 않은 동작(UB)을 하나씩 정리하면서 진단·오류를 요구하거나 구현 정의 동작으로 바꾸고 있음. 이런 식으로 코드를 끼워 넣는 건 상상하기 어려움.
C++ 위원회는 현장에서 통용되는 관행을 무시하는 버릇이 있음. 원자적 연산을 어디에나 강요하려고 volatile 대상의 비트 연산을 의도적으로 망가뜨렸다가, 하드웨어 레지스터를 직접 조작하는 임베디드 라이브러리들을 깨뜨린 뒤 되돌려야 했음.
빈 무한 루프는 임베디드 C에서 main의 초기화가 끝난 뒤나 예외 처리기 안에서도 흔히 쓰임. 자신들의 좁은 시스템 프로그래밍 관점 밖에는 관심이 없어 보임.
GNU C도 비슷한 일을 함. 메모리 복사를 memcpy로 최적화하거나, 여러 연산을 libgcc 호출로 구현할 수 있음.
상수 접기 이후 남은 도달 불가능한 경로에서 매크로나 템플릿이 이런 루프를 생성한다면, 진단이나 오류를 강제하는 방식은 작동하지 않음.
이건 재앙에 가까우며, 주로 C++20부터 언어와 라이브러리의 결합이 심해지고 있음. 변수의 기본 0 초기화, STL 상당 부분을 끌어오고 심볼을 하드코딩하는 비교·메타프로그래밍 기능, 내부용 대체 심볼 없이 std에만 있는 std::initializer_list 등이 대표적임.
코루틴도 coroutine_handle, noop_coroutine, 일시 중단 관련 요소 등을 반드시 제공해야 함. 특히 std::vector, std::ranges, std::allocator를 그대로 하드코딩하는 건 최악임.
this_thread::yield의 관찰 가능한 동작이 전방 진행뿐이라면, as-if 규칙에 따라 선점을 보장하는 실행 환경에서는 실제로 루프를 바꿀 필요가 없음. std::thread가 커널 스레드로 구현되는 경우가 여기에 해당함. M:N 구현에서는 yield가 필요하겠지만, 그때는 바람직한 동작임.
흥미롭게도 POSIX 실시간 FIFO 스케줄링은 커널 스레드 기반이어도 선점하지 않으므로, 표준을 해석하는 방식에 따라 yield가 필요할 수 있음. 결정적 동작을 기대하는 FIFO 스케줄링에서는 치명적일 수 있지만, 실시간 스케줄링 자체가 이미 표준 범위 밖이며 GCC나 Clang이 기본적으로 이런 변환을 할지는 의문임.
실질적으로는 메모리 모델의 까다로운 구석을 성립시키고 원치 않는 최적화를 막기 위한 동등성으로 보임. 컴파일러가 실제 호출 삽입을 구현하더라도 선택적으로 켜는 옵션을 제공하고, 최적화만 호출이 있는 것처럼 수행할 것으로 예상함.
라이브러리 호출이 전혀 없는 무한 루프에 시스템 호출이 삽입된다니 끔찍한 함정임. 전방 진행 보장이라는 개념 자체가 잘못됐음. 무한 루프는 그저 무한 루프로 컴파일되어야 함.
이전에는 UB였으니, 컴파일러는 그때도 원하기만 하면 시스템 호출을 넣을 수 있었던 셈임.
LLM을 한동안 추궁해 기존 전방 진행 규칙을 정당화해 보라고 했는데, 그럴듯했던 건 계산보다 원자적 저장을 먼저 수행하는 최적화뿐이었음. messy_pure_computation(); some_atomic.store(1, relaxed);에서 저장을 계산 앞으로 옮길 수 있다는 것임. 더 강한 메모리 순서의 저장에는 추가 분석이 필요함. 다만 이게 특별히 유용한지는 여전히 확신이 안 듦.
컴파일러 옵션으로 제어해야 한다고 봄. 유용한 최적화일 수는 있지만, 프로그래머가 끌 수 있어야 함.
마이크로컨트롤러 프로그래밍에는 while(1) 무한 루프의 정당한 용도가 있음. STM32의 자동 생성 HAL 코드도 오류 처리기에 이를 사용하고 C++를 지원하므로, 이게 UB였다는 사실이 놀라움.
나는 오류 처리에만 사용함. 전력에 민감한 애플리케이션에서 대기용으로 쓰는 건 좋지 않으며, 그때는 인터럽트로 깨어나는 방식을 써야 함.
오류 처리기에는 소프트웨어 중단점도 넣는 편임. __BKPT(); while (1);처럼 작성하면 물리적으로 개수가 제한된 하드웨어 중단점을 소비하지 않고도 디버깅하기 쉬워짐.
컴파일러 공급업체에 -fallow-infinite-loops 옵션을 요청하면 됨.
표준 위원회가 말하는 UB는 “우리가 그걸 생각하지 못했다”는 뜻임. 실제로는 매번 .loop: jmp .loop로 컴파일된다는 걸 잘 알고 있으니, 문자 그대로 동작을 알 수 없는 건 아님.
스레드가 언젠가는 종료하거나, 라이브러리 입출력 함수를 호출하거나, volatile glvalue에 접근하거나, 동기화·원자적 연산을 수행한다고 가정하는 규칙이 왜 필요한가?for 루프에서 정지 문제를 풀도록 해도 끝나지 않을 텐데, 이 규칙을 우회하는 것 아닌가?
아커만 함수를 계산하고 결과를 쓴 뒤 종료하면 됨. 태양이 적색거성이 되어 지구를 삼킬 즈음에야 끝나겠지만.
이런 변경이 어떻게 유용한지 모르겠음. 그런 루프를 작성하는 건 거의 항상 실수이며, 유일한 용도는 매우 저수준인 코드에서 아무것도 하지 않는 것임. 그럴 때는 어셈블리로 작성한 외부 함수를 써도 충분하므로 C++ 표준을 바꿀 필요가 없음. 얻는 건 거의 없이 표준만 복잡해져 오히려 더 어려워짐.
반복 실행에 특별한 예외 규칙이 필요하다는 발상 자체가 이상함. 인라인화 이후 이런 반복이 드러나는 예제를 들겠지만, 다른 언어는 무한 루프를 금지하지 않고도 잘 작동함.
부작용이 없는 동일한 루프 사이에서 코드 이동을 허용하려는 목적이었다면, 그 조건을 명시하고 평범한 무한 루프는 건드리지 않으면 됐음. C와 무관한 언어였다면 종료할 수 없는 루프 문법을 금지하고, 의도에 따라 도달 불가능 표시나 반복 대기로 고치도록 안내하는 것도 합리적일 수 있음.
이걸 해결하려고 부작용을 주입하는 건 끔찍함.
어셈블리 없이도 매우 저수준인 코드를 작성할 수 있음. ARM Cortex-M은 전부 C로 작성할 수 있도록 설계됐다는 게 분명함. 인터럽트 처리도 플랫폼의 호출 규약을 따르므로, 인터럽트 벡터가 평범한 C 함수를 가리키면 됨.
대개 실수인 코드라고 해서 UB로 만드는 게 좋은 선택은 아니며, 이번에 정의한 동작이 왜 그런지도 설명해 주지 못함.
내게는 클래스가 있는 C를 넘어서는 기능은 모두 인지 부담이 너무 큼. 제네릭을 구현하는 용도로는 템플릿이 괜찮을 수 있지만, 위원회가 만든 나머지 변경 대부분은 머리를 낭비하게 한다고 느낌.
그 예제에서 unreachable()이 실행될 줄은 전혀 예상하지 못했음. 실제로 마주칠 일은 드물 듯하지만, #ifdef가 여러 겹으로 쌓이면 이상한 일이 벌어지는 건 본 적이 있음.
UB에서는 그런 일이 생길 수 있음. 컴파일러는 예상대로 동작할 의무가 없음.
오히려 실전에서 가장 흔한 함정에 가까움. 반환형이 void가 아닌 함수에 return문이 없으면, 함수 끝을 지나서도 실행이 이어질 수 있음. 직접 겪어 봐서 아는 일임.
이런 일을 피하려면 -Wreturn-type 을 켜고 컴파일하는 게 좋음.
함수 하나의 실행이 그대로 다음 함수로 넘어갈 수 있다는 건 생각도 못 했음. 왜 이런 동작이 존재하는 건가?
UB가 발생하면 C++ 실행 모델 밖으로 벗어나므로 구현에 따라 달라짐. 여기서는 컴파일러가 두 함수를 기계어에서 연이어 배치했고, 첫 함수가 복귀하지 않으니 다음 함수의 코드로 실행이 이어지는 것임.
호출되지 않는 함수가 컴파일되고 링크된다는 점도 의아함. 컴파일러가 호출되지 않는다고 판별할 수 있다면 아예 제거하는 게 맞지 않을까?
어셈블리를 보면 main: 바로 뒤에 unreachable():와 push rbx가 나와 단서를 얻을 수 있음. UB 때문에 main을 호출하는 건 불가능하다고 판단하고 본문 생성을 포기한 것임. std::unreachable()로도 같은 결과를 만들 수 있음.
그런데 함수 레이블은 남아 있어서 그곳으로 점프하면 다음 함수로 실행이 이어짐. 결국 함수 배치 순서만 바꿔도 동작이 달라짐.
레이블을 완전히 지우지 못하는 이유는 링크 문제나 한 함수에 여러 레이블을 붙이는 경우 때문일 수 있음. UB에서만 드러나는 결과라 기술적으로 틀린 건 아니지만, 차라리 트랩 명령어를 넣는 게 그렇게 어려운지 모르겠음. “절대 호출되지 않는” 함수라면 최적화 빌드에서도 조금 느려지는 걸 신경 쓸 필요가 없지 않은가?
CPU는 함수가 아니라 명령어를 실행함. 함수는 기계어 위에 얹힌 규약임. 여기서는 컴파일러가 복귀 동작 없이 끝나는, 사실상 잘못된 함수를 생성해서 메모리상 다음 함수로 실행이 이어진 것임.
반환이 필요한 함수에서 return을 빠뜨려도 같은 현상이 생길 수 있음. 반대로 호출 스택에서 두 단계 위로 복귀해, 호출한 함수까지 함께 반환해 버리는 경우도 본 적이 있음. UB는 정상적인 제어 흐름을 완전히 망가뜨릴 수 있음.
추측하자면 한 최적화 단계에서 무한 루프 때문에 함수가 복귀하지 않는다고 보고 중간 표현에서 복귀 명령을 제거한 뒤, 나중 단계에서 루프도 아무 효과가 없는 UB라며 제거했을 수 있음. 그러면 복귀조차 하지 않는 빈 함수만 남게 됨.
AI 자동 생성 콘텐츠
본 콘텐츠는 RSS: GeekNews (한국어)의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기