computer architecture/csapp

챕터3- 시작

로봇0301 2022. 11. 18. 01:13

Chapter-3 Machinne-Level representation of Programs

서론

이 장에서 공부하는 것

이 장에서는 한 종류의 특정 어셈블리어를 배우고, C 프로그램이 어떻게 어셈블리 코드로 컴파일되는지, 그 코드를 어떻게 읽는지 공부한다.

 

-이 장의 내용은 x86-64에 기초하고 있다.

이 장의 내용은 x86-64에 기초하고 있다.

이 기계어는 역사를 거쳐 역사적 관점에서만 이해가 가는 다소 기형적인 설계를 가지게 되었다.

이러한 x86-64의 복잡하고 불가사의한 부분들을 피하기 위해서 다음의 기능들만 다루고자 한다.

 

이 기계어에는 컴파일러와 운영체제에서는 거의 사용되지 않지만 이전 버전과의 호환성을 위해 존재하는 기능들이 많은데 이중 gcc와 리눅스에서 사용하는 일부만 다루고자 한다.

 

최근에 32비트에서 64비트 컴퓨터로 대부분 전환됐다.

32비트 컴퓨터가 오직 4기가바이트(2^32)의 램만을 사용할 수 있는 반면 64비트 컴퓨터는 최대 256테라바이트(2^48)를 사용할 수 있다.

이 책에서는 요즘의 운영체제에서 실행되는 프로그래밍 언어를 컴파일 할 때 생성되는 어셈블리어를 위주로 설명하기 때문에, 16비트 머신이 허용하는 주소범위의 제약으로 프로그래머들이 고생하던 초창기 마이크로프로세서 레거시 지원을 위한 x86-64의 많은 기능에 대해서 설명하지 않는다.

 

-컴파일러가 생성한 어셈블리 코드를 읽는 것은 어셈블리는 작성하는 것과 다른 기술이다

컴파일러가 생성한 어셈블리 코드를 읽는 것은 어셈블리 코드를 작성하는 것과는 다른 종류의 기술을 요한다.

실행 순서를 조정하고, 불필요한 계산을 제거하고, 느린 연산을 빠른 연산으로 교체하고, 심지어 재귀적 연산들을 반복 연산으로 바꾸면서 컴파일러는 C 코드를 최적화한다.

 

-x86-64는 인텔 프로세서와만 호환가능한 것은 아니다.

수년간 여러 업체들이 인텔 프로세서와 호환을 갖는, 즉 인텔 프로세서와 완전히 동일한 기계수준 프로그램을 실행할 수 있는 프로세서들을 생산해왔다. 이들 중 선두주자는 AMD다.

비록 이 책에서는 인텔 프로세서들에 대해서만 다루겠지만, 본문 내용은 인텔의 경쟁사가 만든 호환성이 있는 프로세서들에도 동일하게 적용된다.

 

-목차

  1. C, 어셈블리 코드, 기계어 코드 간의 관계에 대해 간단히 설명한다.
  2. 데이터의 표현과 처리, 제어의 구현 등 x86-64의 세부 사항들을 설명한다.
  3. C언어에서의 제어 구조인 if, while, switch문들이 어떻게 구현되어 있는지 살펴본다.
  4. 지역변수와 프로시저 간의 데이터와 제어를 전달하기 위한 런타임 스택을 프로그램이 어떻게 관리하는지 프로시저를 구현하며 살펴본다.
  5. 배열, 구조체 유니온 같은 자료구조들이 기계수준에서 어떻게 구현되어있는지 다룬다.
  6. 이러한 기계수준 프로그래밍의 배경지식을 바탕으로 out-of-bound 메모리 참조같은 문제나 buffer-overflow 공격에 대한 시스템 취약성을 살펴본다.
  7. 기계수준 프로그램의 런타임 동작을 분석하기 위한 GDB 디버거 사용 시의 유용한 팁을 전달한다.
  8. 부동소수점 데이터와 연산과 관련된 코드의 기계어 표현을 설명한다.

어셈블리 코드를 배우는 이유

1. 어셈블리 코드를 이해하면 코드에 내재된 비효율성을 분석할 수 있다.

 

컴파일러에게 명령어를 넘기면 어셈블리 코드 파일 형태로 출력한다.

5장에서도 다루겠지만, 코드 주요 부분의 성능을 극대화하려는 프로그래머들은 다양한 형태로 소스 코드의 변경을 시도한다. 그 때마다 프로그램의 효율 분석을 위해 생성된 어셈블리 코드를 직접 읽어보곤 한다.

 

2. 고급 언어에서 제공하는 추상화 계층 때문에 이해가 필요한 프로그램의 동작이 감춰지는 경우가 있다.

 

프로그래머들은 기계어를 원래는 어셈블리어로 직접 프로그램을 작성할 수 있어야 하기 때문에 알아야 했지만, 이제는 컴파일러가 생성한 코드를 이해하고 읽을 수 있어야 하기 때문에 알아야 한다.

 

빌드 과정

c 프로그램을 두 개의 파일 p1.c와 p2.c에 작성한다고 하자. 이 파일을 유닉스 커맨드 라인으로 다음과 같이 빌드할 수 있다.

linux> gcc -0g -o p p1.c p2.c

이 명령어는 GCC C컴파일러를 지정한다. 

명령줄 옵션으로 -0g를 주면 이 수준에 따라 코드를 최적화한다.

최적화 수준(0g의 숫자 부분)을 올리게 되면 최종 프로그램은 더 빨리 동작하게 되지만 컴파일 시간이 증가하고 디버깅하기가 어려워질 위험이 있다.

나중에 알게 되겠지만 높은 수준의 최적화를 적용하면 만들어진 코드가 너무 많이 변경되어서 본래의 코드와 생성된 기계어 코드 간의 관계를 이해하기가 어려워지게 된다.

 

그래서 공부를 위해 -0G 최적화를 적용하고 최적화 단계를 올리면 무슨 일이 생기는지 살펴보고자 한다.

실제로는 더욱 높은 단계의 최적화가 프로그램의 성능적인 면에서 더 좋은 선택이다.

 

커맨드를 실행하면 생기는 일>

  1. gcc 명령은 소스 코드를 실행 코드로 변환하기 위해 일련의 프로그램들을 호출한다.
  2. C 전처리기가 #include로 명시된 파일을 코드에 삽입해주고, #define으로 선언된 매크로를 확장해준다. (전처리)
  3. 컴파일러는 두 개의 소스파일의 어셈블리 버전인 p1.s파일과 p2.s파일을 생성한다. (컴파일)
  4. 어셈블러는 어셈블리 코드를 바이너리 목적코드인 p1.o와 p2.o로 변환한다.(어셈블) (모든 인스트럭션을 바이너리로 번역했지만 전역 변수들의 주소는 아직 이 파일들에 포함되지 않았다.)
  5. 마지막으로 링커는 두 개의 목적코드 파일을 라이브러리 함수들을 구현한 코드와 함께 합쳐서 최종파일인 p를 생성한다.(링킹)

실행 코드는 이 장이 아니라 7장에서 다루게 될 기계어 코드이다(이것이 프로세서가 실행할 정확한 코드의 형태이다)\

 

ISA(instruction set architecture).

기계수준 프로그램의 형식과 동작은 ISA(instruction set architecture)에 의해 정의된다. 이 ISA는 instruction의 형식, 프로세서 상태에 대한 각 instruction들의 영향들을 정의한다. 

 

컴퓨터시스템은 다양한 추상화 모델을 사용한다

  • x86-64를 포함하여 대부분의 ISA는 마치 하나의 instruction이 다음 instruction의 실행 전에 완료되는 순차적인 실행을 하는 것처럼 프로그램의 동작을 설명한다. (프로세서 하드웨어는 정교해서 여러 instruction을 동시에 실행하지만, ISA에 의한 순차적 동작과 일치하는 전체 동작을 보이도록 해주는 안전장치를 사용한다.)
  • 기계 수준 프로그램이 사용하는 주소는 가상주소이며, 메모리가 매우 큰 byte 배열인 것처럼 보이게 하는 메모리 모델을 제공한다.

 

x86-64를 위한 기계어 코드는 본래의 C 코드와는 상당히 다르다. 프로세서의 상태는 C 프로그래머에게는 일반적으로 감추어져 있다.

 

  • 프로그램 카운터(일반적으로 PC라고 하며, x86-64에서는 %rip라고 함)는 실행할 다음 instruction의 메모리 주소를 가리킨다.
  • 정수 레지스터 파일은 64비트 값을 저장하기 위한 15개의 이름을 붙인 위치를 가진다. 이들 레지스터나 C언어의 포인터에 해당하는 주소나 정수 데이터를 저장할 수 있다. 일부 레지스터는 프로그램의 중요한 상태를 추적하는데 사용할 수 있으며, 다른 레지스터는 함수의 리턴 값뿐만 아니라 프로시저의 지역변수와 인자 같은 임시 값을 저장하는 데 사용한다.
  • 조건코드 레지스터들은 가장 최근에 실행한 산술 또는 논리 instruction에 관한 상태 정보를 저장한다. 이들은 if나 while문을 구현할 때 필요한 제어나 조건에 따른 데이터 흐름의 변경을 구현하기 위해 사용된다.
  • 벡터 레지스터들의 집합은 하나 이상의 정수나 부동소수점 값들을 각각 저장할 수 있다.

C가 다른 종류의 데이터 타입을 선언하고 메모리에 할당할 수 있는 모델을 제공하는 반면, 기계어 코드는 메모리를 단순히 바이트 주소지정이 가능한 큰 배열로 본다. c에서 배열과 구조체 같은 연결된 데이터 타입들은 기계어 코드에서는 연속적인 바이트들로 표시된다. scalar 데이터 타입의 경우에도 어셈블리 코드는 부호형과 비부호형, 다른 타입의 포인터들, 심지어 포인터와 정수형 사이에도 구분을 하지 않는다.

 

프로그램 메모리는 프로그램의 실행 기계어 코드(code 영역), 운영체제를 위한 일부 정보(data 영역), 프로시저 호출과 리턴을 관리하는 런타임 스택(stack 영역), 사용자에 의해 할당된(예를 들어 malloc 라이브러리 함수를 사용해서)(heap 영역) 메모리 블록들을 포함하고 있다.

 

하나의 기계어 인스트럭션은 매우 기초적인 동작만을 수행한다. 예를 들어, 레지스터들에 저장된 두 개의 수를 더하고, 메모리와 레지스터 간에 데이터를 교환하거나, 새로운 instruction의 주소로 조건에 따라 분기하는 등의 동작을 한다. 컴파일러는 일련의 instruction을 생성해서 산술연산식의 계산, 반복문 프로시저 호출과 리턴 등의 프로그램 구문을 구현해야 한다.

 

 

 

ㅅㅅ