[Windows] - 콘솔 앱과 GUI 앱을 더블클릭하면 과연 어떤 차이가?
#1. 콘솔 앱이 갑자기 뜨고 갑자기 사라지는 이유
▷ Visual Studio에서 C/C++ 파일을 빌드하면 실행 파일(exe)이 생성된다. Visual Studio에서 Ctrl+F5로 실행할 수도 있지만, 빌드 디렉터리에 직접 찾아가 생성된 exe 파일을 더블클릭해도 된다. 하지만 콘솔 애플리케이션일 경우, 갑작스럽게 콘솔 창이 뜨고 사라지는 모습만 휑하니 보게 된다...
▷ 콘솔 창을 열고 거기서 직접 exe를 실행해야만 콘솔 창이 사라지지 않고 아래처럼 정상 실행되는 모습을 볼 수 있다.
▷ 그러나 GUI로 빌드한 Windows 애플리케이션은 더블클릭하면 콘솔이 열리고 닫히는 것이 아니라 처음부터 윈도우 창이 생성되어 실행된다. 마치 Windows가 똑같은 exe 파일인데도 무엇이 콘솔이고 무엇이 GUI인지 미리 알고 있는 듯한 모습이다. 이 비밀은 무엇일까?
▷ 콘솔 프로그램을 더블 클릭해서 실행할 때 갑자기 뜨고 갑자기 사라지는 이유는, 파일을 더블클릭하면, Windows가 해당 파일이 콘솔에서 동작하는 프로그램이라고 판단하고 콘솔 창을 띄워서 실행시켜주기 때문이다. 그런데 해당 프로그램이 "Hello, World"를 출력하고 바로 return;을 했으니 프로세스가 종료되어 함께 실행시킨 콘솔 환경도 사라지는 것이다. 이 과정이 너무나 순식간에 벌어져 사용자 입장에서는 검은 화면이 갑자기 떳다 갑자기 사라지는 것으로 보인다.
▷ 그러나 콘솔 환경을 이미 실행하고 있는 상황에서 해당 실행 파일을 실행하면 설사 작업이 끝나 프로세스가 종료되었다고 하더라도 이미 실행하던 콘솔과는 별개이므로 콘솔은 사라지지 않는 것이다.
▷ 결론은, Windows는 실행 파일이 콘솔이면 더블클릭할 때 미리 콘솔 창을 띄워서 실행해주는 역할을 해준다는 점이다. 해당 파일이 종료되면 열여줬던 콘솔 창도 종료시켜버린다. GUI 프로그램은 콘솔이 필요하지 않으므로 굳이 콘솔을 열어주지 않는 것이다.
#2. PE 포맷의 서브 시스템 (하위 시스템)
▷ 더블 클릭을 했을 때 Windows가 해당 파일이 콘솔인지 GUI인지 판단하는 기준은 PE 파일 헤더에서 "서브 시스템" 항목을 통해 알아낼 수 있다.
▷ C++ 파일을 Windows에서 빌드하면 PE 파일 포맷으로 실행 파일이 생성된다. 확장자는 exe이긴 하지만 내부적인 파일 포맷은 PE라고 부른다. (사실 확장자는 그렇게 중요한 개념은 아니다...) 위 공식 문서를 보면, PE 파일 형식 중 "SUBSYSTEM"이라는 항목이 바로 이 파일이 어떤 환경에서 실행되도록 설계되었는지 OS에게 알려주는 정보다.
▷ 2번과 3번이 가장 대표적인 실행 파일 형식이다. 전자는 GUI이고 후자는 콘솔 환경에서 실행된다고 가정하고 있다. Windows는 실행 파일의 이 항목 정보를 토대로 콘솔인지 GUI인지 판단하고 적절한 조치를 취하는 것이다. 만약 콘솔로 정의되었다면 콘솔창을 대신 열어주고 필요한 작업들을 미리 지원해주는 것이다.
#3. 하위 시스템을 Visual Studio에서 확인하기
▷ Visual Studio는 빌드할 PE 파일 헤더의 '서브시스템' 항목을 변경할 수 있는 옵션을 위와 같이 제공해준다.
▷ 이 옵션은 링커에게 전달하는 것이다. 링커는 컴파일러가 만들어준 오브젝트 파일을 한데 모아 실행 파일(혹은 라이브러리)을 만드는 역할을 수행한다. 만약 WINDOWS나 CONSOLE로 옵션을 설정해주면 링커는 최종 빌드 결과물 PE 파일 포맷에 설정한 서브 시스템을 설정해주는 것이다.
▷ 하지만 경우에 따라 링커가 단순히 PE 파일에 서브 시스템이 무엇인지 정보만 기입하고 끝내지 않을 수도 있다. 링커는 명령 옵션에 /ENTRY 값이 별도로 설정되지 않은 경우, 정의된 서브 시스템에 따라 실행 파일의 진입점(entry point)이 되는 함수를 자동으로 지정한다. 위 그림의 설명 부분을 보자. 설정한 하위 시스템에 따라 링커가 진입점을 결정한다고 나와 있다.
→ /SUBSYSTEM: WINDOWS라면 링커는 표준 런타임 라이브러리에서 mainCRTStartup() 이라는 함수를 찾아 이것을 실행 파일의 엔트리 포인트로 설정하고 PE를 생성한다.
→ /SUBSYSTEM: CONSOLE이라면 링커는 표준 런타임 라이브러리에서 WinMainCRTStartup() 이라는 함수를 찾아 이것을 실행 파일의 엔트리 포인트로 설정하고 PE를 생성한다.
▷ 사실 우리가 사용하는 WinMain()이나 Main() 함수는 엄밀히 말해 실행 파일의 진입점이 아니다. PE 파일에는 프로그램의 진입점에 해당하는 함수의 주소가 있는데, 이 주소가 실은 WinMain()도 아니고 Main()도 아니라는 소리다. 충격!
▷ WinMain()이든, Main()이든 프로그램을 시작하기 전에 별도의 초기화 작업을 거쳐야 한다. 이를 위해 먼저 mainCRTStartup() 혹은 WinMainCRTStartup()이라는 함수가 표준 런타임 라이브러리에 공개되어 있는데, 링커가 이것을 진입점으로 잡아주는 것이다. 이 각각의 함수가 초기화를 수행한 뒤에 main()이나 WinMain()을 실행하는 원리다.
▷ 따라서 만약 main() 함수를 만들고 서브시스템을 WINDOWS로 설정하고 빌드하면 링크 단계에서 아래와 같은 오류를 만난다.
▷ 서브시스템이 WINDOWS이므로 ( 특별히 /ENTRY 옵션을 사용하지 않았다면 ) 링커가 자동으로 진입점을 WinMainCRTStartup()으로 설정하는데, 이 함수는 내부적으로 WinMain()을 호출한다. 그런데 내 코드에 WinMain() 함수가 없으므로 링크에 실패했다는 메시지가 뜬다.
▷ 반대도 마찬가지다. WinMain() 함수를 작성했는데, 정작 링커에게 서브 시스템을 콘솔로 정의(/SUBSYSTEM:CONSOLE)하고 ( 특별히 엔트리 함수를 옵션을 사용하지 않았다면 ) 링커는 자동으로 mainCRTStartup()을 진입점으로 설정한다. 이 함수는 내부적으로 일련의 CRT 초기화 작업을 마친 뒤에 main()을 호출하는데, 이 함수가 내 코드에 정의되어있지 않으니 링커가 짜증내는 것이다.
#4. 서브시스템과 진입점 변경
▷ 그렇다면 서브시스템(/SUBSYSTEM)을 CONSOLE 혹은 WINDOWS로 설정하고, 진입점(/ENTRY)도 특정 값으로 고정시켜버리면 어떻게 될까? 예를 들어, /SUBSYSTEM: WINDOWS /ENTRY: mainCRTStartup 으로 설정하면 어떻게 될까?
▷ main이 있는 평범한 프로젝트의 링크 옵션을 1) 서브시스템: WINDOWS, 2) 진입점 : mainCRTStartup 으로 설정한 모습이다. 놀랍게도.... 빌드가 된다!
▷ 생성된 최종 실행 파일은 서브시스템이 WINDOWS로 정의되었기 때문에 이 파일의 아이콘을 더블클릭해봤자 아무 일도 일어나지 않는다. 서브시스템이 WINDOWS이므로 우리의 OS가 굳이 콘솔을 세팅해주지 않기 때문이다.
▷ 그렇다면 내가 작성한 main이 실행되긴 하는 걸가? 정답은 "실행된다" 이다. 예를 들어, 메모장 파일을 하나 만들어서 텍스트를 입력하고 저장하는 아래의 코드는 설령 서브시스템이 WINDOWS로 설정되었다고 하더라도 엔트리를 mainCRTStartup()으로 설정했기 때문에 main이 호출되어 정상 실행된다. 파일이 만들어진다.
▷ 물론 더블클릭해도 OS가 콘솔을 세팅해주지 않기 때문에 콘솔을 볼 수는 없다. 게다가 printf()와 같이 콘솔에서 입출력하는 함수, 즉 stdout, stdin과 같은 함수는 정상 동작하지 않으며, 일부 동작하지 않는 함수들도 몇 가지 있는 것을 보면 이런 식으로 빌드할 때는 꽤 주의를 기울여야 한다. 아마 OS가 콘솔을 위한 환경을 세팅해주지 않아서 콘솔 기반의 함수들이 잘 동작하지 않는 것 같다.
▷ 이것을 역으로 응용하는 프로젝트도 몇 개 본 기억이 있다. 확실한 동작 원리는 모르겠지만, 대부분 굳이 콘솔을 실행하지 않으면서 main() 함수 형태로 무언가 실행하고 싶을 때 이렇게 사용하는 것 같았다. 내가 본 0 A.D. 게임도 진입점 함수를 따로 코딩한 뒤에 그 진입점에서 OS마다 전처리로 분기해 windows는 wmainCRTStartup()을 실행하는 구조였다.