📚 Book/CS:APP

CSAPP : 3장 프로그램의 기계수준 표현

lazyArtisan 2024. 8. 6. 20:40
  • 컴퓨터는 데이터를 처리하고, 메모리를 관리하고, 저장장치에 데이터를 읽거나 쓰고, 네트워크를 통해 통신하는 등의 하위 동작들을 인코딩한 연속된 바이트인 기계어 코드를 실행한다.
  • 컴파일러는 프로그램 언어의 규칙, 대상 컴퓨터의 인스트럭션 집합, 운영체제의 관례 등에 따라 기계어 코드를 생성한다.
  • GCC C 컴파일러는 기계어 코드를 문자로 표시한 어셈블리 코드의 형태로 출력을 만들어 프로그램의 각 인스트럭션을 만들어 낸다.
  • 그러고 나서, GCC는 어셈블러와 링커를 호출하여 어셈블리 코드로부터 실행 가능한 기계어 코드를 생성한다.

자바, C 같은 고급 언어로 프로그래밍하면 컴파일러가 어셈블리 코드 알아서 잘 짜주는데 왜 기계어 코드를 배워야 하는가? : 컴파일러 최적화 성능 알 수 있음. 코드에 내재된 비효율성을 분석할 수도 있다. 프로그램의 이해를 위해 런타임 동작을 알아야 하는데도 추상화 때문에 감춰지기도 함.

3.2 프로그램의 인코딩

최적화 수준 올리면 프로그램을 더 빨리 동작하지만, 컴파일 시간이 증가하고 디버깅 도구 실행이 어려워짐

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

  • 먼저, C전처리기가 #include로 명시된 파일을 코드에 삽입해 주고, #define으로 선언된 매크로를 확장해 준다.
  • 두 번째로, 컴파일러는 두 개의 소스파일의 어셈블리 버전인 p1.s와 p2.s를 생성한다.
  • 다음으로 어셈블러는 어셈블리 코드를 바이너리 목적코드인 p1.o와 p2.o로 변환한다.
    • 목적코드 : 모든 인스트럭션의 바이너리 표현을 포함하고 있지만 전역 값들의 주소는 아직 안 채워짐
  • 마지막으로 링커는 두 개의 목적파일을 라이브러리 함수들을 구현한 코드와 함께 합쳐서 최종 실행파일인 p를 생성한다.

3.2.1 기계수준 코드

  1. 기계수준 프로그램의 형식과 동작은 인스트럭션 집합구조(instruction set architecture), 즉 "ISA"에 의해 정의된다. ISA는 프로세서의 상태, 인스트럭션의 형식, 프로세서 상태에 대한 각 인스트럭션의 영향들을 정의한다.
  2. 기계수준 프로그램이 사용하는 주소는 가상주소이며, 메모리가 매우 큰 바이트 배열인 것처럼 보이게 하는 메모리 모델을 제공한다.

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

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

C가 다른 종류의 데이터 타입을 선언하고 메모리에 할당할 수 있는 모델을 제공하는 반면,
기계어 코드메모리를 바이트 주소지정이 가능한 큰 배열로 본다.

C에서 배열과 구조체 같은 연결된 데이터 타입들은 기계어 코드에서는 연속적인 바이트들로 표시된다. 스칼라(scalar) 데이터 타입의 경우에도 어셈블리 코드는 부호형과 비부호형, 다른 타입의 포인터들, 심지어 포인터와 정수형 사이에도 구분을 하지 않는다.

프로그램 메모리가 포함하는 것들

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

운영체제는 가상 주소공간을 관리해서 가상주소를 실제 프로세서 메모리 상의 물리적 주소 값으로 번역해 준다.

하나의 기계어 인스트럭션은 매우 기초적인 동작만을 수행한다.

  • 레지스터들에 저장된 두 개의 수를 더하거나,
  • 메모리와 레지스터 간에 데이터를 교환하거나,
  • 새로운 인스트럭션 주소로 조건에 따라 분기하는 등

컴파일러는 일련의 인스트럭션을 생성해서 산술연산식의 계산, 반복문 프로시저 호출과 리턴 등의 프로그램 구문을 구현해야 한다.

3.2.2 코드 예제

목적코드로부터 배울 수 있는 중요한 교훈은 컴퓨터에 의해 실제 실행된 프로그램은 단순히 일련의 인스트럭션을 인코딩한 일련의 바이트라는 점이다. 컴퓨터는 인스트럭션들이 생성된 소스 코드에 대한 정보를 거의 갖고 있지 않다.

기계어 코드의 몇몇 특징과 이들의 역어셈블된 표현

  • x86-64 인스트럭션들은 1에서 15바이트 길이를 갖는다.
  • 인스트럭션의 형식은 주어진 시작 위치에서부터 바이트들을 기계어 인스트럭션으로 유일하게 디코딩할 수 있도록 설계한다. 1대1 대응이 된다는 뜻. 예제 보니까 할당되는 주소 자체는 컴파일 할 때마다 다르게 나오는듯?
  • 역어셈블러는 기계어 코드 파일의 바이트 순서에만 전적으로 의존해서 어셈블리 코드를 결정한다. 소스 코드나 프로그램의 어셈블리 코드 버전을 사용하지 않는다.

3.4 정보 접근하기

x86-64 주처리장치(CPU)는 64비트 값을 저장할 수 있는 16개의 범용 레지스터를 보유하고 있다. 이들 레지스터는 정수 데이터와 포인터를 저장하는 데 사용한다.

일련의 표준 프로그래밍 관습에 의해 스택을 관리하고, 함수의 인자를 넘겨주고, 함수에서 값을 리턴하고, 로컬 데이터와 임시 데이터를 저장하기 위해 어떻게 레지스터가 사용되는지가 정해진다.

3.4.1 오퍼랜드 식별자(specifier)

대부분의 인스트럭션은 하나 이상의 오퍼랜드를 가진다.
오퍼랜드는 연산을 수행할 소스 값과 그 결과를 저장할 목적지의 위치를 명시한다.
소스 값은 상수로 주어지거나 레지스터나 메모리로부터 읽을 수 있다.
결과 값은 레지스터나 메모리에 저장된다.

오퍼랜드의 종류

  • immediate : 상수 값.
  • register : 레지스터의 내용.
  • 메모리 참조 : 유효주소(effective address)라고 부르는 계산된 주소에 의해 메모리 위치에 접근

3.4.2 데이터 이동 인스트럭션

  • 소스 오퍼랜드 : 상수, 레지스터 저장 값, 메모리 저장 값을 표시
  • 목적 오퍼랜드 : 레지스터, 메모리 주소의 위치 지정

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

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

3.4.3 데이터 이동 예제

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

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

popq 인스트럭션이 데이터를 추출하는 반면, pushq 인스트럭션은 데이터를 스택에 추가하는 기능을 제공한다. 이들 인스트럭션은 한 개의 오퍼랜드를 사용한다 - 추가할 소스 데이터와 추출을 위한 데이터 목적지.

 

 

그림에서 보는 것처럼 값 0x123은 다른 값이 덮어써질 때까지 메모리 주소 0x100에 여전히 남아있다. 그렇지만, 스택 탑은 언제나 %rsp가 가리키는 주소를 의미한다. 스택 탑보다 윗부분에 저장된 값은 모두 무효인 값들이다.

3.5 산술연산과 논리연산

오퍼랜드는 길이에 따른 다양한 변형이 가능하다.
예를 들어, 인스트럭션 클래스 ADD는 네 개의 덧셈 인스트럭션
addb, addw, addl, addq-각각 바이트, 워드, 더블워드, 쿼드워드 덧셈-으로 이루어져 있다.

연산 분류 : 유효주소 적재, 단항(unary), 이항(binary), 쉬프트
이항 연산은 두 개의 오퍼랜드를 갖고, 단항 연산은 한 개의 오퍼랜드를 갖는다.

3.5.1 유효주소 적재

유효주소 적재 인스트럭션 leaq는 실제로는 movq 인스트럭션의 변형이다. 이것은 메모리에서 레지스터로 읽어들이는 인스트럭션의 형태를 갖지만, 메모리를 전혀 참조하지 않는다.

이 인스트럭션의 첫 번째 오퍼랜드는 일종의 메모리 참조처럼 보이지만, 가리키는 위치에서 읽기를 수행하는 대신에 유효주소를 목적지에 복사한다. 포인터 만들 때 씀.

3.5.2 단항 및 이항 연산

단항 연산은 하나의 오퍼랜드가 소스와 목적지로 동시에 사용된다. 이 오퍼랜드는 레지스터나 메모리 위치가 될 수 있다.

이항 연산은 두 번째 오퍼랜드가 소스이면서 목적지로 사용된다. 소스가 먼저 오고, 나중에 목적지가 나온다. 이 문법은 C에서의 할당 연산자인 x -= y 같은 문장과 유사하다.

3.5.3 쉬프트 연산

쉬프트 연산은 먼저 쉬프트하는 크기를 주고, 두번째엔 쉬프트할 값을 준다. 산술과 논리형 우측 쉬프트가 모두 가능하다.

x86-64에서는 w비트 길이의 데이터 값에 적용하는 쉬프트 연산은 레지스터 %cl의 하위 m비트로 쉬프트 양을 결정하며, 2^m = w의 관계가 성립한다. 상위 비트들은 무시된다.

예를 들어, 레지스터 %cl이 16진수 0xFF를 가질 때, 인스트럭션 salb는 7만큼 쉬프트하고, salw는 15, sall은 31, salq는 63만큼 쉬프트하게 된다.

3.5.4 토의

xorq, leaq, salq, andl, subq 같은 어셈블리 코드 설명해줘. 그리고 %rsi, %rdi 이런게 뭐야?

어셈블리 명령어 설명

1. xorq

  • 설명: XOR (배타적 OR) 연산을 수행합니다. 두 오퍼랜드의 비트가 서로 다를 때 1, 같을 때 0이 됩니다.
  • 용법: xorq src, dest
  • 예시: xorq %rax, %rax (레지스터 %rax의 값을 0으로 만듭니다).

2. leaq

  • 설명: 메모리 주소를 계산하는 명령어입니다. Load Effective Address의 약자입니다.
  • 용법: leaq src, dest
  • 예시: leaq (%rax,%rbx,4), %rcx (%rax + 4*%rbx의 값을 %rcx에 저장).

3. salq

  • 설명: 산술 좌측 시프트 (Shift Arithmetic Left) 연산을 수행합니다.
  • 용법: salq count, dest
  • 예시: salq $2, %rax (%rax의 값을 2비트 왼쪽으로 시프트).

4. andl

  • 설명: AND 연산을 수행합니다. 두 오퍼랜드의 비트가 모두 1일 때 1이 됩니다.
  • 용법: andl src, dest
  • 예시: andl %eax, %ebx (%eax와 %ebx의 비트별 AND 결과를 %ebx에 저장).

5. subq

  • 설명: 뺄셈 연산을 수행합니다.
  • 용법: subq src, dest
  • 예시: subq %rbx, %rax (%rax에서 %rbx를 뺀 결과를 %rax에 저장).

레지스터 설명

%rsi, %rdi 등 레지스터

  • %rsi (Source Index Register): 데이터 소스 주소를 저장하는 데 사용됩니다. 특히, 문자열이나 배열 조작에 사용됩니다.
  • %rdi (Destination Index Register): 데이터 목적지 주소를 저장하는 데 사용됩니다. 특히, 문자열이나 배열 조작에 사용됩니다.

주로 사용되는 64비트 레지스터

  • %rax: 누산기 (Accumulator) 레지스터로 주로 연산의 결과를 저장하는 데 사용됩니다.
  • %rbx: 베이스 레지스터로 사용됩니다.
  • %rcx: 카운터 레지스터로 반복문에서 주로 사용됩니다.
  • %rdx: 데이터 레지스터로 사용됩니다.
  • %rsp: 스택 포인터로 현재 스택의 꼭대기를 가리킵니다.
  • %rbp: 베이스 포인터로 현재 스택 프레임의 시작을 가리킵니다.
  • %rsi: 소스 인덱스 레지스터.
  • %rdi: 목적지 인덱스 레지스터.
  • %r8 - %r15: 추가적인 범용 레지스터입니다.

예제 코드 설명

다음은 각 명령어와 레지스터를 사용한 예제 코드입니다:

.section .data
    msg: .ascii "Hello, World!\n"

.section .text
    .global _start

_start:
    # 메시지 주소를 %rsi에 저장
    leaq msg(%rip), %rsi

    # 문자열 길이를 %rdx에 저장 (14 바이트)
    movq $14, %rdx

    # 시스템 콜: write(1, msg, 14)
    movq $1, %rax        # 시스템 콜 번호 (sys_write)
    movq $1, %rdi        # 파일 디스크립터 (stdout)
    syscall              # 시스템 콜 호출

    # 시스템 콜: exit(0)
    movq $60, %rax       # 시스템 콜 번호 (sys_exit)
    xorq %rdi, %rdi      # %rdi를 0으로 설정 (exit code 0)
    syscall              # 시스템 콜 호출

이 예제는 "Hello, World!"를 출력한 후 프로그램을 종료하는 어셈블리 코드입니다. leaq, movq, xorq, syscall 명령어와 %rsi, %rdi, %rax, %rdx 등의 레지스터를 사용합니다.

3.5.5 특수 산술연산

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

(chatgpt에 교재 내용 사진 찍어서 올리고 쉽게 설명 요청)

이미지의 내용을 바탕으로 imulq 명령어와 관련된 설명을 쉽게 풀어서 설명드리겠습니다.

imulq 명령어

imulq 명령어는 x86-64 아키텍처에서 사용되는 명령어로, 두 개의 64비트 정수를 곱하여 그 결과를 저장하는 역할을 합니다. 이 명령어는 두 가지 형태로 존재합니다:

  1. 2 오퍼랜드 형태:
    • 두 개의 64비트 오퍼랜드를 곱하여 64비트 결과를 생성합니다.
    • 결과는 지정된 레지스터에 저장됩니다.
  2. 3 오퍼랜드 형태:
    • 두 개의 64비트 값을 곱하여 128비트 결과를 생성합니다.
    • 하위 64비트는 rax 레지스터에, 상위 64비트는 rdx 레지스터에 저장됩니다.

비부호형 곱셈 (mulq)과 부호형 곱셈 (imulq)

  • 비부호형 곱셈 (mulq):
    • 두 개의 64비트 값을 곱하여 128비트 결과를 생성합니다.
    • 오퍼랜드가 비부호형 정수일 때 사용됩니다.
    • 곱셈 결과의 하위 64비트는 rax 레지스터에, 상위 64비트는 rdx 레지스터에 저장됩니다.
  • 부호형 곱셈 (imulq):
    • 두 개의 64비트 값을 곱하여 128비트 결과를 생성합니다.
    • 오퍼랜드가 부호형 정수일 때 사용됩니다.
    • imulq는 특정 조건에서 두 오퍼랜드 형태와 세 오퍼랜드 형태를 모두 지원합니다.

리틀 엔디안 머신이 뭐임

리틀 엔디안 머신

리틀 엔디안(Little Endian) 머신은 데이터를 메모리에 저장할 때, 가장 낮은 바이트(가장 덜 중요한 바이트)를 가장 낮은 메모리 주소에 저장하는 방식입니다. 엔디안(Endianness)은 컴퓨터 시스템이 다중 바이트 데이터를 메모리에 저장하거나 읽는 순서를 정의하는 방법입니다

3.7 프로시저

프로시저는 명령 모음으로 이해하면 된다.
함수, 메소드, 서브루틴, 핸들러 등등이 전부 프로시저라고 할 수 있다.

  • 메소드 : 클래스가 가진 기능(함수)
  • 루틴 : 명령 모음
    • 코루틴 : 실행 중에 잠깐 멈춰놓고 나중에 다시 할 수 있는 명령 모음
    • 서브루틴 : 작은 명령 모음

프로시저가 실행될 때 제어권 전달, 데이터 전달, 메모리 할당과 반납 등이 필요하다.

  • 제어권 전달 : 프로시저가 시작될 때 프로그램 카운터(PC)가 명령에 대한 주소를 준다. 리턴할 때는 인스트럭션 주소를 준다.
    • 프로그램 카운터 : 마이크로프로세서(중앙 처리 장치) 내부에 있는 레지스터. 다음에 실행될 명령어의 주소를 가지고 있어 실행할 기계어 코드의 위치를 지정한다.
  • 데이터 전달 : 프로시저는 매개변수를 받아서 리턴한다.
  • 메모리 할당과 반납 : 프로시저가 실행될 때 지역변수 필요하면 공간 할당하거나 리턴할 땐 반납해야됨.

오버헤드 : 명령을 처리하기 위한 작업들에 필요한 자원

  • 밥을 먹으려고 함. 밥을 먹기 위해선 요리를 하고 숟가락과 젓가락을 가져와야 함. << 일을 하기 위해 필요한 작업들이 오버헤드

3.7.1 런타임 스택

레지스터에 다 안 들어가면 스택에 공간 할당. 이걸 스택 프레임이라고 부름.

일반적인 스택 프레임은 함수 인자 전달, 지역 변수 저장, 리턴 정보 저장 등의 역할을 한다.

항상 다 쓰는 건 아니고 인자 적으면 레지스터에만 전달. 심지어 대부분의 함수들은 스택 프레임을 요청하지도 않음.

스택 프레임 구조

 

아래가 스택 top이다.
스택에 차곡차곡 필요한 정보를 쌓아놓은 그림이다.

3.7.2 제어의 이동

함수 P 실행 중간에 함수 Q를 실행해야 한다고 해보자.

함수 P에서 함수 Q로 제어권을 넘겨준 뒤에 다시 P를 실행해야될 때,
P를 어디서부터 다시 시작해야할지 적힌 위치가 필요하다. 이걸 리턴 주소라고 한다.
리턴 주소도 스택에 넣는다.

x86-64 머신에선 call Q로 프로시저 Q를 호출해서 기록한다고 한다.
call Q 명령어는 2가지 일을 함.

  • 현재 명령어의 다음 주소 (리턴 주소)를 스택에 넣음.
  • 프로그램 카운터(명령어 포인터)를 함수 Q의 시작 주소로 설정해서 함수 Q 실행.

ret 명령어는 리턴 주소를 스택에서 꺼내와서 P로 돌아간다. (프로그램 카운터를 P 주소로 세팅한다)

3.7.3 데이터 전송

리턴할 때 이전 프로시저로 되돌아가기만 하는게 아니라 함수 매개변수같은 값을 전달하기도 한다.

대부분의 전달은 레지스터를 통해서 일어난다.
x86-64에서는 함수 인자 여섯 개까지는 레지스터로 전달됨.
여섯 개 넘어가면 스택으로 전달됨.

3.7.4 스택에서의 지역저장공간

사실 책에 있는 대부분의 프로시저들은 레지스터 말고 다른 곳에 데이터 저장할 필요 없음.

메모리에 저장해야될 경우는 다음과 같음

  • 지역 변수 모두를 저장하기엔 레지스터 수 부족
  • 지역변수에 연산자 '&' (변수의 주소 반환) 가 사용되었으며, 이 변수의 주소를 생성할 수 있어야 할 때. 레지스터는 물리적인 메모리 주소가 아님. CPU 내의 임시 저장소일 뿐임.
  • 지역변수가 배열 또는 구조체여서 이들이 배열이나 구조체 참고로 접근되어야 할 때. 레지스터는 고정된 크기(일반적으로 64비트)를 가지므로 다수의 요소를 갖는 배열이나 구조체는 저장 불가. 그리고 배열이랑 구조체는 포인터를 통해 각 요소에 접근하므로 메모리에 저장되어야 함.

일반적으로 프로시저는 지역 변수를 할당하기 위해 스택 포인터를 감소시켜서 스택 프레임에 공간을 할당함.
(원래 스택이 높은 메모리 주소에서 시작해서 데이터 늘어나면 낮은 메모리 주소까지 할당함.)

함수 인자나 지역 변수가 많은 함수 어셈블리 뜯어보면
함수 호출 준비를 위해서 스택에 저장공간 할당하는 데에 명령어가 많이 쓰이는 걸 알 수 있음.

3.7.5 레지스터를 이용하는 지역저장소

CPU에 들어있는 레지스터들은 모든 프로시저들이 같이 사용하는 단일 자원임.
잠깐 다른 프로시저 실행할 수도 있긴 한데,
나중에 다시 써야 되는 레지스터 값을 덮어쓰지는 않는다.

함수 A 안에 있는 함수 B에 매개 변수 넣었을 때,
그 매개 변수가 B에서 뭔 짓을 당해서 어떻게 변하든
A로 다시 돌아오면 값은 그대로여야 한다.

그래서 그런 변수 값들은 스택에 따로 넣어서
겹치지 않게 한다.

3.7.6 재귀 프로시저

재귀 호출 할 때 각 프로시저들은 스택에 자신만의 저장 공간을 갖는다.
그래서 지역 변수들이 서로 간섭하지 않는다.