Proven C BookEnglish GitHub

53 main의 세 얼굴 — 진입점과 종료 상태

먼저 알아야 할 것

3장 프로그램과 프로세스 · 프로세스로 실행된다는 것
19장 프로그램의 구조 · 프로그램의 뼈대

돌아보기

3장에서 프로세스가 끝나며 종료 상태라는 숫자 하나를 남긴다고 했고, 14장의 헬로 월드는 return 0;으로 끝났다. 그러면 그 0은 누가 받고, 0이 아닌 값을 돌려주면 무슨 일이 일어나는가?

답. 받는 쪽은 이 프로그램을 실행한 쪽이다 — 터미널의 셸, 빌드 도구, 스크립트, 또는 이 프로그램을 자식으로 띄운 다른 프로그램. 그들은 그 숫자 하나로 “잘 끝났는가”를 판단하고 다음 행동을 정한다. 그래서 main의 반환값은 장식이 아니라 프로그램이 바깥세상과 나누는 마지막 대화다.

이 장의 필요성과 맥락

10부는 「한 파일짜리 프로그램」을 졸업하는 부다. 그 첫 걸음이 main 인 것은 프로그램의 경계가 거기이기 때문이다 — 바깥에서 무엇이 들어오고(argv) 바깥으로 무엇이 나가는지(종료 상태). 안쪽을 쉰두 장 동안 다뤘으니, 이제 바깥과 맞닿는 면부터 정리하고 파일을 쪼갠다.

이 장이 끝나면

14장에서 “약속된 출발점”이라고만 하고 지나간 main을 정면으로 다룬다. 표준이 허용하는 세 가지 형태, 명령줄 인자 argc·argv의 정확한 계약, 그리고 돌려주는 값이 어디로 가서 무엇이 되는지 — 리눅스·윈도우·임베디드의 관례까지. void main()이 왜 틀린 표기인지도 여기서 매듭짓는다.

이 장에서 답할 질문

  1. 인자를 해석하는 코드를 직접 짜야 하는가? -v--help 같은 것들 말이다.
  2. main이 특별한 함수라면, 재귀로 부르거나 주소를 얻어도 되는가?

53.1 표준이 허용하는 세 가지 형태

C 표준은 main의 정의를 이렇게 못박는다 — 반환 타입은 int여야 하고, 매개변수는 없거나, 둘이거나, 아니면 구현이 정한 다른 방식이어야 한다. 그래서 세 얼굴이다.

형태언제 쓰나지위
int main(void)명령줄 인자를 쓰지 않을 때표준
int main(int argc, char *argv[])인자를 받을 때표준
그 밖의 형태envp 같은 확장 인자구현 정의

표 53.1

두 번째 형태의 char *argv[]char *argv라고 적어도 같다(42장의 배열 매개변수가 포인터로 무너지는 규칙 그대로다). 이름도 자유다 — argc·argv는 관례일 뿐이다.

세 번째 형태의 대표가 int main(int argc, char *argv[], char *envp[])다. 유닉스 계열과 윈도우가 흔히 지원하지만 표준이 아니고, 환경 변수를 읽는 이식성 있는 길은 getenv(63장)다.

반례. void main()

void main(void) {}        /* 표준이 아니다 */

옛 교재와 Turbo C 시절 코드에서 흔히 보이는 표기다. 표준은 반환 타입을 int로 못박았으므로, 이 표기는 호스트 환경에서는 계약 밖이다. 실제로 많은 컴파일러가 경고를 내고, 일부는 오류로 처리한다.

두 가지 예외적 사정만 알아 두면 된다. 첫째, 자유 사용 구현(61장)에서는 시작 함수의 이름과 형태가 구현이 정하는 것이라 void main(void)를 공식 지원하는 임베디드 컴파일러가 실제로 있다 — 반환값을 받을 곳이 없으니 그 코드를 없애 크기를 아끼려는 것이다. 둘째, 그것은 그 컴파일러의 약속이지 표준의 약속이 아니다. 호스트 환경의 코드에서는 언제나 int다.

53.2 argcargv의 계약

examples/ch53/entry.c

/* main 이 받는 것과 남기는 것 */
#include <stdio.h>
#include <stdlib.h>

/* 프로그램이 끝날 때 불러 달라고 등록해 두는 함수 */
static void farewell(void)
{
    puts("  atexit: the cleanup we registered runs here");
}

int main(int argc, char *argv[])
{
    atexit(farewell);

    printf("argc = %d\n", argc);
    for (int i = 0; i < argc; i++)
        printf("  argv[%d] = \"%s\"\n", i, argv[i]);

    /* 표준이 약속한다: argv[argc] 는 반드시 널 포인터다 */
    printf("is argv[argc] null? %s\n", argv[argc] == nullptr ? "yes" : "no");

    printf("EXIT_SUCCESS = %d, EXIT_FAILURE = %d\n", EXIT_SUCCESS, EXIT_FAILURE);

    /* 성공을 알리는 세 가지 표기는 모두 같은 뜻이다 */
    return EXIT_SUCCESS;        /* == return 0; == (C99 부터) 그냥 끝내기 */
}

실행 결과

argc = 1
  argv[0] = "/opt/data/ai-share/proven_c_book/build/examples-bin/ch53/entry"
is argv[argc] null? yes
EXIT_SUCCESS = 0, EXIT_FAILURE = 1
  atexit: the cleanup we registered runs here

표준이 약속하는 것은 다음과 같고, 이 약속이 인자 처리 코드의 뼈대가 된다.

  1. argc0 이상이다. 인자가 하나도 없을 수 있다.
  2. argv[0]프로그램 이름이다 — 다만 이름을 알 수 없으면 빈 문자열일 수 있다. 그래서 argv[0]을 무조건 믿고 경로를 계산하는 코드는 위험하다.
  3. argv[1]부터 argv[argc-1]까지가 실제 인자다.
  4. argv[argc]는 반드시 널 포인터다. 예제가 그것을 확인했다. 그래서 for (char *p = argv; *p; p++)처럼 널을 끝 표시로 삼는 순회가 성립한다.
  5. 이 문자열들은 수정할 수 있고, 프로그램이 도는 동안 유효하다.

문. 인자를 해석하는 코드를 직접 짜야 하는가? -v--help 같은 것들 말이다.

답. 먼저 두 가지를 갈라야 한다 — 인자를 받는 것인자를 해석하는 것은 다른 일이다.

받는 것은 표준에 있다. 방금 본 argc·argv가 그것이고, 어느 플랫폼 에서든 이 두 매개변수로 인자가 들어온다. 이 자리에 부족한 것은 없다.

해석기가 없는 것이다.-v는 자세히 보기, --out FILE은 값이 붙는 옵션, 나머지는 파일 이름”처럼 해석하는 규칙을 표준 C는 하나도 제공하지 않는다. getopt 같은 함수가 <stdlib.h>에 없다는 뜻이다. 그래서 그 일은 세 갈래로 나뉜다.

직접 짜기 — 짧은 프로그램이면 이것으로 충분하다. argv를 훑으며 strcmp로 가르고, 값이 붙는 인자는 다음 칸을 읽는다. 숫자로 바꿀 때는 atoi가 아니라 strtol을 쓴다(63장).

플랫폼 도구 — 유닉스 계열에는 POSIX의 getopt(<unistd.h>)와 GNU의 getopt_long(--이름 형태를 다룬다)이 있고, glibc에는 도움말까지 만들어 주는 argp가 있다. 전부 표준이 아니라 플랫폼의 것이다. 윈도우의 C 런타임에는 getopt가 없어서, 이식 프로젝트는 대개 getopt 구현 하나를 저장소에 넣어 두거나 자체 해석기를 쓴다.

라이브러리 — 규모가 커지면(하위 명령, 도움말 생성, 설정 파일과의 병합) 인자 해석 전용 라이브러리를 쓴다.

어느 쪽이든 규칙 하나는 지킨다: 인자는 바깥에서 온 입력이다. 44장의 입력 처리 수칙 — 길이를 믿지 않고, 숫자 변환의 실패를 확인하고, 경로를 그대로 이어 붙이지 않는 것 — 이 그대로 적용된다.

플랫폼 노트. 인자는 어떻게 프로그램까지 오는가 — 유닉스와 윈도우

argv가 표준이라고 해서 만들어지는 방식까지 같은 것은 아니다. 두 세계가 정반대다.

유닉스 계열 — 프로그램을 띄우는 쪽이 애초에 문자열의 배열을 넘긴다 (3장의 execve가 그렇다). 셸이 따옴표와 와일드카드를 먼저 처리해 조각내 주므로, 프로그램이 받는 argv는 이미 나뉘어 있다. 커널은 그 배열을 그대로 새 프로세스에 실어 준다.

윈도우CreateProcess문자열 하나를 넘긴다(3장). 즉 나누는 일이 받는 쪽 몫이다. 그래서 C 런타임이 시작 코드에서 그 한 줄을 규칙에 따라 쪼개 argv를 만들어 main에 넘긴다 — argc·argv가 표준대로 오는 것은 런타임이 그 일을 대신해 주기 때문이다.

이 차이가 실무에 두 가지를 남긴다. 첫째, 윈도우에서는 원본 명령줄을 직접 볼 수 있고 필요하면 직접 쪼갤 수도 있다 — GetCommandLineW가 그 한 줄을 돌려주고, CommandLineToArgvW가 표준 규칙으로 쪼개 준다. 유니코드 인자를 온전히 받으려면 wmain(또는 이 두 함수)을 쓰는 것이 관행이다. 둘째, 쪼개는 규칙이 플랫폼마다 다르다 — 따옴표와 역슬래시의 처리가 특히 그렇다. 그래서 다른 프로그램을 띄우며 인자를 만들 때는 “문자열을 손으로 이어 붙이지 말고 인자를 배열로 넘기는” API(posix_spawn, CreateProcess의 인자 조립 규칙)를 쓰라는 조언이 나온다(66장의 system 반례와 같은 결이다).

53.3 돌려주는 값 — 세 가지 표기와 하나의 뜻

main을 끝내는 방법은 셋이고, 셋 다 같은 뜻이다.

return 0;              /* 명시적 */
return EXIT_SUCCESS;   /* <stdlib.h> 의 이름. 값은 0 */
}                      /* 그냥 끝내기 — C99 부터 return 0; 과 같다 */

마지막 것이 C99에서 들어온 특례다. main에 한해 반환값 없이 끝나면 0을 돌려준 것으로 친다(다른 함수에서는 값을 안 돌려주고 그 값을 쓰면 계약 밖이다). C89 시절 코드가 값 없이 끝나는 일이 흔했던 것을 표준이 뒤늦게 추인한 셈이다.

실패를 알릴 때는 EXIT_FAILURE를 쓴다. 예제가 확인한 대로 흔한 값은 1이지만 표준이 1이라고 약속하지는 않는다 — “0이 아닌, 실패를 뜻하는 값”일 뿐이다. 그 밖의 숫자를 돌려주는 것은 구현 정의다.

끝내는 방법하는 일주의
return n; (main 에서)exit(n)과 같다정리 절차가 모두 돈다
exit(n)어디서든 프로그램 종료atexit 등록 함수 실행, 스트림 비움
quick_exit(n)빠른 종료(C11)at_quick_exit 만 돈다
_Exit(n)즉시 종료정리를 하지 않는다
abort()비정상 종료정리 없음. 코어 덤프가 남을 수 있다

표 53.2

예제의 atexit가 그 정리 절차를 보여 준다 — 등록해 둔 함수가 main이 끝난 뒤에 돌았다. 여기에 열린 스트림을 비우는 일(63장의 버퍼)도 포함된다. 그래서 _Exitabort로 끝내면 출력이 사라질 수 있다.

흔한 오해. “종료 상태로 아무 숫자나 돌려줄 수 있다”

돌려줄 수는 있지만 받는 쪽이 그대로 보리라는 보장은 없다. 유닉스 계열은 자식의 종료 상태를 전할 때 하위 8비트만 쓴다. 그래서 return 300;으로 끝낸 프로그램의 상태를 셸에서 보면 300이 아니라 44다(300 − 256).

$ ./ex ; echo $?
44

윈도우는 32비트 종료 코드를 그대로 전하므로 이 잘림이 없다. 이식성 있는 규칙은 하나다 — 0은 성공, 1은 실패, 그 밖에는 0–125 범위의 작은 수만 쓴다. 큰 수나 음수를 상태로 흘려보내지 않는다.

53.4 관례 — 그 숫자를 누가 어떻게 읽는가

플랫폼 노트. 리눅스·유닉스 계열

셸이 마지막 명령의 상태를 $?에 담아 둔다. 관례는 이렇다.

0성공
1일반적인 실패
2사용법 오류(많은 도구의 관례)
126실행할 수는 있으나 권한 등으로 실행 실패(셸)
127명령을 찾을 수 없음(셸)
128 + NN번 신호로 죽었다(셸의 표기)

표 53.3

이 관례 덕에 스크립트가 if ./프로그램; then …처럼 상태만으로 흐름을 정할 수 있고, make와 CI가 실패를 알아챈다. BSD 계열에는 <sysexits.h>에 더 세분한 관례(EX_USAGE 64 등)가 있지만 널리 쓰이지는 않는다.

주의: 128 이상은 신호로 죽은 것과 헷갈리므로, 프로그램이 스스로 돌려주는 값으로는 피하는 편이 좋다.

플랫폼 노트. 윈도우

같은 숫자가 종료 코드(exit code)라 불리고, 배치 파일에서 %ERRORLEVEL%로, PowerShell 에서 $LASTEXITCODE로 읽는다. 프로그램에서 자식의 코드를 받을 때는 GetExitCodeProcess를 쓴다.

차이는 둘이다. 하위 8비트로 잘리지 않는다(32비트 그대로), 그리고 특정 값들이 시스템 오류 코드와 겹쳐 읽힐 수 있다. 일반 프로그램은 여전히 0과 1을 쓰는 것이 안전하다.

플랫폼 노트. 임베디드 — 돌아갈 곳이 없다

운영체제가 없는 기계에서는 main이 끝나도 상태를 받을 상대가 없다. 그래서 이 세계의 main은 대개 끝나지 않는다.

int main(void)
{
    init();
    for (;;) {            /* 영원히 돈다 */
        poll();
    }
}

만약 끝나면 무슨 일이 벌어지는가 — 그것도 구현이 정한다. 시작 코드가 무한 루프로 붙잡아 두거나, 칩을 리셋하거나, 아무 데로나 흘러갈 수 있다. 그래서 임베디드 코딩 규약들은 “main은 반환하지 않는다”를 명시적으로 요구하는 경우가 많다. 진입점의 이름조차 main이 아닐 수 있다는 것은 61장에서 보게 될 그대로다 — 리셋 벡터가 가리키는 함수가 시작점이고, 그 함수가 .data를 옮기고 .bss를 0으로 채운 뒤 main을 부른다(83장).

실제 사례. 종료 상태 하나가 만든 도구 생태계

“0은 성공”이라는 한 줄의 관례가 얼마나 큰 것을 지탱하는지 보면 재미있다. 셸의 &&||, make가 규칙 하나가 실패하면 멈추는 것, CI가 빌드 실패를 판정하는 것, 컨테이너가 프로세스의 종료 코드로 재시작을 결정하는 것, 테스트 러너가 통과·실패를 세는 것 — 전부 이 숫자 하나를 읽는다.

9장에서 본 “작은 프로그램을 텍스트 스트림으로 잇는다”는 유닉스 철학에는 사실 통로가 하나 더 있었던 셈이다. 데이터는 스트림으로 흐르고, 성공과 실패는 종료 상태로 흐른다. 그래서 도구다운 프로그램을 만들려면 두 가지를 지켜야 한다: 결과는 표준 출력으로, 오류 메시지는 표준 오류로, 그리고 성패는 반드시 종료 상태에 담을 것.

문. main이 특별한 함수라면, 재귀로 부르거나 주소를 얻어도 되는가?

답. 호스트 환경의 C에서 main은 문법적으로 평범한 함수라, 부르는 것도 주소를 얻는 것도 문법상 가능하다. 그러나 하지 않는 것이 규범이다 — C++은 아예 금지하고, C에서도 시작 코드와 라이브러리 초기화가 얽힌 자리라 얻을 것이 없다. 프로그램의 시작은 한 번뿐이라고 두는 편이 읽는 사람에게도 낫다.

대신 알아 둘 것이 있다. main이 시작점이라는 말은 C 프로그램 관점의 이야기다. 실제로는 그 앞에 시작 코드(crt0 같은 것)가 돌면서 정적 구역을 준비하고 인자를 챙겨 main을 부른다(83장). main은 “가장 먼저 도는 코드”가 아니라 “우리가 쓰는 코드 중 가장 먼저 도는 것”이다.

복습 정리

기억할 것요지
세 형태int main(void), int main(int, char *[]), 구현 정의
void main()표준이 아니다(임베디드 확장은 별개)
argv[0]프로그램 이름 — 비어 있을 수 있다
argv[argc]반드시 널. 순회의 끝 표시
끝내는 법return·exit(정리함) / _Exit·abort(정리 안 함)
C99 특례main은 값 없이 끝나도 0
값의 범위0–125 의 작은 수. 유닉스는 하위 8비트만 전한다
관례0 성공, 1 실패, 2 사용법 오류(유닉스)
임베디드main은 대개 반환하지 않는다

표 53.4

프로그램의 시작과 끝을 보았다. 이제 프로그램이 여러 파일로 나뉘는 이야기로 넘어간다 — 16장의 링커가 다시 등장하는 자리다.