[C] 오류 핸들링

Chris Kim·2024년 11월 4일

프로그래밍언어

목록 보기
21/25

0. 서론

이번 장에서는 assert 매크로와 errno 변수를 통해 에러를 검사하는 등의 방법을 배울 것이다. 그 외에도 perrorsetrerror도 다룰 것이다.
그 다음에는 <signal.h>, <setjmp.h> 를 다룰 것이다. 오류를 감지하고 이를 다루는 것은 C의 강점이 아니다. C는 런-타임 에러를 일관적인 방법보다는 다양한 방식으로 나타낸다. C언어에서 오류와 관련된 책임은 전적으로 프로그래머의 것이다.
비교적 최신 언어인 C++과 Java, C#는 예외(exception)을 더 잘 다룰 수 있다는 특징이 있으며, 이를 통해 오류를 감지하고 이에 더 잘 대응할 수 있다.

1. <assert.h>: 진단

void assert(scalar expression);

assert는 <assert.h>에 정의되어있으며, 프로그램 자체의 행동을 모니터링하고, 잠재적인 문제를 조기에 감지한다.
assert가 매크로이긴 하지만 함수처럼 사용되도록 설계되었다. assert는 한 개의 입력변수를 가지며 이는 반드시 assetion(역설: 일반적인 상황에서 참이 기대되는 표현식)이어야 한다. assert가 실행될 때마다 이 표현식의 값이 검사되고, 0이 아니라면 아무 일도 일어나지 않지만, 0이되면 stderr 메시지를 작성하고, abort 함수를 호출하여 프로그램 실행을 종료한다.
예시는 다음과 같다.

assert(0 <= i && i < 10);
a[i] = 0;

C99에서는 assert에서 두 가지 변경점을 가진다. C89에서는 assert의 입력변수가 int 형이어야 했다. C99에서는 이 조건을 완화해서, scalar 형을 넣을 수 있게 되었다. 이러한 변화는 입력변수에 부동소수점, 포인터 등이 들어올 수 있게 만들었다. 이에 더해 C99에서는 assert가 실패한 지점의 함수, 소스파일, 코드 위치를 알려준다.

assert는 한 가지 단점을 가진다. 그것은 추가적인 검사 행위로 인한 실행 시간의 증가다. 한 번만 사용하면 귿닥 상관 없겠으나, 상황에 따라 이는 다르게 다가올 수 있다. 이에 많은 프로그램들은 assert를 테스팅 단계에서 사용하며, 그 이후 단계에서는 사용하지 않는다. 사용을 하지 않는 방법은 다음과 같이 NDEBUG 매크로를 쓰면 된다.

#define NDEBUG
#include <assert.h>

[주의!] 부-효과를 가진 표현식을 assert의 입력변수에 넣는 것을 지양하자.(함수 호출도 마찬가지다)

2. <errno.h>: 오류

표준 라이브러리 내의 몇몇 함수들은 오류 코드(에러 코드)를 errno에 저장함으로써 실패(오류)를 가리킨다.(errno는 사실 매크로이지만, C 표준은 lvalue로써 표현할 것을 요구하며, 이로인해 변수처럼 사용할 수 있다.)
함수 호출 이후에는 errno 값을 점검하여 에러 발생 유무를 알 수 있다. 다음은 sqrt 함수 호출이 실패한 예시다.

errno = 0;
y = sqrt(x);
if (errno != 0){
	fprintf(stderr, "sqrt error; program terminated.\n");
   exit(EXIT_FAILURE);
}

errno가 함수 호출 오류를 감지하기 위해 사용되는 경우, errno0을 호출 이전에 저장하는 것이 좋다. errno는 프로그램 시작 시점에 0이 저장되기는 하지만, 그 이후 함수 호출에 의해 다른 값이 들어가 있을 수 있기 때문이다.

errno에 저장된 값은 EDOM 혹은 ERANGE인 경우가 많다.(두 개 모두 <errno.h>에 정의되어있다.) 이 매크로들은 두 종류의 에러를 나타내며, 이는 math 함수가 호출 될 때 발생할 수 있다.

  • Domain errors(EDOM) : 함수 도메인 외의 입력변수가 함수에 전달되는 경우
  • Range errors(ERANGE) : 함수의 반환 값이, 함수 정의에 따른 반환 타입으로 표현할 수 없는 경우.

C99는 여기에 EILSEQ 매크로가 추가되었다. <wchar.h>과 같이 특정 헤더에 정의된 라이브러리 함수는 EILSEQerrno에 저장된다.

perror과 strerror

void perror(const char *s);
char *strerror(int errnum);

이제 errno 변수와 관련된 두 함수를 살펴보자.(<errno.h>에 속하진 않았다.)
errno0이 아닌 값이 저장되는 경우, 우리는 오류의 특징을 메시지로 보고싶다. 이를 위한 방법은 perror 함수를 통해 (1) 입력변수 (2) 콜론 (3) 공백 (4) errno 값에 의한 오류 메시지 그리고 (5) 개행 문자를 순서대로 출력하는 것이다. perrorstrerror 스트림을 작성한다. 예시는 다음과 같다.

errno = 0;
y = sqrt(x);
if (errno != 0) {
	perror("sqrt error");
    exit(EXIT_FAILURE);
}

만약 sqrt 호출이 도메인 오류로 실패하는 경우 perror는 다음과 같이 출력을 수행한다.

sqrt errorL Numerical argument out of domain

perror가 출력하는 메시지는 구현 정의에 따른다. ERANGE는 일반적으로 EDOM과 다른 메시지를 출력한다.
strerror 함수는 <string.h>에 석한다. 에러 코드가 전달된 경우에 strerror은 에러를 나타내는 스트링에 대한 포인터를 반환한다. 예를 들어.

puts(strerror(EDOM));

은 다음과 같이 출력을 수행한다.

Numerical argument out of domain

strerror 입력변수는 일반적으로 errno 값 중 하나다. strerrorperror 함수와 밀접한 연관이 있는데, perrorstrerrorerrno의 값을 전달 받은 경우의 메시지와 같은 메시지를 출력한다.

3. <signal.h>

<signal.h> 예외 조건을 다룬다. Signal(이하 시그널)은 두 종류로 분류된다. 하나는 런-타임 에러고 하나는 프로그램 외부 이벤트다. 많은 운영체제에서 사용자로 하여금 실행중인 프로그램을 kill하거나 프로그램 실행에 인터럽트를 걸 수 있게 한다. 오류 혹은 외부 이벤트가 발생한 경우 우리는 시그널이 "들어올려졌다"라고 말할 수 있다. ㅁ낳은 시그널 들은 비동기화다. 따라서 프로그램 실행 중에 언제든지 발생할 수 있다.

시그널 매크로

<signal.h>은 시그널을 나타내는 많은 매크로를 정의하고 있다. 아래 표는 매크로 리스트다. 각 매크로의 값은 양의 정수(상수)다. C 구현은 다른 시그널 매크로 정의를 허용해주며, SIG로 시작하여 대문자로 이름이 붙여진다.
C 표준은 아래 표의 시그널이 자동적으로 들려질 것을 요구하지 않는다. 왜냐하면 특정 컴퓨터, 운영체제에서 모든 매크로가 의미를 가질 정도로 중요한건 아닐 수 있기 때문이다.

시그널 함수

void (*signal(int sig, void (*func)(int))) (int);

<signal.h> 는 raisesignal 함수 두 개를 지원해준다. 먼저 signal 함수를 살펴보자. 이 함수는 시그널-핸들링 함수를 설치한다. 첫 번째 입력변수는 특정 시그널을 위한 코드, 두 번째 입력변수는 시그널을 다룰 함수에 대한 포인터다. 다음은 signal을 통해 SIGING 시그널을 설치하는 예시다.

signal(SIGINT, handler);

handler는 시그널-핸들링 함수다. 만약 SIGINT 시그널이 발생하는 경우, handler가 자동적으로 호출된다.
모든 시그널-핸들링 함수는 int 매개변수를 가지며 void 형을 반환해야한다. 특정 시그널이 발생하고, 핸들러가 호출되는 경우, 핸들러는 시그널과 관련된 코드를 전달받는다. 어떤 시그널이 발생했는지 아는 것은 굉장히 유용하다. 이를 통해 같은 핸들러를 다른 여러 시그널에 활용할 수 있다.

시그널 핸들링 함수는 다양한 것을 할 수 있다. 시그널 무시, 오류에서의 회복, 프로그램 종료 등이다. abortraise가 호출되지 않는한 시그널 핸들러는 라이브러리 함수를 호출하거나, 정적-스토리지 듀레이션을 같ㅌ는 변수를 사용하려고 시도하지 않는다.

만약 시그널 핸들링 함수 반환이 이뤄지는 경우, 프로그램은 시그널이 발생한 그 지점에서 실행을 이어나간다. 단 두 가지, (1) 시그널이 SIGABRT인 경우에는 핸들러 반환과 함께 프로그램이 비정상적으로 종료되며, (2) SIGFPE을 다루는 함수 반환 효과는 정의되어 있지 않다. 그니까 이건 쓰지 말라고

signal이 어떤 값을 반환하기는 하지만 대부분의 경우 버려진다. 이 값(정의된 시그널에 대한 이전 핸들러에 대한 포인ㅌ터)은 변수에 저장될 수 있다. 특히, 오리지널 시그널 핸들러를 복구하고자 하는 경우, 우리는 signal의 반환 값을 저장해야만 한다.

void (*orig_handler)(int);
...
orig_handler = signal(SIGINT, handler);

이 구문은 SIGINT에 대한 handler를 설치하고 원래 핸들러에 대한 포인터를 변수에 저장한다. 복구를 수행하는 것은 다음과 같다.

signal(SIGINT, orig_handler);

사전 정의된 시그널 핸들러

직접 시그널 핸들러를 정의할 수도 있지만, 사전에 정의된 핸들러를 쓸 수도 있다.

  • SIG_DFL: 기본 설정으로 시그널을 다룬다.
  • SIG_IGN: 해당 시그널을 "무시"한다.

위 두 핸들러에 외에도, 다른 시그널 핸들러가 있다. 이들은 SIG_로 시작하는 대문자 이름을 가지고 있다. 프로그램 시작 시점에는 각 시그널의 핸들러는 기본적으로 SIG_DFL 혹은 SIG_IGN으로 설정되어있다.
SIG_ERR라는 매크로도 정의되어 있다. 이 매크로는 시그널 핸들러처럼 보인다. SIG_ERR는 실제로 시그널 핸들러 설치시, 에러 점검에 사용된다. signal 함수 호출이 실패하는 경우 SIG_ERR이 반환되고, errno에 양의 값을 저장한다. 다음은 사용 예시다.

if (signal(SIGINT, handler) == SIG_ERR) {
	perror("signal(SIGINT, handler) failed");
    ...
}

시그널 핸들러가 시그널을 발생시키는, 무한 재귀 문제를 해결하는 방법은 다음과 같다. 해당 시그널에 대한 핸들러를 SIG_DFL로 리셋하거나, 핸들러 실행 중에는 시그널을 막는 것이다.(SIGILL은 특별한 경우로 아무것도 필요로 하지 않는다.)
C99에선, 시그널 핸들링과 관련해 몇 가지 변경점이 있다. 시그널이 발행하는 경우, 시그널 뿐만 아니라 다른 것도 disable 시킨다. 만약 시그널 핸들링 함수가 SIGILL 혹은 SIGSEGV 시그널로부터 반환되는 경우, 그 이후는 정의되어있지 않다. C99는 abort 함수 호출의 결과 혹은 raise 의 호출로 인해, 시그널이 발생하는 경우 시그널 핸들러는 스스로 raise 함수를 절대 호출하지 않는다.

raise 함수

int raise(int sig)

시그널은 보통 런타임 에러 혹은 외부 이벤트로부터 발생하지만, 시그널을 프로그램 내에서 발생시키기도 한다. raise 함수가 바로 그것이다.

raise(SIGABRT); /* SIGABRT 시그널 발생 */

이 함수의 반환 값은 호출이 성공적으로 이뤄졌는지 검사할 때 사용할 수 있다. 0은 성공을, 그 외의 값은 실패를 가리킨다.

4. <setjmp.h>

int setjmp(jmp_buf env);
void longjmp(jmp_buf env, int val);

일반적으로 함수는 호출되었던 위치로 다시 되돌아 온다. 우리는 goto 구문을 통해 어디든지 갈 수 있는건 아니다. 왜냐하면 goto 구문은 같은 함수 내에서만 이동할 수 있기 때문이다. <setjmp.h>는 이 한계를 뛰어넘게 해준다.
<setjmp.h> 헤더에서 가장 중요한 것은 바로 setjmp 매크로와 longjmp 함수다. setjmp는 프로그램의 스택 환경을 마킹한다. longjmp는 해당 위치로 이동하는데 사용된다. 이는 다양한 응용을 가질 수 있지만, 주로 오류 핸들링에 사용된다.

profile
회계+IT=???

0개의 댓글