3P by neo 5달전 | favorite | 댓글과 토론
  • 현대의 Hello World 프로그램 뒤에 숨겨진 추상화의 세계 탐험

    • 이 글은 C로 작성된 Hello World 프로그램에 대한 내용임. C는 인터프리터/컴파일러/JIT에서 프로그램이 실제 동작하기 전에 언어가 무엇을 하는지 고민할 필요 없는 고급 언어 중에서는 가장 높은 수준임.
    • 원래는 코딩 배경이 있는 사람이라면 누구나 이해할 수 있도록 쓰려고 했으나, 적어도 C나 어셈블리에 대한 지식이 있으면 도움이 될 것 같음.
  • Hello World 프로그램 시작

    • 모든 사람들이 Hello World 프로그램에 익숙할 것임. 파이썬에서는 아마도 처음 작성한 프로그램이 print('Hello World!')와 같은 것이었을 것임.
    • 이 글에서는 C 프로그래밍 언어로 작성된 Hello World를 살펴볼 것임. C에서는 인터프리터를 호출해서 프로그램을 실행할 수 없음. 컴파일러를 먼저 실행해서 컴퓨터 프로세서가 직접 실행할 수 있는 기계어 코드로 변환해야 함.
  • 우리의 프로그램 분석

    • 컴파일된 프로그램 파일을 분석해보면 ELF 실행 파일이고 x86-64 명령어 셋 아키텍처용임을 알 수 있음.
    • ELF 실행 파일은 리눅스에서 윈도우의 .exe 파일과 동등한 것임.
    • x86-64는 1981년 IBM PC가 도입된 이래로 PC에서 사용되어 온 CPU 아키텍처임.
    • 이 파일은 CPU만이 이해할 수 있는 유일한 언어인 기계어 코드를 포함하고 있음.
  • 어셈블리 코드 분석

    • 프로그램의 시작 주소인 엔트리 포인트를 찾아 어셈블리 코드를 분석해봄.
    • 어셈블리어는 기계어 코드를 사람이 읽을 수 있는 형태로 표현한 것임.
    • 컴파일러(정확히는 링커)에 의해 자동으로 추가된 초기화 코드가 보이고, __libc_start_main 함수를 호출하는 것을 볼 수 있음.
    • 하지만 이 코드는 우리 프로그램에 정의되어 있지 않고 다른 어딘가에 있음.
  • C 표준 라이브러리

    • __libc_start_main 함수는 우리 시스템의 표준 C 라이브러리인 libc.so.6에 정의되어 있음.
    • C 표준 라이브러리는 우리 컴퓨터의 거의 모든 프로그램들이 사용하는 루틴과 함수들의 모음임.
    • C 라이브러리에서 초기화 작업을 수행하고 우리가 작성한 main() 함수를 호출함. main()에서 리턴하면 우리가 제공한 종료 코드로 프로그램을 종료시킴.
  • main() 함수 분석

    • main() 함수에서는 스택 프레임을 설정하고, Hello World 문자열의 주소를 함수 호출의 인자로 설정한 후, puts() 함수를 호출함.
    • puts()는 printf()를 호출했는데 컴파일러가 최적화를 수행해서 바꾼 것임. printf()는 복잡하지만 puts()는 단순히 서식 없는 문자열을 출력하기 때문임.
  • Hello World 문자열

    • 문자열은 "Hello World!" 다음에 NULL 종료자가 오는 형태로 되어있음.
    • C에서는 문자열과 관련된 길이 정보가 없기 때문에 NULL 종료자로 문자열의 끝을 표시함. NULL 종료자가 없으면 허용되지 않은 메모리를 읽다가 프로그램이 Segmentation Fault로 죽게 됨.
    • 컴파일러 최적화로 인해 printf()에서 사용한 개행(\n)은 제거되었음. puts()는 문자열 출력 후 개행을 해주기 때문임.
  • puts() 함수

    • puts() 함수는 다시 표준 라이브러리 내의 코드를 호출하게 됨.
    • glibc의 코드를 살펴보면 _IO_puts -> _IO_new_file_xsputn 순으로 호출되는 것을 볼 수 있지만 코드가 복잡해서 설명하기 어려움.
    • musl libc의 경우 좀 더 간단함. puts -> fputs -> fwrite -> __fwritex -> __stdio_write -> syscall 순으로 호출됨.
  • 시스템 호출

    • 아무리 C 라이브러리가 크더라도 하드웨어와 직접 통신하는 것은 불가능함. 그것은 커널만이 할 수 있음.
    • 따라서 puts() 호출은 결국 OS에게 뭔가 해달라고 요청하는 것으로 끝나게 됨. 여기서는 출력 스트림에 문자열을 쓰는 것임.
    • musl libc는 writev라는 시스템 호출을 사용하는데, 이는 여러 개의 버퍼를 한 번에 쓸 수 있게 해줌.
    • 시스템 호출은 레지스터에 파라미터를 설정하고 syscall 명령을 실행함으로써 이뤄짐. 그러면 제어가 커널로 넘어가고 커널이 파라미터를 읽어서 시스템 호출을 수행하게 됨.
  • 커널

    • 리눅스 커널은 시스템 호출에 의해 요청된 동작을 수행해야 함. write 시스템 호출은 파일 시스템의 열린 파일이나 스트림에 쓰라고 커널에게 지시함.
    • write는 쓸 파일 디스크립터, 쓸 버퍼, 쓸 바이트 수 등 3개의 파라미터를 받음.
    • 실제로 어디에 쓰는지는 상황에 따라 다름. 터미널 에뮬레이터인 경우 가상 터미널(pty)로 보이고, 원격 로그인인 경우 sshd로 전달되고, 물리 터미널인 경우 시리얼-USB 어댑터로 감. 프레임 버퍼 콘솔인 경우 커널이 텍스트를 렌더링해서 디스플레이로 출력함.
  • 결론

    • 현대의 소프트웨어 시스템은 하드웨어 위에서 매우 복잡하고 정교하게 동작하기 때문에 컴퓨터가 한 작은 일을 완전히 이해하려고 하는 것은 무의미함.
    • 모든 것을 다 설명하기 위해서는 많은 부분을 생략해야만 했음.
    • Hello World 메시지를 보내는 것은 지금 컴퓨터에서 실행 중인 수많은 시스템 콜과 프로그램들 중 하나에 불과함.

GN⁺의 의견

  • 컴퓨팅 시스템의 각 계층이 추상화를 통해 아래 계층의 복잡성을 감추고 있어 개발자가 편리하게 애플리케이션을 개발할 수 있게 해준다는 점을 보여주는 글이네요.
  • 한편으로는 애플리케이션의 한 줄이 실행되기 위해 그 아래에서 얼마나 많은 일이 일어나는지를 깨닫게 해주고, 디버깅이 왜 어려운지도 알게 해줍니다.
  • 모든 프로그래머는 자신이 주로 사용하는 언어 아래의 시스템까지는 잘 알고 있어야 한다고 봅니다. 전체를 다 알 필요는 없지만, 추상화된 부분이 실제로는 어떻게 동작하는지를 아는 것은 중요해요.
  • 고급 언어를 쓰더라도 메모리 구조, 스택과 힙, 시스템 호출 등의 시스템 프로그래밍 개념을 공부해두면 디버깅이나 성능 최적화에 큰 도움이 될 거예요.
  • 애플리케이션 개발자는 컴파일러나 C 라이브러리를 직접 건드릴 일은 거의 없겠지만, 내가 짠 프로그램이 결국 어떤 식으로 시스템을 사용하는지를 이해하는 것은 좋은 프로그래머가 되는 데 필수적이라고 봅니다.