75 진단과 제어 — <errno.h>·<assert.h>·<signal.h>·<setjmp.h>
먼저 알아야 할 것
돌아보기
51장에서 C의 오류 통보 방식이 반환값·전역 상태·프로그램 중단 셋뿐이라 했다. 그러면 errno라는 전역은 정확히 언제 믿을 수 있는가?
답. 실패가 확인된 직후에만 믿을 수 있다. 표준의 규칙은 두 겹이다. 첫째, 성공한 호출도 errno에 값을 넣을 수 있다 — 그래서 “0이 아니면 실패”가 아니라 “함수가 실패를 알린 뒤에 이유를 읽는” 순서여야 한다. 둘째, 값은 다음 라이브러리 호출이 언제든 덮어쓸 수 있다 — 그래서 읽었으면 즉시 저장한다. 이 두 규칙이 전역 상태로 오류를 나르는 설계의 대가다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
assert는 왜NDEBUG로 통째로 꺼지게 만들어 두었는가 — 검사는 항상 하는 것이 좋지 않은가?
75.1 errno — 전역으로 오류를 나르는 대가
examples/ch75/errno_demo.c
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
int main(void)
{
/* ① 성공한 호출도 errno 를 건드릴 수 있다 — 그래서 직전에 0 으로 놓는다 */
errno = 0;
FILE *ok = fopen("errno_demo_tmp.txt", "w");
printf("errno after success = %d\n", errno);
if (ok) { fputs("x", ok); fclose(ok); }
/* ② 실패하면 errno 에 이유가 남는다 */
errno = 0;
FILE *f = fopen("/definitely/not/here.txt", "r");
if (!f) {
printf("failed: errno=%d, strerror=[%s]\n", errno, strerror(errno));
perror("output of perror"); /* 앞에 문맥을 붙여 stderr 로 */
}
/* ③ 중간에 다른 호출이 끼면 errno 가 덮인다 */
errno = 0;
f = fopen("/definitely/not/here.txt", "r");
int saved = errno; /* 곧바로 저장해 둔다 */
printf("cleaning up...\n"); /* printf 가 errno 를 바꿀 수 있다 */
printf("the errno we saved = %d (%s)\n", saved, strerror(saved));
/* ④ strtol 은 errno 로 범위 초과를 알린다 */
errno = 0;
long v = strtol("999999999999999999999", NULL, 10);
printf("strtol out of range: errno==ERANGE? %s (value=%ld)\n",
errno == ERANGE ? "yes" : "no", v);
remove("errno_demo_tmp.txt");
return 0;
}
실행 결과
output of perror: No such file or directory
errno after success = 0
failed: errno=2, strerror=[No such file or directory]
cleaning up...
the errno we saved = 2 (No such file or directory)
strtol out of range: errno==ERANGE? yes (value=9223372036854775807)
출력이 규칙을 그대로 보여 준다. 호출 직전에 0으로 놓고, 실패를 확인한 뒤 읽고, 다른 일을 하기 전에 저장한다. printf 한 줄이 사이에 끼는 것만으로 값이 바뀔 수 있기 때문이다.
errno는 변수처럼 보이지만 매크로다. 여러 갈래로 도는 프로그램에서는 갈래마다 다른 값을 봐야 하므로, 구현이 스레드 지역 저장소를 가리키는 매크로로 정의한다.
| 함수 | 하는 일 | 메모 |
|---|---|---|
strerror(n) | 오류 번호 → 문장 | ★ 정적 버퍼. 스레드 안전하지 않다 |
perror(s) | s: 이유를 stderr로 | 문맥 문자열을 붙이는 습관 |
strerror_r | 호출자 버퍼에 채움 | POSIX. 두 가지 판이 있어 혼란 |
strerror_s | 같은 취지 | 부속서 K(78장) |
표 75.1
표준이 이름을 정한 오류 번호는 셋뿐이다 — EDOM(정의역), ERANGE(범위), EILSEQ(인코딩). 나머지 ENOENT·EACCES 같은 것들은 POSIX나 플랫폼이 정한다. 즉 errno 값을 비교하는 코드는 이식성이 그만큼 좁아진다.
문. assert는 왜 NDEBUG로 통째로 꺼지게 만들어 두었는가 — 검사는 항상 하는 것이 좋지 않은가?
답. assert가 검사하는 것이 사용자 입력이 아니라 프로그래머의 가정이기 때문이다. “여기 오면 p는 널이 아니다”는 코드가 옳다면 언제나 참이어야 하고, 틀렸다면 그것은 버그다. 그런 검사는 개발 중에 촘촘히 두고 배포판에서는 비용을 없애자는 것이 원래 설계다.
그래서 두 가지가 따라온다. 첫째, assert 안에 부수효과를 넣으면 안 된다 — assert(pop(&s) == 3)은 배포판에서 통째로 사라진다. 둘째, 사용자 입력·파일 내용·네트워크 데이터의 검사는 assert가 아니라 언제나 도는 코드로 해야 한다. 51장의 계약 어휘로 말하면, assert는 전제조건을 개발 중에 확인하는 도구이고 실패를 값으로 알리는 것은 별개의 일이다.
75.2 assert — 계약을 코드로 적는 가장 싼 방법
51장에서 배운 그 매크로다. 규칙을 다시 못박으면 이렇다.
assert는 프로그램 내부의 불변식을 확인한다. “여기까지 왔다면 p는 널이 아니다” 같은 것.- 바깥에서 온 값을 검사하는 데 쓰지 않는다. 사용자 입력·파일 내용·네트워크 데이터는
if로 검사하고 오류로 처리한다. NDEBUG가 정의되면 통째로 사라진다(17장). 그래서 부수효과가 있는 표현식을 넣으면 안 된다.
반례. assert 안에 일을 시키기
assert(fclose(f) == 0); /* 릴리스 빌드에서는 fclose 자체가 사라진다 */
assert(i++ < n); /* i 가 증가하지 않는 빌드가 생긴다 */검사와 부수효과를 분리한다.
int rc = fclose(f);
assert(rc == 0);
(void)rc; /* 릴리스에서 미사용 경고 방지 */C11은 static_assert(C23에서는 _Static_assert 없이도 쓸 수 있다)를 들여왔다. 컴파일 시간에 확인하므로 실행 비용이 0이고, sizeof나 상수 조건에 쓴다.
static_assert(sizeof(int) >= 4, "이 코드는 32비트 int 를 가정한다");75.3 신호 — 다음 장으로
<signal.h>는 프로그램 밖에서 오는 사건(Ctrl+C, 잘못된 기억 접근, 산술 오류)을 다룬다. 표준이 정한 신호는 여섯 개뿐이고(SIGINT·SIGSEGV·SIGFPE·SIGILL· SIGABRT·SIGTERM), 나머지는 플랫폼의 것이다.
핵심 규율 하나만 여기 적어 둔다 — 처리기 안에서 할 수 있는 일이 거의 없다. printf도 malloc도 부를 수 없다. 그래서 실무의 관용구는 “처리기는 깃발만 세우고, 실제 처리는 주 흐름에서”가 된다.
static volatile sig_atomic_t stop = 0;
static void on_int(int sig) { (void)sig; stop = 1; }
/* main 루프에서: while (!stop) { ... } */이 헤더는 다룰 것이 많아 다음 장을 통째로 썼다 — 역사, 함수의 모양과 인자, 반환값, sig_atomic_t의 정체, POSIX sigaction과 그 구조체 속, 그리고 서버·셸·터미널의 실제 사용 사례까지 76장에서 본다.
75.4 비지역 점프(non-local jump) — setjmp/longjmp
setjmp로 지금 자리를 기억해 두고, 나중에 깊은 곳에서 longjmp로 되돌아 오는 장치다. 예외가 없는 언어에서 예외 비슷한 것을 만들려는 시도이고, 그만큼 대가가 크다 — 건너뛴 함수들이 잡아 둔 자원은 아무도 정리해 주지 않는다.
이 헤더도 다룰 것이 많아 따로 한 장을 썼다 — 두 낱말의 반환값 규칙, setjmp를 쓸 수 있는 네 문맥, jmp_buf의 속, volatile 규칙의 실측, libjpeg·Lua 같은 실물의 사용례와 오늘날의 대안까지 77장에서 본다.
실제 사례. 오류 처리 방식이 만든 코드의 모양
같은 프로그램을 세 가지 방식으로 짜면 모양이 이렇게 갈린다.
- 반환값 +
errno: 호출마다if가 붙고 오류 경로가 눈에 보인다. 번거롭지만 흐름이 정직하다. setjmp/longjmp: 본문은 깔끔해지지만 자원 정리를 사람이 전부 기억해야 하고, 어디로 튀는지 코드에서 보이지 않는다.goto cleanup: C 코드베이스에서 가장 널리 정착한 절충이다. 실패하면 한 곳으로 모여 역순으로 정리한다. 리눅스 커널이 이 무늬를 관행으로 못박았고, 30장에서 “goto는 절제해서”라고 한 그 절제된 용법이 바로 이것이다.
세 방식 모두 “실패를 값으로 다루고, 정리를 잊지 않는다”는 같은 문제를 푼다. 제12부의 라이브러리는 여기에 네 번째 답 — 실패를 타입으로 만드는 것 — 을 얹는다.
복습 정리
진단과 제어 요약.
| 도구 | 쓰는 자리 | 규칙 |
|---|---|---|
errno | 라이브러리 호출의 실패 이유 | 직전 0, 실패 확인 후 읽기, 즉시 저장 |
assert | 내부 불변식 | 부수효과 금지, 입력 검사에 쓰지 않기 |
static_assert | 컴파일 시간 가정 | 비용 0 |
signal | Ctrl+C 같은 외부 사건 | 처리기에서는 깃발만 |
SIGSEGV | — | 잡아도 복구 불가 |
setjmp/longjmp | 특수한 복구 | volatile 아니면 값 미정, 정리 안 됨 |
| 실무 관용구 | 실패 시 정리 | goto cleanup으로 한 곳에 모으기 |
표 75.2
여기까지가 C89 시절부터 있던 헤더들이다. 다음 장은 그 뒤로 표준이 보탠 것들 — 그리고 “안전한 함수”를 만들려던 시도가 어떻게 되었는지의 이야기다.