CSAPP 독서 내용 정리 3-7 ~ 3-8, 7-1 ~ 7-4

이형준·2023년 5월 10일
0

CSAPP

목록 보기
6/10

스택에서의 지역저장공간

지금까지의 대부분의 예제에서는 레지스터에 저장할 수 있는것 이상의 지역저장소를 요구하지 않았다. 하지만 때때로 지역 데이터가 메모리에 저장되어야 하는 경우가 있다. 이 경우들의 공통점으론

  • 지역 데이터 모두를 저장하기에는 레지스터의 수가 부족하다.
  • 지역변수에 연산자 '&'가 사용되었으며, 따라서 이 변수의 주소를 생성할 수 있어야 한다.
  • 일부 지역변수들이 배열 or 구조체여서 이들이 배열이나 구조체 참조로 접근되어야 한다.


위의 예제를 통해 스택 운영방식의 여러 가지 측면을 엿볼 수 있다. 이 예제는 지역변수를 위해 스택에 알맞은 공간(32byte)를 할당하고, 각 자료형에 맞게 변수를 담고, proc() 함수를 부르기 위한 준비과정을 거치는 일련의 과정을 잘 보여준다.

프로시저 proc을 실행한 후 call_proc으로 리턴될 때, 이 코드는 네 개의 지역변수 값을 가져오고 최종 계산을 수행한다. 모든 작업이 끝난 후에는 스택 프레임을 반환하기 위해 스택 포인터를 32 감소시켜 마무리한다.

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

프로그램 레지스터들은 모든 프로시저들이 공유하는 단일 자원의 역할을 한다. 비록 한 순간에 하나의 프로시저만이 활성화 될 수 있지만, 호출자가 차후에 사용할 계획인 레지스터를 피호출자가 덮어쓰지 않는다. 이는 스택 포인터에 해당 레지스터 값을 푸시해두었다가, 호출이 종료되면 팝해오는 방식으로 구현된다.

  • 함수 call_proc에 대한 스택 프레임

배열의 할당과 접근 🤔

C언어는 매우 단순한 구현 방법을 사용하고 있어서 기계어로의 번역도 매우 손쉽게 할 수 있다. 하지만 최적화 컴파일러에는 배열의 인덱스를 사용할 때 필요한 주소계산을 단순화하는데 특히 우수한 성능을 보이기도 해서, C코드와 기계어 번역 간의 관계가 다소 해석하기 어려워질 수도 있다.

기본 원리

다른 고급 언어들과는 달리 C에서의 배열은 단순히 연속된 메모리를 뜻한다. T라는 자료형을 N개 담는 A라는 배열은 T A[N]; 과 같이 선언할 수 있다. 이 경우 배열의 i번째 원소들은 배열 시작 위치 + (L*i) 에 저장될 것이고, 0에서 N-1 사이의 정수형 인덱스를 사용해 접근이 가능하다.

포인터 연산

이 부분은

C언어 포인터 개인 공부 정리에서 자세히 다뤘으니 패스~

다중 배열

배열 할당과 참조에 관한 일반적인 원칙들은 심지어 배열의 배열을 생성할 때도 적용된다.
예를 들어 int A[5][3] 은 다음의 선언문과 동일하다.

typedef int row3_t[3];
row3_t A[5];

자료형 row3_t는 세 정수의 배열로 정의된다. 배열 A는 다섯 개의 배열을 원소로 가지며, 이들 각각은 세 개의 정수를 저장하기 위해 12바이트를 필요로 한다. 따라서 배열의 총 크기는 4x3x5 = 60바이트이다.

배열의 원소들은 메모리에 "행 우선(row major)"순서로 저장되는데, 이는 A[0]으로 표시하는 모든 행 0의 원소들 다음에 행1(A[1])의 원소가 따라오는 방식이다. 이는 다음의 그림으로 쉽게 이해 가능.

다차원 배열에 접근하기 위해서 컴파일러는 원하는 원소의 오프셋을 계산하는 코드를 생성하고, 배열의 시작을 기본 주소로, 오프셋을 인덱스(배율이 적용될 수 있음)로 하는 MOV인스트럭션을 사용한다.

예를 들어 일전의 5x3 정수 배열 A은 다음과 같은 코드로 각 원소에 접근할 수 있다.

위에서 나타낸 것처럼 각 원소의 주소를 시작주소 + 12i + 4j = 시작주소 + 4(3i+j)형태로 계산하였다.

고정크기의 배열

C컴파일러는 고정크기의 다차원 배열을 위한 코드에 대해 다양한 최적화를 수행할 수 있다.

  • 최적와 예시

    기존의 C 코드에서 정수 인덱스 j를 제거하고, 포인터 연산만을 이용하여 빠르게 연산을 할 수 있게 코드가 최적화된 모습을 확인할 수 있다. 컴파일러는 이런 최적화를 자동으로 실행한다.

7. 링커

링킹(linking)은 여러 개의 코드와 데이터를 모아서 연결하여 메모리에 로드될 수 있고 실행될 수 있는 한 개의 파일로 만드는 작업이다. 현대 시스템에서 이는 링커라고 부르는 프로그램에 의해 자동으로 수행된다.

링킹은 대개 링커에 의해 처리되며, 소규모의 프로젝트를 다룬다면 중요한 주제는 아니다. 그렇다면 왜 링킹에 대해 배워야 하는가?

  • 링커를 이해하면 큰 프로그램을 작성하는 데 도움이 된다.

  • 링커를 이해하면 디버깅하기 어려운 위험한 프로그래밍 에러를 피할 수 있게 된다.

  • 링킹을 이해하면 어떻게 변수 영역 규칙이 구현되었는지 이해하는 데 도움이 된다.

  • 링킹에 대해 이해하면 다른 중요한 시스템 개념을 이해할 수 있다.

  • 링킹을 이해하면 공유 라이브러리에 대해 이해할 수 있다.

컴파일러 드라이버

대부분의 컴파일 시스템은 사용자를 대신하여 언어 전처리기, 컴파일러, 어셈블러, 링커를 필요에 따라 호출하는 컴파일러 드라이버를 제공한다. C에서 흔히 사용한 GCC 드라이버를 예로 들 수 있겠다.

linux> gcc -Og -o prog main.c sum.c

위와 같은 명령을 쉘에 입력했다고 가정하면, 우선 드라이버는

  • 전처리기를 돌리고, 이것은 C 소스 파일 main.c를 ASCII 중간 파일인 main.i로 번역한다.

  • C 컴파일러를 돌려서 main.i를 ASCII 어셈블리 언어 파일인 main.s로 번역한다.

  • 어셈블러를 돌려서 main.s를 재배치 가능한 바이너리 목적파일인 main.o로 번역한다.

  • sum.o를 생성하기 위해 위의 과정을 반복하고, 마지막으로 링커 프로그램 ld를 실행한다.

  • 링커는 필요한 시스템 목적파일들과 함께 실행 가능 목적파일 prog 프로그램을 생성하기 위해서 main.osum.o를 연셜한다.

정적 연결

실행파일을 만들기 위해서 링커는 두 가지 주요 작업을 수행해야 한다.

  1. 심볼 해석
    목적파일들은 심볼들을 정의하고 참조하며 여기서 각 심볼은 함수, 전역변수 또는 정적변수에 대응된다. 심볼 해석의 목적은 각각의 심볼 참조를 정확하게 하나의 심볼 정의에 연결하는 것이다.

  2. 재배치(Relocation)
    컴파일러와 어셈블러는 주소 0번지에서 시작하는 코드와 데이터 섹션들을 생성한다. 링커는 이 섹션들을 각 심볼 정의와 연결시켜서 재배치하며, 이 심볼들로 가는 모든 참조들을 수정해서 이들이 이메모리 위치를 가리키도록 한다.

이 작업들에 대해 상세하게 알아보기 전에 숙지할 점은 '목적파일들은 단지 바이트 블록들의 집합'이라는 것이다. 각 바이트 블록에는 프로그램 코드가 있을 수도, 링커와 로더를 안내하는 데이터 구조를 포함할 수도 있다. 아무튼 링커는 이러한 블록들을 연결하고 조정해준다는 것!

목적파일

목적파일에는 세 가지 형태가 있다.

  1. 재배치 가능 목적파일(Relocatable object file)
    실행 가능 목적 파일을 만들기 위해 다른 재배치 가능 목적파일과 결합될 수 있는 바이너리 코드와 데이터를 포함하는 목적파일

  2. 실행 가능 목적파일(Executable object file)
    메모리에 직접 복사될 수 있고, 실행될 수 있는 형태로 바이너리 코드와 데이터를 포함하는 목적파일

  3. 공유 목적파일(Shared object file)
    로드타임 혹은 런타임 시에 동적으로 링크되고 메모리에 로드될 수있는 특수한 유형의 재배치 가능 목적 파일

컴파일러와 어셈블러는 재배치 가능 목적파일을 생성한다. 또한 목적파일들은 시스템마다 각각의 형식을 가지고 있으며, 여기서는 현대의 X86-64 리눅스와 유닉스 시스템들이 사용하는 ELF를 다룬다.

재배치 가능 목적파일


위의 그림은 전형적인 ELF 목적파일의 포맷을 보여준다. ELF 헤더는 이 파일을 생성한 워드 크기와 시스템의 바이트 순서를 나타내는 16바이트 배열로 시작한다. 나머지 공간에는 링커가 목적파일을 구문분석하고 해석하도록 하는 정보(예를 들어 목적파일 타입, 머신 타입 등)를 포함하고 있다.

ELF 헤더와 섹센 헤더 테이블 사이에는 섹션들이 들어있다. 전형적인 ELF 재배치 가능 목적파일은 다음과 같은 섹션들을 가진다.

  • .text : 컴파일된 프로그램의 머신 코드
  • .rodata : printf 문장의 포맷 스트링, switch문의 점프 테이블과 같은 읽기-허용 데이터
  • .data : 초기화된 C 전역변수, 정적변수
  • .bss : 초기화되지 않은 C 전역변수, 정적변수, 0으로 초기화된 변수, 이는 실제 공간을 차지하진 않음.
  • .symtab : 프로그램에서 정의되고 참조되는 전역변수들과 함수에 대한 정보들
  • .rel.text : 링커가 이 목적파일을 다른 파일들과 연결할 때 수정되어야 하는 .text 섹션 내 위치들
  • .rel.data : 이 모듈에 의해 정의되거나 참조되는 전역변수들에 대한 재배치 정보
  • .debug : 프로그램 내에서 정의된 지역변수들과 typedef 등을 갖는 디버깅 심볼 테이블
  • .line : 최초 C 소스 프로그램과 .text 섹션 내 머신 코드 인스트럭션 내 라인 번호간의 매핑
  • .strtab : .strtab.debug 섹션들 내에 있는 심볼 테이블과 섹션 헤더들에 있는 섹션 이름들을 위한 스트링 테이블

실행 가능 목적파일의 로딩

실행 가능 목적파일 prog 를 실행하기 위해서 다음과 같은 명령어를 실행한다고 가정해보자.

linux> ./prog

prog가 내장 쉘 명령어에 대응되지 않기 때문에 쉘은 이것이 실행 가능한 목적 파일이라고 생각하며, 로더(loader)라고 알려진 메모리 상주 운영체제 코드를 호출해서 이 프로그램을 실행한다.

로더는 디스크로부터 실행 가능한 목적파일 내의 코드와 데이터를 메모리로 복사하고 이 프로그램의 첫 번째 인스트럭션, 즉 엔트리 포인트로 점프해서 프로그램을 실행한다. 이런 과정을 로딩이라고 한다.

모든 실행 중인 리눅스 프로그램은 위의 그림과 유사한 런타임 메모리 이미지를 가진다. 이를 이해하는 것이 상당히 중요한데, 우선 코드 세그먼트는 0x400000에서 시작하고, 뒤이어 데이터 세그먼트가 온다.

  • 런타임 힙은 데이터 세그먼트 다음에 따라오고, malloc 라이브러리를 호출해서 위로 성장한다.

  • 이 다음에는 공유 모듈들을 위해 예약된 영역이 존재한다.

  • 사용자 스택은 가장 큰 합법적 주소(2**48 - 1)아래에서 시작해서 더 작은 메모리 주소 방향인 아래로 성장한다. 지금까지 다뤘던 스택이 이 부분!

  • 스택 위의 영역은 운영체제의 메모리 상주 부분인 커널의 코드와 데이터를 위해 예약되어 있다.

이 그림은 단순화되었지만 실제로는 코드와 데이터 세그먼트 사이에 공간이 존재한다. 또한, 링커는 런타임 주소를 스택, 공유 라이브러리, 힙 세그먼트에 할당할 때, *주소 공간 배치 랜덤화(ASLR)을 사용한다. 비록 이들 위치가 프로그램이 실행될 때 매번 변경될지라도 이들의 상대적인 위치는 동일하다.

로더가 돌아갈 때, 위의 그림과 유사한 메모리 이미지를 생성한다. 실행파일 내부의 프로그램 헤더 테이블에 따라 실행파일의 덩어리를 코드와 데이터 세그먼트로 복사한다. 그 다음 로더는 프로그램의 첫 인스트럭션으로 점프하며, 이는 항상 _start함수의 주소가 된다.

profile
저의 미약한 재능이 세상을 바꿀 수 있을 거라 믿습니다.

0개의 댓글

관련 채용 정보