광범위한 이식성, 호환성, 그리고 실제 구현에서의 신뢰성을 우선하는 C의 부분집합에 대한 안내서입니다.
신뢰할 수 있는 C는 신뢰할 수 있는 C를 작성하고자 하는 개발자를 위해 C의 부분집합을 문서화하려는 시도이다.
Python, Java, C# 같은 고급 언어를 사용하고 있다고 상상해 보자. 복잡한 수학 계산, 파일 파싱, 또는 기타 기본 기능을 처리할 유틸리티 라이브러리가 필요하다. 이 라이브러리가 어떤 언어로 작성되기를 원하는가? 가장 뻔한 답은 지금 사용 중인 언어일 것이다. 하지만 대부분의 언어는 이 목적에 충분히 안정적이지 않다. 10년 된 Python 라이브러리는 지속적으로 유지보수하지 않으면 지금 사용하는 Python 버전에서 실행되지 않을 가능성이 높다. 또한 빠르기를 원하므로 컴파일 언어가 더 낫다. C++와 Rust도 같은 하위 호환성 문제를 겪고 특수한 툴체인이 필요하며, 다른 언어에서 사용하려면 특수한 바인딩도 필요하다. 당신이 원하는 것은 C로 작성된 라이브러리다. 빠르고, 단순하고, 어디서나 실행되며, 급할 때는 대부분의 사람이 코드를 읽을 수 있다. 하지만 어떤 종류의 C로 작성되길 원하는가? 가능한 한 가장 평범한 C, 확장 기능도, 컴파일러 설정도, 추가 빌드 단계도 필요 없는 C로 작성되길 원한다. 플랫폼에서 사용 가능한 어떤 툴체인으로도 사용할 수 있어야 하며, 특정 구현이나 특정 언어 버전에 의존해서는 안 된다.
이것이 바로 신뢰할 수 있는 C로 작성된 라이브러리다. 화려하지도 않고, "현대적"이지도 않지만, 항상 동작하고 빠르게 실행된다. 어떤 코드에서 가장 중요한 기능은 의도한 대로 컴파일되고 실행되게 만들 수 있다는 점이다. 신뢰할 수 있는 C가 우선하는 것은 바로 그것이다. 프로그래머의 사용 편의성이 아니라, 사용자가 자신의 용도에서 그 코드가 동작한다고 얼마나 의지할 수 있는가이다.
신뢰할 수 있는 C를 직접 쓰고 싶어 하는 사람은 거의 없지만, 다른 모든 사람이 자기 코드를 신뢰할 수 있는 C로 작성했으면 하고 모두가 바란다.
C23과 다가오는 C2Y는 점점 더 복잡해지고, 많은 새로운 키워드와 흐름 제어를 포함하며, "고전적 C"와 다른 개정된 헌장을 포함하는 언어 버전들이다. 후속 버전의 C는 수백 개의 C 구현 중 겨우 두 구현에서만 지원된다. ANSI C와 C2Y 사이의 차이는, 어쩌면 ANSI C와 첫 번째 C++ 버전 사이의 차이보다 더 크다. 이는 고전적 C로 널리 이식 가능하고 표준을 준수하는 소프트웨어를 개발하고자 하는 개발자에게 최신 ISO C 표준이 형편없는 안내서라는 뜻이다. 이전 버전의 표준을 읽는 것도 충분하지 않다. 이후에 폐기된 기능 목록이나, 어떤 부분이 구현 지원이 부실했는지에 대한 안내가 포함되어 있지 않기 때문이다. 이것이 신뢰할 수 있는 C가 존재하는 이유다. 어디서나 받아들여지는 C 코드를 작성하도록 돕는 안내서이기 때문이다.
C는 가장 이식성이 높고 가장 널리 구현된 언어다. C는 컴퓨팅의 공용어라고 불려 왔다. C로 해결된 문제는 앞으로도 계속 해결된 상태로 남을 것이다. 운영체제, 컴퓨팅 환경, 하드웨어의 변화가 잘 작성된 C 구현을 쓸모없게 만들 가능성은 낮다. C로 작성된 라이브러리는 거의 모든 언어에서 사용할 수 있다. 많은 프로그래머가 C를 쓰지는 않더라도, 많은 이들이 C를 읽고 이해할 수는 있다. 즉 C로 작성된 코드는 더 큰 규모의 프로그래머 집단에 의해 수정될 수 있다.
수명이 품질의 척도라면, C는 고품질 코드를 작성하기에 매우 유력한 후보이다.
모든 C 코드가 이식 가능한 것은 아니며, 모든 컴파일러에서 같은 방식으로 컴파일되는 것도 아니고, 대부분의 C 프로그래머가 이해할 수 있는 것도 아니다. C는 오랫동안 기묘함과 모서리 사례의 역사를 가지고 있어서 다루기 어려울 수 있다. 특정 플랫폼에서만 실행되고 특정 툴체인으로만 빌드되도록 의도된 비이식적 코드를 작성하는 것은 완전히 정당하다. 그러나 이식 가능하고 수십 년 동안 사용 가능한 코드를 작성하고 싶다면 이 안내서가 당신을 위한 것이다. 최신 언어 기능보다도, 확실하게 컴파일되고 올바르게 동작하는 코드를 작성하는 가치를 중시하는 사람을 위한 것이다.
신뢰할 수 있는 C는 방언의 반대편에 있다. 가능한 한 중도적인 C가 되려고 하며, 최대한 널리 이해되고 구현되기를 목표로 한다. 뉴스 진행자 C라고 생각하라. 중립적이고, 보편적으로 이해되는 언어이다.
신뢰할 수 있는 C는 스타일 가이드가 아니다. 포맷팅, 들여쓰기, 스타일을 규정하지 않는다. 단지 어떤 C 기능을 신뢰할 수 있는지, 그리고 어떻게 신뢰할 수 있는지를 문서화하려고 한다. Misra 같은 스타일 가이드를 따르면서 동시에 신뢰할 수 있는 C를 어떤 기능을 사용할지에 대한 안내서로 사용하는 것은 완전히 타당하다. Misra 표준은 안전성을 우선하고, 신뢰할 수 있는 C는 호환성을 우선한다. 두 가지를 동시에 따르는 것도 충분히 가능하다.
어떤 경우에는 후속 버전에서 도입된 기능이 필요하고, 그런 경우에는 그 기능을 신뢰할 수 있는 방식으로 접근하는 방법을 문서화하려고 한다.
많은 언어가 C로부터 문법을 파생했다. C++, Java, C#, D, JavaScript, Objective-C 등이 그 예이다. 이들 거의 모두는 C89를 기반으로 하며, C99 이후의 기능은 받아들이지 않았다.
이 프로젝트의 목적은 신뢰할 수 있는 C의 작은 부분집합을 문서화하는 것이며, 따라서 UB가 전혀 없는 표준 준수 코드를 작성하는 것을 강하게 권장한다. 그러나 아주 드문 경우에는 기술적으로 UB이지만 실제로는 신뢰할 수 있기 때문에 허용되는 코드를 이 안내서가 강조할 것이다. 반대로 기술적으로는 표준을 준수하지만 전혀 신뢰할 수 없는 코드도 수없이 많고, 어떤 경우에는 그런 구현이 아예 존재하지도 않는다(annex K 참조). 목표는 현실 세계의 실제 구현에서 동작하는 코드를 어떻게 작성할지 안내하는 것이지, 표준 기구가 만든 종이 문서 속 제품에만 맞는 코드를 쓰게 하려는 것이 아니다. 그렇다 해도 대부분의 구현은 표준을 신중히 연구하고 최대한 따르려고 노력하며, 표준 기구 역시 표준을 가능한 한 완전하고 명확하게 만들기 위해 상당한 노력을 기울인다.
대부분의 다른 언어는 하나 또는 극소수의 구현만 가진다. 따라서 구현의 동작이 플랫폼마다 달라지지 않는다고 기대할 수 있다. C는 수많은 구현이 있고, 복잡성과 기능 지원 범위도 매우 넓다. 많은 C 구현에는 버그가 있으며, 그런 버그는 대체로 언어를 한계까지 밀어붙일 때 드러난다. 가장 관용적인 코드는 가장 많이 테스트된 코드이기도 하므로 기본 기능은 신뢰할 수 있다. 컴파일러 개발자들은 공개된 코드를 사용해 구현을 테스트하므로, 흔한 구문일수록 난해한 모서리 사례보다 훨씬 엄격하게 테스트되었을 가능성이 높다. 모든 컴파일러가 과거에 접했을 것이라 확신할 수 있는 문법으로 코드를 작성하면, 버그를 유발할 가능성을 최소화할 수 있다. 코드는 사용자가 항상 최신 버전의 컴파일러를 가지고 있다고 가정하지 않도록 해야 한다. 어떤 플랫폼은 주요 컴파일러의 지원이 중단되었을 수 있고, 오직 특정 컴파일러만 지원할 수도 있다.
컴파일러는 더 큰 C 구현 생태계의 한 작은 부분일 뿐이다. 린터, 새니타이저, 포매터, 디버거, 구문 강조기, 문서 생성 도구 등 수많은 도구가 어느 정도 언어를 구현하며, 모두 저마다의 한계를 가진다. 이상적으로는 이러한 모든 도구의 한계 안에 머물러 코드가 그 혜택을 누릴 수 있기를 원할 것이다.
신뢰할 수 있는 C는 모든 C 버전의 부분집합 사용을 지지한다.
C89가 C 표준들 가운데 가장 작기 때문에, 실제로는 C89의 부분집합을 의미한다. 단순히 C89 표준만 사용하는 것으로는 C를 완전히 이해하기에 충분하지 않다. C 표준 문서가 발표된 이후 수년에 걸쳐 이루어진 많은 변경은 이전 버전의 모호함과 문제를 해결하기 위한 것이었다. 한 표준에서는 불명확하지만 나중 표준에서 명확해진 사항이 있다면, 사용자가 컴파일러를 이전 표준에 맞추더라도 대개는 명확해진 동작을 얻게 된다. C89/ANSI C는 언어의 첫 번째 버전이었으므로 구현 경험이 가장 적은 상태에서 쓰였고, 따라서 많은 문제를 가진다.
많은 언어가 C로부터 문법을 파생했다. C++, Java, C#, D, JavaScript, Objective-C 등이 그 예이다. 이들 거의 모두는 C89를 기반으로 하며, C99 이후의 기능은 받아들이지 않았다. 이는 주로 이런 언어를 사용하는 프로그래머들이 후속 버전으로 작성된 코드를 읽기 어려워한다는 뜻이기도 하다.
C89 부분집합이 권장되지만, 신뢰할 수 있는 C를 쓰는 목적은 어디서나 받아들여지는 것이며, 여기에는 어떤 C 버전으로 설정된 컴파일러에서도 받아들여지는 것이 포함된다. 코드가 더 새로운 기능을 사용하지 않는지 검증하기 위해 컴파일러를 엄격한 C89 부분집합에 맞출 수는 있지만, 다른 어떤 C 버전을 사용해도 똑같이 잘 실행되어야 한다. 코드가 C89 모드를 가진 컴파일러를 필요로 해서는 안 된다. 보편적이어야 한다. यही가 신뢰할 수 있는 C가 폐기된 기능이나 새 C 기능과 충돌하는 기능("auto" 참조)의 사용을 권장하지 않는 이유다.
C89 이후 C 표준에 추가된 기능의 대부분은, 알고만 있다면 C89에서도 이미 할 수 있는 일을 하는 새로운 방법들이다. 우리는 시간이 지나면서 가능한 한 많은 것을 문서화하려 한다. 어떤 경우에는 후속 버전에서 도입된 기능이 필요하고, 그런 경우에는 가능한 가장 신뢰할 수 있는 방식으로 그 기능에 접근하는 방법을 문서화하려 한다.
C 프로그래밍 언어는 안타깝게도 고칠 수 없다. 다행히도 C는 고칠 필요가 없을 만큼 충분히 좋다.
C의 가장 큰 강점 중 하나는 호환성이다. C는 어떤 프로그래밍 언어보다도 더 많은 구현, 더 많은 기존 코드, 더 많은 문서, 더 많은 숙련된 프로그래머를 가지고 있다. 이 모든 호환성을 깨는 비용은, 언어의 어떤 개선이 가져올 가치보다도 단순히 더 크다.
C의 결함을 고치려는 다양한 C 방언과 대체 제안들이 있었지만, 거의 어느 것도 성공하지 못했다.
ISO C 표준은 C23까지는 하위 호환성을 진지하게 다뤘다. 이는 기능을 제거할 수 없고, 새로운 기능만 추가할 수 있었다는 뜻이다. 드물게 일부 기능이 폐기 예정으로 표시된 적은 있지만, 실제로는 사용자가 기존 코드를 컴파일하기 위해 그 기능이 필요했기 때문에 구현에서 이를 제거할 수 없었다.
기능은 추가만 가능하고 제거는 불가능한 상황은 C 같은 언어에 잘 맞지 않는다. C의 핵심 가치 중 하나가 단순성, 간결성, 구현 용이성이기 때문이다. 안정성 역시 언어 기능을 설계하고 싶어 하는 언어 설계자 집단에 의해 잘 유지되기 어렵다. 사람들은 표준 조직에 표준을 발전시키지 않기 위해 가입하지는 않는다. (일반적으로 말해, ISO C wg14 표준 기구 구성원들은 유능하고, 근면하며, 매우 박식하고, 좋은 의도를 가진 분들이라는 것이 내 개인적 경험이다. 그러나 충분히 많은 사람들이 "딱 하나만 더"를 추가하고 싶어 하면, 결과는 깔끔한 설계가 되지 않는다.)
wg14는 역사적으로 하위 호환성을 유지하려고 열심히 노력했지만, 반대 방향의 호환성은 무시해 왔다. 더 새로운 버전의 C로 코드를 작성하면 많은 플랫폼과 구현과 호환되지 않게 된다. 흔히 최신 버전으로 작성한 코드는 오래된 구현에서 컴파일되지 않을 뿐 아니라, 때로는 코드의 의미 자체가 달라진다. 이는 분명히 매우 위험하다.
이 위험의 한 예가 일부 UB의 제거이다. 언뜻 보면 과거에 불필요하게 정의되지 않았던 동작을 정의하는 것은 분명한 개선처럼 보인다. 그러나 대부분의 구현이 그 동작이 정의되기 전에 작성되었고, 실제로는 보장되지 않는 상황에서 프로그래머가 나중 표준을 읽고 보장을 기대하면 문제가 된다. 기술적으로는 정의되었을지라도, 실제로는 정의되지 않았던 역사가 있으므로 여전히 신뢰할 수 없다. 그리고 과거와 달리 이 위험은 이제 표준에서 명확히 드러나지도 않는다. 문제를 제거하려는 선의의 노력이 오히려 문제를 만든다. 이것이 신뢰할 수 있는 C 노력을 촉발한 이유 중 하나다.
1972년으로 돌아가는 시간 여행자는 C의 많은 문제를 해결할 수 있었겠지만, 오늘날 상황은 훨씬 더 복잡하다. 다행히 여기서 정의하는 작은 부분집합만으로도 해야 할 모든 일을 충분히 해낼 수 있다. 큰 틀에서 보면 희생은 사소하다. 개발자에게 있어 C의 대부분의 문제는 그냥 "그렇게 하지 않으면 된다"로 해결할 수 있다. 구현은 그렇게 할 수 없다. 항상 잘 작성된 것은 아닌 기존 코드를 컴파일해야 하기 때문이다.
C는 이식 가능한 코드와 이식 불가능한 코드를 모두 작성할 수 있는 언어다. C는 다양한 플랫폼에서 컴파일되고 실행될 수 있는 코드를 작성하는 데 사용되며, 우리는 보통 이것을 이식 가능한 코드라고 부른다. C는 또한 주류 아키텍처와는 매우 다른 매우 이국적인 아키텍처에서도 구현될 수 있도록 설계되었다. 예를 들어 바이트가 32비트인 DSP도 있다. 이런 아키텍처에 C가 이토록 넓은 범위의 아키텍처를 지원하는 것은 엄청나게 중요하다. 종종 C가 그런 아키텍처를 위한 유일한 프로그래밍 언어이기 때문이다(어셈블리를 제외하면). 이런 플랫폼은 매우 다르므로, 우리가 이식 가능한 C라고 생각하는 대부분의 코드는 그곳에서 동작하지 않는다. 따라서 이런 플랫폼을, 이식 가능한 C 코드를 실행하는 플랫폼이라기보다는 C로 프로그래밍할 수 있는 플랫폼으로 보는 편이 낫다. 나는 이를 "이국적" 플랫폼과 "전통적" 플랫폼으로 구분하겠다.
완전히 표준을 준수하는 이식 가능한 C 코드를 작성하여, C 구현이 있는 전통적 플랫폼과 이국적 플랫폼 모두에서 올바르게 실행되게 하는 것은 아주 제한적인 경우에만 가능하다. 이유는 몇 가지에 불과해도 충분하다.
설령 이런 제약 안에서 프로그램을 작성하더라도, C 언어는 최소 스택 크기나 다른 자원의 가용성을 보장하지 않으므로, 여전히 가능한 모든 플랫폼에서 프로그램이 실행될 것이라는 보장은 없다.
이 극단적으로 학문적인 관점에서 완전히 이식 가능한 C 코드를 쓰려는 시도는 다소 무의미해진다. 목표는 요구 사항에 대해 합리적인 결정을 내리고, 코드를 가능한 한 이식 가능하게 만드는 것이어야 한다. 꼭 필요할 때만 비이식적인 코드를 작성하라. 당신의 소프트웨어가 소수의 현대적 아키텍처에서만 실행될지라도, 앞으로 하드웨어가 어디로 갈지는 모르고, 그 작은 일부를 초소형 임베디드나 오래된 아키텍처에서 다시 쓰고 싶어질 때가 언제일지도 모른다.
일반적인 규칙으로는, 특별한 이유가 없는 한 항상 표준 준수 코드를 작성하는 것이 좋다.
어떤 일을 하는 방법이 두 가지 있는데, 하나는 올바르고 다른 하나는 그렇지 않으며 결과는 동일하다면, 언제나 올바른 방법을 택하라. 코드의 이식성을 제한할 때는 반드시 의도적으로 하라. 중요하지 않을 것 같다는 이유로 무심코 제한하지 마라. 그 한 예가 널 종료 문자이다. 널 종료 문자 '\0'은 모든 비트가 0으로 설정된 문자로 정의된다. 정수 0의 비트 표현은 플랫폼에 의해 정의되므로, 모든 비트가 0인 것이 0을 뜻하지 않는 플랫폼을 나는 들어본 적이 없지만, 이론적으로는 다음 문자열이 널 종료되지 않을 구현도 가능하다.
char hi\[] = {'H', 'i', 0};
나는 항상 문자열을 0 대신 '\0'으로 종료한다. 0을 널 종료자로 사용해서 문제가 생길 것이라 예상해서가 아니라, 그게 올바른 방법이고 올바르게 하는 데 비용이 들지 않기 때문이다.
어떤 플랫폼을 일반적이라고 보고 어떤 플랫폼을 이국적이라고 봐야 할지 명확한 선을 긋는 것은 어렵고, 각 응용 프로젝트가 스스로 요구 사항을 정해야 한다. 누구나 이국적이거나 주류라고 동의할 수 있는 플랫폼도 있지만, 의외로 큰 사용 기반을 가진 덜 알려진 플랫폼도 많고, 미래의 플랫폼은 오늘날의 일반적인 플랫폼과 다를 수도 있다.
모든 소프트웨어 프로젝트는 요구 사항을 가진다. 코드가 언어 표준을 따른다고 해서 그 언어를 지원하는 모든 플랫폼에서 실행하는 것이 의미 있다는 뜻은 아니다. C는, 예를 들어 바이트가 8비트가 아니거나 메모리가 매우 제한된, 매우 이국적인 플랫폼에서도 구현될 수 있는 언어다. 신뢰할 수 있는 C 지침을 따르지만 포인터가 64비트보다 작은 하드웨어에는 이식되지 않는 소프트웨어를 작성하는 것은 완전히 합리적이다.
이 어느 것도 표준이 보장하지 않는다.
개발을 더 쉽게 하기 위해 플랫폼에 대해 할 수 있는 가정들이 있다. 하지만 그런 가정은 우리가 일반적인 플랫폼이라고 볼 수 있는 범위를 줄인다. 가정을 하나 할 때마다 코드의 신뢰성은 그만큼 줄어든다.
이러한 가정은 합리적이라고 볼 수 있고 대부분의 플랫폼에서 동작하겠지만, 비교적 쉽게 피할 수 있으며, 그 방법은 뒤이어 나오는 장들에서 다룬다.
표준은 어떤 경우 구현이 진단 메시지를 내도록 강제하지만, 무엇에 대해서든 경고를 낼지는 구현에 맡긴다. 따라서 경고가 전혀 없는 C 코드를 작성하는 방법은 없다. 구현은 사용자가 애초에 C를 쓰고 있다는 사실에 대해 경고할 자유도 있다.
그러므로 경고는 무시할 수 있도록 의도된 것이다. 많은 구현은 모든 경고를 오류로 바꾸는 옵션을 가지고 있고, 많은 개발자는 이를 정책으로 켠다. 이는 문제를 만든다. 구현이 발전하여 더 많은 문제를 감지할 수 있게 되면, 새 경고가 생기면서 빌드가 깨지기 때문이다. 그러면 사용자들은 구현자들에게 불평하고, 구현자들은 추가 진단을 제공할 유인을 잃는다. gcc, llvm, Msvc 같은 주요 C 구현들 다수는 이 이유로 거의 새 경고를 추가하지 않는다.
이는 많은 경고가 기본적으로 꺼져 있고, 사용자가 수동으로 켜야 함을 의미한다. 많은 경고는 무해한 문제를 경고하고, 정말 중요한 경고 중 일부는 기본적으로 꺼져 있을 수도 있다. 이 때문에 자신과 자신의 코딩 스타일에 관련 있는 경고를 켜고 끄는 데 시간을 들일 것을 권한다. 또한 개발 중에는 관련 있는 경고를 오류로 올리는 것도 권한다. 어떤 경고를 켜고 끄고 오류로 격상할지는, 요구 사항, 자신이 자주 만드는 버그의 종류, 자신과 팀의 경험 수준에 달려 있다. 모든 개발자는 다르며, 어떤 개발자가 컴파일러가 경고해 주길 원하는 관행을 다른 개발자는 편안하게 활용하기도 한다.
신뢰할 수 있는 C는 모든 인터페이스에서 C++ 호환성을 장려하지만, 코드가 C++ 컴파일러에서 올바르게 컴파일될 것을 보장하지는 않는다. C++는 C의 부분집합이 아니며, 두 언어의 차이는 미묘하고 종종 의도되지 않은 것이다. C와 C++ 모두에서 같은 결과를 내는 코드를 확실하게 작성하려면 두 언어에 대한 깊은 지식이 필요하며, 우리는 이를 권장하지 않는다. 헤더 파일은 C++와 호환되고 함수 본문을 포함하지 않도록 강하게 권장한다. 또한 C++ 키워드의 사용도 권장하지 않는다.
일반적으로 표준 라이브러리 구현의 품질은 대부분의 플랫폼에서 매우 좋다. 따라서 신뢰할 수 있다고 볼 수 있다. 반면 표준 라이브러리의 설계에는 다소 결함이 있다. 여러 이유로 표준 라이브러리 전체를 사용하는 것은 권장되지 않으며, 선별적으로 사용하는 것이 좋다.
표준 라이브러리는 기술적으로는 라이브러리가 아니다. 표준의 표현을 빌리면 언어의 "구현"의 일부이다. 구현에서는 라이브러리를 언어와 완전히 분리할 수 없다.
C 표준에 기능이 부족하다는 불평은 많지만, 내 생각에는 이것이 오히려 좋은 점이다. 대부분의 언어는 하나 또는 극소수의 구현만 가진다. 예를 들어 Python은 사실상 CPython 구현으로 사용되며, 따라서 사용자는 이 구현이 제공하는 방대한 Python 표준 라이브러리에 의존할 수 있다. 구현이 사실상 언어를 정의하는 셈이다. C는 다르다. C는 구현이 매우 많고, 산문으로 쓰인 표준 문서로 정의된다. 이 산문을 코드로 옮기는 과정은 필연적으로 버그와 표준 해석 차이 때문에 미묘한 구현 차이를 만든다. Curl, zlib, SQLite 같은 매우 신뢰할 수 있는 API를 생각해 보라. C 표준이 이런 인터페이스를 채택해서 그 사양을 쓰고, 각 표준 라이브러리 구현이 그것을 구현하라고 요구한다면, 결국 훨씬 덜 신뢰할 수 있는 인터페이스가 될 것이다. 단일 구현에 기반하지 않기 때문이다. 이런 인터페이스는 독립 프로젝트로 남아 있을 때 훨씬 더 신뢰할 수 있다. C 표준 기구보다 훨씬 나은 도메인 경험을 가진 사람들이 운영하기 때문이다. 같은 기능을 여러 구현이 나눠 갖는 과정 자체가 분기를 낳고, 그것은 표준화의 목표와 정반대다. 이런 종류의 유틸리티는 표준 라이브러리가 해주길 바라기보다 좋은 구현에 의존하라.
표준 라이브러리를 써야 하는 이유는 두 가지다. 이식성 때문이거나, 내장 최적화 기능 때문이거나 둘 중 하나다.
malloc, free, fopen, fclose, fread, fwrite처럼 운영체제를 다루는 함수는 보편적으로 구현되어 있고 이식 가능하므로 좋은 신뢰성을 제공한다. 같은 일을 하는 플랫폼 고유 함수가 추가 기능을 제공할 수는 있지만, 본질적으로 그만큼 신뢰할 수는 없다. 이 함수들은 신뢰성, 가독성, 보편적 구현성 면에서 매우 좋으므로 강하게 권장된다. printf 계열 함수 역시 신뢰할 수 있지만, 그 모든 기능이 그런 것은 아니다.
표준 라이브러리는 독립적인 라이브러리가 아니라 언어의 일부이므로, 많은 컴파일러는 표준화된 라이브러리 호출을 매우 잘 작성되고 최적화된 플랫폼 고유 어셈블리로 대체한다. 종종 C 프로그래밍 언어에서는 달리 접근할 수 없는 기계 고유 명령을 사용한다. cosf()를 호출하는 것은 수학 연산을 호출하는 것이며, 이는 + 연산자를 입력하는 것 못지않게 언어에 통합되어 있다. 컴파일러는 그 기능이 무엇을 하는지 알고, 대응되는 구현으로 대체할 것이라 기대할 수 있다.
흔히 내장 최적화로 구현되는 함수는 다음과 같다: memcpy, memove, memset, malloc, free(예, 컴파일러는 메모리 할당을 최적화로 없애기도 한다), 그리고 math.h의 수학 함수들.
malloc realloc free calloc (calloc에 대해서는 "C에서의 초기화"를 더 보라)
memset memcpy memmove
exit abort
assert
수학 함수
malloc이 느리다는 오해가 있다. 메모리 할당이 느린 것이다. 그건 어려운 문제이기 때문이다. 하지만 malloc은 거의 항상 이 문제에 대한 매우 잘 구현된 해결책이다. malloc은 광범위하게 사용되고, 매우 성능에 민감한 수많은 작업이 그것에 의존하기 때문에, 많은 주의와 최적화의 초점이 된다.
일반적으로는 메모리 할당을 가능한 한 피하는 것이 좋은 조언이다. 하지만 메모리를 할당해야 할 때는 malloc이 가장 신뢰할 수 있는 방법이다.
커스텀 할당기는 많은 메모리를 미리 할당한 뒤 그 풀을 사용하는 경우에 제자리가 있다. 하지만 큰 단점도 있다. 메모리 풀 하나를 할당하면 미래에 필요한 메모리 양을 미리 예상해야 하고, 한계에 도달하면 복잡성을 더하는 우아한 대체 경로를 마련해야 한다. 메모리를 사용하지 않거나 사용량이 줄어들더라도, 같은 컴퓨터에서 실행될 다른 작업에 일부 메모리를 돌려줄 방법이 없다.
malloc 구현은 종종 MMU 같은 하드웨어 기능에 접근할 수 있으며, 이를 통해 실제 메모리 페이지를 주소 공간에 자유롭게 매핑할 수 있다. 이런 기능은 특별한 하드웨어 기능에 접근할 수 없는 커스텀 할당기(적어도 이식 가능한 방식으로는)보다 메모리 파편화 문제를 훨씬 더 잘 해결할 수 있다.
운영체제는 메모리 할당을 위한 여러 다른 기능도 제공한다. 예를 들어 Windows에는 Virtualalloc이 있고 Linux에는 mmap이 있다. 당연히 이것들을 사용하는 것은 이식 가능하지도, 신뢰할 수도 없다.
C는 거의 어떤 하드웨어에서도 실행될 수 있다. 표준이 가능한 모든 하드웨어에서 코드가 어떻게 실행되는지를 설명하는 것은 불가능하다. 대신 C 표준은 "추상 기계"라는 가상의 하드웨어 아키텍처를 설명하고, 실제 하드웨어 위의 실제 구현은 결과가 추상 기계에서 실행된 것과 동일하기만 하면 무엇이든 해도 된다고 규정한다.
AS-IF 개념은 C의 토대이며, 이것이 컴파일러가 코드를 최적화할 수 있게 해서 C를 성능과 전력 효율의 황금 표준으로 만든다. C의 어떤 것들은 프로그램의 출력으로 지정되므로, 반드시 추상 기계의 출력과 정확히 일치해야 한다. 이를 "관측 가능한 동작" 또는 그냥 "동작"이라 한다(정의되지 않은 동작은 동작이 아니다). 추상 기계 내부에서 일어나는 연산과, 추상 기계 바깥에서 일어나 관측 가능한 것들을 구분하는 것은 매우 중요하다. C의 대부분 연산은 추상 기계 안에서 일어나며, printf 같은 입출력 기능과 volatile 한정자를 가진 값만이 C에서 관측 가능하다.
구현은 추상 기계 내부에 있는 코드에 대해 원하는 어떤 변환도 할 수 있지만, 관측 가능한 동작의 출력과 순서는 엄격히 따라야 한다.
다음을 보라.
int x;
x = 3;
x += 2;
printf("Hello");
printf(" World %u\n", x);
이 프로그램에서 변수 x에 대한 대입과 덧셈은 전적으로 추상 기계 내부에서 일어난다. 반면 두 개의 printf는 관측 가능하므로 순서대로 실행되어야 하며, 프로그램이 추상 기계에서 실행되었을 때와 같은 출력을 내야 한다. 컴파일러는 이를 다음처럼 바꿀 수 없다. 관측 가능한 동작이 바뀌기 때문이다.
int x;
x = 3;
x += 2;
printf(" World %i\n", x);
printf("Hello");
하지만 변수 자체를 제거하고 이렇게 바꾸는 것은 완전히 가능하다.
printf("Hello");
printf(" World %u\n", 2 + 3);
심지어 이렇게까지도 가능하다.
printf("Hello World 5\n");
보듯이 컴파일러는 프로그램의 관측 가능한 동작이 소스 코드에서 설명된 것과 동일하게 유지되기만 하면 코드를 과격하게 다시 쓸 자유가 있다.
C의 설계는 여러 면에서 하드웨어가 구현하는 명령과 매우 잘 대응된다. 이것은 C가 고급 어셈블러이며, 소스 코드에 적힌 명령이 관련 어셈블리 명령으로 일대일 번역된다는 잘못된 인상을 준다. 사실이 아니다. 추상 기계는 컴파일러에게 최적화를 위한 큰 재량을 준다. rand sort를 구현했다면, 컴파일러가 그것을 더 빠른 merge sort로 바꾸는 것도 완전히 합법적이다(그럴 가능성이 높지는 않더라도).
C는 어셈블리에 매우 잘 대응하는 것처럼 보이지만, 많은 면에서 그렇지 않다. 가장 첫 번째이자 가장 명백한 차이는, 대부분의 컴퓨팅 아키텍처가 메모리 내용을 먼저 레지스터로 옮기지 않고는 메모리에서 연산할 수 없다는 점이다. 메모리 입출력은 느리므로, 이상적으로는 상태를 레지스터에 유지하고 싶다. 이를 위해 컴파일러는 코드를 과감하게 변형해야 한다.
AS-IF는 프로그래머와 구현 사이의 방화벽이 되도록 의도되었다. 이상적으로 프로그래머는 하드웨어나 구현을 대상으로 코드를 쓰는 것이 아니라 추상 기계를 대상으로 코드를 쓴다. 구현은 이 추상 기계의 연산을 실제 기계를 위한 명령으로 변환한다. 이 방화벽은 하드웨어 업체에게 새로운 아키텍처를 발명할 자유를, 구현자에게는 그 하드웨어 아키텍처를 활용할 자유를, 소프트웨어 개발자에게는 안정적인 플랫폼을 제공한다.
C는 모든 연산이 순서대로 실행되고 다음 연산은 이전 연산이 끝나기 전에 실행되지 않는다는 환상을 유지한다. 사실이 아니다. 컴파일러는 연산 순서를 바꿀 수 있고, CPU 아키텍처는 이 환상을 유지하도록 돕지만 수십 년 동안 비순차 실행 아키텍처였다. RAM과 디스크의 지연 시간을 고려하면, 하드웨어 아키텍처는 캐시, 분기 예측, 비순차 실행, 파이프라이닝 등 수많은 기법을 사용하여 다른 작업이 끝나기를 기다리는 동안 가용 계산 자원을 활용한다. 이러한 개념은 C 프로그래머에게 거의 보이지 않지만, 이런 종류의 하드웨어에 최적화할 수 있으려면 컴파일러는 코드를 깊이 있게 분석해야 한다. 대부분의 프로그래머에게 이런 것들은 C와 컴파일러가 완전히 감춘다.
이러한 최적화를 허용하기 위해 표준은 추상 기계에서의 실행 순서에 대해 어떤 요구도 하지 않는다. 오직 관측 가능한 동작만이 엄격히 순서대로 실행되어야 한다. 이는 복잡한 아키텍처 위에서 동작하면서도 단순한 아키텍처의 환상을 만들어 낸다. 대부분의 경우 프로그래머는 여기에 의존할 수 있다. 프로그래머가 파일에 X를 쓰고 그 다음 Y를 쓴다면, 파일에서 X가 Y보다 먼저 온다는 것이 보장된다. 구현은 프로그램을 바꾸지 않는 한도 내에서 얼마든지 재배열할 수 있다.
이 엄격한 순서 요구는 프로그램이 이 모델의 예외를 위반하지 않을 때만 유지된다. 예외는 다음과 같다.
정의되지 않은 동작
동시 처리
시간
Volatile
프로그램이 정의되지 않은 동작 상태에 들어가는 순간, 구현은 어떤 요구도 더 이상 지킬 필요가 없다. 그러니 모든 것이 무너진다. 어떤 UB는 특정 구현의 특정 상황에서만 순서에 영향을 미치는 매우 미묘한 효과를 가질 수 있다. 그래서 "내 컴퓨터에서는 되는 것 같은데" 식의 정의되지 않은 동작은 신뢰할 수 없다.
모든 동시 실행은 올바르게 동작하기 위해 동기화 원자(atomic, mutex, semaphore...)를 필요로 한다. 자세한 내용은 동시성에 관한 별도 문서를 보라.
C 표준은 실행 시간에 대해 어떤 보장도 하지 않는다. 어떤 실행이 다른 실행보다 빠르다고 보장되지 않으며, 실행 시간의 최대치나 최소치도 보장하지 않는다. 구현의 대다수는 가능한 한 빨리 프로그램을 실행하려고 하지만, 그렇게 하리라는 보장은 없다.
volatile 메모리에 대한 모든 연산은 순서대로 실행된다는 것이 보장되지만, 이것이 실제로 어떻게 달성되는지는 플랫폼에 따라 다르다. volatile A에 쓰고 그 다음 volatile B에 쓰면 프로세서는 그 순서대로 수행하지만, 다른 프로세스, CPU, 다른 하드웨어가 그 순서대로 그것을 인지하리라는 보장은 없다. 이 모든 것은 플랫폼 규정 사항이다. C 표준은 한 네트워크 카드에 쓴 뒤 다른 카드에 쓰면 CPU가 그 순서대로 쓴다는 점은 보장할 수 있지만, 어떤 네트워크 카드가 먼저 메시지를 받고 네트워크에 먼저 보낼지는 보장할 수 없다.
C는 CPU 간 동기화 의미론은 가지고 있지만, CPU와 다른 하드웨어 간 통신 의미론은 가지고 있지 않다. volatile은 이를 위한 만능 해결책이며, 따라서 volatile은 구현/플랫폼 정의일 수밖에 없다.
volatile은 순서 의미론은 갖지만, 원자성이나 release/acquire 의미론은 갖지 않는다. 64비트 volatile 값에 대한 쓰기는 어떤 아키텍처에서는 두 번의 32비트 연산으로 바뀔 수 있다. 이 경우 다른 CPU나 하드웨어 장치가 반쯤만 써진 값을 읽는 "찢어진 쓰기"가 발생할 수 있다. 그래서 volatile에 대해 어떤 보장이 있는지 보려면 항상 플랫폼 문서를 확인해야 한다.
다소 아이러니하게도, C에서 의존할 수 있는 유일한 것은 관측 가능한 동작이고, 어떤 것을 관측 가능하게 만드는 방법은 그것을 volatile로 만드는 것인데, volatile이 어떻게 동작하는지의 세부 사항은 모두 플랫폼에 의존한다. 왜냐하면 그것은 플랫폼과 상호작용하기 때문이다.
이 문서는 ISO wg14의 정의되지 않은 동작 연구 그룹이 작성한 기술 보고서의 초기 초안이다.
이 문서는 C 프로그래밍 언어에서 "정의되지 않은 동작" 개념을 설명하려는 교육용 문서이다. 이는 ISO WG14의 정의되지 않은 동작 연구 그룹이 협력하여 이 용어와 그 함의를 명확히 하려는 노력의 결과이다.
ISO C는 3.4.3절에서 정의되지 않은 동작(UB)을 다음과 같이 정의한다.
오류가 있거나 이식 가능하지 않은 프로그램 구성 또는 오류 데이터의 사용 시,
이 문서가 어떤 요구도 부과하지 않는 동작
항목에 대한 주석 1: 가능한 정의되지 않은 동작은, 예측 불가능한 결과와 함께 상황을 완전히 무시하는 것부터, 환경의 특징적인 문서화된 방식으로 번역 또는 프로그램 실행 중 동작하는 것(진단 메시지를 발행하거나 하지 않고), 번역 또는 실행을 종료하는 것(진단 메시지를 발행하면서)까지 폭넓다.
항목에 대한 주석 2: J.2는 정의되지 않은 동작으로 이어지는 C 프로그램 속성의 개요를 제공한다.
항목에 대한 주석 3: 프로그램 실행 중의 다른 모든 동작은, 오류가 있거나 이식 가능하지 않은 프로그램 구성 또는 데이터를 만났을 때 일어나는 구체적 동작의 직접적인 결과로만 영향을 받는다. 특히 프로그램 실행 중 정의되지 않은 동작을 가진 연산보다 먼저 발생하는 모든 관측 가능한 동작(5.1.2.4)은 이 문서에 지정된 대로 보인다.
ISO C 프로그래밍 언어 명세에는 본질적으로 일부 동작 집합이 정의되지 않는다는 개념이 들어 있다. 이로부터 명세는 여러 강점과 여러 약점을 도출한다. UB는 플랫폼이 플랫폼 고유 동작을 정의하거나, 오류 상태의 가능성을 무시할 수 있게 한다. 언어는 플랫폼에게 이러한 오류를 감지하라고 요구하지 않는다.
정의되지 않은 동작은 C 표준의 여러 곳에서 여러 이유로 사용된다.
정의되지 않은 동작은 표준에 명시적으로 규정될 수도 있고, 표준이 동작을 정의하지 않아 암묵적으로 남을 수도 있다. C 표준 기구는 모든 UB를 C 표준에 문서화하는 것을 목표로 하지만, 모든 UB를 식별하는 것은 어렵고 노동 집약적인 작업이다. 표준은 정의되지 않은 동작에 관한 규칙이 표준에서 지정되지 않은 동작에도 확장된다고 말한다.
또한 표준에는 어떤 동작이 정의되는지 아닌지 불분명한 문단들도 있다. 이는 어떤 플랫폼은 어떤 동작을 정의된 것으로 취급하고, 다른 플랫폼은 정의되지 않은 것으로 취급할 수 있음을 뜻한다. 예를 들어 표준은 구조체의 첫 멤버가 구조체 자체로부터 0 오프셋을 가진다고 말한다. 어떤 사람들은 이것이 구조체의 첫 멤버가 구조체와 같은 포인터 주소를 가져야 한다는 뜻이라고 주장하고, 다른 사람들은 표준이 이 모호함을 명시적으로 해결하지 않았으므로 구조체가 첫 멤버와 같은 주소를 갖는지 여부는 정의되지 않는다고 주장한다.
정의되지 않은 동작 외에도, C 표준은 여러 종류의 동작 용어를 정의한다. 정의되지 않은 동작과 달리, 이들 각각은 구현이 어떤 형태로든 책임지고 유지해야 하는 제한된 동작을 정의한다. 구현마다 다를 수는 있지만 말이다.
이들은 모두 정의되지 않은 동작과 다르다. 구현마다 다른 동작을 만들 수는 있지만, ISO 준수 C 구현에서 사용자가 의존할 수 있는 동작이기 때문이다.
C 표준은 어떤 플랫폼이든 원한다면 UB를 감지하고, 플랫폼 고유 동작을 제공하고, 이를 문서화할 자유가 있다고 말한다. 이런 의미에서 엄격한 ISO C 용어로는 "UB"인 것이 특정 구현에서는 잘 정의된 동작일 수 있다.
이는 매우 유용할 수 있다. 구현자가 C의 능력을 확장하고, 그로써 사용자가 플랫폼 고유 기능을 사용할 수 있게 해주기 때문이다. C 언어는 크로스 플랫폼 개발을 가능하게 하도록 설계되었지만, 개발자는 제한된 플랫폼 집합만 지원하도록 자유롭게 선택할 수 있다. 예를 들어 어떤 C 구현은 배열 경계 밖 쓰기, 부호 있는 정수 오버플로, 널 포인터 역참조의 동작을 정의한다.
간결함을 위해, 별도로 언급하지 않는 한 이 문서는 구현이 플랫폼 고유 동작이나 구현 고유 동작을 정의하지 않은 경우의 UB만을 다룬다.
다음 코드를 보자.
int a[5];
a[x] = 0;
x가 42라면 무슨 일이 일어나야 할까? 언어 설계에 따라 오류를 낼 수도, 프로그램을 종료할 수도, 배열 크기를 늘릴 수도 있다. 하지만 어떤 선택을 하든, 언어 구현은 값이 배열의 유효 범위 안에 있는지 검사해야 한다.
int a[5];
if (x < 0 || x >= 5) {
/* 경계 밖 쓰기 처리 */
} else {
a[x] = 0;
}
이 범위 검사는 컴파일러와 실행 환경에 일을 더한다. 대입이 경계를 벗어났는지 감지하라는 어떤 요구라도 추가하면, 실행 시간 성능과 복잡성 비용이 생긴다. 구현은 배열에 대한 각 접근을 검사해야 할 뿐 아니라, 유효한 배열 범위를 추적해야 한다.
C는 빠르고, 단순하고, 쉽게 구현될 수 있도록 설계되었다. 이것이 C가 경계 밖 상태를 감지하도록 요구하지 않는 이유다. 따라서 C는 감지되지 않은 상태에 대한 동작을 정의할 수 없다. 그 동작은 정의되지 않을 수밖에 없다.
표준의 모든 정의되지 않은 동작이 단순한 실수나, 적절한 동작에 대해 표준 기구가 합의하지 못한 결과라는 오해가 흔하다. 위 예는 구현에 상당한 부담을 지워 그 상태를 감지하게 하지 않는 한, 경계 밖 배열 접근에 대해 어떤 일관된 동작도 정의하는 것이 실용적이지 않음을 분명히 보여 준다. 오류 상태를 감지하는 비용 때문에, 언어는 그 상태가 발생했을 때의 동작을 정의할 수 없다.
더 나아가, 표준이 경계 밖 쓰기 시 프로그램이 종료되어야 한다고 요구한다면, 다음 코드는 프로그램을 종료하는 유효한 방법이 되어 버린다.
int a[5];
a[24] = 0;
이것은 프로그램을 의도적으로 종료하는 좋은 방법이 아니다. 표준이 종료로 명시한 방식, 예를 들어 exit라는 함수를 호출하는 방식으로 종료하는 것이 바람직하다.
다시 이 코드를 보자.
int a[5];
a[x] = 0;
이 코드에 대한 또 다른 해석은, 구현이 경계 밖 접근을 처리해야 할 요구가 없다면, 코드 자체가 x는 0에서 4 사이여야 한다는 암묵적 계약을 포함한다는 것이다. 그러면 구현은 사용자가 이 계약을 알고 동의한다고 가정할 수 있으며, 구현이 가능한 x 값에 대한 분석만으로 이 계약이 유효한지 확인할 수 없더라도 말이다. 따라서 구현은 x 값을 검사할 필요가 없다.
사용자가 x가 범위 안에 있음을 보장할 수 없다면, 코드를 다음처럼 다시 쓸 수 있다.
int a[5];
if (x >= 0 && x < 5)
a[x] = 0;
많은 동작이 정의되지 않은 큰 이유 중 하나는, 그런 정의되지 않은 동작을 컴파일 시점에 감지하기 어렵거나, 실행 시점에 너무 큰 성능 손실을 줄 수 있기 때문이다.
정의되지 않은 동작이 존재한다는 것은 반대로, 프로그램에 정의되지 않은 동작이 없다면 그 동작이 ISO C 표준과 실행 플랫폼에 의해 잘 규정된다는 뜻이다. 이는 ISO C 표준, 플랫폼, 개발자 사이의 약속 또는 계약이다. 프로그램이 이 약속을 깨면 결과는 무엇이든 될 수 있고, 사용자의 의도와 어긋날 가능성이 높으며, 이식 가능하지도 않다. 우리는 이 약속을 "UB의 부재를 가정함"이라 부르겠다.
UB 상태에 들어가는 C 프로그램은, 플랫폼이 잡거나 보고할 의무가 없는 오류를 포함하고 있다고 볼 수 있으며, 결과는 무엇이든 될 수 있다.
다음 코드를 보자.
x = (x * 4) / 4;
수학적으로 보면 이 연산은 x 값을 바꾸지 않아야 한다. 곱셈과 나눗셈이 서로 상쇄되기 때문이다. 하지만 컴퓨터에서 계산할 때는 x * 4의 결과가 x의 타입으로 표현할 수 없는 값이 될 수 있다. x가 값 2,000,000,000을 가진 부호 없는 32비트 정수라면, 4를 곱했을 때 32비트 플랫폼에서 래핑되어 3,705,032,704가 될 수 있다. 뒤이은 4로의 나눗셈은 926,258,176을 만든다. 표준은 부호 없는 정수 연산에 대해 래핑 동작을 정의하고 있으므로, 두 연산은 서로 상쇄되지 않는다.
대신 같은 연산을 부호 있는 정수 타입으로 수행하면 이야기가 달라질 수 있다. 부호 있는 정수 오버플로는 UB이기 때문이다. 부호 있는 정수를 사용함으로써 프로그래머는 그 타입에 대한 어떤 연산도 절대 오버플로를 일으키지 않을 것이라는 계약에 동의한 셈이다. 따라서 최적화기는 잠재적 오버플로를 무시하고 두 연산이 서로 상쇄된다고 가정할 자유가 있다. 즉, 부호 있는 정수 오버플로를 UB로 선언하는 것은 상당한 최적화 이점을 준다.
프로그램에 UB가 없다고 가정하는 것은, 컴파일러가 최적화를 찾기 위해 코드를 분석할 때 사용할 수 있는 강력한 도구이다. 프로그램에 UB가 없다고 가정하면, 이 정보를 이용해 실행의 예상 상태를 배울 수 있다. 다음을 보자.
int a[5];
a[x] = 0;
x가 0보다 작거나 4보다 큰 값이면 이 코드는 UB를 포함한다. 많은 플랫폼에서 a[-1]과 a[5]는 a의 경계를 벗어난 주소에 쓰게 된다. 구현이 명시적으로 경계 검사를 추가하지 않아도 되도록 허용하면, 경계 밖 쓰기의 부작용을 예측하는 것은 불가능해진다. 따라서 구현은 UB가 일어나지 않는다고 가정할 수 있다. 이 현상을 "UB의 부재를 가정함"이라 하며, 컴파일러가 추가적인 추론을 할 수 있게 해준다. 위 코드를 작성함으로써 프로그래머는 x가 절대 배열 범위를 넘지 않는다는 계약을 컴파일러와 맺는다.
다음을 보자.
int a[5];
a[x] = 0;
if (x > 5) {
// ...
}
이 경우, 컴파일러는 x가 0에서 4 사이여야 한다고 가정하므로 if 문은 참일 수 없다. 따라서 if 문 전체를 최적화로 제거할 수 있다. 이는 표준에 완전히 부합하지만, UB의 예측 가능성을 일부 없애고 UB를 가진 프로그램을 훨씬 디버그하기 어렵게 만든다. 경계 밖 쓰기는 더 이상 예측 가능한 엉뚱한 쓰기만을 일으키지 않고, if 문 제거까지 일으킨다.
흔한 버그는 다음과 같은 코드로 부호 있는 정수 오버플로를 감지하고 피하려는 것이다.
if (x + 1 > x) {
x++;
}
UB가 발생할 수 없다고 가정하면, if 조건은 항상 참이어야 한다. 따라서 많은 컴파일러는 if 문 전체를 최적화로 제거한다.
UB와 더 공격적이지만 표준 준수인 컴파일러 최적화가 결합되면, 사용자의 의도대로 보일 수도 있었던 잠재 버그가 드러난다. 이런 버그는 찾기 어렵고 진단하기도 어렵다. 이런 버그는 종종 낮은 최적화 수준에서는 나타나지 않는다. 즉 개발자가 개발 중 만드는 실행 파일에는 나타나지 않는다. 결과적으로 이런 버그는 많은 테스트를 빠져나갈 수 있다. 디버거는 대개 낮은 최적화 설정으로 컴파일된 실행 파일에서 동작하는데, 그 환경에서는 이런 문제 다수가 드러나지 않는다. 그래서 이런 버그를 찾고 고치기가 더 어려워진다.
이처럼 공격적인 최적화에서 비롯된 취약점의 초기 사례로는 CERT vulnerability 162289를 들 수 있다.
UB를 논할 때 흔히 고려되는 점은 UB가 언제 발동되는가 하는 문제다. UB를 일으킬 수 있는 프로그램은 아무 요구도 받지 않는다고 주장한 사람들도 있지만, WG14 UB 연구 그룹의 입장은, 프로그램은 먼저 UB 상태에 도달해야만 언어 표준의 요구가 중단된다는 것이다. 이 견해는 구현자들도 공유하며, 그렇지 않은 사례는 역사적으로 컴파일러 버그로 분류되어 왔다.
다음을 보라.
int a[5], x;
scanf("%i", &x);
a[x] = 0;
이 예에서 사용자가 제공한 인덱스가 다섯 원소 배열에 접근하는 데 사용된다. 이 프로그램은 형편없는 스타일일 수는 있어도, scanf가 x를 배열 범위 밖으로 설정하지 않는 한 잘 정의되어 있다. 개발자는 배열 접근에 사용되는 인덱스가 배열 경계 안에 머무른다는 것을 암묵적으로 보장했지만, 이 보장은 프로그램 바깥에서 유지된다. 많은 프로그램은 정확히 동작하기 위해 입력이 일정한 요구 사항을 엄격히 만족하길 기대한다. 이는 안전성과 보안 문제를 낳을 수 있지만, 개발자는 성능 같은 다른 요인과 저울질해야 한다. 엄격히 표준을 준수하는 프로그램도 어떤 환경 조건에서는 UB 상태에 들어갈 수 있다. 프로그램은 UB에 도달했을 때에만 오류가 된다. 어떤 프로그램을 실행할 때 UB가 가능하다는 이유만으로 구현이 ISO C 표준 준수 의무에서 풀리는 것은 아니다. 프로그램이 실제로 UB 상태에 들어간 후에야 풀린다.
C 표준의 핵심 원칙은 "as-if" 규칙이다. 이 규칙은 구현이 프로그램이 문자 그대로 쓰인 방식대로 동작할 필요는 없고, 구현의 관측 가능한 동작(C23, s5.1.2.3p6에서 정의)이 프로그램과 동일하기만 하면 된다고 말한다. 작성된 프로그램이 실행된 것처럼 동작해야 하지만, 꼭 그렇게 작동할 필요는 없다.
즉 구현이 프로그램을 어떻게 변환할 수 있는가에 따라 실제 프로그램 동작은 급격히 달라질 수 있지만, 관측 가능한 동작은 일정해야 한다. 예를 들어 두 비관측 연산은 재정렬될 수 있다. 다음을 보자.
int a, b;
a = 0;
b = 1;
이는 두 개의 비관측 대입이다(a와 b 모두 volatile이 아니기 때문이다). 독립적인 두 연산이므로 특정한 순서로 실행될 필요가 없다. 실제로 동시에 실행될 수도 있다. 이제 다음을 보자.
*p = 0;
x = 42 / y;
이 둘도 비관측 연산이다. 하지만 두 연산 모두 UB를 일으킬 수 있다(p가 잘못된 주소를 가리키거나, y가 0이라 나눗셈이 발생하는 경우). 비관측 연산이므로 재정렬될 수 있다. y가 0이라면, 프로그램이 UB 상태에 들어가기 전에 *p가 쓰였다는 보장은 없다.
비관측 연산은 재정렬되고 변환될 수 있으므로, 프로그램은 소스 코드에 명시된 순서와 다른 순서로 UB 상태에 도달할 수 있다. UB 부재 가정과 "as-if" 규칙 때문에, 프로그램은 실제 UB를 실행 중 만나기 전에 UB의 증상을 보일 수 있다. 다음을 보자.
int a[5];
if (x < 0 || x >= 5)
y = 0;
a[x] = 0;
UB 부재 가정을 사용하면, 구현은 x가 0에서 4 사이 값이어야 함을 알아내고 따라서 if 문을 제거할 수 있다. 이것은 "시간 여행 UB"라 불리는 순서 뒤집힘 동작을 만든다. 프로그램 버그가 실제 실행 중 UB를 만나기 전에 의도치 않은 결과를 초래하는 것이다. 마치 UB가 배열 접근으로부터 거슬러 올라가 if 문에 영향을 준 것처럼 보인다.
시간 여행 UB는, UB 상태에 들어가기 전에 일어나는 관측 가능한 동작을 방해하지 않는 한 허용된다. 다음을 보자.
int a[5];
if (x < 0)
y = 0;
if (x >= 5)
printf("Error!\n");
a[x] = 0;
이 경우 printf 호출은 관측 가능한 사건이며, 어떤 재정렬도 UB 상태가 선행하지 않는 한 그것이 올바르게 실행되도록 해야 한다. 컴파일러는 두 번째 if 문을 최적화로 제거할 수 없다. 그러나 첫 번째 if 문은 관측 가능한 동작에 영향을 주지 않으므로 제거될 수 있다.
참고: 역사적으로 시간 여행이 관측 가능한 상태에 영향을 준 사례들이 있었다. 구현자들은 일반적으로 이를 구현 버그로 여겨 왔다. 실제로 버그임을 명확히 하기 위해 [N3128 Uecker] 문서가 제안되었고 C23에 채택되었다. 이 문서는 표준에서 문제를 명확히 하는 비규범적 3번째 주석을 추가한다.
다음 코드를 보자.
int a[5];
a[42] = 0;
이 코드는 실행될 때마다 UB를 만든다. UB 상태는 코드가 실행된다는 사실 외에 어떤 동적 요인이나 외부 요인에도 의존하지 않는다. 우리는 이런 종류의 UB를 "정적 UB"로 정의하기로 한다. 컴파일 시점에 알려진 변수에만 의존하기 때문이다. "정적 UB"라는 용어는 다소 복잡하다. 구현마다 컴파일 시점에 UB를 감지하는 능력이 다르기 때문이다. 다음을 보자.
int a[5];
if (x > 0) {
y = 42;
} else {
y = MAX_INT;
}
a[y] = 0;
이 코드 역시 정적 UB를 포함하지만, 그렇게 결론 내리려면 더 복잡한 분석이 필요하다. "정적 UB"는 실행 시 상태에 의존하지 않는 모든 UB를 뜻한다. 구현은 정적 UB를 감지할 의무가 없지만, 감지한다면 우리는 어떻게 처리해야 하는지에 대한 권고가 있다. 정적 UB는, 그 식이 실제로 평가될 것임이 증명되지 않았더라도 언제나 UB를 만드는 식을 뜻한다.
UB 상태를 만드는 어떤 문장도(unreachable() 매크로는 예외), 구현이 그 문장에 대한 자체 동작을 정의하지 않은 한 오류이다. 구현은 어떤 UB도 감지할 의무가 없다. 그러나 구현이 정적 UB를 감지하지 않더라도, 그 문장이 UB를 만들지 않을 것이라고 가정할 자유는 있다. 따라서 모든 정적 UB(다시 말해 unreachable()을 제외한)는 개발자 오류로 간주되어야 하며, 의도된 언어 사용으로 보아서는 안 된다. 이런 경우 구현은 UB를 감지했을 때 적절한 진단과 함께 오류를 내야 한다.
구현은 프로그램이 UB 상태에 들어가지 않을 것이라 가정할 수 있지만, UB 상태에 도달한 프로그램이 의도적이라고 가정해서는 안 된다.
다시 다음을 보자.
int a[5];
a[x] = 0;
이 대입은 UB를 만들 수도 있고 아닐 수도 있다. 이 경우 "UB의 부재를 가정함" 규칙을 따르면, x는 0에서 4 사이여야 한다고 가정할 수 있다. 이 대입은 대입이지만, 동시에 x가 어떤 값일 수 있는지에 대한 힌트를 컴파일러에 제공한다. 여기에 다음을 더하면,
int a[5];
a[x] = 0;
if (x > 4)
...
여기서 if 문은 도달 불가능 코드로 간주되어 최적화로 제거될 수 있다. if 문 자체는 UB를 만들지 않지만, UB 없이는 일어날 수 없는 상황이다. 대신 다음을 보자.
int a[5];
if (x > 4) {
a[x] = 0;
}
이 코드도 UB를 일으킬 수도 있고 아닐 수도 있지만, 대입이 실행되기만 하면 반드시 UB를 일으킨다. (구현이 이 UB를 감지할 의무는 없다는 점에 유의하라.) 다시 말해, UB는 정적이지만, 그 대입이 실제로 실행될 때에만 그렇다.
감지된 정적 UB를 올바르게 해석하는 방법은, 그 코드가 오류라는 것이다. 위 코드를 사용자가 x가 4 이하임을 표현하는 유효한 방식으로 해석하는 것은 잘못이다. "UB의 부재를 가정함" 규칙은, 어떤 구문이 어떻게 실행된다고 가정할 수 있는지에만 적용되며, 항상 UB를 만드는 구문이 실행되지 않을 것이라고 가정하는 데 적용되는 것이 아니다. X로 나누는 1은 컴파일러가 X가 0이 아니라고 가정하게 하지만, 0으로 나누는 X는 의도치 않은 사용자 오류로 간주되어야 한다.
여기서 유일한 예외는 unreachable() 매크로이다. unreachable() 매크로만이 어떤 문장이 절대 실행되지 않는다고 사용자가 표현할 수 있는 유일한 방법이다. 우연히도 unreachable()을 실행하는 것은 UB이지만, 이 점에서 다른 UB와 동등하다고 보아서는 안 된다.
예를 들어:
if (x > 4)
unreachable();
이는 컴파일러가 x가 4 이하라고 가정해도 된다고 표현하는 올바른 방법이다. unreachable()이 UB이기는 하지만, 다음과 동등하지는 않다.
if (x > 4)
x /= 0;
0으로 나누는 것은 UB이지만, unreachable()과 달리 이는 사용자 오류로 간주된다. 그러므로 unreachable() 매크로는, unreachable() 이외의 다른 방식으로 UB를 만들어 사용자가 직접 구현할 수 없다. 또한 UB는 실제로 실행되지 않음이 판정되더라도 여전히 오류다. 다음은 오류로 감지될 수 있다.
if (0)
x /= 0;
C는 순진한 구현도, 매우 공격적으로 최적화하는 구현도 가능하도록 설계되었다. 따라서 C 표준은 구현이 코드를 얼마나 분석해야 하는지에 대해 어떤 요구도, 어떤 제한도 두지 않는다. 그러므로 어떤 오류성 UB가 감지되는지는 구현마다 다를 수밖에 없다.
운영체제와 심지어 하드웨어조차, 우발적인 UB나 UB를 이용한 의도적 파괴 행위의 부작용을 완화하기 위해 실행 파일이 있는 메모리나 실행 스택 보호 같은 기능을 설계해 왔다. 이런 보호 장치들 덕분에 일부 UB는 실행 시점에 예측 가능하게 잡힌다. 이는 UB의 예측 불가능성을 완화하고 시스템의 안정성과 보안을 향상시킨다. 그러나 동시에, 일부 UB가 예측 가능한 부작용을 가진다는 잘못된 인상을 줄 수도 있다. 널 포인터 역참조는 기술적으로 UB이지만, 많은 플랫폼에서는 매우 예측 가능한 결과(트랩)를 낸다. 설령 널 역참조의 동작이 어떤 플랫폼에서 신뢰 가능하더라도, 컴파일러가 코드가 널을 역참조하지 않을 것이라고 가정하기 때문에 신뢰할 수 없게 된다.
일부 UB는 처음에 C 표준에 포함된 이유가, 서로 다른 플랫폼 설계를 허용하기 위해서였다. 세월이 흐르며 어떤 설계들은 너무 지배적이 되어, 대부분 개발자는 그런 지배적 설계와 다르지 않은 플랫폼만 접하게 되었다. 그 한 예가 2의 보수 연산이며, 이는 부호 있는 정수 오버플로가 래핑되게 만든다.
이는 많은 UB가 대부분의 플랫폼에서 예측 가능한 동작을 가진다는 뜻이다.
| UB | 관행 |
|----|------------|
| 널 포인터 역참조 | 트랩 |
| 부호 있는 정수 오버플로 | 래핑 |
| 두 할당 사이의 오프셋 사용 | 포인터를 정수 주소처럼 취급 |
| 해제된 메모리의 포인터를 새로 할당된 포인터와 비교 | 포인터를 정수 주소처럼 취급 |
| 초기화되지 않은 메모리 읽기 | 그 안에 있는 값을 얻음 |
이러한 동작은 C 표준이 정의한 것은 아니지만 예측 가능해 보일 수 있다. 예측 가능성은 대부분의 개발자에게 매우 큰 가치다. 기반 플랫폼이 어떻게 동작하는지에 대한 지식은 개발자가 버그를 예측하고 진단하게 해준다. 트랩되는 널 포인터 역참조는 디버거에서 찾기 쉽다. 실제로 어떤 프로그래머는 코어 덤프를 유도하기 위해 고의로 널 포인터 역참조를 프로그램에 넣기도 한다. MSVC에서는 초기화되지 않은 메모리가 0xCDCDCDCD로 초기화되는데, 이는 경험 많은 Windows 프로그래머라면 즉시 알아볼 수 있는 패턴이다. [https://en.wikipedia.org/wiki/Magic_number_(programming)] 두 개의 큰 양의 부호 있는 정수를 더한 결과가 음수가 나오면, 영리한 프로그래머는 래핑된 부호 있는 정수 오버플로를 의심할 것이다.
많은 종류의 UB가 이런 식으로 겉보기에는 예측 가능해 보이지만, 이는 UB가 실제로는 예측 가능하지 않다는 사실을 가린다. 이 때문에 많은 프로그래머가 이런 동작 일부가 정의되지 않았다는 사실을 인식하지 못하거나, UB를 구현 정의 동작과 혼동한다. C 표준이 UB를 정의하고 있고, 비이식적일 수는 있어도 자기 플랫폼의 동작이 모든 플랫폼이나 심지어 같은 플랫폼의 다른 호스트에도 적용된다고 가정할 수 있다고 믿을 수 있다. 이런 잘못된 가정은 우리가 더 살펴볼 다양한 진단 어려운 문제를 만든다.
경계 밖 쓰기는 많은 종류의 상태를 교란할 수 있으므로 매우 다양한 결과를 낳을 수 있다. 그러나 대부분의 개발자는 경계 밖 쓰기도 결국 쓰기 연산으로 실행된다고 가정할 것이다. 일반적으로는 그렇지 않다. 부호 있는 정수 오버플로 같은 다른 UB를 생각해 보면, 단순한 산술 연산이 광범위한 예측 불가능한 결과를 낳을 수 있다는 사실은 더욱 덜 직관적이다.
C의 정의되지 않은 동작은 구현에게 코드를 최적화할 넓은 재량을 준다. 이 자유 덕분에 구현자들은 계속해서 더 빠른 기계어 코드를 생성해 왔고, 이는 광범위한 작업 부하에서 계산 시간과 에너지 소비를 크게 줄여 준다. C는 다른 언어들이 비교하고 따라잡으려 하는 사실상의 효율성 기준이다.
Aliasing, Provenance, Overflow 같은 상당한 UB 범주는 구현이 최적화를 할 수 있게 하도록 특별히 설계되었다. 이 범주의 UB를 위반하면, 구현이 그런 최적화 기회를 실제로 활용할 때에만 예측 불가능한 동작을 일으킬 가능성이 높다.
많은 구현이 다양한 수준의 최적화를 지원하기 때문에, C 커뮤니티 일부에서는 컴파일러가 높은 최적화 수준에서 C 표준을 무시하고 코드를 "망가뜨린다"는 인식이 형성되었다. 이는 오해다. 대부분의 C 구현은 가장 높은 최적화 설정에서도 C 표준과 일치한다. 최적화는 컴파일러의 버그보다 소스 코드의 기존 버그를 훨씬 더 자주 드러낸다. 이런 버그는 프로그램이 개발자의 기대대로 일관되게 동작할 때조차도 대개 C 표준을 위반하고 있다.
최적화 수준이 높을수록 더 많은 버그가 드러나지만, 코드가 더 많이 변형될수록 디버깅은 더 어려워진다. 디버거 같은 많은 도구는 이진 실행을 소스 코드에 올바르게 대응시키기 위해 낮은 최적화 수준에 의존한다. 이로 인해 UB 버그를 진단하는 어려움이 더 커진다.
최적화가 코드를 망가뜨리는 것이 아니라 잠재 버그를 드러낸다는 사실에 대한 오해 때문에, 구현자들은 종종 UB에서 비롯된 문제의 부당한 비난을 받는다. 이로 인해 많은 컴파일러는, 명세가 허용하더라도 사용자 반발이 예상되면 특정 최적화를 피한다. 이는 UB를 포함하는 불완전한 코드가 문서화되지 않은 신뢰 가능한 또는 반쯤 신뢰 가능한 동작을 가지는 회색 지대를 만든다. 이 회색 지대의 대가는, 표준이 허용하는 성능을 올바른 코드가 누리지 못하게 되는 것이다.
C는 "안전하지 않은 언어"로 여겨진다. 엄밀하게 말하면, 이는 사실이 아니다. C 표준은 구현이 몇몇 오류를 검사하도록 요구하지 않지만, 그렇다고 막지도 않는다. 따라서 각 구현은 보장하고자 하는 안전 수준을 선택할 수 있다.
실제로 C가 안전하지 않은 언어인 이유는, 가장 인기 있는 C 구현들이 많은 추가 보장을 하지 않기로 선택하고, 대신 성능과 전력 효율을 우선하기 때문이다. 그래서 대부분 사용자가 C를 그런 방식으로 사용해 왔기 때문에, C는 사실상 안전하지 않은 언어로 인식된다.
더 안전한 구현도 존재하지만, 이들은 주로 배포 환경에서 추가 보호를 제공하기보다는 개발 중 문제를 감지하는 데 사용된다. 한 예가 Valgrind이며, 기본 도구인 "memcheck"는 힙 메모리에서의 경계 밖 읽기와 쓰기, 초기화되지 않은 읽기, 해제 후 사용 오류, 메모리 누수를 감지한다. Valgrind는 이런 안전 제약을 상당한 성능 비용을 치르고 달성한다. GCC, LLVM, MSVS 같은 다양한 구현은 UB를 감지하고 진단하는 여러 도구를 제공한다. 여러 정적 분석기들도 이 문제를 완화하기 위해 존재한다.
사용자는 메모리 추적 셈을 직접 작성해, 작은 경계 밖 쓰기, 이중 해제, 메모리 소비, 메모리 누수를 감지할 수도 있다. 매크로를 이용해서 말이다.
#define malloc(n) my_debug_mem_malloc(n, __FILE__, __LINE__) /* malloc 대체 */
#define free(n) my_debug_mem_free(n, __FILE__, __LINE__) /* free 대체 */
C 명세가 어떤 방식으로든 이를 요구하는 것은 아니지만, 오늘날 C 사용자들의 주된 작업 방식은 배포 시 방어선으로 쓰기보다 개발 중 안전 관련 도구를 사용해 문제를 감지하는 것이다. 이 접근의 큰 단점은, UB는 종종 실행 시점에 실제로 발생하기 전까지 확정적으로 감지할 수 없는 상태이므로, 프로그램이 UB 상태에 들어가지 않음을 확실히 보장할 쉬운 방법이 없다는 점이다.
그럼에도 주목할 가치가 있는 점은, Linux 커널, Apache, MySQL, Curl, OpenSSL, Git 같은 세계에서 가장 신뢰받는 소프트웨어 일부가 C로 작성되었다는 것이다. C의 단순성 덕분에 코드를 읽고 문제를 감지하기가 훨씬 쉽다.
C는 표준이 불명확할 때 어려움을 겪는다. 특히 메모리 모델과 동시 실행 영역에서 그렇다. aliasing, active type, thread safety, volatile에 대한 규칙은 무엇이 UB이고 아닌지에 대해 많은 해석의 여지를 남긴다. 이런 문제들 중 다수에 대해 WG14 내부에서도 합의가 부족하다. 대부분의 구현은, 엄격한 표준 해석으로는 UB로 간주될 동작조차 사용자 기대 때문에, 그리고 중요한 기존 소프트웨어를 컴파일할 수 있어야 하기 때문에 지원한다. 그런 의미에서 대부분 구현은 표준에서 벗어나지만, 어떻게 얼마나 벗어나는지는 각기 다르다. Linux Kernel 같은 일부 프로젝트는 이러한 모호함을 명시적으로 배제하고 자체 요구 사항을 정의했다.
이 문서가 보여 주었기를 바라듯이, C 맥락에서의 정의되지 않은 동작은 복잡하다. 그 동작이 표준에서 빠져 있다고만 말해서는 이런 복잡성을 전달할 수 없다.
C는 개발자를 신뢰하는 언어로 설계되었다. UB의 경우 개발자는 이것을 "개발자가 UB를 시작하지 않으리라 신뢰한다"로 해석해야지, "기반 구현과 플랫폼을 알면 개발자는 UB를 신뢰해도 된다"로 해석해서는 안 된다. 따라서 정의되지 않은 동작 연구 그룹은, 플랫폼이 그 동작을 명시적으로 정의하지 않았다면 어떤 UB도 피할 것을 개발자에게 강하게 권고한다. 이식 가능하지 않거나 오류가 있는 프로그램 구성이 플랫폼에서 어떤 관측 가능한 효과를 가지는지 테스트로 확인했다는 사실만으로, 그 UB가 앞으로 코드를 실행할 다음 플랫폼을 포함한 모든 플랫폼에서 일관되게 같은 동작을 하리라고 가정해서는 안 된다. 오직 동작을 정의하는 언어 확장에 대한 구현의 명시적 문서화만을 신뢰하라. 우리는 사용자가 그런 확장과, 예측 가능해 보이지만 여전히 의도되지 않은 동작을 구분할 수 있도록, 구현이 UB를 대체하는 어떤 언어 확장이든 명확히 문서화할 것을 권고한다.
컴퓨터 언어는 인간이 컴퓨터와 소통하는 도구이지만, 컴퓨터가 인간과 소통하는 도구이기도 하다. 인간은 컴퓨터보다 자신이 쓴 코드를 읽고, 왜 동작이 기대와 다르는지 파악하는 데 더 많은 시간을 쓴다. 전통적으로 구현은 사용자가 어떻게 동작하는지 이해하지 못한 채 의존해야 하는 블랙박스였다. UB는 이 접근이 문제를 낳음을 보여 준다. 현대 컴파일러는 많은 사용자가 기대하는 방식으로 동작하지 않기 때문이다. 따라서 우리는 구현이 자신들의 변환을 더 투명하게 보여 줄 방법을 찾기를 권한다. 변환된 코드를 사용자가 들여다볼 수 있다면, 순서 뒤바뀜 문제, 코드 제거, load/store 생략, 기타 비자명한 변환을 드러낼 수 있다. 이것이 상당한 사용자 인터페이스와 아키텍처 문제를 포함한다는 점은 인정한다.
이 문서는 Eskil Steenberg Hald가 작성했다. 이 문서는 정의되지 않은 동작 연구 그룹과 ISO WG14에서의 수많은 귀중한 논의의 결과이므로, 그 구성원 다수가 이 문서의 탄생에 공로를 가진다. 특히 저자는 피드백, 편집, 개선 제안을 제공한 David Svoboda, Chris Bazley, Martin Uecker에게 감사를 표하고 싶다.
if, for, while, do, goto, break, continue, return은 모두 신뢰할 수 있다.
하지만 if, for, while 문 안에서 할 수 있는 일에는 한계가 있다. C99는 첫 문장에서 변수 선언을 허용한다.
for(int i = 0; i < 10; i++)
이것은 C89에서는 합법이 아니므로 항상 신뢰할 수 있는 것은 아니다. C89에서 변수 선언이 가능한 기능은 다른 흐름 제어에는 확장되지 않는다. 다음은 모두 불법이다.
for(i = 0; int x = i < 10; i++)
if(int x = i < 10)
while(int x = i < 10)
switch(int x = i < 10)
for 루프는 종종 다음처럼 while 루프와 동등하다고 설명된다.
for(<statement0>; <statement1>; <statement2>)
{
...
}
이는 다음과 동등하다고 한다.
<statement0>
while(<statement1>)
{
...
<statement2>
}
이것은 C++에서는 사실이지만 C에서는 아니다. C에서는 statement0가 for 루프 안에서 타입을 정의할 수 없지만, 루프 앞에서는 정의할 수 있기 때문이다.
auto는 변수가 "자동 저장 기간"을 가진다는 것을 나타내는 잘 알려지지 않은 키워드다. 이는 함수 스코프 내부 변수의 기본 한정자이며, 함수 스코프 밖의 변수에는 사용할 수 없다(일부 C 컴파일러는 허용하지만).
불행히도 auto는 쓸모없음에서 위험함으로 바뀌었다. C23에서 auto는 대입을 이용해 변수 타입을 자동 추론하는 새로운 의미를 부여받았다.
auto x = 0.0;
C23에서 위 코드는 0.0이 double이므로 x를 double 타입 변수로 만든다. 이 기능은 신뢰할 수 없다. 사실 더 오래된 C 버전에서 위 코드를 쓰면, 타입을 전혀 명시할 필요가 없고, 그러면 기본으로 int 타입 변수를 얻게 된다. C89에서는 위 코드가 x를 int로 만든다. 기본 타입은 C99에서 폐기되었지만, 거의 모든 컴파일러는 경고와 함께 이를 지원한다. 즉 아주 최신 컴파일러는 이것을 int로 컴파일하고, 더 최신 컴파일러(C23 지원)는 이제 이것에 대해 더 이상 경고조차 하지 않는다.
따라서 auto 키워드를 사용하는 모든 경우는 신뢰할 수 없는 것으로 간주해야 한다.
_ (밑줄)로 시작하는 어떤 키워드도 예약되어 있으며 사용해서는 안 된다.
다음 키워드는 후속 C 버전에서 사용되므로 피해야 한다.
true
false
null
alignas
alignof
bool
constexpr
inline
nullptr
static_assert
thread_local
typeof
type_unequal
많은 구현은 다음 키워드를 예약하는 확장을 지원한다.
asm
fortran
다음은 C++에서 사용되는 키워드 목록이다. 신뢰할 수 있는 C에서 사용할 수는 있지만, 가능하면 명확성을 위해 피하는 것이 좋다.
and
and_eq
atomic_cancel
atomic_commit
atomic_noexcept
bitand
bitor
catchclass
compl
concept
consteval
constexpr
constinit
const_cast
contract_assert
co_await
co_return
co_yield
decltype
delete
dynamic_cast
explicit
export
friend
mutable
namespace
new
noexcept
not
not_eq
operator
or
or_eq
private
protected
public
reflexpr
reinterpret_cast
requires
static_cast
synchronized
template
this
throw
try
typeid
typename
using
virtual
xor
특별한 의미를 가진 식별자:
final
override
transaction_safe
transaction_safe_dynamic
import
module
pre
post
trivially_relocatable_if_eligible
replaceable_if_eligible
모든 현대 아키텍처는 리틀 엔디언으로 수렴하고 있다. 리틀 엔디언이 단순히 객관적으로(직관적이지는 않더라도) 더 낫다. 우리는 응용프로그램이 "리틀 엔디언 우선" 설계로 작성되길 권한다. 즉 파일, 네트워크 프로토콜, 기타 디지털 표현은 대부분의 하드웨어가 리틀 엔디언이므로 리틀 엔디언이어야 한다. 그런 다음 그 위에 다른 표현을 비리틀 엔디언으로 뒤섞는 코드를 추가하면 된다.
엔디언은 여러 다른 방식으로 이루어질 수 있다는 점 때문에 복잡하다. 어떤 플랫폼은 16비트 또는 32비트마다 교환한다. 그래서 ABCDEFGH는 HGFEDCBA, DCBAHGFE, BADCFEHG처럼 배열될 수 있다.
return ((data << 24) & 0xFF000000) |
((data << 8) & 0x00FF0000) |
((data >> 8) & 0x0000FF00) |
((data >> 24) & 0x000000FF);
왜 리틀 엔디언이 더 나은가: 숫자 1337을 읽어 보라. "천삼백삼십칠". 첫 숫자는 1이지만, 그것이 천의 자리인지 백의 자리인지 십의 자리인지 알려면 뒤에 오는 숫자를 세어 봐야 한다. 반대로 오른쪽에서 왼쪽으로 읽는다면, "칠 삼십 삼백 일천" 식이 되어 첫 숫자 7이 그냥 7이라는 사실을 나머지를 해석하지 않고도 즉시 알 수 있다. 칠십도, 칠천도 아니다. 오른쪽에서 왼쪽으로 읽으면 첫 숫자는 뒤에 몇 숫자가 오든 항상 같은 의미를 가진다. 좋다. 첫 숫자를 읽었으니 두 번째 숫자도 읽을 수 있고, 그 숫자가 직전 것보다 한 자릿수 더 큰 자릿수라는 것도 안다. 이 문제는 인간이 아주 큰 수를 읽을 때 특히 분명해진다. 발음하려면 자릿수를 먼저 세어야 하기 때문이다. (한때 나는 아랍어가 오른쪽에서 왼쪽으로 쓰이니, 우리가 아라비아 숫자를 쓰는 이상 이것은 단지 라틴 문화권이 아라비아 숫자를 받아들이면서 생긴 결함, 즉 왼쪽에서 오른쪽으로 읽는 습관 때문에 잘못된 순서로 읽는 문제일지도 모른다고 생각했다. 하지만 아니었다. 아랍어에서도 숫자는 왼쪽에서 오른쪽으로 읽고 발음하므로, 그들 역시 우리만큼 똑같이 잘못하고 있다.)
float와 double 모두 신뢰할 수 있다고 볼 수 있으며 대부분의 C 구현에서 잘 지원된다. 하지만 몇 가지 고려할 점이 있다.
일부 작은 임베디드 플랫폼은 FPU가 없어서 부동소수점 연산을 소프트웨어 에뮬레이션하거나 아예 지원하지 않을 수 있고, 또는 32비트 float만 지원할 수도 있다. 따라서 예를 들어 파일 형식, 네트워크 프로토콜, 압축을 구현하는 작은 라이브러리를 만들 때, 라이브러리가 그 외에는 부동소수점 연산이 없다면 벤치마킹을 위한 부동소수점 계측을 불필요하게 쓰지 않음으로써 더 이식 가능하게 만들 수 있다.
거의 모든 부동소수점 구현이 IEEE 754 표준 표현을 사용하지만, 각 구현이 반올림을 처리하는 방식이 다르기 때문에(때로는 같은 업체 하드웨어끼리도) 다른 결과를 낼 수 있다는 점을 짚고 넘어갈 가치가 있다. 즉 완전히 같은 명령을, 완전히 같은 입력 데이터로 실행해도, 같은 실행 파일을 실행하는 서로 다른 두 기계에서 다른 결과가 나올 수 있다. 따라서 float는 lockstep 동기화에 신뢰할 수 없다.
부동소수점 산술은 다른 값과 비교 가능한 정확한 결과를 얻는 데 절대로 의존해서는 안 된다. 다음은 x와 y가 같지 않을 수 있는 몇 가지 예다.
x = (y * 2.0) / 2.0;
x = a / 2.0;
y = a / 2.0;
컴파일러는 일부 연산을 컴파일 시점에 한 구현으로 접을 수 있고, 다른 연산은 접지 않고 실행 시점에 다른 구현으로 계산하게 할 수 있다.
일반 규칙으로는, 대입된 부동소수점 값은 == 비교가 안전하지만, 계산된 값은 안전하지 않다. 또는 NaN 상태를 탐지하기 위해 자기 자신과 비교하는 것은 안전하다.
x = 6.0;
...
if(x == 6.0) /* 테스트해도 안전 */
x = 6.0 / 2;
...
if(x == 3.0) /* 테스트해도 안전하지 않음 */
x = 1.0 / y;
if(x == x) /* x가 NaN인지 테스트하는 안전한 방법 */
C에서의 초기화에는 여러 함정이 있으며, 대개는 프로그래머(또는 언어 설계자)가 영리해지려 할 때 생긴다. 일반적인 조언은 =를 사용해 값을 직접 대입하라는 것이다.
간단한 예로 시작하자.
int a = 0;
이 코드에서 a는 초기화되었는가? 아니, 꼭 그렇지는 않다. a는 이 문장이 등장한 이후의 스코프에서 선언되지만, 값이 초기화되려면 실제로 실행되어야 한다. 그런데 goto나 switch를 사용하면 이를 우회할 방법이 있다.
goto label;
{
int a = 0;
label :
printf("%i", a); /* a는 선언되었지만 초기화되지 않음 */
}
switch(1)
{
case 0 :
{
int a = 0;
case 1 :
printf("%i", a); /* a는 선언되었지만 초기화되지 않음 */
}
}
C99처럼 어디서나 선언할 수 있으면 문제는 훨씬 나빠진다. 간단한 해결책은 항상 스코프의 시작 부분(C89 스타일), 그리고 함수 스코프에서만 변수를 선언하는 것이다. 다른 스코프에서는 절대 변수를 선언하지 않으면, 서로 다른 스코프의 변수가 같은 이름을 가져 생기는 여러 버그도 피할 수 있다.
배열은 중괄호로 초기화할 수 있다. 이는 대체로 좋지만 몇 가지 함정이 있다. 그중 하나가 배열 길이 정의다. 다음을 보자.
int a[] = {1, 2, 3};
int b[3] = {1, 2, 3};
a의 크기는 초기화로부터 암묵적으로 정해지며, 참조할 수 있는 명확한 배열 크기 정의가 없다. sizeof를 써서 크기를 얻을 수는 있지만, 다시 int 크기로 나눠야 한다. 일반적으로 나는 어떤 배열에 대해서도 sizeof를 사용하는 것을 권하지 않는다. 배열은 포인터로 decay될 수 있고, 리팩터링 중 포인터로 쉽게 바뀔 수 있으며, 배열 타입이 바뀌었는데 나누는 값이 바뀌지 않으면 또 다른 버그 원인이 되기 때문이다. b의 크기는 명시적이어서 훨씬 명확하고, define으로 선언했다면 그 define을 재사용할 수 있다. 그래도 배열 원소 수보다 초기화 수가 적을 수 있다는 문제는 남는다. 따라서 가능하다면 배열을 중괄호 없이 초기화하는 것이 가장 안전하다.
#define LENGTH_OF_ARRAY 3
int c[LENGTH_OF_ARRAY];
for(i = 0; i < LENGTH_OF_ARRAY; i++)
c[i] = i + 1;
더 장황한 것은 맞지만 실패에 강하다.
배열 초기화에서 또 하나 알아둘 점은, 초기화자가 하나뿐일 때 전체 값을 초기화하는 특별 규칙이 있다는 것이다. 다음을 보라.
char string[1024] = {'\0'};
이 코드는 문자열을 널 종료하기 위해 단 한 바이트만 쓰는 것이 아니다. 전체 문자열을 널 종료 문자로 채운다. 따라서 다음보다 상당히 느리다.
char string[1024];
string[0] = '\0';
일반적으로는, 스택 위에 복잡한 연산을 가진 큰 배열을 두는 것을 피하려고 할 것이다. 스택 오버런 버그는 힙 오버런보다 훨씬 위험하고 디버깅도 어렵다.
NULL은 예약된 주소이며 0x0 주소에 있을 수도 있고 아닐 수도 있다. NULL이 어디에 있는지는 플랫폼 정의다. C에서 NULL은 두 가지 정의를 가진다(C23에서는 세 가지). (void *)0와 0이다. 다음을 입력할 때:
int *p = (void *)0;
당신은 p에 0을 넣는 것이 아니라 NULL을 넣는 것이다. 즉 컴파일러는 포인터를 대입하고 있다는 사실과 NULL의 두 정의 중 하나를 인식한 뒤, 그 플랫폼에서 NULL의 정의가 무엇이든 그것으로 포인터를 설정할 수 있어야 한다. 따라서 다음은 이식 가능한 C 코드가 아니다.
int *p;
memset(&p, 0, sizeof p);
memset으로 NULL 포인터를 초기화해서는 안 된다!
컴파일러가 NULL과 다른 값을 구별할 수 있어야 하므로, 0을 NULL로 사용하는 것은 일반적으로 좋지 않다고 여겨진다. 따라서 항상 NULL을 (void *)0으로 정의해야 한다. यही가 문자열의 널 종료자로도 NULL을 절대 사용해서는 안 되는 이유다. 널 종료자는 실제로 NULL이 아니다. 예약된 문자이며(모든 비트가 0이어야 한다). 사용해야 하는 널 종료자는 '\0'이다. 그러므로 다음은 권장되지 않는다.
char string[] = {'H', 'i', '!', NULL};
printf("%s", string);
NULL이 (void *)0으로 정의되면, 0이 아닌 예약 주소로 변환되었다가 다시 char 정수로 변환될 수 있고, 그 결과는 '\0'과 같지 않을 수 있다.
C23은 nullptr이라는, NULL을 정의하는 세 번째 방법을 추가하여 상황을 더 혼란스럽게 만들었다. 쓰지 마라. (void*)0만 사용하라.
아주 지나치게 엄밀해지자면(그렇게 하자!), C에서 타입의 표현은 대부분 플랫폼 정의다. 즉 구현은 숫자를 원하는 방식으로 표현할 수 있다. 어떤 플랫폼은 int에서의 0을, 부호 비트만 1이고 나머지는 모두 0인 비트 패턴으로 표현하기로 결정할 수도 있다. 어쩌면 그저 잡학 수준일 뿐이지만, 비트 표현을 가정하는 순간마다 엄밀히는 이식 가능한 코드를 쓰고 있지 않다는 점을 보여 준다. 이것이 실제 문제가 되는 예는 IEEE 754 부동소수점 값이다. 부호 비트만 세트하면 -0이 나온다. -0은 0과 같지만 같은 비트 표현을 가지지 않는다. (두 float를 비교하는 것은 두 float의 비트 표현을 비교하는 것과 다르다. -0과 0은 같지만, 동일한 두 NaN은 서로 같지 않다.)
0 초기화는 NULL이 아니기 때문에, struct 메모리를 0으로 초기화할 때 문제가 생긴다. 다음은:
struct my_struct s = {0};
메모리를 0으로 설정하는 것이지, NULL로 설정하는 것이 아니다! C23에서는 이를 해결하려는 시도로 NULL 초기화 기능을 추가했다.
struct my_struct s = {};
C23에서 이는 모든 멤버를 0으로, 모든 포인터를 NULL로 초기화한다. 하지만 이것은 함정이다. 이 기능을 지원하지 않는 컴파일러로 누군가가 이것을 컴파일하면, 경고나 오류도 없이 완전히 초기화되지 않은 메모리를 얻게 된다. 이 기능에서 멀리 떨어져 있어라. 가능하다면 우발적 사용을 감지하는 도구도 추가하라.
일반적으로 나는 struct를 초기화할 때 중괄호를 사용하는 것이 나쁜 관행이라고 생각하며, 절대 해서는 안 된다고 본다. 다음을 보자.
struct my_struct s = {1, 5, NULL};
이 코드는 극도로 불명확하다. 세 개의 매개값이 초기화되는데, 무엇을 하는가? 누군가 새 매개값을 추가하거나 순서를 바꾸면 어떻게 되는가? 이것은 프로그래머가 항상 struct 내용에 매우 엄격해야 하는, 극도로 취약한 코드다. C99는 멤버 지정 기능을 추가했다.
struct my_struct s = {.member = 1, .other = 5, .pointer = NULL};
훨씬 낫지만, 언어 요구 수준을 높이고 긴 코드 줄을 만들게 된다. 훨씬 더 단순한 초기화 방법은 값을 명시적으로 설정하는 것이다.
struct my_struct s;
s.member = 1;
s.other = 5;
s.pointer = NULL;
그래, 더 장황하다. 하지만 분명하다는 장점이 있다. 이 방식의 가장 큰 장점은 작성 과정에 있다. struct 정의를 그대로 복사해 초기화하고 싶은 코드 위치에 붙여 넣고, 그것을 초기화 코드로 편집하라. 그러면 모든 멤버를 빠짐없이 다룰 수 있고, 이름도 정확히 쓸 수 있으며(어떤 사람에게는 이게 더 큰 장점일 수 있다...), 타입도 바로 눈앞에 있으니 올바른 타입으로 초기화되는지 확인할 수 있다.
다시 강조하자면:
struct my_struct s;
memset(&s, 0, sizeof s);
이것은 struct의 포인터 멤버를 NULL로 설정하는 이식 가능한 방법이 아니다!
(주: 나는 항상 struct 키워드를 typedef에 감싸서 쓰기 때문에, 내 코드의 변수 정의에서는 찾을 수 없을 것이다. 여기서는 우리가 struct를 초기화하고 있다는 점을 명확히 하려고 사용했다.)
포인터에 0을 초기화하는 것이 어떤 플랫폼에서는 NULL이 아닐 수 있지만, 그런 플랫폼은 드물다. 많은 프로그램은 "내가 신경 쓰는 모든 기계에서는 된다"라고 주장할 것이다. 일반적으로는 이는 이 글에서 다 다루지 못하는 온갖 문제를 일으킬 C 프로그래밍 방식이지만, 메모리를 0으로 초기화하지 말아야 하는 주된 이유는 그것만이 아니다.
실수를 했을 때는 그 실수가 가능한 한 눈에 띄기를 원하고, 아픈 엄지손가락처럼 튀어 보이기를 원한다. 0x0은 포인터와 다른 변수 모두에서 매우 흔한 값이므로, 초기화되지 않은 값으로 명확히 드러나지 않는다. 가능한 한 알아보기 쉬운 값을 원한다. 그래서 많은 컴파일러는(디버그 모드에서) 메모리를 감시용 마법 숫자로 초기화한다. VisualStudio는 0xCD 또는 0xCC를 사용하고, 다른 플랫폼은 0xDEADBEEF 같은 값을 사용한다. 플랫폼이 이런 기능을 제공하지 않는다면, 직접 쉽게 구현할 수 있다.
#ifdef DEBUG_MODE
void *debug_malloc(size_t size)
{
void *p;
p = malloc(size);
memset(p, 0xCD, size);
return p;
}
#define malloc(x) debug_malloc(x)
#endif
0xCDCDCDCDCDCDCDCD로 설정된 포인터를 읽거나 쓰면 프로그램은 충돌할 것이고, 문제가 초기화된 값이라는 사실이 매우 분명해진다. 포인터가 0x00000000으로 초기화되면 충돌할 가능성이 훨씬 낮다. 어떤 형태의 널 검사에 걸려서 충돌을 피할 가능성이 높기 때문이다. 그게 좋은 것 아닌가? 아니다! 코드는 우연히 동작해서가 아니라, 올바르기 때문에 동작해야 한다! 오류가 즉시 실패하지 않는다고 해서 문제가 없다는 뜻이 아니라, 단지 문제를 찾기 더 어려워진다는 뜻이다! 링크드 리스트를 하나 작성한다고 가정해 보자. 링크들의 풀을 미리 할당해 두고, 링크를 가져오고 반납하는 API를 만든다. 그 다음 이 코드에 버그를 하나 넣고, 왜 0 초기화가 이 버그를 훨씬 찾기 어렵게 만드는지 논의하겠다.
typedef struct{
void *data;
void *next;
}Link;
void free_link(Link *l)
{
l->data = NULL;
}
Link *alloc_link(Link *link_array, uint link_array_length, void *data)
{
unsigned int i;
for(i = 0; i < link_array_length; i++)
{
if(link_array[i].data == NULL)
{
link_array[i].data = data; /* link_array[i].next = NULL; 이런! 이 줄이 실수로 사라졌다!*/
return &link_array[i];
}
}
return NULL;
}
void do_something()
{
Link *link_array;
link_array = calloc(1024, sizeof *link_array);
for(i = 0; i < link_array_length; i++)
link_array[i].data = NULL;
.... alloc_link와 free_link를 사용해 data로 작업 ...
}
link_array가 쓰레기 값으로 초기화된다면, next 포인터를 처음 사용하는 순간 충돌할 것이다. 하지만 NULL로 초기화되면 널 검사에 걸려 넘어간다. alloc_link가 이전에 사용된 링크를 반환할 때에야 비로소 next 멤버가 널이 아닐 수 있다. 그 널이 아닌 next 멤버는 링크드 리스트 안의 유효한 링크를 가리킬 것이다! 이 때문에 이 버그는 엄청나게 찾기 어려워진다. 사용자는 위 코드 자체가 아니라, 위 코드를 사용하는 코드에 버그가 있다고 가정할 것이기 때문이다. 초기화는 첫 사용 시에만 일어나므로, 두 번째 사용에서야 버그가 나타날 수 있다. 이런 코드에 대한 테스트 코드를 써도, 메모리를 충분히 써서 링크를 재사용하지 않는다면 테스트를 통과하기 쉽다. 이 예는 단순한 버그를 완화하면 더 복잡한 버그를 훨씬 찾기 어려워진다는 것을 잘 보여 준다.
핵심적으로 나는 이런 이유로 calloc이나 할당 시 memset으로 메모리를 0으로 채우는 것을 강하게 권장하지 않는다.
calloc은 malloc보다 한 가지 장점이 있는데, 할당하려는 원소 수와 타입 크기를 곱할 때 오버플로를 감지할 수 있다는 것이다. 그러나 이는 비교적 드문 문제다. 이게 당신에게 중요하다면, calloc으로 할당한 뒤 메모리를 0이 아닌 다른 값으로 초기화하는 래퍼를 작성하라.
드물게는 memset으로 struct를 "미리 프라이밍"해서 성능 이득을 볼 수 있는 경우가 있다. 다음을 보자.
typedef struct{
int a;
short b;
char c;
}MyStruct;
void my_function(MyStruct *s)
{
memset(s, 0xCD, sizeof *s);
s->a = 1;
s->b = 2;
s->c = 3;
}
여기서(대부분의 일반적인 플랫폼에서) struct 멤버는 각각 4, 2, 1바이트다. 구현은 8바이트 패딩을 추가한다. memset이 없으면 많은 컴파일러는 세 멤버를 각각 따로 쓰는 세 개의 명령을 생성한다. 컴파일러는 패딩 내용은 바꾸지 않도록 조심한다. 하지만 memset이 추가되면, 이제 컴파일러는 패딩까지 포함해 struct 전체 내용을 알고 있으므로, 세 개의 명령을 훨씬 빠른 단일 64비트 쓰기 명령으로 바꿀 수 있다.
나쁘다. 생각보다 더 나쁘다. 표준에 따르면 초기화되지 않은 값/메모리로 무엇을 할 수 있는지에 대해 약간의 논쟁이 있다. 초기화되지 않은 값의 모든 사용이 UB인 것은 아닐 수도 있지만, 나는 동시에 초기화되지 않은 값을 사용하는 명확하게 UB가 아닌 용도도 없다고 주장하고 싶다.
표준은 초기화되지 않은 값의 값을 설명하기 위해 "indeterminate state"라는 용어를 쓴다. 이는 단지 값이 0과 1의 임의 조합일 수 있다는 뜻이 아니다. 0과 1로 표현될 수 없는 값일 수도 있다! "trap representation"일 수도 있다. 즉 사용되는 순간 실행을 트랩으로 보내는 값이다. 정상 값으로는 할 수 없는 일을 하는 값일 수도 있다. 본질적으로 UB다.
현실 세계의 그런 예 중 하나가 "흔들리는 값"이다. 다음 코드를 보자.
int *p, a, b;
p = malloc(sizeof *p);
a = *p;
b = *p;
if(a != b)
printf("WTF!");
실제로 사용하는 플랫폼 중에는 "WTF!"가 출력될 수 있는 곳이 있다. 왜 그런지 파고들어 보자. 현대 컴퓨터에서 malloc을 호출하면, 운영체제는 두 가지를 해야 한다. 할당을 위한 주소 범위를 예약하고, 그 메모리를 저장할 충분한 메모리 페이지를 할당해야 한다. 모든 주소 페이지를 곧바로 할당하는 것은 느릴 수 있고, 프로그램은 그 메모리를 즉시 전부 필요로 하지 않을 수 있다. 그래서 현대 운영체제는 주소 범위만 할당하고, 필요한 페이지는 일부만 또는 하나도 할당하지 않은 채, 애플리케이션이 메모리를 실제로 쓰기 시작할 때 페이지를 더 할당할 수 있다. 프로그램이 a = *p;를 실행할 때, 운영체제는 읽을 메모리 페이지를 제공해야 한다. 하지만 애플리케이션이 그 페이지에 아직 아무것도 저장하지 않았으므로, 그 페이지를 꼭 그 프로그램에 귀속시킬 필요는 없다. 여기서 운영체제가 a와 b 대입 사이에 태스크 스위치를 한다고 가정해 보자. 다른 프로그램이 메모리 페이지를 필요로 할 수 있다. 원래 프로그램에 그 페이지가 아직 배정되지 않았으므로, 운영체제는 그 페이지를 다른 애플리케이션에 줄 자유가 있다. 원래 프로그램으로 다시 돌아와 b의 대입을 실행할 때, 다시 읽을 메모리 페이지를 찾아야 한다. 그러면 프로그램은 다른 초기화되지 않은 메모리 페이지를 받게 되고, 따라서 b는 a와 다른 값이 될 수 있다.
초기화되지 않은 값을 읽는 더 흔한 문제는, 그것이 UB이므로 컴파일러가 그런 일이 일어나지 않는다고 가정할 수 있다는 점이다. 다음을 보자.
int x;
if(a)
x = 0;
if(b)
func(x);
여기서 컴파일러는 b가 참이라면 a도 참이어야 한다고 추론할 수 있다. 그렇지 않으면 x가 초기화되지 않았고, x 사용은 UB가 되기 때문이다. 따라서 코드를 다음처럼 바꿀 수 있다.
int x;
if(a)
{
x = 0;
if(b)
func(x);
}
이런 종류의 UB는, 어떤 프로그램이 좋은 보안용 난수를 만들기 위해 엔트로피를 얻으려고 초기화되지 않은 메모리를 읽으려 했을 때 문제를 일으킨 적도 있다. 컴파일러가 초기화되지 않은 값 읽기를 최적화로 없애 버렸고, 결과는 치명적이었다.
마지막으로, 값을 설정하라고 말했다고 해서 컴파일러가 꼭 그래야 하는 것은 아니라는 점을 기억하자. memset 다음에 struct 멤버 세 개를 대입한 예에서 그것이 단일 64비트 명령으로 바뀌었던 것처럼, 컴파일러는 많은 것을 최적화로 제거할 수 있다. 이것이 가장 문제가 되는 경우는 메모리에서 값을 지우고 싶을 때다. 다음을 보자.
{
char password[64];
... /* 배열에 비밀번호를 저장하고 비밀 작업에 사용 */
memset(password, 0, sizeof pasword); /* 메모리에서 비밀번호 지우기 */
}
여기서 컴파일러는 memset을 제거할 수 있다. 값이 설정된 뒤 읽히지 않는 변수는 설정할 이유가 없다고 결론 내릴 수 있기 때문이다. 이 문제를 피하기 위해 컴파일러들이 지원하는 다양한 memset 변형이 있으며, C23은 memset_explicit를 추가한다. 하지만 volatile을 사용해 직접 안전한 memset을 구현할 수도 있다.
Eskil Steenberg Hald
현대 플랫폼에서 포인터가 64비트라고, 그리고 앞으로도 항상 그럴 것이라고 가정하는 데는 주의하라고 말하고 싶다. 이미 128비트 포인터를 가능하게 하는 새로운 플랫폼들이 등장하고 있기 때문이다. 이것이 곧 주류가 될 가능성은 낮지만, 성장하는 틈새가 될 가능성은 있다. (128비트 포인터는 매우 큰 NUMA 아키텍처를 매핑할 수 있게 한다. 예를 들어 네트워크 주소(MAC 또는 IP)와 메모리 주소를 하나의 포인터 안에 인코딩하는 것도 가능하다.)
_Bool은 C99에서 도입된 타입이며, 나중에 C23에서 폐기되고 stdbool.h의 bool로 대체되었다. _Bool이 해결하려는 문제는 C에서 0은 거짓이고, 그 외 모든 숫자는 참으로 간주된다는 점이다. 즉 두 값이 모두 참이면서도 서로 같지 않을 수 있다. 다음을 보자.
#define TRUE 1
if(x == TRUE)
x는 참일 수 있지만 1과 같지 않을 수 있다. _Bool은 0과 1 두 값만 가질 수 있으므로 이 문제를 해결한다. 하지만 _Bool 없이도 여러 다른 간단한 해결책이 있다. 첫 번째는 x를 아무것과도 비교하지 않는 것이다.
if(x)
또는 FALSE와 비교한다.
#define FALSE 0
if(x != FALSE)
또는 ! 연산자를 두 번 쓴다.
if(!!x == TRUE)
! 연산자는 어떤 참 값도 0으로 만들고, 어떤 0도 1로 만든다.
TRUE와 비교할 때의 함정을 알고 있기만 하면 _Bool 없이도 살 수 있다.
사실 _Bool을 쓰지 말아야 할 이유도 충분하다. 우선 그것은 폐기되었고 bool로 대체되었다. 그리고 bool 키워드는 다른 bool 정의와 충돌할 가능성이 크다. bool을 반환하는 함수를 정의하는 헤더 파일은 stdbool.h 포함을 요구하고, 그러면 자체 bool을 정의하는 어떤 코드도 그 함수에 접근하기 어려워진다. 우리는 bool뿐 아니라 true와 false도 예약어처럼 간주할 것을 강하게 권고한다.
그리고 _Bool은 Boolean 값을 저장하는 좋은 컨테이너가 아니다. 내 생각에 Boolean을 저장하는 실용적인 방법은 세 가지다.
더 큰 정수 타입 안의 단일 비트
바이트
int
이 목록은 가장 압축적인 것에서 가장 덜 압축적인 것으로 가며, 동시에 접근하기 가장 어려운 것에서 가장 빠르게 접근할 수 있는 것으로도 간다.
일반적인 지침으로는, 반환값이나 변수에는 int를, 빠른 접근이 필요하거나 패딩 때문에 비트 패킹이 의미 없는 struct 안에는 byte를, 디스크나 네트워크용 압축 데이터에는 더 큰 타입 안의 개별 비트를 사용하는 것이 좋다.
== 같음
!= 같지 않음
큼
< 작음
= 크거나 같음
<= 작거나 같음
모든 비교 연산자는 신뢰할 수 있다. 다만 몇 가지 주의점이 있다.
모든 부동소수점 NaN은 자기 자신과도 같지 않다. 따라서 부동소수점 값을 자기 자신과 비교하면 NaN인지 확인할 수 있다.
if(f != f)
printf("f is a NaN!\n");
C11은 math.h에 isnan 함수를 추가한다. 이것은 신뢰할 수 없지만, 함수로 쉽게 구현할 수 있다.
int dependablec_isnanf(float x)
{
return (x != x);
}
int dependablec_isnand(double x)
{
return (x != x);
}
타입에 구애받지 않는 매크로로도 구현할 수 있다.
#define dependablec_isnan(x) ((x) != (x))
단점은, 같은 매개변수를 두 번 사용하는 어떤 매크로든, 매개변수가 두 번 평가되거나 실행될 때 의도치 않은 버그를 일으킬 위험이 있다는 점이다.
x = dependablec_isnan(my_function());
/* 다음으로 확장됨 */
x = ((my_function()) != (my_function()));
모든 포인터는 서로 같은지 비교할 수 있다. 하지만 provenance가 같지 않은 두 포인터에 대해 대소 비교를 하는 것은 정의되지 않는다. 다시 말해 서로 다른 객체를 가리키는 경우다.
int a[2] b, *pa0, *pa1, *pb, x;
pa0 = &a[0];
pa1 = &a[1];
pb = &b;
x = pa0 < pa1; /* 정의되어 있으며 신뢰 가능 */
x = pa0 < pb; /* 정의되지 않았고 신뢰할 수 없음 */
어떤 플랫폼에서는 NULL이 0x0이 아닌 주소에 존재하고, 심지어 여러 개의 NULL이 있을 수도 있다. 모든 NULL 값은 같다고 평가되어야 하지만, 비트 표현은 같지 않을 수 있다.
두 시프트 연산자 >>와 <<는 부분적으로 신뢰할 수 있다. 하지만 왼쪽 피연산자가 음수이거나, 타입의 비트 수 이상이면 UB이다.
음수 값에 대한 부호 있는 정수의 오른쪽 시프트(>>)는 구현 정의이므로 신뢰할 수 없다. 문제는 비트를 이동할 때 부호 비트가 어떻게 되는가이다. 이상적으로는 부호 비트를 제외한 나머지만 이동하고 싶다. 대부분의 현대 아키텍처에는 이를 위한 명령이 있다: x86_32, x86_64, ARM64, MIPS32, MIPS64, SPARC32, SPARC64, RISC-V32, RISCV64. 하지만 없는 아키텍처도 있다: ARM32, power, S390. (아키텍처 동작 목록을 제공해 준 Aaron Peter Bachmann에게 특별히 감사한다.)
나 <<를 사용해 타입이 가진 비트 수보다 많이 시프트하거나 음수 값만큼 시프트하는 것은 정의되지 않은 동작이다. 이를 해결하려면 가능할 때 항상 타입 비트를 마스크하라.
x <<= y & (sizof(y) * BITS_IN_BYTE - 1);
C99는 표준 C에 대한 첫 번째 주요 개정이며, C89 다음으로 가장 널리 지원된다. 도입된 지 4반세기가 지났음에도 C99는 여전히 완전히 지원되지 않는다. 주요 구현조차 사소한 결함이 있다.
C99는 _inline이라는 단어를 추가한다. 이 키워드는 "AS-IF" 때문에 의미가 없다. 어떤 것이 인라인인지 아닌지는 구현 정의다. _inline은 C23에서 폐기되기도 했다.
이 기능은 언어에 새로운 능력을 추가하지 않는다. 단지 선언 위치를 옮길 뿐이다. 우리의 생각에 모든 변수를 앞부분에 선언하는 편이 더 명확하다. 일반 규칙으로는, 변수 오버로드와 초기화 문제(초기화 절 참조)를 피하기 위해, 변수는 앞부분에서 그리고 함수 스코프에서만 선언하는 것이 더 안전하다.
가변 배열은 매우 망가져 있으며 C99의 느린 채택에 기여했다. VLA에 대한 더 긴 논의는 메모리 모델의 별도 문서를 보라. (VLA는 C11에서 선택 사항이 되었고, C23에서 다시 다소, 하지만 완전히는 아니게 의무화되었다.)
지정 초기화자는 새로운 기능을 가능하게 하지 않는 단순한 문법 설탕이다. 사용자에게 다음을 허용한다.
struct struct s = {.member = 42};
이는 다음과 같다.
struct my_struct s;
s.member = 42;
복합 리터럴은 struct와 union의 리터럴 생성 기능을 제공한다.
function((struct my_struct)(.member = 42, .other_member = 1138));
신뢰할 수 있는 C에서는 그냥 변수를 만들어서 할 수 있다.
struct struct s;
s.member = 42;
s.other_member = 1138;
function((struct my_struct)(.member = 42, .other_member = 1138));
restrict는 aliasing을 전달하기 위한 매우 유용한 개념이다. 이것은 어떤 변수나 매개변수가 다른 포인터와 alias하지 않을 것이라고 선언하게 해준다. 이 규칙의 정확한 의미는 불분명하며, 과거에도 restrict의 의미를 명확히 하려는 시도가 있었다. 앞으로 우리는 restrict를 자세히 이해하고 문서화하려고 한다.
restrict를 써야 한다면, 키워드를 제거해도 코드 의미가 바뀌지 않으므로 매크로를 통해 사용할 수 있다. (Restrict는 구현에 당신이 어떤 일을 하지 않겠다고 약속하는 키워드이며, 구현은 따라서 이 키워드를 무시할 수 있다.) 우리는 다음 코드를 권장한다.
#if defined(__GNUC__) && ((__GNUC__ > 3) || (__GNUC__ == 3 && __GNUC_MINOR__ >= 1))
#define DC_RESTRICT __restrict
#elif defined(_MSC_VER) && _MSC_VER >= 1400
#define DC_RESTRICT __restrict
#elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 199901L
#define DC_RESTRICT restrict
#else
#define DC_RESTRICT
#endif
C99는 유연 배열 멤버를 지원한다. 이를 통해 struct의 마지막 멤버를, 더 많은 원소를 저장할 만큼 메모리를 할당하는 것만으로 확장 가능한 배열로 둘 수 있다. 유연 배열 멤버는 빈 대괄호 []로 표시한다. 예:
typedef struct{
size_t length;
char text_buffer[];
}TextBuffer;
#define TEXT "Hello World"
TextBuffer *t;
t = malloc(sizeof(TextBuffer) + sizeof TEXT);
t.length = sizeof TEXT;
memcpy(t.text_buffer, TEXT, sizeof TEXT);
C89에서는 마지막 멤버를 길이 1의 배열로 선언해 같은 일을 하곤 했다. 이는 기술적으로 UB다. 오래된 코드베이스에서 흔하기 때문에 주요 컴파일러들은 여전히 이를 지원한다. 하지만 많은 정적 분석기에서는 실패한다. C99가 표준화되기 전에는 많은 컴파일러가 이 사용 사례를 위해 마지막 멤버에 길이 0 배열 [0]을 두는 확장을 지원했다. 대부분의 컴파일러는 이 확장도 지원한다.
유연 배열 멤버가 필요하다면, 모든 표준 버전에서 합법적인 올바른 방법은 유연 배열을 struct 할당 뒤에 그냥 두는 것이다.
typedef struct{
size_t length;
}TextBuffer;
#define TEXT "Hello World"
TextBuffer *t;
char *buffer;
t = malloc(sizeof(TextBuffer) + sizeof TEXT);
t.length = sizeof TEXT;
buffer = (char *)&t[1]; /* TextBuffer struct 뒤의 주소 얻기 */
memcpy(buffer, TEXT, sizeof TEXT);
그러나 이 방식은 패딩에 주의해야 한다. 꼬리 타입의 정렬이 헤더 구조체의 정렬보다 작거나 같음을 확인해야 한다. 위와 같은 많은 경우에는 표준의 타입 크기 요구로 이를 보장할 수 있지만, 때로는 구현 정의이다.
Annex K의 구현은 존재하지 않는다. 이보다 덜 신뢰할 수 있는 것은 없다.
이 기능은 선택 사항이므로 신뢰할 수 없다.
C11 버전 표준은 주로 수정 사항을 추가한다. 하지만 C11의 가장 큰 추가 사항은 멀티스레딩 지원이다.
표준 라이브러리 threads.h는 스레드를 생성하고 관리하는 기능을 추가한다. 엄밀히 말해 C11 스레드를 사용하지 않는 모든 멀티스레드 C 프로그램은 UB다. 실제로는 C11 스레드는 POSIX Threads보다 훨씬 덜 신뢰할 수 있다. POSIX 스레드 지원이 C11 스레드보다 훨씬 더 널리 퍼져 있으므로, 실제로는 POSIX 스레드가 더 신뢰할 수 있다. 권장 사항은 POSIX나 다른 네이티브 스레딩 API를 사용하는 것이다. 거의 모든 스레딩 API는 매우 비슷하므로, 다양한 플랫폼의 여러 스레딩 해법을 지원할 수 있도록 스레드 사용을 래핑하는 것을 권장한다.
원자 연산에 대해서는 별도 문서를 보라.
C11은 stdalign.h에 _Alignas, _Alignof, aligned_alloc 같은 정렬 지원 기능을 추가한다. _Alignof는 타입의 정렬 요구를 알려 준다. 하지만 sizeof도 그렇다. int가 24비트이지만 32비트 정렬을 요구하는 가상의 플랫폼을 생각해 보자. sizeof(int)는 여전히 4바이트로 평가된다. sizeof는 패딩까지 고려해야 하기 때문이다. 그렇다, sizeof가 3바이트로 평가되더라도 다음 코드는 동작할 것이다.
int *x;
x = malloc(sizeof(int));
하지만 배열을 할당하면 실패한다.
int *x;
x = malloc(sizeof(int) * 2);
이 경우 두 정수에 접근 가능하려면
#if __STDC_VERSION__ < 201112L
#define quick_exit(x) exit(x)
#endif
VLA는 C99에서 도입되었고, C11에서 선택 사항이 되었으며, C23에서 다시 부분적으로 의무화되었다. 이것은 신뢰할 수 없다.
C는 메모리 관리를 수동으로 한다는 사실로 정의되는 언어다. 충분한 메모리가 있는지, 가진 것보다 더 많이 쓰지 않는지 확인하는 책임은 당신에게 있다. 이런 수준의 제어는 할당이 언제 일어나는지도 제어할 수 있다는 뜻이다.
VLA는 이 패러다임을 완전히 깨뜨린다. 그냥 아무 때나 메모리를 요청하면 그것이 항상 사용 가능하고 항상 빠르게 할당된다고 주장하는 셈이다. 그것은 환상이며 근본적으로 망가져 있다. 이런 종류의 메모리 관리가 필요하다면(속도는 제외하고), 다른 언어가 많다. 애초에 C에 추가되었어서는 안 되었다.
신뢰할 수 있게 빠른 VLA를 만드는 것이 불가능하다는 점 외에도, 진짜 핵심은 이것이다. 얼마나 많은 메모리에 접근할 수 있는가? 정의되지 않는다. 메모리가 다 떨어지면 무슨 일이 일어나는가? 정의되지 않는다. int a[n];이 무엇을 하는지 보장할 수 없다면, 사실상 UB다. (VLA는 Annex J에 추가되어야 한다.)
사람들은 C에서 NULL 검사를 놓치는 것에 불평한다. C에서는 포인터가 NULL인지 검사하는 것이 완전히 가능한데도 말이다. 그런데 VLA는 옹호한다. 프로그래머가 안전하고 이식 가능한 VLA를 작성하기 위해 할 수 있는 일이 문자 그대로 아무것도 없는데도 말이다. "내 컴퓨터에서는 돼"라거나 "요즘 컴퓨터는 메모리가 많아" 같은 두루뭉술한 답이 나온다. 하지만 아니다. C는 아주 작은 플랫폼에서도 돌아간다. 그리고 다시 아니다. Linux의 기본 스택 크기는 8메가이고 Windows는 1메가다. 금방 바닥난다. VLA는 "컴파일러를 믿는 것"도 아니고 "프로그래머를 믿는 것"도 아니다. 아무도 믿지 않는 것이다.
동적으로 할당된 메모리가 필요하다면, C에는 malloc, calloc, realloc을 사용해 이를 하는 여러 방법이 있다.
스크래치 메모리가 필요하다면, 내가 제안하고 싶은 전략이 몇 가지 있다. 첫 번째는 미리 할당한 자체 스택을 만드는 것이다.
array = &my_pre_allocated_stack[my_usage_of_said_stack];
my_usage_of_said_stack += n * sizeof(*array);
....
my_usage_of_said_stack -= n * sizeof(*array);
필요한 만큼의 메모리를 미리 할당하고, 초과하지 않도록 필요한 검사를 얼마든지 넣을 수 있다. 빠르다는 것이 보장된다. 또 다른 방법은 버퍼를 점진적으로 반환하지 않고, 코드의 특정 지점에서 스택 사용량을 0으로 리셋하는 것이다. 이 방법은 많은 상태를 되돌아볼 수 있으므로 디버깅 측면에서 장점이 있다. 변형은 많지만 요점은 이것이다. 제어권은 당신에게 있다. (Jai 언어에서는 이상하게도 이것이 언어 기능이다.)
내가 가끔 쓰는 또 다른 접근은 다음과 같다.
int *array, buffer[1024];
if(n > 1024)
{
array = malloc((sizeof *array) * n);
if(array == NULL)
my_abort();
}else
array = buffer;
....
if(array != buffer)
free(array):
이 방식은 기반 구조가 덜 필요하지만, n이 대체로 작고 드물게 더 커질 수 있음을 알고 있을 때 신뢰할 수 있다. 참고로 이것이 일부 컴파일러가 VLA를 구현하는 방식이기도 하다. NULL 검사만 빠져 있을 뿐이다.
C11 표준은 처음으로 C에 동시성 모델을 추가했다. 이 모델은 C++와 함께 설계되었으므로 C++11 메모리 모델과 동등하다. 이 기본 모델은 대체로 건전하며, 다른 많은 메모리 모델의 기반을 이룬다. 이론적으로는 더 이른 버전의 C를 사용하거나 다른 형태의 동기화(Posix threads 같은)를 사용하는 모든 동시 프로그램은 기술적으로 UB다. 실제로는 C11 이전이든 이후든 대부분의 C 구현은 견고한 동시성 지원을 가지고 있으며, C 표준 안팎에서 정의된 다양한 동기화 원시를 허용한다. 여기서 설명하겠지만, 이런 대체 동기화 방법들이 실제로는 훨씬 더 신뢰할 수 있다.
실제로는 C11은 신뢰할 수 없고, 구현도 몇 개 되지 않는다. 멀티스레딩이 필요하다면, Posix threads나 플랫폼별 스레딩 라이브러리를 감싼 단순 래퍼가 훨씬 더 신뢰할 수 있다. 널리 지원되고, 충분히 테스트되었으며, 성숙했기 때문이다. C11 스레드는 ifdef를 사용해 스레딩 기능에 접근하는 여러 경로 중 하나로 사용할 수는 있다.
Mutex는 널리 지원되며 대부분의 동시성 프로그래밍에 충분하다. Atomics는 덜 지원되고, 훨씬 사용하기 어렵지만(동시성이 원래 어렵지 않다는 듯이), 몇 가지 장점을 제공한다. 원자 명령은 운영체제의 도움 없이 특정 하드웨어 명령을 호출하여 동기화를 수행한다. mutex를 잠그려다 실패하면 운영체제가 스레드를 멈추고 다른 스레드를 실행한다. 즉 그 스레드는 꽤 오랫동안 깨어나지 못할 수 있다. 마찬가지로 잠금을 해제하면 운영체제가 그 잠금을 기다리던 스레드를 찾아 다시 활성화할 수 있다.
스레드가 드물게, 예를 들어 초당 1000번 이하 정도로만 데이터를 공유하고, 작업이 주어질 때만 일하는 스레드라면 mutex가 선호된다. 반면 atomics는 데이터가 여러 스레드에 의해 초당 수백만 번 접근되는 매우 높은 경합 상황, 즉 접근 하나하나가 사이클 단위로 계산되는 상황을 위한 것이다.
Mutex는 원자 연산보다 훨씬 더 많은 시스템과 구현에서 지원되므로, mutex를 사용하는 프로그램은 atomics를 사용하는 프로그램보다 본질적으로 더 이식 가능하고 신뢰할 수 있다.
현대 컴퓨터에서 C 프로그래밍은 매우 오래된 컴퓨터를 프로그래밍하는 느낌을 준다. 모든 것이 순차적으로 일어나고, 메모리는 균일하다. 실제로 현대 CPU는 비순차 실행을 하고 메모리 접근을 가속하는 여러 단계의 캐시를 가진다. 이 모든 것은 사용자가 멀티스레드 코드를 작성하기 전까지 숨겨져 있다. 다음 코드 조각을 보자.
shared_pointer = &data;
이 예에서는 다른 스레드와 공유하고 싶은 데이터를 준비한 뒤, shared_pointer를 설정해서 그 데이터를 공유한다. 일반적인 단일 스레드 코드에서는 문제가 없고, 어떤 절차든 이 포인터를 사용해 데이터에 접근할 수 있다. 여기서 첫 번째 추가 요구 사항은 "원자성"이다. 다른 스레드가 shared_pointer를 읽을 때, 포인터가 설정되기 전 값이나 후 값 둘 중 하나를 얻어야 한다. 반쯤만 써진 포인터, 즉 일부 비트는 옛값이고 일부는 새값인 포인터를 읽고 싶지는 않다. 이것을 "찢어진" 쓰기라고 한다. 예를 들어 어떤 하드웨어 아키텍처는 64비트 쓰기를 두 번의 32비트 쓰기로 나눌 수 있다. 어떤 아키텍처는 기본적으로 원자 읽기/쓰기를 제공하고(x86/64), 어떤 아키텍처는 그렇지 않다(ARM64). 원자성 요구 사항의 일부는, 이 쓰기가 다른 스레드에게 보이도록 만들고 CPU 코어의 로컬 캐시에만 머물지 않게 하는 것이다.
두 번째 문제는 순서다. 다른 스레드와 data에 대한 포인터를 공유하고자 한다면, data를 공유하기 전에 x가 data에 기록되었음을 보장해야 한다. 또한 다른 스레드가 data에 접근하려 할 때 최신 버전을 실제로 얻도록, 그 데이터가 flush되도록 해야 한다. 이를 위해 C11은 "happens before"(즉 Acquire)와 "happens after"(즉 Release) 개념을 정의한다. 이 경우 shared_pointer에 대한 쓰기는, 위에 있는 코드 뒤에 그 쓰기가 일어나도록 보장하기 위해 "happens after" 의미를 가져야 한다. 마찬가지로 코드의 반대편을 보자.
p = shared_pointer;
여기서 스레드는, 읽기 뒤에 접근하는 메모리가 오래된 캐시 값이 아니도록 보장하기 위해, "happens before" 의미로 shared_pointer를 읽어야 한다. 어떤 연산은 "happens before"와 "happens after" 둘 다 필요할 수도 있고, 둘 다 필요 없는 원자 연산은 흔히 "relaxed"라고 불린다. 이런 의미론은 저수준 메모리 모델 전반에 퍼져 있다. Mutex는 완전한 fence를 구현해 release와 acquire를 모두 제공하므로, mutex 사용자들은 이런 의미를 신경 쓸 필요가 없지만 CPU가 캐시의 추가 flush와 동기화를 해야 하므로 성능은 잃는다.
C11이 잘못한 대부분의 것은 의도는 좋고 이론적으로도 타당했지만, 실제로는 잘못된 결정으로 판명되었다.
C11은 원자성을 타입의 일부로 만들기로 했다. 아이디어는 변수를 atomic으로 설정한 뒤 그 변수에 일반 산술을 사용하게 하자는 것이었다. 예를 들어 다음 gcc 내장 함수를 쓰는 대신:
__atomic_fetch_add(&a, 42, __ATOMIC_ACQUIRE);
이 설계는 원자 연산 사용을 단순화하는 면에서 명백한 이점처럼 보인다. 하지만 메모리 순서 의미론이 어디에도 정의되지 않았다는 점을 눈치챌 수 있을 것이다. 따라서 프로그램은 별도의 상태를 설정해야 하고, 이는 읽기 어렵게 만든다. 실제로 원자성은 타입이 아니라 연산이다. 같은 메모리 조각에 대해 원자적으로도, 비원자적으로도 연산하고 싶은 경우가 있다.
C 프로그래밍 언어는 C99 이후 "유효 타입"이라고 알려진 개념을 갖고 있다. 유효 타입 규칙은 컴파일러의 aliasing 분석 능력을 향상시키기 위해 설계되었다. 이 규칙과 그 함의는 거의 아무도 이해하지 못한다. 거의 아무도란 정말로 세상에 아마 열 명도 안 될 것이라는 뜻이다. 나는 25년 동안 C를 프로그래밍했지만 최근까지는 이것에 대해 들어 본 적조차 없었다. 나는 스웨덴을 대표해 ISO wg14 표준 그룹에 참여하고 있고, C 메모리 모델 연구 그룹에도 있는데, 아주 최근에야 이 개념을 이해하기 시작했다. wg14의 여러 사람들이 Effective type 개념을 이해하는 데 도움이 되는 문서를 요청했기 때문에, 이제 내가 이해한 방식대로 최선을 다해 설명해 보려 한다.
이 문서에서는 구현이 실제로 무엇을 하는지가 아니라, 표준이 무엇을 말하는지를 설명하려고 한다. 내가 알기로 컴파일러들은 이 규칙을 깨뜨리지는 않으므로, 이 규칙 안에서 작성한다면 괜찮을 것이다(물론 컴파일러 버그는 제외인데, 이 영역은 이해하는 사람이 거의 없어서 버그가 꽤 많다).
이것은 내가 C 표준을 해석한 최선의 결과다. 이 규칙과 그 함의를 이해하려고 상당한 시간과 노력을 기울였고, 특히 Jens Gustedt와 Martin Uecker의 도움에 큰 감사를 빚지고 있다. 그래도 이것은 내 해석일 뿐이며, wg14나 메모리 모델 연구 그룹이 공식적으로 승인한 문서는 아니다.
시작하기 전에 한 가지만 더. 이 문서는 C의 유효 타입 시스템이 어떻게 작동하는지 설명하려는 것이지, 그 설계를 지지하려는 문서가 아니다.
유효 타입 시스템 자체를 말하기 전에, 그것이 해결하려는 문제가 무엇인지부터 이야기해야 한다.
다음 코드를 보자.
typedef struct{
unsigned int length;
float *array;
}MyStruct;
void function(MyStruct *s)
{
unsigned int i;
for(i = 0; i < s->length; i++)
s->array[i] = 0.0f;
}
컴파일러는 이 코드를 어떻게 최적화할 수 있을까? 여기에는 여러 기회가 있다. 끝 포인터를 계산해 앞으로 전진할 수도 있고, 내장 memset 호출로 바꿀 수도 있고, 벡터 명령과 온갖 하드웨어 특화 기법을 쓸 수도 있으며, 단지 length 값을 레지스터에 두는 것만 해도 된다. 이 모든 접근은 한 가지에 의존한다. array에 쓰는 것이 struct의 length 멤버를 덮어쓰지 않는다는 것이다. array 포인터가 length 멤버를 가리킨다면, 이 코드는 전혀 다른 의미가 된다. 이것이 별칭(aliasing)이다. 어떤 것이 포인터로 접근되고 있으면서 동시에 직접 접근되거나 다른 포인터로도 접근될 때, 둘은 서로 alias한다. 아주 단순한 예를 보자.
void function(float *a, int *b)
{
*a = 2.0f;
*b = 3;
}
a와 b가 alias하지 않는다고 보장된다면, 두 연산의 순서는 중요하지 않으며 심지어 병렬로도 일어날 수 있다. alias한다면 연산 순서가 보장되어야 한다.
무엇이 alias하는지 결정하는 것은 따라서 매우 중요하다. C99가 만들어질 즈음 이것은 분명한 문제가 되었고, 해결책 중 하나는 컴파일러가 메모리 사용 방식에 대해 특정 가정을 할 수 있게 하는 규칙 집합을 추가하는 것이었다. 이 규칙을 깨는 메모리 사용은 모두 정의되지 않은 동작이 된다. 이렇게 해서 "유효 타입" 규칙이 등장한다.
두 값이 alias하는지를 결정하는 한 가지 방법은 그 타입을 보는 것이다. 이것을 타입 기반 별칭이라 한다. C는 타입 기반 별칭을 사용한다.
처음 예로 돌아가면, length 변수는 unsigned int 타입이고 array 포인터는 float 타입 값을 가리키므로 array가 length를 가리키지 않는다고 생각할 수 있다. 이 가정은 틀렸다. C는 포인터의 타입이나 그 포인터가 가리키는 값의 타입에 의존하지 않는 시스템을 쓴다. 전혀 다른 것, 즉 유효 타입에 의존한다.
대부분의 경우 객체는 한 가지 타입으로만 접근되며, 그 경우에는 모든 것이 예상대로 동작한다. 하지만 메모리를 둘 이상의 타입으로 접근하고자 하는 순간, 이 규칙들이 작동하기 시작한다. 우리는 예를 들어 재사용할 메모리를 할당하거나, 포인터를 캐스팅하거나, 타입 퍼닝을 하거나, 임의 메모리에 암호화 연산을 수행하거나, 개별 바이트보다 더 큰 타입으로 메모리를 이동하고 싶을 수 있다.
먼저 C 표준의 규칙 자체를 보자.
객체의 저장된 값에 접근할 때 그 객체의 유효 타입은, 선언된 타입이 있다면 그 선언된 타입이다. (할당된 객체에는 선언된 타입이 없다.) 선언된 타입이 없는 객체에 문자 타입이 아닌 타입의 lvalue를 통해 값을 저장하면, 그 lvalue의 타입이 그 접근과 이후 값이 수정되지 않는 접근들에 대해 그 객체의 유효 타입이 된다. 선언된 타입이 없는 객체에 memcpy나 memmove를 사용하여 값을 복사하거나 문자 타입 배열로 복사하면, 수정된 객체의 그 접근과 이후 값이 수정되지 않는 접근들에 대한 유효 타입은, 원본 객체가 유효 타입을 가지고 있다면 원본 객체의 유효 타입이 된다. 선언된 타입이 없는 객체에 대한 그 밖의 모든 접근에서, 객체의 유효 타입은 단순히 그 접근에 사용된 lvalue의 타입이다.
7 객체의 저장된 값은 다음 타입들 중 하나를 가진 lvalue 식으로만 접근되어야 한다. (이 목록의 의도는 객체가 alias할 수 있는지 없는지를 규정하는 상황을 명시하는 것이다.)
객체의 유효 타입과 호환되는 타입
객체의 유효 타입과 호환되는 타입의 한정된 버전
객체의 유효 타입에 대응하는 부호 있는 타입 또는 부호 없는 타입
객체의 유효 타입의 한정된 버전에 대응하는 부호 있는 타입 또는 부호 없는 타입
위 타입들 중 하나를 멤버로 포함하는 aggregate 또는 union 타입(재귀적으로 하위 aggregate나 포함된 union의 멤버도 포함)
문자 타입
(참고: "Access"라는 용어는 C 표준에서 값을 읽거나 수정하는 것으로 정의된다. 이 텍스트의 한 부분에서는 다른 타입으로 덮어씀으로써 유효 타입을 바꿀 수 있다고 말하고, 뒤에서는 호환 타입으로만 "접근"할 수 있다고 하므로 다소 혼란스럽다.)
요약하면, 어떤 것이 유효 타입을 가지면, 그 타입과 호환되는 타입으로만 접근할 수 있다는 뜻이다. 대부분의 경우 객체의 유효 타입은 그 타입과 같다. 하지만 항상 그런 것은 아니며, 우리는 메모리를 여러 타입으로 접근하고 싶을 수 있다.
이를 생각하는 한 가지 방법은, 모든 메모리 바이트 옆에 그 바이트에 저장된 데이터 타입을 저장하는 별도의 저장 장치가 있는 하드웨어 아키텍처를 상상하는 것이다. 이 장치는 그 메모리를 가리키는 포인터의 타입과는 완전히 별개다. 메모리에 쓸 때마다, 그 바이트와 연결된 타입 저장소는 쓴 타입으로 업데이트된다(몇 가지 예외가 있다). 메모리에서 읽을 때마다, 그 메모리에 저장된 타입과 같은 타입으로 읽어야 하며, 그렇지 않으면 이 가상의 아키텍처는 실패할 것이다. memcpy, memmove, 바이트 복사는 메모리뿐 아니라 메모리와 연결된 타입도 복사하는 내장 기능으로 구현된다. 스택은 모든 변수와 연결된 타입을 자동으로 초기화하고, 그 타입 데이터는 쓰기 보호된다.
(참고: 간결성을 위해 이 문서는 float와 int가 같은 크기이고, malloc/calloc이 NULL을 반환하지 않는다고 가정한다.)
int를 선언하면 그것은 int의 유효 타입을 가지며 int로 사용해야 한다. 따라서 다음은 정의되지 않은 동작이다.
int i = 0;
float *fp;
fp = &i;
x = *fp;
i는 int로 선언되었기 때문에 유효 타입이 int이고, 따라서 float 포인터로 역참조해서 접근하는 것은 UB다. 지금까지는 꽤 직관적이다. 그렇다면 할당된 메모리는 어떨까? 할당된 메모리는 선언되지 않는다. malloc, calloc, realloc 같은 함수가 반환한다. 다음을 보자.
int *ip;
float *fp;
ip = calloc(sizeof *fp);
fp = ip;
x = *fp;
이 경우 calloc이 반환한 메모리는, 비록 int 타입 포인터에 저장되었더라도 유효 타입을 가지지 않는다. 메모리가 유효 타입을 가지지 않을 때, 어떤 타입으로 읽으면 그 메모리는 그 유효 타입을 갖게 되므로, float로 읽는 것도 완전히 유효하다. 사실 정수로 읽는 것도 가능하다.
int *ip;
float *fp;
ip = calloc(sizeof *fp);
fp = ip;
y = *ip;
할 수 없는 것은 둘 다 읽는 것이다.
int *ip;
float *fp;
ip = calloc(sizeof *fp);
fp = ip;
y = *ip;
x = *fp;
여기서는 먼저 int로 역참조하면서 메모리에 int 유효 타입을 부여하므로, 그 뒤 float로 역참조하는 것은 UB가 된다.
선언되지 않은 타입의 객체에 쓸 때마다 유효 타입은 쓰기에 사용한 타입으로 바뀐다. 다음을 보자.
int *ip;
float *fp;
ip = malloc(sizeof *fp);
*ip = 0;
fp = ip;
x = *fp;
이 코드는 UB다. malloc이 메모리를 할당할 때는 유효 타입이 없지만, int 포인터로 써 넣는 순간 int 유효 타입이 부여되고, 그 뒤 그 메모리 접근은 int와 호환되는 타입으로만 이루어져야 한다. float는 int와 호환되지 않으므로, fp의 역참조는 UB가 된다. 할당된 메모리에 대한 어떤 쓰기든(나중에 논의할 예외 하나를 제외하면) 자동으로 유효 타입을 쓰인 타입으로 바꾼다. 따라서 다음은 UB가 아니다.
int *ip;
float *fp;
ip = malloc(sizeof *fp);
fp = ip;
*ip = 0;
*fp = 0.0f;
x = *fp;
이 경우 malloc이 반환한 메모리는 유효 타입이 없지만, 정수 0을 쓸 때 int 유효 타입을 얻고, 같은 메모리에 숫자 0.0을 쓸 때 다시 float 유효 타입을 얻는다. 그 뒤 float 포인터로 메모리를 역참조하는 것은 메모리의 유효 타입이 float이므로 합법이다.
이것은 할당된 메모리가 선언된 메모리와 근본적으로 다르게 동작함을 말해 준다.
선언된 메모리는 선언된 타입과 같은 유효 타입을 가지며 바뀔 수 없다.
할당된 메모리는, 유효 타입이 부여되기 전에 쓰기나 읽기를 통해 유효 타입을 얻을 수 있고, 다른 타입으로 다시 쓰면 유효 타입이 바뀔 수 있다.
표준은 할당된 메모리에 쓸 때 유효 타입이 바뀐다고 분명히 말한다. 선언된 메모리는 그렇지 않으며, 호환 타입이어야 한다. 다음을 보자.
float f;
int *ip;
ip = &f;
*ip = 42; // 호환되지 않는 타입으로 접근하므로 UB.
유효 타입 규칙의 흥미로운 부작용은, 이 규칙을 깨는 버그가 실제 버그가 발생하는 코드에서는 전혀 보이지 않을 때가 많다는 것이다. 다음을 보자.
void this_function()
{
int *ip;
ip = malloc(sizeof *ip);
other_function(ip);
printf("%i", *ip);
free(ip);
}
이 코드는 other_function이 할당된 메모리에 무엇을 하는지에 따라 유효 타입 규칙을 위반하고 UB가 될 수 있다. even if other_function 자체에는 UB가 전혀 없더라도 말이다. other_function이 그 메모리에 int와 호환되지 않는 타입으로 쓰면(자유롭게 할 수 있다), 예를 들어 이렇게:
void other_function(void *p)
{
*(float *)p = 3.14f;
}
...그러면 ip의 역참조는 UB가 된다. this_function도 other_function도 각각 단독으로는 코드가 UB가 되도록 만들 충분한 정보를 가지고 있지 않다. 이 문제는 사용자에게만 있는 것이 아니라 컴파일러에게도 있다. 다음을 보자.
void function(int *ip, float *fp)
{
ip[0] = 0;
fp[0] = 1.0f;
ip[1] = 2;
fp[1] = 3.0f;
}Â
이 함수는 하나는 float 포인터, 하나는 int 포인터라는 두 매개변수를 가진다. 포인터 타입은 그들이 가리키는 메모리의 유효 타입에 대해 아무것도 말해 주지 않는다. 유효 타입 시스템은 이 둘이 alias하는 것을 허용한다. 함수는 어느 포인터도 역참조해 읽지 않기 때문에, 컴파일러는 유효 타입 시스템을 이용해 두 포인터가 alias하는지 아닌지 알아낼 수 없다. 마지막 쓰기와 호환되는 타입으로 읽기만 한다면, 같은 메모리를 서로 다른 타입으로 덮어쓰는 것은 합법이다. 즉 컴파일러는 유효 타입 규칙을 사용해, 예를 들어 이 네 번의 32비트 쓰기를 두 번의 64비트 store 명령으로 최적화할 수 없다.
흥미로운 관찰은, 어떤 경우 컴파일러가 메모리가 할당된 메모리여야만 한다고 그 접근 방식만 보고 추론할 수 있다는 점이다. 다음을 보자.
int function(float *fp)
{
int *ip;
float x;
x = *fp;
ip = fp;
*ip = 42;
return *ip;
}
여기서 함수는 fp가 가리키는 메모리를 사용해 서로 호환되지 않는 두 타입을 저장하고 접근한다. 그러므로 그 메모리는 할당된 메모리여야 한다. 그렇지 않다면 이 코드는 UB다.
이는 메모리를 가리키는 포인터를 매개변수로 받는 함수가, 그 메모리는 선언된 메모리가 아니라 할당된 메모리여야 한다는 사용 요구 사항을 문서화해야 할 수도 있음을 뜻한다.
원래 예로 돌아가자.
typedef struct{
unsigned int length;
float *array;
}MyStruct;
void function(MyStruct *s)
{
unsigned int i;
for(i = 0; i < s->length; i++)
s->array[i] = 0.0f;
}
순진한 관점에서는 length 변수는 unsigned int 타입이고 array 포인터는 float 타입을 가리키므로 array가 length를 가리킬 수 없다고 생각할 수 있다. 하지만 우리가 배운 것처럼 그렇지 않다. 이 경우 컴파일러는 array가 length를 가리키지 않는다고 가정할 수 있는데, 그 이유는 그렇게 되면 array 멤버를 통해 메모리에 float 유효 타입이 부여되고, 그 뒤 length 멤버를 통해 unsigned int로 읽게 되어 UB가 되기 때문이다. 차이는 미묘하지만, 이 경우 결과는 같다.
모든 읽기는 오직 마지막으로 쓴 타입으로만 할 수 있다고 가정하면, 곧 여러 문제에 부딪힌다. C에서는 메모리를 바이트 배열 형태로 접근하고 이동하고 싶은 경우가 자주 있다. 기본 유효 타입 규칙만으로는 이것이 불가능해진다. 예를 들어 memcpy를 구현하거나 사용하는 것조차 불가능해진다. 그래서 여러 예외가 만들어졌다.
단순한 예외는 정수 부호와 한정자다. 타입 호환성을 판정할 때 부호와 한정자는 무시된다. Atomic signed int는 volatile unsigned int와 호환된다.
memcpy와 memmove는 선언된 메모리와 할당된 메모리 모두에 쓸 수 있게 해 주는 특별한 성질을 가진다. 하지만 할당된 메모리에 쓸 때는, 소스의 유효 타입도 목적지에 복사한다.
다음을 보자.
int i = 42;
int *ip;
float *fp;
ip = fp = malloc(sizeof *ip);
*fp = 3.14;
memcpy(ip, &i, sizeof(int));
x = *ip;
여기서 할당된 메모리는 먼저 float 값을 대입하면서 float 유효 타입을 얻는다. 그 다음 memcpy를 사용해 다시 덮어쓴다. 마지막으로 그 메모리를 정수로 역참조한다. 이것이 합법이려면 메모리의 유효 타입이 int여야 한다. 따라서 memcpy(와 memmove)는 메모리뿐 아니라 유효 타입도 복사한다.
이제 다음을 보자.
int i = 42;
float f;
memcpy(&f, &i, sizeof(int));
x = f;
이 예에서 f는 선언된 타입을 가지므로, 그 유효 타입은 항상 float다. memcpy로 덮어쓰는 것은 합법이지만 유효 타입은 바뀌지 않으므로, f를 float로 읽는 것은 합법이다. 이제 다음을 보자.
int i = 42;
float *fp;
fp = mallof(sizeof(float));
memcpy(fp, &i, sizeof(int));
x = *fp;
free(fp);
겉보기에는 이전 예와 거의 같지만, 이제는 할당된 메모리를 사용하고 있다. 그러나 이 코드는 UB다. 할당된 메모리가 memcpy에 의해 int 유효 타입을 얻고, 그 뒤 float로 역참조하는 것은 UB이기 때문이다.
어떤 객체든 유효 타입과 무관하게 문자 타입으로 읽고 쓸 수 있다. 즉 다음은 합법이다.
int i = 42;
char *p;
p = (char *)&i;
x = *p;
여기서 i는 int 유효 타입을 가지며, 문자 타입(char)으로 접근하는 것은 합법이다.
memcpy와 memove에 대한 특별 규칙은 문자 복사에도 적용된다. 다음을 보자.
for(i = 0; i < length; i++)
((char *)p)[i] = ((char *)x)[i];
p가 할당된 메모리를 가리킨다고 가정하면, 이 연산은 값과 유효 타입 모두를 x가 가리키는 메모리에서 복사한다.
이제 다음을 보자.
for(i = 0; i < length; i++)
((char *)p)[i] = ((char *)x)[i] + 0;
이제는 단지 바이트를 복사하는 것이 아니다. 따라서 특별 규칙이 더 이상 적용되지 않으며, p가 가리키는 메모리는 x가 가리키는 무엇이든의 유효 타입이 아니라 char 유효 타입을 갖게 된다.
문자 타입 예외의 흥미로운 점 하나는, 이것이 바이트가 아니라 문자 타입에 대한 예외라는 것이다. 따라서 uint8_t는 포함되지 않지만 wchar_t는 포함된다.
union이 메모리의 유효 타입을 포함하고 있다면, union을 사용한 메모리 접근은 모두 합법이다. 다음을 보자.
union{
int i;
float f;Â
}*u;
float f = 3.14;
u = &f;
x = u->i;
이 경우 u가 가리키는 메모리는 선언된 유효 타입 int를 갖고 있고, u가 int를 포함하는 union이므로 i 멤버를 사용한 접근은 합법이다. 여기서 주목할 점은 union의 f 멤버는 전혀 사용되지 않으며, 단지 유효 타입과 호환되는 타입의 멤버가 있다는 요구를 만족시키기 위해 존재한다는 것이다.
즉 유효 타입을 알 수 없는 메모리에 접근할 때는, 모든 타입을 담는 "union condom"을 언제나 사용할 수 있다.
int function(void *p)
{
union{ // C98 타입만
char c;
short s;
int i;
long l;
long long ll;
float f;
double d;
 }*uber_union;
 u = p;
 return uber_union->i;
}
memcpy, memove, 문자 타입은 유효 타입 시스템을 수용하기 위해 특별한 성질을 갖지만, 많은 다른 함수는 그렇지 않다. 표준 라이브러리 안에서도 문제가 될 수 있는 함수 범위에는 calloc, memset, fwrite 같은 것이 포함된다. 다음을 보자.
float f = 3.14;
fwrite(&f, sizeof f, 1, stream );
fwrite가 문자 타입으로 f에 접근하기만 한다면 이것은 합법이어야 한다. 마찬가지로:
float f;
fread(&f, sizeof f, 1, stream );
x = f;
도 합법이다. 선언된 타입에 쓰는 것은 유효 타입을 바꾸지 않으므로, fread가 f에 쓸 때 어떤 타입을 사용하는지는 중요하지 않다. 반면 다음을 보자.
float *fp;
fp = malloc(sizeof *fp);
fread(fp, sizeof *fp, 1, stream);
x = *fp;
이것은 거의 확실히 UB다. fread가 메모리에 어떤 유효 타입을 부여하는지는 알 수 없지만, 표준이 일정 수의 문자를 쓴다고 말하므로 char 타입일 것이라 짐작할 수 있다. fread는 자신이 쓰는 포인터의 타입을 알 방법이 없으므로, 이 경우 메모리에 float 유효 타입을 부여해야 한다는 사실을 알 길이 없다.
fread 출력의 유효 타입은 알 수 없으므로, 안전하게 읽는 유일한 방법은 문자 타입이나 union을 사용하는 것이다. memset도 마찬가지일 것이다.
아직 답하지 않은 질문 하나는, 유효 타입이 struct와 union을 어떻게 다루는가이다. union을 사용해 유효 타입과 다른 타입으로 메모리에 접근할 수 있다는 것은 이야기했지만, union이나 struct로 선언되지 않은 메모리에 쓸 때 그 메모리는 어떤 유효 타입을 얻는가? 멤버의 유효 타입을 얻는가, 아니면 struct/union의 유효 타입을 얻는가?
표준은 이에 대해 아무 말도 하지 않지만, 분명 매우 중요한 문제다. 명세에서 답을 찾을 수는 없다. 하지만 우리는 합리적 추론을 통해 논리적 결론에 이를 수 있다. 다시 말하지만, 이것은 내 표준 해석이며 다른 사람들은 다르게 읽을 수 있다.
먼저 struct 멤버부터 보자.
구조체의 멤버는, 상상해 보면 멤버의 유효 타입, struct의 유효 타입, 혹은 둘 다를 동시에 가질 수도 있다.
다음을 보자.
struct{
int i;
}s;
s.i = 5;
ip = &s.i;
x = *ip;
이것이 UB가 되도록 의도된 것은 아니라고 생각한다. 구조체 멤버는 포인터로 접근할 수 있어야 한다. 이것이 불가능하다면 C 코드의 대다수는 동작하지 않을 것이다. 그러므로 C에서 이것이 가능하길 의도했다는 점은 분명히 추론할 수 있다. 즉 그것은 int 유효 타입을 가지거나, int와 struct 둘 다를 동시에 가져야 한다. 명세는 메모리가 둘 이상의 유효 타입을 가질 수 있다고 말한 적이 없으므로, 그냥 int일 가능성이 더 높다고 생각한다. 따라서 답은: struct는 멤버의 유효 타입을 가진다.
그렇다면 union도 같을까? 보자. 다음을 보자.
union{
int i;
float f;
}*u;
u->i = 5;
x = u->f;
이 역시 합법이어야 한다. 값을 그 유효 타입을 "포함하는" union으로 접근할 수 있다는 점을 고려하면, 활성 타입이 int라면 이것은 합법일 것이다. 유효 타입이 union이라도 같은 타입으로 접근하므로 역시 가능하다. 대신 다음을 보자.
union{
int i;
float f;
}*u;
u->i = 5;
fp = &u->f;
x = *fp;
이것은 UB라는 데 어느 정도 합의가 있는 것 같고, 실제 컴파일러에서 이런 코드가 깨지는 예도 본 적이 있다. *fp는 호환 타입으로 접근해야 하는데, int도 union도 호환 타입이 아니다. 그럼 다음을 보자.
union{
int i;
float f;
}*u;
u->i = 5;
ip = &u->i;
x = *ip;
이것은 동작해야 한다고 생각한다(다만 누구에게도 이 동작에 의존하라고 권하고 싶지는 않다). 이것은 유효 타입이 실제로 int라는 뜻일 것이다.
따라서 내 결론은 다음과 같다. struct와 union 멤버는 멤버의 유효 타입을 가진다.
이 문서를 읽고 나면, 아마 당신이 작성한 코드까지 포함해 엄청나게 많은 C 코드가 UB이며 따라서 망가졌다는 사실에 공포를 느낄 수 있다. 그러나 어떤 것이 기술적으로 UB라고 해서, 컴파일러가 그 사실을 이용해 코드를 깨뜨리려 든다는 뜻은 아니다. 대부분의 컴파일러는 코드를 깨뜨리기보다 컴파일하고 싶어 한다. 거의 아무도 이 규칙을 이해하지 못하기 때문에, 컴파일러는 프로그래머에게 많은 여지를 준다. 기술적으로 이 규칙을 깨는 많은 코드가 실제로는 결코 문제를 일으키지 않는다. 모든 코드가 항상 이 규칙을 100% 준수한다고 가정할 정도로 미친 컴파일러는 사실상 사용자들에게 망가진 컴파일러로 간주될 것이기 때문이다. fwrite를 사용해 구조체를 채워 넣는다면, 실제로는 괜찮다. 제정신인 컴파일러라면 그런 코드를 깨뜨리지 않을 것이다.
문제는 구현이 사용자에게 여지를 주지 않는 것이 아니라, 얼마나 많은 여지가 주어지는지가 불분명하다는 점이다.
많은 컴파일러(Visual Studio 같은)는 이 규칙을 그냥 무시하고, 원하는 만큼 깨뜨리게 놔둔다. gcc와 llvm 같은 다른 컴파일러는 no-strict-aliasing 같은 옵션을 제공해, 이런 규칙에 의존하는 최적화를 꺼 버릴 수 있게 한다. 많은 프로젝트(가장 유명하게는 Linux kernel)와 보안 지침은 이 문제를 아예 우회하기 위해 no-strict-aliasing을 요구한다. 확신이 없거나 안전하고 싶다면, 특히 경험 수준이 다양한 큰 팀에서 일한다면, 이런 옵션 사용을 권한다.
확신이 없거나 안전하고 싶다면, 나는 다음을 권한다.
memset(&p 0x0, sizeof(void *))는 NULL이 되리라는 보장이 없다. 다만 때로는 성능을 위해 memset으로 메모리를 "미리 프라이밍"한 뒤 초기화하는 것이 유리할 수 있다.)C 표준에 따르면 배열 밖을 읽는 것은 언제나 정의되지 않은 동작이다. 다음을 보자.
#include <stdio.h>
typedef struct{
float array[1];
int value;
}MyStruct;
void test(MyStruct *s, int index)
{
int i;
i = s->value;
s->array[index] = 3.14;
s->value = i;
}
컴파일러는 여기서 i 변수의 읽기와 쓰기를 최적화로 없앨 수 있다. 배열 접근이 value 멤버 값을 바꿀 수 없기 때문이다. 이 함수를 0이 아닌 다른 index로 호출하는 것은 정의되지 않은 동작이다.
하지만 C89가 최신 표준이던 시절, 많은 사용자들은 의도적으로 struct에 더 많은 메모리를 할당하고, 마지막 멤버를 의도적으로 덮어써서 가변 크기 멤버처럼 썼다. 다음은 이 요령으로 텍스트 타입을 구성하는 예다.
typedef struct{
int string_length;
char string[1];
}MyStringType;
MyStringType *c_string_to_my_string_type(char *c_string)
{
MyStringType *s
int i;
for(i = 0; c_string[i] != '\0'; i++);
s = malloc((sizeof *s) + sizeof(char) * (i - 1));
s->string_length = i;
for(i = 0; c_string[i] != '\0'; i++)
s->string[i] = c_string[i];
}
이것은 구조체와 배열을 하나의 할당으로 묶어 성능을 높이는 매우 편리한 방법이다. 이를 위해 어떤 컴파일러들은 배열의 마지막 멤버가 길이 0이어도 되는 확장을 지원하기 시작했다. 이것은 할당을 단순화하고, 그 배열이 유연 배열 멤버임을 더 분명히 한다. C99는 이 패턴을 인정해, 빈 대괄호를 쓰는 약간 다른 문법으로 표준화했다.
기존 코드가 존재하므로 모든 컴파일러는 세 가지 유연 멤버 선언을 모두 지원한다.
a[] /* >= c99 */
a[0] /* 확장 */
a[1] /* C89 "해킹" */
이 중 가장 신뢰할 수 있는 것은 [1]이다. 기술적으로 UB이긴 하지만, 모든 컴파일러가 이런 스타일의 레거시 코드를 지원하고 싶어 하며, 확장 지원 없는 C89 컴파일러에서도 동작하기 때문이다. 하지만 gcc의 -fstrict-flex-array 같은 옵션은 이런 종류의 유연 배열 멤버를 허용하지 않을 수 있고, 린터와 다른 정적 분석 도구들이 문제로 표시할 수 있다.
따라서 유연 배열 멤버를 만드는 가장 신뢰할 수 있는 방법은, 언어 버전과 컴파일러 기능을 감지하는 define을 사용하는 것이다. 다음 define을 권장한다.
#if __STDC_VERSION__ >= 199901L
#define DEPENDABLE_C_FLEXIBLE_ARRAY_MEMBER
#define DEPENDABLE_C_FLEXIBLE_ARRAY_MEMBER_SUBTRACT 0
#elseif defined(__clang__) || defined(__GNUC__)
#define DEPENDABLE_C_FLEXIBLE_ARRAY_MEMBER 0
#define DEPENDABLE_C_FLEXIBLE_ARRAY_MEMBER_SUBTRACT 0
#elseif
#define DEPENDABLE_C_FLEXIBLE_ARRAY_MEMBER 1
#define DEPENDABLE_C_FLEXIBLE_ARRAY_MEMBER_SUBTRACT 1
#endif
이 define을 사용하는 예는 다음과 같다.
typedef struct{
int string_length;
char string[DEPENDABLE_C_FLEXIBLE_ARRAY_MEMBER];
}MyStringType;
s = malloc((sizeof *s) + sizeof(char) * (i - DEPENDABLE_C_FLEXIBLE_ARRAY_MEMBER_SUBTRACT));
확신이 없거나 안전하고 싶다면, 나는 다음을 권한다.
memset(&p 0x0, sizeof(void *))는 NULL이 되리라는 보장이 없다. 다만 때로는 성능을 위해 memset으로 메모리를 "미리 프라이밍"한 뒤 초기화하는 것이 유리할 수 있다.)원칙적으로 모든 UB는 피해야 한다. UB가 무엇을 하는지 안다고 생각하는 것은 매우 위험하다.
UB를 피할 방법이 있다면 언제나 그렇게 해야 한다. UB가 C에서 어떻게 작동하고 왜 신뢰할 수 없는지 깊이 있게 알고 싶다면 이 페이지의 UB 문서를 보라. 그렇다 해도, 기술적으로 UB이거나 표준에 결함 또는 불명확함이 있어 어떤 것들이 누락 때문에 UB가 되거나, 그것이 UB인지 아닌지를 두고 이견이 있는 경우가 몇 가지 있다.
다음은 모든 알려진 구현에서 그 동작이 신뢰 가능하리라 기대할 수 있는 UB 사례 목록이다. 이 목록은 특히 C 표준을 구현하는 사람들에게 중요하다. 표준상으로는 UB일지 몰라도, 많은 코드가 여기에 의존하기 때문이다.
표준은 struct의 첫 멤버 앞에는 패딩이 있을 수 없다고 선언한다. 이것이 struct에 대한 포인터와 struct의 첫 멤버에 대한 포인터가 같다는 뜻인지 표준은 명확하지 않다. 이론적으로는 포인터 안에 타입 정보가 인코딩되는 아키텍처를 상상할 수 있고, 그런 경우 둘은 달라질 수 있다. 하지만 실제로는 알려진 모든 구현에서 struct에 대한 포인터는 첫 멤버에 대한 포인터와 동등하며, 다형성을 구현하기 위해 많은 코드가 여기에 의존한다. 따라서 이것은 신뢰 가능하다고 볼 수 있다. union에도 같은 논리가 적용될 수 있지만, union 멤버의 포인터를 취하는 것은 신뢰할 수 없다.
이는 피할 수 있음에도 기술적으로 UB이지만 신뢰 가능하다. 유연 배열 멤버 절을 보라.
매크로로 표준 함수를 대체하는 것은 UB다.
#define my_printf printf
그러나 이 관행은 신뢰 가능하며, 이 기법을 사용해 표준 라이브러리 함수의 디버그 버전을 만드는 것은 좋은 관행이다. (추가 항목의 메모리 디버깅 참조)
C 표준은 프로그램이 main을 진입점으로 가져야 한다고 정의하며, main 함수가 없는 프로그램은 UB라고 한다. 당연히 많은 플랫폼은 자기만의 진입점을 정의하고, 모든 플랫폼은 라이브러리 개발에 C를 사용할 수 있게 하며, 이 경우 사용자는 자기 진입점을 선언할 수 있다. main이 없는 라이브러리를 C로 작성하는 것은 신뢰 가능하다.
6.5.7.8절은 포인터 산술에 대해 다음과 같이 말한다.
"이 연산자들의 목적상, 배열의 원소가 아닌 객체를 가리키는 포인터는, 그 객체 타입을 원소 타입으로 하고 길이가 1인 배열의 첫 원소를 가리키는 포인터와 똑같이 동작한다."
이제 다음을 보자.
int *p, *p2;
p = malloc((sizeof *p) * 3);
p2 = p + 2;
p는 배열을 가리키지 않으므로, 위 정의에 따르면 길이 1인 것으로 취급된다. 따라서 객체를 2칸 넘어선 포인터를 만드는 것은, 할당 범위 안에 있더라도 정의되지 않는다. 이는 분명 표준의 결함이며, 할당된 메모리에 대한 포인터는 배열인 것처럼 다뤄져야 한다.
C에서 함수의 유효 타입은 선언이나 접근에 의존하지, 포인터 타입에 의존하지 않는다. 포인터가 함수 매개변수로 전달될 때, 포인터 타입을 사용해 그 포인터를 접근해도 유효 타입 규칙을 위반하지 않는다고 가정하는 것은 신뢰 가능하다. 다음은 유효 타입 규칙을 위반하므로 UB다.
int function()
{
int *ip;
float *pf;
ip = malloc(sizeof *ip);
*ip = 42;
*fp = (float *)ip;
printf("%f", *fp);
*fp = 3.15;
return *ip;
}
메모리에 int 유효 타입이 부여된 뒤 float로 읽히기 때문에 이 코드는 UB다. 이후 메모리에 float로 쓰고 int로 다시 읽는 것도 UB다. (이 예는 float와 int가 같은 크기인 플랫폼을 가정한다. 그것이 사실인지는 플랫폼 정의다.) 마찬가지로 다음도 같은 이유로 기술적으로 UB다.
void other_function(float *fp)
{
printf("%f", *fp);
*fp = 3.15;
}
int function()
{
int *ip;
ip = malloc(sizeof *ip);
*ip = 42;
other_function((float *)ip);
return *ip;
}
그러나 실제로 나는 이런 코드를 신뢰 가능하다고 보겠다. 함수 경계를 넘어 메모리를 전달하는 것은, 호출자와 피호출자 모두 그 공유 메모리가 올바른 실행에 필요한 유효 타입을 가진다고 가정하도록 강제한다. 즉 컴파일러는 other_function이 float 유효 타입을 가져 읽을 수 있는 메모리를 받는다고 가정하고, function은 other_function이 돌려준 메모리가 int 유효 타입을 가진다고 가정한다. 왜냐하면 그 방식으로 읽을 것이기 때문이다. 이를 표현하는 한 방법은, 함수 경계를 넘는 모든 메모리는 유효 타입이 지워진다고 말하는 것이다. 이는 표준은 아니지만, 나는 신뢰 가능하다고 본다.
C 표준은 C11의 스레딩과 atomics 외의 어떤 스레딩 기능을 사용하는 프로그램도 UB라고 본다. 즉 pthreads(POSIX)를 사용하는 어떤 프로그램도 기술적으로는 UB다. Posix threads 같은 플랫폼 고유 스레딩 기능은 실제로 C11 스레드보다 훨씬 더 신뢰 가능하고, 더 많이 지원되며, 더 전투 검증되었다.
많은 사람들이 C에서 메모리 손상 때문에 고생한다. 나는 거의 그렇지 않은데, 주된 이유 중 하나는 수년간 사용해 온 메모리 디버거 패턴 때문이다. 이 메모리 디버거는 어떤 C 프로젝트에도 추가할 수 있는 include와 c 파일 형태를 가진다. 완전히 플랫폼 독립적인 C89이며, 포함하는 것 외에는 디버깅할 코드에 수정이 필요 없다.
개념은 매우 단순하다. 매크로를 사용해 모든 할당과 해제를 가로채 메모리를 추적하는 것이다. 이 문서에서는 이 기법의 내 구현체인 "Forge"를 설명한다. 자유롭게 사용할 수 있지만, 몇 시간 안에 당신만의 변형도 쉽게 구현할 수 있다.
우리가 정의하는 매크로는 다음과 같다.
#define malloc(n) f_debug_mem_malloc(n, __FILE__, __LINE__) /* malloc 대체 */
#define calloc(n, m) f_debug_mem_calloc(n, m, __FILE__, __LINE__) /* calloc 대체 */
#define realloc(n, m) f_debug_mem_realloc(n, m, __FILE__, __LINE__) /* realloc 대체 */
#define free(n) f_debug_mem_free(n, __FILE__, __LINE__) /* free 대체 */
이 매크로들을 헤더 파일에 넣고, 프로젝트의 모든 c 파일에서 include한다. 또한 이 매크로들을 ifdef로 감싸 필요에 따라 켜고 끌 수 있게 한다. 이 시스템은 디버깅 전용이므로, 디버깅하지 않을 때는 꺼져 있어야 한다. 이 접근의 장점은 코드를 디버깅 시스템에 맞춰 작성하거나 특별한 메모리 할당기를 쓸 필요가 없다는 점이며, 따라서 기존 프로젝트에 적용하거나 메모리 관리 테스트가 끝난 뒤 프로젝트에서 제거하기 쉽다.
이 매크로들은 표준 할당 함수 호출을 우리의 디버그 코드로 우회시킨다. 또한 호출이 일어난 파일과 줄 번호를 함께 전달함으로써, 모든 할당과 해제가 어디서 발생하는지 목록화할 수 있다. 이것은 꽤 유용한 도구들을 만들 수 있게 해 준다.
초기화되지 않은 메모리를 더 쉽게 찾기 위해, 이 시스템은 malloc과 realloc으로 할당된 모든 메모리를 항상 0xCD로 초기화한다. 일부 개발 환경도 디버그 모드에서 이렇게 하지만, 이 시스템은 모든 플랫폼에서 같은 기능을 제공하며, 릴리스 모드에서만 나타나는 문제를 추적해야 할 때도 켤 수 있다.
애플리케이션의 전체 메모리 소비를 추적하려면 다음을 호출할 수 있다.
size_t f_debug_mem_consumption(void);
그러면 할당된 메모리 총합을 반환한다(Forge 내부에서 사용하는 메모리는 제외되며, Forge 자체가 내부적으로 소비하는 메모리는 상당할 수 있다).
시스템이 모든 할당을 추적하므로, 할당 요약 목록을 출력할 수 있다.
void f_debug_mem_print(unsigned int min_allocs);
각 출력은 소비된 메모리 양, 수행된 할당 횟수, 그리고 그중 몇 개가 이후 해제되었는지를 함께 보여 준다. 이를 통해 메모리가 어디서 할당되는지, 코드의 어느 부분이 가장 많은 메모리를 소비하는지, 그리고 메모리 누수를 매우 쉽게 식별할 수 있다. 내 구현에는 최소 할당 횟수 매개변수가 있어, 일정 횟수 이상 호출된 할당만 출력하도록 제한할 수 있다. 이는 시작 시 한두 번만 일어나는 할당을 무시하는 간단한 방법으로 유용하다. 또한 다음을 호출할 수도 있다.
void f_debug_mem_log(void *file_pointer);
유효한 FILE 포인터(또는 비활성화를 위한 NULL)를 넘기면, 모든 할당과 해제의 로그를 출력한다. 메모리 누수를 찾는 또 다른 방법은 다음을 호출하는 것이다.
void f_debug_mem_check_heap_reference(unsigned int minimum_allocations);
이 함수는 힙 메모리 안에서 모든 할당된 포인터를 찾아, 포인터를 찾을 수 없는 할당 메모리를 보고한다. 이것은 거짓 양성과 거짓 음성을 낼 수 있다. 다른 데이터를 저장하는 메모리가 우연히 할당된 포인터와 같은 비트 조합을 가질 수 있고, 힙에서 발견되지 않은 할당 포인터가 사실은 스택에 보관되어 있을 수도 있다. 그래도 메모리 누수를 선별하는 데는 유용하다. minimum_allocations 매개변수는, 지정한 횟수보다 많이 할당하는 시스템에 대해서만 누수를 검사하도록 제한하게 해 준다.
시스템은 모든 메모리를 추적하므로 다양한 메모리 손상을 감지할 수 있다. 이를 위해 시스템은 메모리 목록을 순회하며 오류를 검사해야 한다. 다음을 호출하면 된다.
boolean f_debug_mem_check_bounds();
문제가 언제 발생하는지 좁혀 가기 위해 이 함수를 원하는 만큼 여러 곳에 배치할 수 있다. 또한 define FORGE_MEMORY_CHECK_ALWAYS를 활성화하면 malloc, calloc, free, realloc이 호출될 때마다 f_debug_mem_check_bounds를 실행하게 할 수 있다. 할당이 많으면 성능은 크게 떨어지지만, 문제를 빠르게 좁혀 가는 데 매우 효과적이다.
버퍼 오버런을 잡기 위해 시스템은 항상 초과 할당을 수행하고, 모든 할당 주변 메모리를 마법 숫자(0xCF)로 채운다. f_debug_mem_check_bounds가 호출될 때마다 모든 할당 주변 메모리를 검사하여, 그 메모리에 쓰기가 발생했는지 확인한다. 이는 거의 모든 힙 버퍼 오버런 버그를 잡아낸다. 추가로 할당할 메모리 양은 define FORGE_MEMORY_OVER_ALLOC을 바꿔 조절할 수 있다. 클수록 더 많은 것을 잡지만 더 느리고 낭비도 커진다. define FORGE_MEMORY_PRE_PADDING을 사용하면 그 메모리 중 얼마나 많은 양을 할당 앞쪽에 둘지 정할 수 있고, 나머지는 뒤쪽에 간다. (일반적으로 버퍼 언더런보다 오버런이 훨씬 더 흔하므로, 할당 뒤쪽 패딩을 더 많이 두는 것이 좋다.) 이 도구는 힙에서 버퍼 오버런을 잡는 데 너무 강력해서, 나는 힙 메모리가 스택 메모리보다 덜 오류가 나기 쉽다고 생각하기 시작했다. (보안 관점에서는 스택 메모리 배치가 대체로 알려져 있으므로 공격에 더 취약하다.)
define FORGE_DOUBLE_FREE_CHECK가 켜져 있으면, 시스템은 모든 해제된 포인터 목록을 보관하고, 해제하려는 포인터를 이 목록과 대조한다. 이것으로 어떤 이중 해제도 효과적으로 감지할 수 있다.
메모리가 해제될 때마다 항상 마법 숫자(0XCD)로 채워지므로, 우연히 다시 읽더라도 유용한 데이터를 얻지 못하게 된다. 하지만 해제된 메모리를 가리키는 포인터가, 그 메모리가 다시 할당된 뒤에도 여전히 살아 있는 버그는 찾기 어려울 수 있다. 포인터가 다시 유효한 메모리를 가리키기 때문이다. 이런 버그를 찾기 위해 define FORGE_USE_AFTER_FREE_CHECK를 켜면, free에 전달된 메모리를 실제로 해제하지 않고 마법 숫자(0XCD)로만 지운다. 그 뒤 f_debug_mem_check_bounds를 호출할 때마다, 해제된 메모리가 덮어써졌는지 검사한다. 당연히 이것은 많은 메모리를 소비한다.
가상 메모리를 사용하는 현대 컴퓨터는 메모리 할당에 실패하는 일이 매우 드물다. 대부분 할당이 실패할 때는 사용자가 언더플로 같은 실수의 결과로 터무니없이 큰 메모리를 할당하려 했기 때문이다. FORGE_MEMORY_NULL_ALLOCATION_ERROR를 켜면, 시스템은 할당 실패가 발생할 때마다 이를 표시하고 표준 출력에 오류를 출력한다.
잘못해서 재할당된 포인터를 계속 사용하는 코드를 잡기 위해, 모든 realloc은 malloc, memcpy, free로 바뀌며, 해제된 메모리는 다른 해제와 같은 방식으로 처리되어 use-after-free와 double free를 막는다. 시스템은 또한 realloc이 유효한 할당에 대해서만 수행되는지 검사하며, 포인터가 할당의 기본 포인터가 아니면 오류를 낸다.
시스템은 스택 위 변수의 포인터를 잡고, 어떤 포인터가 주소 공간상 그 포인터에 가까운지를 측정함으로써 어떤 포인터가 스택을 가리키는지 대략 추정할 수 있다. 다음을 사용해 힙 할당 메모리 안에서 스택을 가리키는 것으로 의심되는 포인터를 찾게 할 수 있다.
f_debug_mem_check_stack_reference();
이는 할당된 메모리가, 이미 수명이 끝났을 수 있는 스택 포인터를 참조하는 버그를 추적하는 데 매우 유용하다. 이 테스트는 거짓 양성을 낼 수 있다. 특히 32비트 머신에서 힙과 스택이 주소 공간상 가까이 있을 수 있고, 단순한 데이터가 스택 근처 포인터와 같은 비트 조합을 가질 수도 있다. 그럼에도 어려운 종류의 버그를 추적할 때는 매우 유용하다.
스택의 크기와 위치가 알려진 플랫폼이라면, 다음을 호출할 수 있다.
void f_debug_mem_stack_pointer_set(void *lowest_stack_pinter, size_t stack_size_in_bytes);
스택을 정의해 두면, 스택 위치를 사용하는 모든 기능이 더 정확해지고, f_debug_mem_check_heap_reference는 스택 안에서도 할당에 대한 참조를 찾을 수 있게 된다.
디버거는 메모리를 질의하는 데도 쓸 수 있다. 다음을 호출함으로써:
void *f_debug_mem_query_origin(void *pointer, unsigned int *line, char **file, size_t *size);
사용자는 어떤 포인터가 어디서 할당되었고 얼마나 크게 할당되었는지를 질의할 수 있다. 이 함수는 할당의 기본 포인터도 반환한다. 포인터를 forge 인덱스에서 찾지 못하면 NULL을 반환한다. 이는 할당의 출처를 추적하는 데 매우 유용하다. 또한 다음을 호출할 수 있다.
boolean f_debug_mem_query_is_allocated(void *pointer, size_t size, boolean ignore_not_found);
메모리 범위가 유효한 할당 안에 있는지 질의할 수 있다. 유효하면 TRUE, 범위를 벗어났거나 기존 할당/해제된 할당 일부이면 FALSE를 반환한다. 포인터를 찾지 못하면, 인자 ignore_not_found를 반환한다.
forge 디버거 매크로가 켜진 상태에서 할당된 포인터는, free나 realloc도 forge 디버거 매크로로 감싸진 경우에만 해제할 수 있다(FORGE_MEMORY_PRE_PADDIG가 0이 아니라면), 그렇지 않으면 메모리 안전 시스템에서는 대개 충돌을 일으킨다. 하지만 forge 디버거 매크로 밖에서 할당된 포인터는, 경고와 함께 forge 매크로로 free나 realloc할 수 있다. 이것은 모든 코드에 forge 헤더를 포함시키기 어려운 상황에서도 forge를 쓸 수 있게 하기 위한 것이다. forge가 모르는 포인터가 주어질 때마다, 그것이 스택 포인터일 가능성이 있는지 추정한다.
이 라이브러리는 어떤 의존성이나 플랫폼 고유 코드도 없는 순수한 이식 가능한 "신뢰할 수 있는" C89로 작성되었다. 따라서 기본 상태로는 스레드 안전하지 않다. 스레드 안전하게 만들려면 다음을 호출해야 한다.
void f_debug_mem_thread_safe_init(int (*lock)(void *mutex), int (*unlock)(void *mutex), void *mutex);
이를 통해 디버거에 플랫폼 고유의 lock, unlock 함수 포인터를 넘길 수 있다. POSIX threads를 사용한 예는 다음과 같다.
pthread_mutex_t *mutex;
pthread_mutex_init(mutex, NULL);
f_debug_mem_thread_safe_init(pthread_mutex_lock, pthread_mutex_unlock, mutex);
기술적으로 이 구현에는 알려진 정의되지 않은 동작 사례가 세 가지 있다. 이 도구의 맥락에서는 무해해야 한다. 그렇더라도 forge를 릴리스 빌드에서 쓰는 것은 권장하지 않으며, 디버깅 용도로만 사용하는 것이 좋다. (이 문제들이 실제로 문제를 일으키는 플랫폼/구현을 찾는다면 꼭 알려 달라. 매우 흥미로울 것이다.) 내장 함수나 표준 라이브러리 함수를 대체하는 매크로를 사용하는 것은 C 표준에서 기술적으로 UB다. 나는 이것이 문제가 되는 구현을 본 적이 없다. 알려진 모든 구현은 여기서 기대한 대로 동작한다. 두 번째 UB는, 구현이 서로 다른 객체에서 나온 포인터를 비교할 때 <와 >를 사용한다는 점이다. 이는 할당된 모든 바이트의 주소와 비교하면 해결할 수 있지만, 엄청나게 느릴 것이다. 세 번째는, 어떤 것이 스택 안에 있는지 추정할 때 할당 범위 밖의 포인터를 만든다는 점이다. 이론적으로는 이것이 wrap된 포인터를 만들 수 있어 출력이 신뢰할 수 없게 될 수 있다. 32비트 이하 포인터를 가진 플랫폼에서는 문제가 될 수 있으며, 이 경우 스택 포인터를 직접 얻는 것을 권한다. 기술적으로 코드는 여전히 UB지만, 문제를 일으킬 가능성은 훨씬 낮아진다.
UB 사례는 소스 코드에서 표시해 두었다.
두 언어 모두 자신이 의도한 용도에서는 훌륭하게 수행한다. 결국 중요한 것은 작업에 맞는 언어를 고르는 일이다. Rust는 C에 반대하는 논쟁에서 사용되도록 설계되었고, C는 소프트웨어를 개발할 때 사용되도록 설계되었다.
나는 C의 _Generic 키워드가 프로그래머가 쓰라고 발명된 것이 아니라고 확신한다. 그것은 ISO C wg14를 끝없는 제안, 논쟁, 모서리 사례로 바쁘게 만들기 위해 발명된 것이다.
고급 언어의 장점은 사람들이 컴퓨터가 어떻게 동작하는지 몰라도 소프트웨어를 만들 수 있다는 것이다. 고급 언어의 단점은 이제 컴퓨터가 어떻게 동작하는지 모르는 사람들이 만든 소프트웨어가 많다는 것이다.
나는 "여기서 발명되지 않았음" 증후군은 없다. 이미 필요한 대부분의 것을 작성해 두었다. 내가 겪는 것은 "최근에 발명되지 않았음" 증후군이다. 이미 내가 쓴 라이브러리를 쓸 수도 있지만, 지금은 더 나은 걸 쓸 수 있을 것 같으니까…
나는 C의 register 키워드에 대한 생각을 완전히 바꾸었다. 전에는 그저 힌트일 뿐이라 쓸모없다고 생각했다. 이제는 struct 안에 쓸 수 없어서 쓸모없다고 생각한다.
C에서는 추상화를 쓰기 어렵다. 추상화의 비용이 너무도 명확하게 보이기 때문이다. 다른 언어에서는 뒤에서 너무 많은 일이 벌어지고 있어서 차이를 잘 느끼지 못하므로 추상화가 공짜처럼 보인다.
왜 사람들은 코드 품질 == 유지보수성이라고 생각할까? 세상에서 "품질"이라는 단어는 무언가가 고장 나지 않는다는 뜻이다. 끊임없는 유지보수가 필요하지만 분해하기는 쉬운 자동차/집/냉장고를 "고품질"이라 부르지는 않을 것이다. 그냥 형편없는 것이다.
처음에는 결과만 원하고, 끝에 가서는 제어만 원하게 된다.
사람들은 이렇게 말할 것이다. "자기만의 OS/Compiler/GameEngine/Browser/Threading/Language.... 만들려고 하지 마. 어려워." 나는 이렇게 말한다. 어려운 것을 함으로써 어려운 것을 배워라. 세상은 어려운 일을 할 수 있는 사람을 가치 있게 여기고, 채용하고, 필요로 한다.
프로그래밍의 추상화는 대개 사용자가 무슨 일이 일어나는지 알고 싶어 하지도, 신경 쓰고 싶어 하지도 않는다는 전제 위에 세워진다. 나는 그것이 함수인지 매크로인지 알아야 하고, 그 타입이 값인지 포인터인지 struct인지도 알아야 한다. 그래서 추상화는 결국 내가 파헤쳐야 할 쓰레기일 뿐이다.
당신이 큰일을 해낼 사람이라고 믿을 만큼 대담해야 하며, 동시에 그 목표에 도달하기 위해 끊임없이 스스로를 수정할 만큼 자기비판적이어야 한다. 둘 중 하나는 쉽다. 둘을 결합하는 것은 드물다.
2023년, 한 정예 프로그래밍 부대가 하지도 않은 죄로 Rust 군중에게 체포되어 감옥에 갔다. 이 남자들은 곧 최고 보안 수용소를 탈출해 인터넷 지하 세계로 숨어들었다. 오늘날 이들은 여전히 정부의 수배를 받으면서 시스템 개발자로 살아가고 있다. 성능 문제가 있다면, 다른 누구도 도와줄 수 없고, 그들을 찾을 수 있다면, 어쩌면 당신은... C-Team을 고용할 수 있을지도 모른다.
OpenSSL, Apache, SQLite, Curl, CPython, FFMpeg, PHP, GNU 자유 소프트웨어 모음, 대부분의 OS 커널, 대부분의 파일 시스템처럼 세상에서 가장 신뢰받는 소프트웨어는 모두 C로 작성되었다. 다른 어떤 언어도 C만큼 많은 안전 및 보안 핵심 소프트웨어를 전 세계에 배포해 내지 못했다. 진화론적 관점에서 보면, 보안 핵심 C 프로젝트가 다른 어떤 언어로 작성된 보안 핵심 프로젝트보다 훨씬 높은 생존율을 가진다는 것은 분명하다.
그럼에도 많은 보안 연구자들은 C가 안전하지 않은 언어이며, 보안 핵심 소프트웨어에는 피해야 한다고 주장한다. 이들은 C의 몇몇 기능, 특히 필수 경계 검사 부재 때문에 C가 안전하지 않다는 것을 당연시한다. 하지만 분명 이 기능이 C가 보안 분야에서 가장 성공적인 언어가 되는 것을 막지 못했음에도, 이 특징 하나가 C를 사용할 때의 다른 가능한 이점을 압도한다고 가정하는 것은 매우 비과학적이다. 가정은 한 가지를 말하는데 현실 세계의 경험은 완전히 다른 것을 말한다면, 가정을 다시 평가할 때다.
보안 연구자들이 과학적 방법에 기반한 연구에 관심이 있다면, 분명히 C가 왜 그렇게 많은 성공적인 보안 핵심 프로젝트를 만들어 냈는지를 조사하고 싶어 해야 한다. 명백한 증거를 무시한 채 C를 불안전한 언어라고 일축할 것이 아니라 말이다. 보안 연구자들은 누군가가 생각하기에는 성공하지 못했어야 할 이유가 있음에도, 왜 C가 그렇게 성공적이었는지에 대한 다양한 설명을 조사해야 한다. C를 언어로 택했고, 그 뒤 지구(와 다른 행성들)에서 가장 신뢰받는 소프트웨어 일부를 만들어 낸 사람들을, 그저 자기들이 뭘 하는지 모르는 사람들로 치부하는 것은 무례하고 무지하며, 진실을 찾고 진전을 이루려는 과학적 논의의 일부가 되어서는 안 된다.
보안 핵심 소프트웨어에서 C가 왜 그렇게 성공적이었는지에 대한 실제 연구는, 미래의 보안 정책, 가능한 언어 설계, C의 미래를 바라보는 방식, 그리고 전반적으로 소프트웨어 개발을 생각하는 방식에 큰 영향을 줄 수 있다. C 개발자들의 성공을 그냥 무시하거나, 더 나쁘게는 C 개발자들이 소프트웨어 보안에 큰 기여를 하지 않았다고 세상을 속이려 드는 것은, 성공적인 소프트웨어가 어떻게 개발되는지에 대한 귀중한 통찰을 세상으로부터 빼앗는 일이다.
경험 많은 C 개발자로서, 나는 C가 왜 그렇게 성공적이었는지에 대해 더 연구할 가치가 있는 여러 가설을 갖고 있다. 분명히 하고 싶다. 나는 연구자가 아니며, 이 이론들에 대한 정량적 증거를 제시한다고 주장하지도 않는다. 하지만 적어도 철저히 조사해야 할 이론들 중에는 들어간다고 생각한다. 또한 이 이론 목록의 일부로서, 만약 이것들이 사실로 드러난다면 소프트웨어 개발을 어떻게 바라봐야 하는지에 대한 몇 가지 함의도 짚고 싶다.
C는 추상화가 매우 적은 언어다. 이것은 코드를 읽고 추론하기 매우 쉽게 만든다. 코드를 작성하는 어려움은 거의 언제나, 코드가 하는 일과 코드가 하려는 일 사이의 간극을 메우는 데 있다. 따라서 실행의 모든 단계를 따라갈 수 있고, 각 단계가 명시적이라는 것은 큰 도움이 된다. C 코드는 추론하기 쉽고 감사하기 쉬우므로 안전해진다. 대부분의 것이 제자리에서 작성되기 때문에, 이해를 위해 더 큰 시스템에 대한 지식이 덜 필요하다. 추상화가 만드는 이 상호 연결성의 부족은 또한 코드의 한 부분 변화가 다른 부분을 깨뜨리기 어렵게 만든다. C는 장황하고 명시적인 언어이며, 이것은 덜 편리한 프로그래밍 경험을 만들지만, 신뢰할 수 있는 소프트웨어를 만든다. 흥미로운 관찰 하나는, 다른 "안전하지 않은" C 파생 언어인 C++와 Objective-C는 훨씬 덜 신뢰받는 안전 핵심 소프트웨어를 만들어 냈다는 점이다. 이는 C의 단순함과 기능 부족이 기여 요인이라는 뜻일 수 있다. 또 다른 관찰은, C를 사용하는 성공적인 소프트웨어 프로젝트들은 대체로 오래된 버전의 C를 사용하고 기능 사용을 제한한다는 점이다. 최근 Python과 Lua가 Perl 같은 더 복잡한 언어보다 성공한 것도, 기능이 적고 읽기 쉬운 단순한 언어가 실제로 더 신뢰할 수 있음을 시사한다.
언어 설계에서 표현력과 영리함보다 명확성을 우선하는 일반적인 초점은 더 안전하고 더 단순한 코드로 이어질 수 있다. 우리는 컴퓨터 과학에서 추상화를 더 비판적으로 보기 시작해야 한다. C 개발자에게 이것은 복잡한 매크로를 피하고, 길고 표현적인 이름을 사용하는 것을 의미한다. 도구를 개선할 기회도 많다. 컴파일러는 최적화를 위해 코드를 추론하지만, 그 추론을 프로그래머에게 보여 주는 일은 거의 없다. 최적화로 제거된 코드 경로, 값의 가능한 범위에 대한 가정, 메모리 모델 가정 등이 사용자에게 제시된다면 매우 가치 있을 것이다.
많은 C 프로그래머들은 C가 사용하기 즐거운 프로그래밍 언어라고 주장한다. 재미 요소는 프로그래밍에서 과소평가된 요소다. 즐거운 프로그래밍 경험은 더 큰 몰입과 소프트웨어 프로젝트의 장기 유지보수로 이어진다. 약한 소프트웨어 프로젝트라도 오랜 기간 꾸준히 갱신되고 개선된다면, 아무도 개선하려 하지 않는 좋은 소프트웨어 프로젝트를 결국 능가하게 된다. 이 이론을 뒷받침하는 점은, 앞서 언급한 많은 C 프로젝트가 주로 자원봉사자들에 의해 유지되는 오픈소스 프로젝트라는 것이다. 자원봉사 프로젝트가 성공하려면 동기가 핵심이다.
보안 관점에서, 우리는 안전 절차와 프로그래밍의 즐거움 및 민첩성 사이의 올바른 균형을 찾아야 한다. 모든 변경이 지나치게 번거로운 재인증 절차와, 해결해야 할 무의미한 경고와 절차로 이어진다면 소프트웨어는 제대로 유지보수되지 않을 것이다. 위험이 어디에 있는지를 더 잘 선별하고, 그에 맞게 시간을 쓰며, 필요한 변경을 어렵게 만들지 않는 방법을 찾아야 한다.
다른 언어에 대해서도, 재미가 어디에 있는지 생각해 볼 수 있다. C++는 많은 사람이 코드를 설계하는 것을 매우 즐기는 언어다. 복잡한 구조를 만들기 위한 풍부한 기능 집합이 있다. 이런 구조는 발명할 때는 재미있지만 유지보수하기는 덜 재미있다. 그래서 대부분의 C++ 프로그래머는 다시 설계하고 싶지 않은 C++ 코드를 본 적이 거의 없다. 프로젝트가 커지면서도 프로그래밍이 계속 재미있도록 언어를 어떻게 설계할 것인지는 열린 질문이며, 진지하게 탐구되어야 한다.
C는 프로그래머에게 매우 높은 수준의 제어를 준다. 내가 즐겨 말하듯이: "처음에는 결과만 원하고, 끝에는 제어만 원하게 된다." C는 기성 설비와 최소한의 표준 라이브러리만 제공하므로, 대부분의 일을 직접 해야 한다. 즉 C로 프로젝트를 완성했다면, 더 높은 수준 언어로 작성했을 때보다 자신의 소프트웨어를 훨씬 더 완전하게 이해하고 있다는 뜻이다. 이런 깊은 이해는 잠재적인 보안 취약점을 식별하는 데 결정적이다. Linus Torvalds는 한때, 자신이 C를 쓸 때 머릿속으로 실행되는 어셈블리 명령을 볼 수 있다고 말했다.
여러 스타일과 패러다임이 공존하는 C++ 같은 언어와 비교하면, C는 비교적 단순하다. C++, Java 등 많은 다른 언어가 C로부터 문법을 빌려 왔기 때문에, C를 쓰지 않는 많은 프로그래머도 어느 정도는 C를 읽고 이해할 수 있다. 이런 컴퓨팅의 공용어 지위는 핵심 코드에 더 많은 눈이 가게 하므로 C 코드를 더 안전하게 만든다. C++ 같은 언어와 달리, C에서는 언어를 어떻게 프로그래밍해야 하는지에 대한 견해 차이가 적다. 단지 일을 하는 방법이 적기 때문이다. 이것은 또한 더 많은 기여자를 오픈소스 프로젝트가 끌어들이기 쉽게 만든다.
우리는 평범한 코드를 작성하는 데 더 높은 가치를 두고, 영리한 요령이나 새로운 언어 기능을 피해야 한다. 이런 지침에 따라 코드를 평가하는 새 가이드라인과 도구를 만들 수 있다. MISRA와 CERT는 안전과 보안에 초점을 둔 그런 지침이지만, 단순성, 가독성, 이식성에 초점을 둔 추가 지침도 만들 수 있다.
C가 겉보기 보안 결함에도 불구하고 성공적인 이유는, 사용자가 다른 우선순위를 가지기 때문일 수도 있다. 보안을 제칠 수 있는 주요 우선순위는 C의 성능이다. 성능은 실행 시간이 얼마나 걸리는지만의 문제가 아니다. 배터리 수명, 전력 소비, 하드웨어 규모, 냉각 비용, CO2 배출, 그리고 운영 비용과도 직결된다. 대규모 클라우드 운영에서 몇 퍼센트의 성능 저하는 하드웨어, 전력, 냉각을 위한 추가 자본 지출 수억 달러로 이어진다. 데이터 센터만 해도 10년 말까지 전체 발전량의 3~4%를 사용할 것으로 추정되며, 여기에 C 기반 소프트웨어를 실행하는 다른 장치들의 전력 소비도 더해진다. 이 규모에서 효율이 조금만 떨어져도 비용과 CO2 배출은 막대해진다.
신뢰받는 레거시 C 소프트웨어를 버리고, 상당히 덜 검증된 다른 언어로 작성된 새 소프트웨어로 옮기며, 훨씬 더 높은 실행 비용을 감수하고 미래의 더 나은 보안을 약속받는 것은 상당한 신앙 도약이다.
이 이론에 대한 또 다른 근거는, C가 반드시 메모리 불안전할 필요는 없다는 점이다. C 표준은 표준이 정의하지 않은 모든 것에 대해, 구현이 자유롭게 안전하고 문서화된 동작을 정의할 수 있다고 분명히 말한다. UBSan과 Valgrind 같은 그런 구현도 존재한다. 보안을 우선하는 사람이라면 누구나 그런 구현에서 C 소프트웨어를 실행할 수 있다. 그런데도 거의 아무도 그렇게 하지 않는다(디버깅을 제외하면). 이는 대부분의 사용자가 다른 우선순위를 가진다는 뜻이다.
성능과 메모리 사용량 같은 것이 C 소프트웨어의 매우 중요한 속성이라면, 이런 인기가 C 프로젝트에게 더 성숙할 시간, 그리고 보안 문제 같은 다른 문제를 다룰 시간을 주는 것일 수도 있다.
보안 전문가들은 자신들이 사는 세계에서 보안보다 다른 고려 사항이 우선될 때가 많다는 사실을 받아들이는 법을 배워야 한다. 그 예가 대부분 사용자에게 불필요하게 강제된 여러 speculative execution 완화책들이다. 이런 완화책은 아마도 셀 수 없는 수십억의 비용을 초래했을 것이다. 보안이 논의될 때 이런 비용은 드물게 고려되며, 개별 시스템에 미치는 영향은 작을지라도, 집합적으로, 그리고 여러 보안 보호 계층 위에 다시 쌓이면, 전 세계적 규모에서는 매우 큰 영향을 미친다.
C는 오래되었다는 점에서 여러 이점을 누린다. 많은 프로젝트가 성숙할 시간을 가졌고, 사람들도 언어에 대한 기술과 지식을 쌓을 시간을 가졌다. 마찬가지로 C는 성숙하고 많은 장점을 제공하는 광범위한 도구와 구현을 갖고 있다. 대부분의 안전 핵심 소프트웨어는 수년 동안 성숙하고 신뢰를 쌓아 왔다. 이 요인이 C의 성공 일부를 설명할 수는 있지만, C++나 Java 같은 많은 언어도 이제 충분히 오래되었으므로, C보다 큰 이점을 제공했다면 지금쯤 C를 대체했어야 한다. 분명히 그런 일은 일어나지 않았다.
컴퓨터 과학은 새롭다는 것을 더 낫다는 것과 동일시하는 일을 멈춰야 한다. 부정은 증명할 수 없기 때문에, 소프트웨어에 결함이 없다는 것을 증명할 방법은 없다. 사실 우리가 어떤 것이 좋은지 평가하는 가장 좋은 방법은 그것의 수명이다. 언어에도, 코드베이스에도 모두 적용된다.
C 프로그래머는 메모리 관리 같은 일을 너무 자주 다루기 때문에, 그것이 사고방식의 자연스러운 일부가 되어 외부인이 생각하는 것보다 훨씬 덜 문제가 될 수도 있다.
자동차에 익숙하지 않은 보행자에게는, 2톤짜리 금속 상자들이 시속 50킬로로 도로를 질주하는 도시가 주민들이 차에 치이지 않으려 엄청난 스트레스를 항상 받는 극도로 위험한 환경처럼 보일 수 있다. 하지만 오랫동안 자동차와 함께 도시에서 살아온 사람에게는, 그것은 알려진 위험이기는 하지만 삶의 걱정거리 중 극히 작은 일부일 뿐이다. 메모리를 대신 처리해 주는 언어에서 온 개발자에게는 그 작업이 벅차게 느껴질 수 있지만, 오래된 C 개발자에게는 개발의 자연스러운 일부이며 유지하는 데 큰 정신력을 쓰지 않는다. 경험 많은 C 프로그래머가 걱정하는 것들의 규모에서 보면, 메모리 관리는 우선순위가 낮다. 완료해야 할 다른 훨씬 더 어려운 일이 있기 때문이다.
이것은 부분적으로는 경험의 결과지만, C를 찾고 C를 선택해 프로그래밍하는 사람들은 C 설계에 잘 맞는 방식으로 사고하도록 "배선"되어 있기 때문이기도 하다. C 프로그래머는 실제로 다른 종류의 프로그래머인 경향이 있다. 금속에 가까이 가길 원하고, 편의보다 제어를 가치 있게 여긴다.
아마 우리는 C 프로그래머를 별개로 보아야 하며, C는 다른 언어와는 다른 사고방식을 요구하는 언어임을 인정해야 한다. 많은 프로그래머가 언어를 자주 바꿔도 된다고 생각하지만, 누가 C를 맡을지 더 신중해야 할지도 모른다. C를 또 하나의 언어로 가르치기보다, 일종의 전문 분야처럼 다뤄야 할 수도 있다. C/C++ 프로그래머를 구한다는 광고는 크게 도움이 되지 않는다. 두 언어는 매우 다른 기술과 사고방식을 요구하기 때문이다.
아마도 C라는 언어의 장점은, 단지 좋은 프로그래머를 끌어들인다는 데 있을 수도 있다. 심지어 C 코드를 쓰는 것이 어렵다는 인식 자체가 숙련된 프로그래머 사이에서 그 인기에 기여할 수도 있다. 이것은 C 프로젝트가 부분적으로는 C 프로그래머가 좋은 프로그래머인 경향 때문에 성공적이며, 같은 사람들이 다른 언어를 썼더라도 비슷한 성공을 거두었을 것이라는 뜻이다. Linus Torvalds는 유명하게도 Linux Kernel에 C++를 들이지 않겠다고 선언했다. 단순한 이유는 C++를 좋아하는 사람들과 일하고 싶지 않기 때문이었다.
최고의 프로그래머들 중 많은 이가 C를 선택한다면, 아마 C는 그렇게 나쁘지 않은지도 모른다.
대부분의 언어는 하나 또는 극소수의 구현만 가지는 반면, C는 매우 다른 목적을 가진 광범위한 구현을 누린다. C는 또한 디버깅을 돕는 여러 디버거, 린터, 퍼저, 정적 분석 도구를 갖고 있다. C 도구의 범위, 품질, 성숙도는 견고한 소프트웨어 개발에 큰 도움을 준다.
다른 많은 언어는 문법보다 도구에 더 많은 초점을 맞추면 큰 이득을 볼 것이다. 내 생각에 좋은 디버거는 프로그래머에게 가장 중요한 도구인데도, 많은 프로그래머는 디버거를 사용하지 않으며, 많은 언어는 디버깅 도구가 매우 적다. C는 도구가 많지만, 여전히 크게 개선할 여지가 있다.
C는 가차 없는 성격으로 유명하다. 많은 C 버그가 세그폴트나 다른 치명적인 문제를 일으키기 때문에, 디버깅을 통과하는 버그는 훨씬 적다. 현대 C 도구는 초기화되지 않은 변수 읽기 같은 것까지 감지함으로써 C를 더 가차 없게 만들기도 한다. 이는 모든 값을 자동 초기화하는 언어보다 훨씬 더 엄격한 요구 사항이며, 개발자가 의도를 명시적으로 적게 만든다. 컴파일러는 사용자가 기본 초기화를 원한다고 가정하지 않기 때문이다. 많은 언어는 편의를 위해 프로그래머가 나중에 찾기 어려운 버그를 만들 수 있는 일을 그냥 지나가게 한다. 예를 들어 JavaScript는 객체 멤버를 미리 선언하지 않고도 접근하게 해 준다. object->member = 42;를 쉽게 쓸 수 있고, 실수로 x = object->Member;처럼 접근할 수도 있다. 그러면 JavaScript는 오류를 내는 대신 x에 기본값을 넣어 준다. 물론 미리 멤버를 선언하지 않아도 되는 건 편리하다. 하지만 몇 초 타이핑을 아끼는 대신, 디버깅에 수 시간을 잃거나 더 나쁘게는 망가진 소프트웨어를 배포하는 것은 훨씬 나쁘다.
경고만 하거나, 더 나쁘게는 사용자의 의도를 추정하는 대신, 프로그래머가 문제와 모호함에 직접 마주하게 만드는 정책은 보안을 향상시키는 데 중요하다. 대부분의 C 컴파일러는 선택된 경고를 만났을 때 컴파일 실패로 설정할 수 있다. 이것을 훨씬 더 엄격하게 만들어, 기술적으로 표준 준수인 코드도 더 많이 거부하게 할 수 있다. 암시적 타입 변환은 애초에 C의 일부가 되었어서는 안 되지만, 도구로 쉽게 감지하고 바로잡을 수 있는 기능 중 하나다.
오래되고 잘 알려진 언어이기 때문에, C의 문제들은 대체로 이미 잘 알려져 있다. 언어가 많은 다른 언어보다 작다는 사실은, 컴파일러와 다른 기반 도구가 아직 탐험되지 않은 모서리 사례를 건드리는 코드를 만날 가능성이 낮다는 뜻이기도 하다. 대부분의 보안 문제는 잘 알려져 있으며 감사하기 쉽다. 또한 C가 널리 퍼져 있기 때문에 C 코드를 검토하고 읽을 수 있는 사람도 많다.
C 코드가 그렇게 성공적이었기 때문에, 이 널리 배포된 C 코드에서 발생한 어떤 문제든 과장된 영향을 갖게 된다. OpenSSL의 보안 취약점은, 구현 언어가 무엇이든 사용자 수가 훨씬 적은 다른 SSL 구현의 취약점보다 훨씬 더 큰 파장을 일으킨다. 잘 알려진 취약점을 가진 JavaScript나 SQL 코드는 공격 표면이 훨씬 더 분산되어 있다. 수많은 웹사이트가 다양한 취약점을 갖고 있을 수 있지만, 그런 취약점 발견은 Linux kernel 같은 널리 쓰이는 C 시스템의 취약점처럼 헤드라인을 장식하지는 않는다. Linux kernel 같은 프로젝트의 규모와, 그것을 보는 사람 수를 생각하면, 심각한 취약점이 놀랄 만큼 드물게 발견된다는 것이 오히려 놀랍다.
C로 작성된 주요 보안 핵심 소프트웨어에서 중대한 보안 문제가 얼마나 드물게 나타나는지를 인정할 가치가 있다.
다른 사람의 코드에서 버그를 찾는 사람은, 그 버그를 가능하게 했거나 가능성을 높인 설계 결정에 편향을 가지기 쉽다. 이것도 또 다른 형태의 생존자 편향이다. 하지만 그들이 보지 못하는 것은, 그 설계가 막아낸 문제들이다. 모든 공학은 본질적으로 절충에 관한 것이다. 어떤 결정이든 어떤 문제는 더 생기기 쉽고, 다른 문제는 덜 생기게 만든다. C의 설계 절충이 몇몇 반복되는 버그 종류를 만들더라도, 전체적으로 보면 그것이 만드는 문제보다 훨씬 더 많은 문제를 막아내고 있을 가능성이 있다.
어떤 분야에서든 좋은 엔지니어는, 어떤 결정이든 잠재적 위험과 이익을 저울질해야 한다는 것을 안다. 아무리 미미한 위험이라도 전부 덮으려 하는 것은, 실제로 문제를 일으킬 가능성이 큰 문제들에 시간, 노력, 자원을 적절히 배분하지 않는 것이다. 보안 연구자들은 악용될 확률이 극히 낮은 보안 문제를 제기해 온 긴 역사를 가지고 있다.
이 페이지는 Eskil Steenberg Hald가 관리한다. 나는 오랫동안 C 개발을 해 왔고, C 표준 위원회에서 스웨덴을 대표한다. 이 페이지는 언어에 대한 나 자신의 이해를 기록하고, 내 직원들과 신뢰할 수 있는 C를 작성하고자 하는 누구에게나 안내서가 되기 위해 유지되고 있다. 나는 C 소프트웨어 작성, 정의되지 않은 동작에 대해서는 전문가라고 생각하며, 메모리 모델과 동시성 모델에도 능숙하다(이 두 영역에서는 아마 세계적 전문가 중 한 명일 것이다. 그래도 완전히 이해한다고까지는 주장하고 싶지 않다...). 반면 언어의 "현대적" 버전에 대해서는 경험이 덜하다고 생각한다. 이 문서에 대한 수정이나 추가 제안을 매우 환영한다. 특히 어떤 C 기능이 어떤 구현에서든 신뢰할 수 없었다는 사례를 듣고 싶다. dependablec dot org의 eskil로 메일을 보내 달라. 이 웹사이트는
영원히 진행 중인 작업이며, 아직 완전하지 않다.
wg14 C 표준 위원회에 내가 참여하는 목적은 내 자신의 교육과 메모리 모델 및 정의되지 않은 동작 연구 그룹 참여를 위한 것이다. 의도는 wg14에서 배운 것을 가능한 한 많이 이 웹사이트에서 공유해 보는 것이다. 나는 더 새로운 언어 버전은 절대 사용하지 않을 것이고, 그 사용도 권하지 않기 때문에, 언어 개발에 대한 투표는 기권한다.