Proven C BookEnglish GitHub

78 새 표준이 보탠 것과 *_s 논쟁

먼저 알아야 할 것

62장 표준 라이브러리 한눈에 보기 · 표준 라이브러리의 전체 얼개
64장 읽고 쓰기의 함정 · 경계와 잘림

돌아보기

62장에서 헤더가 늘어나는 속도가 언어의 변화 속도와 같다고 했고, 64장에서는 gets의 장례가 20년 걸렸다고 했다. 그러면 그 자리를 메우려고 표준이 들여온 “안전한 함수”들은 지금 어디에 있는가?

답. 대부분 어디에도 없다. C11이 부속서 K(Annex K)로 gets_s·strcpy_s 같은 함수 수십 개를 들였지만, 그것은 선택 사항이었고 주요 구현들이 채택을 거부했다. 이 책을 만든 기계에서 확인한 결과도 그렇다 — 예제가 찍은 “부속서 K: 이 구현에는 없다”가 그것이다. 이 장의 뒷부분이 그 이야기다.

이 장의 필요성과 맥락

11부를 닫는 자리에 「실패한 시도」를 놓는다. 부속서 K 이야기가 앞쪽에 있었다면 그저 흥미로운 일화였겠지만, 스물두 장 동안 표준 함수의 함정을 직접 겪은 지금은 왜 고치기가 그토록 어려운가에 대한 답으로 읽힌다. 정독의 결론에 알맞은 이야기다.

이 장이 끝나면

이 부의 마지막 장이다. C99·C11·C23이 표준 라이브러리에 보탠 헤더들을 훑고, 그다음 이 언어의 가장 유명한 실패한 시도 — “안전한 함수”를 표준에 들이려던 부속서 K — 의 전말을 본다. gets_s·strcpy_s가 왜 널리 쓰이지 않는지, Microsoft의 같은 이름 함수들은 무엇이 다른지까지.

이 장에서 답할 질문

  1. 부속서 K의 *_s 함수들이 실패했다면, 그 자리를 지금은 무엇이 채우는가?

78.1 C99가 보탠 것

헤더무엇오늘의 위치
<stdint.h>고정 폭 정수(int32_t 등)사실상 필수. 27장
<inttypes.h>그 타입들의 서식 매크로PRId32 계열. 부록 B
<stdbool.h>bool·true·falseC23에서 키워드가 되어 불필요
<complex.h>복소수선택 사항. 지원이 고르지 않다
<fenv.h>부동소수점 환경73장
<tgmath.h>타입 제네릭 수학73장

표 78.1

<stdint.h>가 이 목록의 승자다. “정확히 32비트”를 표준적으로 표현할 방법이 드디어 생겼고, 그 전까지 프로젝트마다 자기 typedef를 두던 관행이 정리됐다.

문. 부속서 K의 *_s 함수들이 실패했다면, 그 자리를 지금은 무엇이 채우는가?

답. 하나가 아니라 셋이 나눠 가졌다. 컴파일러의 진단(경고와 _FORTIFY_SOURCE 같은 강화 빌드), 새니타이저(17장), 그리고 길이를 함께 나르는 API 설계다. 마지막 것이 이 책이 밀어 온 방향이고, 88·90장의 뷰가 그 구현이다.

교훈은 함수 이름에 _s를 붙이는 것으로는 안전이 오지 않는다는 것이다. 실패를 확인하게 만들고, 경계를 타입 안에 넣고, 도구가 검사할 수 있게 만드는 쪽이 실제로 통했다. 85장이 다섯 버그를 그렇게 정리한 이유다.

78.2 C11이 보탠 것

헤더무엇오늘의 위치
<stdatomic.h>원자적 연산·메모리 순서동시성의 토대. 12장의 그 이야기
<threads.h>스레드·뮤텍스·조건 변수★ 채택이 더디다 — 대개 pthread를 쓴다
<stdalign.h>alignas·alignofC23에서 키워드로
<stdnoreturn.h>noreturnC23에서 폐기 예정. [[noreturn]]으로
<uchar.h>char16_t·char32_t67장

표 78.2

<threads.h>의 사정이 흥미롭다. 표준에 있는데도 오랫동안 glibc가 제공하지 않아, 이식성 있는 코드는 여전히 POSIX 스레드를 쓴다. 표준에 들어가는 것과 쓸 수 있게 되는 것은 다른 일이라는 사실을 보여 주는 사례다.

78.3 C23이 보탠 것

examples/ch78/newheaders.c

#define __STDC_WANT_LIB_EXT1__ 1
#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <stdckdint.h>
#include <stdalign.h>
#include <stddef.h>

int main(void)
{
    /* ① 부속서 K 가 실제로 있는가 — 구현이 알려 준다 */
#ifdef __STDC_LIB_EXT1__
    printf("Annex K: present (__STDC_LIB_EXT1__)\n");
#else
    printf("Annex K: not in this implementation\n");
#endif

    /* ② C23 의 검사 산술 — 넘침을 값으로 알려 준다 */
    int a = 2000000000, b = 2000000000, sum;
    if (ckd_add(&sum, a, b))
        printf("ckd_add: overflowed (it does not fit in an int)\n");
    else
        printf("ckd_add: %d\n", sum);

    size_t count = (size_t)-1 / 2, elem = 8, bytes;
    if (ckd_mul(&bytes, count, elem))
        printf("ckd_mul: overflowed - no allocation is attempted\n");

    /* ③ 고정 폭 정수와 정렬 */
    printf("int32_t=%zu bytes, alignof(max_align_t)=%zu\n",
           sizeof(int32_t), alignof(max_align_t));

    /* ④ C23 의 bool 은 키워드다 — <stdbool.h> 없이 쓴다 */
    bool ready = true;
    printf("bool is a keyword: %s\n", ready ? "true" : "false");

    /* ⑤ 표준이 정한 매크로로 판을 확인한다 */
    printf("__STDC_VERSION__ = %ldL\n", (long)__STDC_VERSION__);
    return 0;
}

실행 결과

Annex K: not in this implementation
ckd_add: overflowed (it does not fit in an int)
ckd_mul: overflowed - no allocation is attempted
int32_t=4 bytes, alignof(max_align_t)=16
bool is a keyword: true
__STDC_VERSION__ = 202000L

<stdckdint.h>가 이 판의 실용적 승자다. 65장에서 본 크기 계산의 감김을 값으로 알려 준다 — ckd_add·ckd_sub·ckd_mul은 넘치면 참을 돌려주고, 결과는 감긴 값이 아니라 “쓸 수 없음”으로 다루면 된다.

<stdbit.h>도 새로 왔다. 앞의 0 개수 세기, 켜진 비트 세기, 2의 거듭제곱 올림 같은 비트 조작이 표준 함수가 됐다 — 그전까지는 컴파일러 내장 함수 (__builtin_clz 등)에 기대던 자리다.

그 밖에 C23은 bool·true·false·static_assert·thread_local을 키워드로 승격시키고, nullptr을 들였으며(36장), <stdbool.h>· <stdnoreturn.h> 같은 호환용 헤더를 사실상 은퇴시켰다.

78.4 부속서 K — “안전한 함수”의 실패한 시도

이제 이 장의 본론이다.

2000년대 초, Microsoft는 자사 컴파일러에 strcpy_s·sprintf_s 같은 함수들을 넣고 기존 함수 사용에 경고를 띄우기 시작했다. 그 설계를 표준으로 만들자는 제안이 C11의 부속서 K(Bounds-checking interfaces)로 들어갔다.

핵심 착상은 셋이었다.

  1. 목적지 크기를 반드시 인자로 받는다.
  2. 문제가 생기면 잘라 넘어가지 않고 오류를 돌려준다(errno_t).
  3. 계약 위반이 감지되면 프로그램이 정한 제약 처리기를 부른다.
#define __STDC_WANT_LIB_EXT1__ 1
#include <string.h>

char dst[8];
errno_t e = strcpy_s(dst, sizeof dst, src);   /* 넘치면 오류 */

방향은 62장에서 우리가 정리한 “필요한 것”과 닮았다. 그런데 결과는 실패였다.

실제 사례. 부속서 K가 채택되지 못한 이유

2015년, C 표준 위원회 문서 N1967 “Field Experience With Annex K”가 실태를 조사했다. 요지는 냉정했다 — 널리 구현되지 않았고, 구현된 곳에서도 서로 다르게 동작하며, 실제 코드를 더 안전하게 만들었다는 증거가 없다.

구체적으로는 이런 사정들이었다.

  • Microsoft의 함수와 표준의 함수가 같지 않다. 이름은 같은데 인자와 동작이 어긋나는 경우가 있어, 한쪽에 맞춘 코드가 다른 쪽에서 깨졌다.
  • glibc를 비롯한 주요 구현이 채택하지 않았다. 오늘까지도 그렇다 — 앞의 예제가 “이 구현에는 없다”를 찍은 이유다.
  • 제약 처리기라는 전역 상태가 라이브러리 사이에 충돌을 일으켰다.
  • 기존 코드를 기계적으로 바꿔 놓았을 뿐, 실제 결함은 크기를 잘못 계산하는 자리에 남아 있었다.

위원회는 부속서 K를 제거하자는 논의까지 갔고, 결국 남기되 사실상 권장하지 않는 상태로 정리됐다. “표준에 넣으면 안전해진다”는 기대가 현실에서 어떻게 어긋나는지 보여 주는 대표 사례다.

흔한 오해. _s 붙은 함수를 쓰면 안전하다”

세 가지를 확인해야 한다. 첫째, 그 함수가 있는가 — 표준 부속서 K는 선택이고 대부분의 유닉스 계열에 없다. 둘째, 어느 쪽 판인가 — 표준의 것과 Microsoft의 것이 다를 수 있다. 셋째, 무엇이 안전해지는가 — 크기를 받는다는 것이지, 여러분이 넘긴 크기가 옳다는 뜻이 아니다. sizeof 대신 strlen을 넘기는 실수는 _s 함수에서도 똑같이 사고다.

이식성 있는 코드에서 현실적인 선택은 지금도 이렇다 — 표준 안에서는 snprintf와 명시적 경계 검사, 플랫폼이 허용하면 strlcpy 계열, 그리고 반복되는 위험 지대에는 검사를 내장한 부품(제12부).

플랫폼 노트. Windows에서 만나는 _s 함수들

MSVC는 strcpy_s·sprintf_s·fopen_s 등을 오래전부터 제공하고, 기존 함수를 쓰면 C4996 경고를 낸다. 경고를 끄려면 _CRT_SECURE_NO_WARNINGS를 정의하는 것이 관행이다.

주의할 점은 이 함수들이 표준 부속서 K와 완전히 같지 않다는 것이다. 예를 들어 인자 검증 실패 시의 동작과 반환값 규칙이 다를 수 있다. 그래서 Windows 전용 코드가 아니라면 _s 계열에 기대지 않고, 크로스 플랫폼 프로젝트는 대개 자체 래퍼를 하나 두는 쪽을 택한다.

78.5 이 부가 남기는 것

열 장에 걸쳐 표준 라이브러리를 걸었다. 함수 이름을 외우는 것이 목적이 아니었으므로, 남길 것은 몇 가지 태도다.

  1. 계약을 먼저 읽는다. 크기를 받는가, 실패를 어떻게 알리는가, 돌려준 포인터의 주인은 누구인가.
  2. 반환값을 버리지 않는다. 특히 fclose·snprintf·scanf 계열.
  3. 전역 상태를 의심한다. errno, 로케일, 정적 버퍼, 반올림 모드.
  4. 표준에 있다고 안전한 것은 아니다. gets가 22년을 살아남았다.
  5. 플랫폼 확장은 이득과 이식성을 저울질해서.

복습 정리

판별 표.

대표 보탬실제로 쓰이는가
C99<stdint.h>·<inttypes.h>예 — 사실상 필수
C99<complex.h>드물다
C11<stdatomic.h>예 — 동시성의 토대
C11<threads.h>드물다 — pthread가 우세
C11부속서 K(*_s)아니오 — 이 장의 이야기
C23<stdckdint.h>예 — 크기 계산의 정답
C23<stdbit.h>늘어나는 중
C23키워드 승격(bool·nullptr 등)

표 78.3

판별 표의 아래 세 줄 — <stdatomic.h>, <stdckdint.h>, 키워드 승격 — 은 얼굴만 비쳤다. 이 부의 남은 세 장이 그 셋을 하나씩 자세히 다룬다. 동시성의 토대부터 시작한다.