61 표준 라이브러리의 지형
먼저 알아야 할 것
돌아보기
16장에서 링커가 printf의 몸통을 표준 라이브러리에서 찾아 이어 준다고 했고, 43장에서 gets는 표준에서 삭제됐다고 했다. 표준 라이브러리는 누가 정하고 어떻게 바뀌는가?
답. 언어 표준이 함께 정한다 — C 표준 문서는 언어 문법만이 아니라 제공 되어야 할 라이브러리 목록과 각 함수의 계약까지 못박는다(그래서 “C 표준 라이브러리”다). 바뀌는 속도는 언어만큼 느리고, 무언가를 빼는 일은 훨씬 더 느리다 — gets의 장례가 수십 년 걸린 이유이자, 이 라이브러리의 성격을 설명하는 열쇠다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- 그러면 입력 해석은 늘
sscanf로 하면 되는가? - 그러면 표준 라이브러리는 낡아서 안 쓰는 것이 좋은가?
61.1 지형 — 도구 상자들
자주 만나는 헤더를 갈래로 묶으면 지형이 한눈에 들어온다.
| 갈래 | 대표 헤더와 내용 |
|---|---|
| 입출력 | <stdio.h> — 스트림, printf/scanf 계열, 파일 |
| 문자열·기억 | <string.h> — 복사·비교·검색, <ctype.h> — 문자 분류(character classification) |
| 수 | <stdlib.h> — 변환·난수, <math.h> — 수학 함수, <stdint.h>·<limits.h>·<float.h> — 타입의 한계 |
| 기억 관리 | <stdlib.h> — malloc/free 계열 |
| 시간·환경 | <time.h>, <stdlib.h>의 환경 접근 |
| 계약·진단 | <assert.h>, <errno.h> |
| 현대의 보탬 | <stdbool.h>(C99, C23에서 불필요해짐), <stdatomic.h>·<threads.h>(C11), <stdckdint.h>(C23 검사 산술) |
표 61.1
이 목록에서 두 가지가 눈에 띈다. 첫째, 얇다 — 자료구조(목록·해시 표), 정규식, 네트워크, 그래픽이 없다. 둘째, 오래됐다 — 대부분이 C89에 있던 것들이고, 새로 들어온 것은 손에 꼽는다.
61.2 왜 얇은가 — 설계와 역사
이유는 C가 자란 자리에 있다. C는 운영체제를 만드는 언어로 태어났고 (4장), 운영체제 위에서 도는 프로그램만이 아니라 운영체제 자신과 임베디드 펌웨어까지 같은 언어로 짜야 했다. 그런 곳에는 파일 시스템도, 동적 할당도, 심지어 운영체제도 없을 수 있다 — 그래서 표준 라이브러리는 “어디서나 있을 수 있는 최소한”으로 좁혀졌다(표준이 프리스탠딩 환경을 따로 정의해 두는 이유다). 풍부함을 포기한 대신 이식성을 얻은 것이다.
그리고 얇음에는 대가가 따랐다 — 세상은 결국 필요한 것을 만들었고, 그 결과가 플랫폼마다 다른 API들과 서드파티 라이브러리들의 숲이다. “C는 라이브러리를 고르는 일이 절반”이라는 말이 나온 배경이고, 이 책이 43장에서 proven을 고른 것도 그 선택의 한 예다.
61.3 출력 서식 문자열의 해부
가장 많이 쓰는 함수를 정면으로 뜯어볼 자리다. 22장에서는 최소 집합 (%d, %s, %%)만 익혔지만, printf의 서식은 사실 다섯 조각으로 된 작은 언어다.
뒤에서부터 읽는 것이 이해가 빠르다. 변환(conversion)이 “무엇으로 찍을 것인가”를 정하고 — 이것만 필수다 — 나머지는 그 위에 얹는 장식이다.
- 플래그(flag):
-왼쪽 정렬,0남는 자리를 0으로,+양수에도 부호, 빈칸 양수 앞에 빈칸,#대체 형식(%#x는0x를 붙인다). - 폭(width): 최소 글자 수. 모자라면 채우고, 넘치면 자르지 않는다 — 폭은 하한이지 상한이 아니다.
- 정밀도(precision):
.으로 시작한다. 실수에는 소수 자릿수, 문자열 에는 최대 길이, 정수에는 최소 자릿수다. - 길이 수식어(length modifier): 인자의 폭을 알려 준다.
l(long),ll(long long),z(size_t),h(short로 줄여 해석).
폭과 정밀도 자리에 *를 쓰면 그 값을 인자로 넘길 수 있다. 10장의 뷰처럼 “포인터와 길이”로 다니는 문자열을 찍을 때 %.*s가 요긴하다.
examples/ch61/fmtspec.c
#include <stdio.h>
int main(void) {
int n = 42;
unsigned u = 255;
double x = 3.14159265;
const char *s = "proven";
size_t sz = 1234;
long long big = 9000000000LL;
/* 플래그와 폭: 오른쪽 정렬이 기본, - 는 왼쪽, 0 은 빈칸 대신 0 */
printf("[%d] [%6d] [%-6d] [%06d] [%+d]\n", n, n, n, n, n);
/* 정밀도: 실수는 소수 자릿수, 문자열은 최대 길이 */
printf("[%f] [%.2f] [%10.3f] [%e] [%g]\n", x, x, x, x, x);
printf("[%s] [%10s] [%-10s] [%.3s]\n", s, s, s, s);
/* 진법과 문자 */
printf("[%c] [%x] [%X] [%#x] [%o] [%u]\n", 'A', u, u, u, u, u);
/* 길이 수식어: 인자의 폭을 서식에 알려 준다 */
printf("[%zu] [%lld]\n", sz, big);
/* 폭·정밀도를 인자로 넘기기 */
printf("[%.*s] [%*d]\n", 4, s, 6, n);
/* 퍼센트 글자 자체 */
printf("100%%\n");
return 0;
}
실행 결과
[42] [ 42] [42 ] [000042] [+42]
[3.141593] [3.14] [ 3.142] [3.141593e+00] [3.14159]
[proven] [ proven] [proven ] [pro]
[A] [ff] [FF] [0xff] [377] [255]
[1234] [9000000000]
[prov] [ 42]
100%
읽을거리가 몇 개 있다. %06d가 000042가 되는 것은 0 플래그가 빈칸 대신 0으로 채우기 때문이고, %.3s가 pro에서 멈추는 것은 문자열에서 정밀도가 최대 길이이기 때문이다. %#x의 0x는 #가 붙인 것이다. %g가 3.14159로 짧아진 것은 이 변환이 %e와 %f 중 짧은 쪽을 자동으로 고르기 때문이다.
길이 수식어는 장식이 아니라 계약이다. 58장에서 본 대로 가변 인자에는 타입 정보가 실려 가지 않으므로, printf는 서식이 말한 폭 그대로 스택을 읽는다. size_t를 %d로 찍는 코드가 64비트에서 조용히 어긋나는 이유가 이것이다 — 8바이트를 넣었는데 4바이트만 꺼내 읽는다.
| 데이터형 | 출력 서식 | 메모 |
|---|---|---|
int | %d %i | 기본형 |
unsigned int | %u %x %o | 비트를 볼 때는 16진 |
short | %d | 승격되므로 %hd는 선택 |
long | %ld | |
long long | %lld | |
size_t | %zu | ★ %d로 찍으면 어긋난다 |
ptrdiff_t | %td | |
double | %f %e %g | float도 승격되어 같다 |
long double | %Lf | |
char(문자로) | %c | 인자는 int로 승격된다 |
char * | %s | NUL 종단이어야 한다 |
void * | %p | 형식은 구현 정의(implementation-defined) |
bool | %d | 0 또는 1로 찍힌다 |
표 61.2
흔한 오해. “float을 찍을 때는 %f, double은 %lf”
float이 가변 인자 기본 진급(29장)으로 double이 되어 넘어가므로 둘 다 %f다 — %lf도 표준이 허용하지만 같은 뜻이다. 헷갈림의 진짜 뿌리는 입력에 있다. scanf는 주소를 받으므로 진급이 일어나지 않고, float *와 double *를 반드시 구별 해야 한다 — %f는 float *, %lf는 double *다. 같은 글자가 방향에 따라 다른 뜻이 되는, 이 두 함수의 가장 유명한 비대칭이다.61.4 입력 서식 문자열 — 무엇이 다른가
scanf 계열의 서식은 출력과 글자가 겹쳐서 같은 언어처럼 보이지만, 하는 일은 반대이고 규칙도 다르다. 다른 점을 다섯으로 정리한다.
첫째, 인자는 값이 아니라 담을 곳의 주소다. &n을 빠뜨리면 정수를 주소로 착각해 그 번지에 쓰려 든다 — 25장에서 만난 그 &가 여기서 필수인 이유다.
둘째, 서식의 공백은 “공백 몇 개든(없어도 좋다)”이다. 출력에서 공백은 그저 공백 한 칸이지만, 입력에서는 건너뛰기 지시다. 대부분의 변환은 앞선 공백을 알아서 건너뛴다 — 예외가 %c와 %[로, 이 둘은 공백도 글자로 읽는다.
셋째, 서식 안의 보통 글자는 입력과 그대로 맞아야 한다. "x=%d"는 입력이 x=로 시작할 것을 요구한다. 맞지 않으면 매칭 실패로 멈춘다.
넷째, 반환값은 찍은 글자 수가 아니라 성공한 항목 수다. 그래서 셋을 읽으려 했는데 2가 돌아오면 하나는 채워지지 않았다는 뜻이고, 그 인자는 손대지 않은 채로 남는다. 입력이 아예 없으면 EOF(음수)가 돌아온다 — 0과 구별해야 한다.
다섯째, %s에는 반드시 최대 폭을 준다. 폭 없는 %s는 목적지 크기를 모르는 채로 쓰는 것이라 43장의 gets와 같은 위험이다.
examples/ch61/scanspec.c
#include <stdio.h>
int main(void) {
int a = 0, b = 0, k;
double d = 0.0;
char word[8] = {0};
char rest[16] = {0};
/* 서식의 공백 하나가 "공백 몇 개든" 을 뜻한다 */
k = sscanf(" 12 34", "%d %d", &a, &b);
printf("ints : k=%d a=%d b=%d\n", k, a, b);
/* %s 는 공백에서 멈춘다. 폭을 주어 버퍼를 지킨다 */
k = sscanf("hello world", "%7s", word);
printf("bounded : k=%d word=[%s]\n", k, word);
/* 서식 안의 보통 글자는 입력과 그대로 맞아야 한다 */
k = sscanf("x=5", "x=%d", &a);
printf("literal : k=%d a=%d\n", k, a);
/* 맞지 않으면 매칭 실패 — 0 을 돌려주고 인자는 건드리지 않는다 */
k = sscanf("y=5", "x=%d", &a);
printf("mismatch: k=%d (a stays %d)\n", k, a);
/* 변환 실패도 0 이다 — 숫자가 아니면 아무것도 읽지 않는다 */
k = sscanf("abc", "%d", &b);
printf("nonnum : k=%d (b stays %d)\n", k, b);
/* 입력이 비면 EOF(음수)다 — 0 과 구별해야 한다 */
k = sscanf("", "%d", &b);
printf("empty : k=%d\n", k);
/* 부분 성공: 앞은 읽히고 뒤에서 멈춘다 */
k = sscanf("7 oops", "%d %lf", &a, &d);
printf("partial : k=%d a=%d\n", k, a);
/* 집합 지정자: 쉼표가 아닌 글자를 모은다 */
k = sscanf("name,42", "%15[^,],%d", rest, &b);
printf("set : k=%d rest=[%s] b=%d\n", k, rest, b);
/* 실수와 길이 수식어: double 은 %lf 다 */
k = sscanf("3.5", "%lf", &d);
printf("double : k=%d d=%.2f\n", k, d);
return 0;
}
실행 결과
ints : k=2 a=12 b=34
bounded : k=1 word=[hello]
literal : k=1 a=5
mismatch: k=0 (a stays 5)
nonnum : k=0 (b stays 34)
empty : k=-1
partial : k=1 a=7
set : k=2 rest=[name] b=42
double : k=1 d=3.50
출력을 한 줄씩 짚으면 규칙이 눈에 보인다. mismatch와 nonnum은 둘 다 k=0인데, 인자가 그대로 남아 있다는 점이 중요하다 — 값을 확인하지 않고 쓰면 이전 값을 새 입력으로 착각한다. empty의 -1은 “입력이 끝났다” 이지 “형식이 틀렸다”가 아니다. partial은 앞의 정수만 읽히고 뒤에서 멈춘 경우다. set의 %15[^,]는 “쉼표가 아닌 글자를 최대 15개”라는 뜻으로, 구분자가 있는 줄을 자를 때 쓴다.
| 데이터형 | 입력 서식 | 인자 |
|---|---|---|
int | %d | int * |
unsigned | %u %x | unsigned * |
long | %ld | long * |
long long | %lld | long long * |
size_t | %zu | size_t * |
float | %f | ★ float * |
double | %lf | ★ double * |
long double | %Lf | long double * |
| 문자 하나 | %c | char *(공백도 읽는다) |
| 단어 | %99s | char[100] — 폭 필수 |
| 글자 집합 | %15[^,] | char[16] |
| 건너뛰기 | %*d | 읽되 저장하지 않는다 |
표 61.3
실제 사례. 서식 하나가 무너뜨린 것 — 형식 문자열 취약점
22장에서 이름만 스쳤던 사고를 여기서 완성한다. 사용자가 준 문자열을 서식 자리에 그대로 넣은 코드(printf(user))는 공격자가 %s나 %x를 입력해 스택을 읽게 만들 수 있고, 옛 %n(찍은 글자 수를 인자가 가리 키는 곳에 쓴다)까지 살아 있던 시절에는 임의의 메모리 쓰기로까지 이어졌다. 1999년 무렵 이 부류가 대규모로 발견되면서 wu-ftpd를 비롯한 여러 서버가 원격 침해됐다.1 교훈은 한 줄이다 — 서식 문자열(format string)은 언제나 프로그램이 쓴 상수여야 하고, 사용자 입력은 인자로만 들어간다 (printf("%s", user)).문. 그러면 입력 해석은 늘 sscanf로 하면 되는가?
답. 간단한 형식에는 충분하다. 다만 sscanf는 어디에서 왜 실패했는지를 알려 주지 않는다 — 돌려주는 것은 성공한 항목 수뿐이라, “세 번째 필드가 숫자가 아니었다”와 “줄이 일찍 끝났다”를 구별할 수 없다. 게다가 정수 오버플로를 감지하지 못하고(%d에 99999999999를 주면 계약 밖이다), 실패한 자리까지 몇 글자를 소비했는지도 알 수 없다. 형식이 복잡해지고 입력이 남이 준 것이라면, 실패가 값으로 드러나고 남은 입력을 손에 쥘 수 있는 도구가 필요하다 — 제12부의 스캐너가 그 답이다.
61.5 조심할 자리들
이 책이 지나오며 만난 함정들을 라이브러리 관점에서 모으면 이렇다.
- 크기를 안 받는 함수들 —
strcpy,strcat,sprintf계열은 목적지 그릇의 크기를 모른다(43장의gets가 극단이었다). 대안은 크기를 받는 판(snprintf)이나 경계를 관리하는 부품이다. - 반환값이 실패를 알리는 함수들 —
malloc,fopen,fgets는 실패 시 널을 준다(51장의 규율: 확인 없이 쓰지 않는다). errno라는 전역 상태 — 많은 함수가 실패의 이유를 전역 변수에 남긴다. 성공해도 지우지 않는 함수가 있어 “호출 직전에 0으로 초기화하고 직후에 읽는” 규약을 지켜야 한다 — 전역 가변 상태의 불편함을 보여 주는 교과서 사례다(44장).- 지역/문화 의존 함수들 —
toupper같은 문자 함수와strtod의 소수점 해석은 로케일 설정에 따라 동작이 달라진다. 데이터 형식을 다룰 때는 로케일 독립적인 처리를 쓰는 것이 안전하다(9장의 “인코딩을 추측하지 말라”와 같은 결). - 정적 버퍼를 돌려주는 함수들 —
asctime,strtok계열은 내부의 고정된 자리를 재사용해서, 다음 호출이 이전 결과를 덮는다. 여러 갈래로 도는 프로그램(12장의 멀티코어)에서는 특히 위험하다.
문. 그러면 표준 라이브러리는 낡아서 안 쓰는 것이 좋은가?
답. 전혀 아니다 — 어디에나 있다는 것이 압도적인 미덕이다. 어떤 플랫폼, 어떤 컴파일러에서도 같은 계약으로 존재하는 부품은 이것뿐이고, 이 책의 예제 대부분도 표준 라이브러리만으로 돌았다. 옳은 태도는 “쓰지 않기”가 아니라 어느 함수가 어떤 계약인지 알고 고르기다 — 크기를 받는 판을 고르고, 반환값을 확인하고, 전역 상태에 기대는 함수는 규약을 지켜 쓰는 것. 그리고 반복되는 위험 지대(문자열 조립, 입력 해석)에는 검사가 내장된 부품을 얹는 것 — 다음 장이 그 실천이다.
지형을 폈다. 다음 부(제11부)는 이 지형의 구역들을 하나씩 걸으며, 각 헤더에서 실제로 사고가 나는 자리를 정면으로 다룬다 — 스트림과 파일, 문자열, 변환과 할당, 문자와 로케일, 수와 시간, 그리고 진단과 제어까지.
주
- Tim Newsham. 2000. Format String Attacks. White paper, Guardent, Inc.
faculty.digipen.edu/~dvolper/copies/tn-usfs.pdf. 분류 자체는 CWE-134 로 정리되어 있다 —cwe.mitre.org/data/definitions/134.html↩