크래프톤정글/CS:APP

CH03 프로그램의 기계 수준 표현 3.1-3.5

아람2 2024. 9. 27. 17:10
반응형

 

컴퓨터는 하위 동작들을 인코딩한 연속된 바이트인 기계어 코드 machine code 를 실행한다

컴파일러는 프로그램 언어의 규칙, 대상 컴퓨터의 인스트럭션 집합, 운영 체제의 관례 등에 따라 기계어 코드를 생성한다

어셈블리 코드로 프로그램을 짤 때는 프로그래머가 계산을 하기 위해 사용해야 하는 저급 인스트럭션들을 명시해야 한다 

대개의 경우 고급 언어가 제공하는 높은 수준의 추상화를 사용하는 것이 보다 더 생산적이고 안정적이다 

어셈블리 코드를 이해하면 1) 컴파일러의 최적화 성능을 알 수 있으며 2) 코드에 내재된 비효율성을 분석할 수 있다 

쓰레드 패키지를 사용해서 동시성 프로그램을 작성할 때 어떻게 프로그램의 데이터가 공유되고, 쓰레드들이 이들을 사적 Private 으로 어떻게 유지하고, 공유된 데이터가 정확히 어디서, 어떻게 접근되는지 아는 것이 중요하다 

+ 역엔지니어링 - 시스템이 만들어진 과정을 시스템을 연구하고 역방향으로 분석하여 이해하려는 작업 

+ 인스트럭션은 명령어로 이해하면 됨

IA32 프로그래밍
대부분의 x86 마이크로프로세서와 이들 머신에 설치된 대부분의 운영 체제는 x86-64 를 실행하기 위해 설계되는데, 이들은 역방향 호환성 모드 때문에 IA32 프로그램들도 실행할 수 있다, 그 결과로 많은 응용 프로그램들은 여전히 IA32 에 기초하고 있다

 

3.1 역사점 관점 

x86 이라고 통칭하는 인텔 프로세서 제품군은 오랜 기간 진화를 통한 개발을 해왔다 

펜티엄4E (2004, 125 M 트랜지스터) - 두 개의 프로그램을 하나의 프로세서에서 동시에 실행할 수 있는 하이퍼쓰레딩 Hyperthreading 기법의 추가와 AMD 사에서 개발한 IA32 의 64비트 확장 구현인 EM64T 도 추가되었으며, 이를 x86-64 라고 한다

IA32 - Intel Architecture 32-bit
intel64 - IA32 의 64비트 확장형 == x86-64 

 

3.2 프로그램의 인코딩

C 프로그램을 p1.c 와 p2.c 에 작성하고 유닉스 커맨드 라인에서 컴파일하려면

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

명령어는 GCC C 컴파일러를 지정한다, -0g 는 본래 C 코드의 전체 구조를 따르는 기계어 코드를 생성하는 최적화 수준을 적용한다

일반적으로 최적화 수준을 올리게 되면 최종 프로그램은 더 빨리 동작하게 되지만, 컴파일 시간은 증가하고, 디버깅 도구를 실행하기가 어려워질 위험이 있다 

 

gcc 명령은 소스 코드를 실행 코드로 변환하기 위해 일련의 프로그램들을 호출한다. 

1) C 전처리기가 #include 로 명시된 파일을 코드에 삽입해 주고, #define 으로 선언된 매크로를 확장해 준다

2) 컴파일러는 두 개의 소스 파일의 어셈블리 버전인 p1.s 와 p2.s 를 생성한다

3) 어셈블리는 어셈블리 코드를 바이너리 목적 코드인 p1.o 와 p2.o 로 변환한다 (목적 코드는 기계어 코드의 한 유형)

4) 링커는 두 개의 목적 코드 파일을 라이브러리 함수들을 구현한 코드와 함께 합쳐서 최종 실행 파일인 p 를 생성한다 

3.2.1 기계 수준 코드 

컴퓨터 시스템은 보다 간단한 추상화 모델을 이용해서 세부 구현 내용을 감추면서 추상화의 여러 가지 다른 형태를 사용하고 있다

1. 기계 수준 프로그램의 형식과 동작은 인스트럭션 집합 구조 Instruction Set Architecture, "ISA" 에 의해 정의된다

  -  ISA 는 프로세서의 상태, 인스트럭션의 형식, 프로세서 상태에 대한 각 인스트럭션들의 영향을 정의한다

2. 기계 수준 프로그램이 사용하는 주소는 가상 주소이며, 메모리가 매우 큰 바이트 배열인 것처럼 보이게 하는 메모리 모델을 제공한다

프로그램 카운터 PC/ %rip - 실행할 다음 인스트럭션의 메모리 주소를 가리킨다
정수 레지스터 파일은 64비트 값을 젖아하기 위한 16개의 이름을 붙인 위치를 갖는다 
조건 코드 레지스터들은 가장 최근에 실행한 산술 또는 논리 인스트럭션에 관한 상태 정보를 저장한다
벡터 레지스터들의 집합은 하나 이상의 정수나 부동소수점 값들을 각각 저장할 수 있다 

 

3.2.2 코드 예제

이 부분은 잘 이해가 안 가서 일단은 넘어감

출처 https://hon99oo.github.io/csapp/csapp_05/

x86-64 인스트럭션들은 1에서 15바이트 길이를 갖는다 

인스트럭션의 형식은 주어진 시작 위치에서부터 바이트들을 기계어 인스트럭션으로 유일하게 디코딩할 수 있게 설계한다

역어셈블러는 기계어 코드 파일의 바이트 순서에만 전적으로 의존해서 어셈블리 코드를 결정한다 

역어셈블러는 GCC 가 생성한 어셈블리 코드와는 약간 다른 명명법을 인스트럭션에 사용한다 

 

3.2.3 형식에 대한 설명

각 라인은 참조를 위해 왼쪽에 숫자를 표시하고,

오른쪽에는 각 인스트럭션의 효과와 그것이 어떻게 원본 C 코드와 연관되는지 간략하게 주석 표시

(이 예제에는 없지만 . 으로 시작하는 모든 라인은 어셈블리와 링커에 지시하기 위한 디렉티브 directive 들이며, 이들은 무시해도 된다)

 

3.3 데이터의 형식

인텔에서 "워드" 라는 단어는 16비트 데이터 타입을 나타내고, 32비트의 양은 "더블워드", 64비트는 "쿼드워드"라고 부른다 

인텔과 ATT 형식은 다음과 같은 차이점이 있다 (본문에서는 어셈블리어를 ATT 형식으로 나타낸다)
인텔 코드는 크기를 나타내는 접미어를 생략한다, 인스트럭션 pushq 와 movq 대신 push 와 mov 를 사용
인텔 코드는 '%' 문자를 레티스터 이름 앞에서 생략한다, %rbx 대신 rbx 를 사용한다
인텔 코드는 다른 방법을 사용해서 메모리 위치를 나타낸다, (%rbx) 대신 QWORD PTR [rbx] 사용
여러 개의 오퍼랜드 operand 를 갖는 인스트럭션들은 역순으로 나열한다 

 

C에서의 기본 데이터 타입에 사용되는 x86-64 표시

표준 int 값들은 더블워드로 저장되고 (32비트) 포인터와 long 데이터 타입은 쿼드워드 (64비트) 로 동작한다 

부동소수점 숫자에는 두 개의 기본 형태가 있다

1) C 의 float 타입에 대응되는 단일 정밀도 (4바이트) 의 값

2) C 의 double 타입에 대응되는 이중 정밀도 (8바이트) 의 값 

그림 3.1 x86-64 에서 C 자료형의 길이, 64비트 머신에서 포인터는 8바이트 길이를 갖는다

GCC 가 생성한 대부분의 어셈블리 코드 인스트럭션들은 오퍼랜드의 크기를 나타내는 단일 문자 접미어를 가지고 있다

데이터 이동 인스트럭션 - movb (바이트 이동) movw (워드 이동) movl (더블워드 이동) movq (쿼드워드 이동)

접미어 'l' 은 더블워드의 경우에 사용되는데, 8바이트 더블 정밀도 부동소수점 수를 나타내기 위해서도 사용되니 주의 

 

3.4 정보 접근하기

x86-64 주처리장치 CPU 는 64비트 값을 저장할 수 있는 16개의 범용 레지스터를 보유하고 있다 

이들 레지스터는 정수 데이터와 포인터를 저장하는 데 사용한다 

레지스터들의 이름은 %r 로 시작하고 뒤에는 다른 이름들을 가지고 있다 (ex. %rax, %r10)

인스트럭션들은 16개의 레지스터 하위 바이트들에 저장된 다양한 크기의 데이터에 대해 연산할 수 있다 

 

그림 3.2 정수 레지스터, 전체 16개 레지스터의 하위바이트들은 바이트, 워드 (16bit), 더블워드 (32bit), 쿼드워드 (64비트) 씩 접근할 수 있다

3.4.1 오퍼랜드 식별자 Specifier 

대부분의 인스트럭션은 하나 이상의 오퍼랜드를 가진다

오퍼랜드는 연산을 수행할 소스 Source 값과 그 결과를 저장할 목적지 Destination 의 위치를 명시한다

소스 값은 상수로 주어지거나 레지스터나 메모리로부터 읽을 수 있고, 결과 값은 레지스터나 메모리에 저장된다

그래서 여러 가지 오퍼랜드의 종류는 세 가지 타입으로 구분할 수 있다

그림 3.3 오퍼랜드의 형식, 오퍼랜드는 즉시값 (상수), 레지스터 값, 메모리에서 가져오는 값으로 표시할 수 있다, 배율 인자 s 는 1, 2, 4, 8 중에 하나가 될 수 있다

1) immediate - 상수 값을 말한다, 상수는 '$' 기호 다음에 C 표준 서식을 사용하는 정수 ex) $-577, $0x1F 

2) Register - 레지스터의 내용을 나타내며, 각각 16개의 64비트, 32비트, 16비트, 8비트 레지스터들의 하위 일부분인 8, 4, 2, 1, 바이트 중 하나의 레지스터를 가리킨다, 레지스터 집합을 배열 R 과 레지스터 식별자를 인덱스로 사용하는 형태로 나타낸다 

3) Operand - 메모리 참조, 유효 주소 Effective Address 라고 부르는 계산된 주소에 의해 메모리 위치에 접근하게 된다 

메모리는 거대한 바이트의 배열로 생각할 수 있으므로 M[Addr] 과 같이 표시하며 메모리 주소 Addr 부터 저장된 b 바이트를 참조하는 것을 나타낸다, 단순화를 위해 일반적으로 아래 첨자 b 는 생략한다 

3.4.2 데이터 이동 인스트럭션

가장 많이 사용되는 인스트럭션은 데이터를 한 위치에서 다른 위치로 복사하는 인스트럭션이다 

* MOV 클래스 - 소스 위치에서 데이터를 목적지 위치로 어떤 변환도 하지 않고 복사한다 

소스 오퍼랜드는 상수, 레지스터 저장 값, 메모리 저장 값을 표시한다

목적 오퍼랜드는 레지스터 또는 메모리 주소의 위치를 지정한다 

x86-64 는 데이터 이동 인스트럭션에서 두 개의 오퍼랜드 모두가 메모리 위치에 올 수 없도록 제한하고 있다

하나의 메모리 위치에서 다른 위치로 어떤 값을 복사하기 위해서는 두 개의 인스트럭션이 필요하다 

1) 소스 값을 레지스터에 적재하는 인스트럭션 2) 이 레지스터의 값을 목적지에 쓰기 위한 인스트럭션 

이 인스트럭션들의 레지스터 오퍼랜드는 레지스터 16개 중에서 이름을 붙인 부분이 될 수 있으며 여기서 레지스터의 크기는 인스트럭션의 마지막 문자 ('b', 'w', 'l', 'q') 가 나타내는 크기와 일치해야 한다 

대부분의 경우 MOV 인스트럭션들은 특정 레지스터 바이트들이나 대상 오퍼랜드에 의해 지정된 메모리 위치만을 업데이트할 것이다. 유일한 예외는 movl 이 레지스터를 목적지로 갖는 경우로, 이 경우 레지스터의 상위 4바이트도 0으로 설정한다. 이러한 예외는 어떤 레지스터를 위한 32비트 값을 생성하는 인스트럭션은 레지스터의 상위 바이트들을 또한 0으로 설정하도록 32비트 값을 생성하는 관습에 의해 생긴 것이며, x86-64 에서 채택되었다 

 

레지스터 movq 인스트럭션은 오직 32비트 2의 보수 숫자로 나타낼 수 있는 상수 소수 오퍼랜드들만을 갖는다, 이 값은 그 후 부호 확장되어 목적지를 위해 64비트 값을 생산한다

movabsq 인스트럭션은 임의의 64비트 상수 값을 소스 오퍼랜드로 가질 수 있으며, 목적지로는 레지스터만을 가질 수 있다 

MOVZ 클래스의 인스트럭션들은 목적지의 남은 바이트들을 모두 0으로 채워주며,

MOVS 클래스는 이들을 소스 오퍼랜드의 가장 중요한 비트를 반복해서 복사하는 부호 확장으로 채운다 

이 명령들의 이름에는 마지막 두 개의 문자가 크기를 나타내는 지시자를 갖는다 - 첫번째는 소스의 크기, 두번째의 목적지의 크기 

그림 3.5 0으로 확장하는 데이터 이동 인스트럭션, 이 인스트럭션들은 레지스터나 메모리를 소스로 가지며, 레지스터를 목적지로 갖는다

cltq 인스트럭션은 오퍼랜드가 없다, 언제나 레지스터 %eax 를 소스로, %rax 를 목적지로 사용해서 부호 확장 결과를 만든다

그래서 이것은 movslq, %eax, %rax 와 정확히 동일한 효과를 내지만, 이것은 좀 더 압축적인 인코딩을 갖는다 

3.4.3 데이터 이동 예제

함수 exchange 는 두 개의 데이터 이동 (movq) 와 함수가 호출된 위치로 리턴하는 인스트럭션 한 개 (ret) 총 3개의 인스트럭션으로 구현되었다

이 어셈블리 코드에서 주목할 점 
1) C 언어에서 "포인터" 라고 부르는 것이 어셈블리어에서는 단순히 주소라는 점이다, 포인터를 역참조하는 것은 포인터를 레지스터에 복사하고, 이 레지스터를 메모리 참조에 사용하는 과정으로 이루어진다 
2) x 같은 지역 변수들은 메모리에 저장되기보다는 종종 레지스터에 저장된다, 레지스터의 접근은 메모리보다 속도가 훨씬 빠르다 

 

인자 xp 는 long int 의 포인터이고 y 는 long Integer 이다, long x = *xp; 는 xp 로 표시되는 위치에 저장된 값을 읽어서 지역 변수 x 에 저장한다는 것을 의미한다 

C 연산자 * 는 포인터 역참조를 실행하고, *xp = y; 은 정반대의 연산을 수행한다 (매개변수 y 의 값을 xp 가 지정하는 위치에 쓴다)

3.4.4 스택 데이터의 저장과 추출 Push, Pop 

Push 와 Pop 은 프로그램 스택에 데이터를 저장 Push 하거나 스택에서 데이터를 추출 Pop 하기 위해 사용된다

스택은 프로시저 호출을 처리하는 데 중요한 역할을 한다 

x86-64 에서 프로그램 스택은 메모리의 특정 영역에 위치한다

스택의 탑 Top 원소가 모든 스택 원소 중에서 가장 낮은 주소를 갖는 형태로, 스택은 아래 방향으로 성장한다 

스택 포인터 %rsp 는 스택 맨 위 원소의 주소를 저장한다

popq 인스트럭션이 데이터를 추출하는 반면, pushq 인스트럭션은 데이터를 스택에 추가하는 기능을 제공한다

이들 인스트럭션은 한 개의 오퍼랜드를 사용한다 - 추가할 소스 데이터와 추출을 위한 데이터 목적지 

스택이 pop 이 되어도 stack 의 top 을 표현하는 주소가 올라간거지 값은 여전히 pop 이 된 위치에 남아 있다 

스택이 프로그램 코드와 다른 형태의 프로그램 데이터와 동일한 메모리에 저장되기 때문에 프로그램들은 표준 메모리 주소 지정 방법을 사용해서 스택 내 임의의 위치에 접근할 수 있다 

3.5 산술 연산과 논리 연산

x86-64 정수와 논리 연산의 리스트

x86-64 정수와 논리 연산의 리스트, 오퍼랜드의 길이에 따른 다양한 변형이 가능하기 때문에 

대부분의 연산을 인스트럭션 클래스에 따라 나열하였다 (leaq 만은 길이에 따른 변형이 없다)

인스트럭션 클래스 ADD 는 네 개의 덧셈 인스트럭션으로 이루어져 있다

addb, addw, addl, addq 로 각각 바이트, 워드, 더블워드, 쿼드워드 덧셈을 의미한다

연산들은 네 개의 그룹으로 나누어진다: 유효주소 적재, 단항 unary, 이항 binary, 쉬프트

이항 연산은 두 개의 오퍼랜드를 가지는 반면, 단항 연산은 한 개의 오퍼랜드를 갖는다

3.5.1 유효주소 적재 

유효주소 적재 인스트럭션 leaq 는 실제로 movq 인스트럭션의 변형이다

이 인스트럭션의 첫 번째 오퍼랜드는 일종의 메모리 참조처럼 보이지만, 가리키는 위치에서 유효주소를 목적지에 복사한다

연산자 &S 는 나중의 메모리 참조에 사용하게 되는 포인터를 생성하기 위해 사용한다

또한, 일반적인 산술연산을 간결하게 설명하기 위해 사용된다 

레지스터 %rdx 가 x 를 가지고 있다면, 인스트럭션 leaq 7(%rdx, %rdx, 4) 는 레지스터 %rdx 에 5x + 7 을 저장한다
이 말이 이해가 안 가서 찾아봤는데, leaq 명령어는 Load Effective Address 의 약자로, 메모리 주소를 계산하여 레지스터에 저장하는 데 사용한다고 한다, [%rdx] 는 이 레지스터의 값을 사용하는 것이고, [%rdx, 4] 는 %rdx 의 값을 4배한 값을 계산하는 것이므로 7(%rdx, %rdx, 4) 는 7을 더한 후, %rdx 의 값과 %rdx 의 4배 값을 합치는 것이라 %rdx 에 5x+ 7 을 저장하는 것이다 

목적 오퍼랜드는 반드시 레지스터만 올 수 있다 

3.5.2 단항 및 이항 연산 

단항 연산

  • 하나의 오퍼랜드가 소스와 목적지로 동시에 사용된다 
  • C 에서 증가 ++ 과 감소 -- 연산자와 유사 
  • 오퍼랜드는 레지스터나 메모리 위치가 될 수 있다 
  • ex) incq (%rsp) 는 스택 탑의 8바이트 원소의 값을 증가시켜준다

이항 연산

  • 두 번째 오퍼랜드는 소스이면서 목적지로 사용된다 (소스가 먼저 오고, 나중에 목적지가 나온다)
  • C 에서 할당 연산자인 x -= y 같은 문장과 유사 
  • ex) subq %rax, %rdx 는 레지스터 %rdx 에서 %rax 값만큼 빼준다 
  • 첫 번째 오퍼랜드는 상수나 레지스터, 메모리 위치가 올 수 있다, 두 번째는 레지스터나 메모리가 올 수 있다 
  • 두 개의 오퍼랜드가 모두 메모리 위치가 될 수는 없다 
  • 두 번째 오퍼랜드가 메모리 위치일 때 프로세서가 메모리에서 값을 읽고, 연산을 하고, 그 결과를 다시 메모리에 써야한다는 점에 유의해야 한다 

3.5.3 쉬프트 연산

쉬프트하는 크기를 먼저 주고, 쉬프트할 값을 두 번째로 준다 

산술과 논리형 우측 쉬프트가 모두 가능하다 

쉬프트 인스트럭션들은 쉬프트할 양을 즉시 값이나 단일 바이트 레지스터 %cl 로 명시할 수 있다 

좌측 쉬프트 인스트럭션에는 두 가지 이름이 있고, 동일한 효과를 내며 우측에서부터 0을 채운다 

1) SAR - 산술 쉬프트, 부호 비트를 복사해서 채운다

2) SHL - 논리 쉬프트, 0으로 채운다 

쉬프트 연산의 목적 오퍼랜드는 레지스터나 메모리 위치가 될 수 있다 

3.5.4 토의

대부분의 인스트럭션들은 비부호형과 2의 보수 산술연산에 사용될 수 있다

오직 우측 쉬프트만이 부호형과 비부호형 데이터를 구분하느 인스트럭션을 요구한다 

이것이 부호형 정수 산술연산을 구현하는 방시그올 2의 보수 산술연산을 선호하는 주요 특징이다 

3.5.5 특수 산술 연산

그림 3.12 특수 산술연산, 이들 연산은 부호형과 비부호형의 완전 128비트 곱셈과 나눗셈을 제공한다, 레지스터 %rdx 와 %rax 는 한 개의 128비트 8워드를 구성하는 것처럼 사용된다

두 개의 64비트 부호형 또는 비부호형 정수들 간의 곱셈은 결과를 표시하기 위해 128비트를 필요로 한다

x86-64 인스트럭션 집합은 128비트 (16바이트) 숫자와 관련된 연산에 대해서는 제한적인 지원을 제공한다

워드 (2바이트), 더블워드 (4바이트), 쿼드워드 (8바이트) 방식을 이어가면서 인텔은 16바이트 워드를 옥토워드 oct word 라고 명명하였다

imulq 인스트럭션은 두 가지 형식을 갖는다 

1) IMUL 인스트럭션 클래스의 멤버인 형태다, 이 형식은 두 개의 64비트 오퍼랜드로부터 64비트 곱을 생성하는 "2 오퍼랜드" 곱셈 인스트럭션을 제공한다 (곱을 64비트로 절삭할 때는 비부호형에서와 2의 보수 곱셈에서 모두 동일한 비트 수준 동작을 갖는다)

2) x86-64 는 두 개의 다른 "단일 오퍼랜드" 곱셈 인스트럭션을 제공하며, 두 64비트 값의 완전한 128비트 곱을 계산한다, 하나는 비후호형이고 (mulq) 다른 하나는 2의 보수 (imulq) 곱셈이다, 이들 모두 한 개의 인자는 레지스터 %rax 에 보관해야 하고, 다른 하나는 인스트럭션 소스 오퍼랜드로 주어진다, 곱은 레지스터 %rdx (상위 64비트) 에 저장된다

 

다음의 C 코드는 두 개의 비부호형 수 x 와 y 의 128비트 곱을 생성하는 것을 보여준다 

이 코드는 곱셈 결과 값이 포인터 dest 로 지시된 16바이트에 저장되어야 한다는 것을 명시한다 

곱을 저장하기 위해서는 아래의 그림처럼 두 개의 movq 인스트럭션이 필요하다 

하나는 하위 8바이트 (4번 줄), 다른 하나는 상위 8바이트 (5번 줄)

이들 연산은 단일 오퍼랜드 곱셈 인스트럭션과 비슷한 단일 오퍼랜드 나눗셈 인스트럭션으로 제공된다 

부호형 나눗셈 인스트럭션 idivq 는 피제수 dividended 를 128비트로 레지스터 %rdx (상위 64비트) 와 %rax (하위 64비트) . 에저장한다, 제수 divisor 는 인스트럭션의 오퍼랜드로 주어진다, 인스트럭션의 몫은 레지스터 %rax 에, 나머지는 레지스터 %rdx 에 저장한다 

 

 + 24.09.30 MON 특강에서

피연산자 Operator, 연산 결과, 프로그램 카운터 값 (현재 실행 중인 명령어의 주소) 는 레지스터에 저장이 되고

명령어 Instruction 은 메인 메모리에 저장된다고 한다 

 

반응형