컴퓨터는 low-level 작업을 인코딩한 연속적인 바이트 모음인 기계어를 실행하는 장치입니다. 컴파일러는 여러단계를 거쳐 소스 코드로 부터 기계어를 생성합니다. 프로그래밍 언어 규칙, 타겟 머신의 인스트럭션 집합 그리고 운영체제에 의해 규정된 컨벤션에 기초해 생성합니다. GCC
(C 컴파일러) 컴파일러는 어셈블리 형태의 기계어를 만들어냅니다. 어셈블리 코드는 프로그램 개별 인스트럭션을 텍스트 형태로 나타낸 코드입니다. GCC는 어셈블러(assembler), 링커(linker)가 어셈블리 코드로부터 실행 가능한 기계어를 생성하게 합니다. 3장에서 기계어와 어셈블리 코드를 살펴볼 예정입니다.
프로그래밍 언어들(C 또는 자바 등등)은 프로그램 machine-level로부터 추상화 되어있습니다. 대조적으로, 어셈블리 프로그램는 프로그래머가 반드시 low-level 인스트럭션을 정해야만 합니다. 대개는 high-level 언어가 제공하는 추상화를 활용해 개발하는 것이 더 생산적이고 신뢰할만 합니다. 컴파일러가 제공하는 타입 체킹은 많은 프로그램 오류를 사전에 발견하는데 도움이 되며, 데이터를 일관성있게 참조하고 활용할 수 있게 해줍니다. 최적화된 현대 컴파일러를 통해, 숙련된 어셈블리 개발자보다 더 효율적으로 코드를 작성할 수 있습니다. 무엇보다 high-level 언어로 작성된 프로그램은 다양한 기계에서 동작하지만, 어셈블리 프로그램은 더 기계에 의존적입니다.
이런 단점에도 불구하고 왜 머신 코드를 배워야 할까요? 비록 컴파일러가 어셈블리 코드를 생성에 있어 대부분의 일을 책임지지만, 어셈블리를 읽고 이해하는 것은 여전히 프로그래머에게 중요한 기술 중 하나입니다. 컴파일러에 적절한 argument 명령어를 주면, 컴파일러는 어셈블리 형태의 파일을 생성합니다. 이 코드를 통해, 컴파일러의 최적화 능력을 확인할 수 있고 더 나아가 해당 코드에 내재된 비 효율성을 분석할 수 있습니다. 5장에서 살펴보겠지만, 코드에 있어 퍼포먼스를 최대로 끌어올리기를 원하는 개발자가 종종 다른 형태의 소스코드를 만드는데, 그 때마다 컴파일하고 생성된 어셈블리코드를 확인하면서 프로그램이 어떻게 효율적으로 동작할 수 있을지 알아낼 수 있습니다. 게다가, 고차원 언어가 제공하는 추상화가 프로그램 런타임 동작에 대한 정보를 숨기는 경우가 있습니다. 예를 들어, (12장에서 확인할 내용입니다.) thread package를 활용하여 concurrent한 프로그램을 작성할 때 서로 다른 스레드가 프로그램 데이터를 어떻게 공유하는지 그리고 private하게 유지되는지 그리고 어디서, 어떻게 공유된 데이터를 접근하는지 이해하는 것이 중요합니다. 이러한 정보들은 기계어 레벨에서 확인할 수 있습니다. 다른 예시로는, 프로그램 해킹이 있습니다. 대표적인 해킹 공격에는 멀웨어가 시스템을 오염시켜 시스템에 존재하는 런타임 제어 정보들을 저장하는 것이 있습니다. 이런 해킹 공격들은 시스템의 약점을 활용하여 정보를 덮어써 궁극적으로 시스템에 대한 통제권을 가져옵니다. 이런 시스템 취약성을 이해하고 이를 어떻게 방어할 것인가는 기계어 수준에 대한 이해가 필요합니다. 기계어 이해 필요성은 어셈블리 코드를 작성하는 것에서 이제 어셈블리 코드를 읽고 이해하는 수준으로 왔습니다.
이번 장에서, 어셈블리 코드를 배우고 C 프로그램이 어떻게 기계 코드로 변환되는지 살펴볼 것입니다. 어셈블리 코드를 읽는 것은 어셈블리어를 작성하는 것과는 다른 기술이 요구됩니다. 컴파일러가 C언어를 기계 코드로 바꾸는 방식을 이해해야 합니다. C언어로 작성된 계산과 비교하여, 최적화된 컴파일러는 실행 순서를 재정렬하기도하고 필요하지 않는 계산을 제거하거나 느린 작업을 더 빠른 작업으로 교체하기도하고 심지어 recursive한 계산을 iterative하게 변경하기도 합니다. 소스코드와 어셈블리 코드 사이의 관계를 이해하는 것은 어려운 일입니다. 이것은 reverse-engineering
의 한 형태입니다. 시스템을 공부하고 역으로 진행함으로써 system이 어떻게 만들어졌는지를 이해할 수 있습니다. 시스템은 어셈블리 코드로 구성된 프로그램입니다. 이를 통해 reverse engineering을 다소 단순화할 수 있는데, 왜냐하면 생성된 코드는 전형적인 패턴을 따르고 컴파일러가 몇가지 프로그램 코드를 확인함으로써 이러한 패턴을 확인할 수 있기 때문입니다. 많은 예시와 연습문제들을 붚면서 여러 어셈블리 코드와 컴파일러를 확인할 것입니다. 이 작업은 앞으로 더 깊고 기초적인 개념들을 이해하는데 밑바탕이 될 것입니다. 제공되는 예시와 연습문제들을 꼭 풀어보고 해답과 스스로 만든 해결책을 꼭 비교해보시기 바랍니다.
이 책은 x86-64에 기초합니다. x86-64 기계어는 오랜 기간동안 진화를 거듭해왔고 현재 64bit 프로세서에 기반합니다. GCC와 리눅스에 기반한 특징들에 집중해 공부해 보겠습니다. C 언어 if
, while
, switch
문법을 살펴보고 어떻게 실행되는지까지 살펴봅니다. 프로그램이 어떻게 런타임 스택을 유지하고 프로시저 사이 데이터와 제어권을 주고 받는지도 확인해봅시다. 그 다음 배열, 구조체, union등의 데이터 구조가 machie level에서 어떻게 실행되는지 살펴봅니다. 이러한 machie level 프로그래밍에 대한 이해를 바탕으로 범위를 벗어난 메모리 참조, buffer overflow 공격과 같은 시스템 취약점에 대해 알아볼 것입니다. 마지막으로 GDB 디버거를 활용해 machine-level 프로그램 런타임 동작을 살펴보고 floating-point 데이터 동작과 실행이 machine-level에서 어떻게 되는지 공부하면서 이 장을 마무리합니다.
컴퓨터는 최근 32-bit에서 64-bit 컴퓨터로 전환되고 있습니다. 32-bit 기계는 RAM을 4기가바이트까지만 사용합니다. 메모리 가격이 급격히 낮아지면서 컴퓨터가 필요로 하는 데이터 크기가 증가했고, 32-bit를 넘어선 메모리가 필요했습니다. 현재 64-bit 컴퓨터는 256 테러바이트의 메모리를 사용할 수 있고 16 엑사바이트까지 사용량을 늘릴 수 있습니다. 비록 그렇게 많은 메모리를 가진 컴퓨터가 와닿지 않지만, 4기가 바이트는 32-bit 기계가 가질 수 있는 최대 메모리였으며 이는 70, 80년대에는 일반적 상식이었습니다.
이번 장의 목표는 현대 운영체제를 타겟으로한 프로그래밍 언어와 C언어가 컴파일 될때 생성되는 machine-level 프로그램을 이해하는 것입니다. 따라서 x86-64의 보편적인 부분을 주로 다루겠습니다.
IA32는 32-bit 프로세서에서 x86-64로 넘어가는 1985년에 인텔에서 소개되었습니다. IA32는 몇 십년 동안 기계 언어로서 동작했습니다. 오늘날 시중에 판매되는 대부분 x86 마이크로프로세서들과 컴퓨터에 설치되는 대부분 운영 체제들은 x86-64에서 동작하게끔 설계되어 있습니다. 하지만 그들은 또한 하위 호환 모드로 동작할 경우 IA32를 실행할 수 있습니다. 결과적으로, 많은 애플리케이션 프로그램들이 여전히 IA32에 기반하고 있습니다. 추가적으로, 현존하는 많은 시스템들이 하드웨어와 시스템 한계로 인해 x86-64를 실행할 수 없습니다. 이렇듯 IA32는 여전히 중요한 기계어입니다. x86-64 배경을 보다보면 IA32 기계어에 대해서도 많이 배울 수 있게될 것입니다.