기존 책 내용에 보충하고 싶은 부분들(ex: 컴파일 과정)을 이것저것 추가해서 1.7.3 가상메모리까지만 정리해 보았다.(그 뒤는 시간관계상 생략...)
지금은 3장 읽고 있는데 책이 이상하게 잘 읽히지가 않아서 머리 싸매고 있다...
정글러 분들... 요약정리글 공유해요...😂
서문
프로그램의 수명주기를 따라가면서 주요 개념과 용어, 관련된 구성요소를 간략하게 소개하고자 한다.
다음 코드로 이루어진 hello.c 프로그램을 통해 이를 공부해 보자.
#include <stdio.h>
int main()
{
printf("hello, world\n");
return 0;
}
1.1 정보는 비트와 컨텍스트로 이루어진다
- hello 프로그램은 소스 프로그램으로 생성되어, hello.c라는 텍스트 파일로 저장된다.
- 소스 프로그램 : 0 또는 1로 표시되는 비트들의 연속. 바이트라는 8비트 단위로 구성됨
- 각 바이트는 프로그램의 텍스트 문자를 나타냄
- 대부분의 컴퓨터 시스템은 텍스트 문자를 아스키 표준을 사용하여 표시
- 아스키 표준 : 각 문자를 바이트 길이의 정수 값으로 나타냄
- hello.c처럼 오로지 아스키 문자들로만 이루어진 파일들을 텍스트 파일이라고 부름. (↔바이너리 파일)
- 모든 시스템 내부의 정보(디스크 파일, 메모리상의 프로그램, 데이터, 네트워크를 통해 전송되는 데이터)는 비트들로 표시되며, 컨텍스트에 의해 구분된다.
- 동일한 일련의 바이트가 컨텍스트에 따라 정수, 부동소수, 문자열, 기계어 명령 등으로 다르게 해석될 수 있다.
1.2 프로그램은 다른 프로그램에 의해 다른 형태로 번역된다
- hello 프로그램은 생성될 당시 인간이 이해하고 읽을 수 있는 고급 C 프로그램이다.
- 이를 시스템에서 실행시키려면 각 문장들은 저급 기계어 명령어들로 번역되어야 한다.
- 이 명령어들은 실행가능 목적 프로그램(=실행 프로그램)이라고 하는 형태로 합쳐져서 바이너리 디스크 파일로 저장된다.
- 실행 프로그램은 실행 파일이라고도 부른다.
- 실행 파일 : 실행 코드로 이루어진 파일. 목적 파일에서 링킹이 완료되면 실행 파일이 된다.
- 컴파일러 드라이버는 유닉스 시스템에서 소스파일을 실행 파일로 번역한다.
linux> gcc -o hello hello.c
- GCC 컴파일러 드라이버(GNU에서 만든 C 컴파일러)는 소스파일 hello.c를 실행 파일인 hello로 번역한다.
- 번역은 전처리기, 컴파일러, 어셈블러, 링커를 거쳐 다음의 4단계로 이루어진다.(*컴파일 과정)
각 단계는 다음과 같다.
- 전처리 단계
- 전처리기(cpp)에 의해 이루어진다.
- 소스파일에 다른 파일의 텍스트를 포함시키거나 일부 문장을 다른 문장으로 바꾸는 작업 등을 수행한다.
- 전처리 명령어는 ‘#’ 기호로 시작하며, 본래의 C 프로그램을 해당 명령어에 따라 수정한다.
- 예를 들어 hello.c 파일 첫 줄의 #include<stdio.h>는 전처리기로 하여금 시스템 헤더파일인 stdio.h를 프로그램 문장에 직접 삽입하라는 의미이다.
- 그 결과 일반적으로 .i로 끝나는 새로운 C 프로그램이 생성되며, 전처리가 끝난 파일 역시 소스파일과 마찬가지로 텍스트 파일이다.
- 컴파일 단계
- 컴파일러(cc1)에 의해 이루어진다.
- 텍스트 파일 hello.i를 어셈블리어 프로그램이 저장된 텍스트 파일인 hello.s로 번역한다.
- 어셈블리어 : 저급 언어의 한 종류로, 기계어를 읽기 편한 형태로 번역한 언어
- 어셈블리어는 여러 상위 수준 언어의 컴파일러들을 위한 공통의 출력 언어를 제공하기 때문에 유용하다.
- 어셈블리 단계
- 어셈블러(as)에 의해 이루어진다.
- 어셈블리어 파일 hello.s를 기계어 명령어로 번역하고, 이들을 재배치가 가능한 목적 프로그램의 형태로 묶어서 hello.o라는 목적 파일에 저장한다.
- 목적 코드 : 사람이 알아볼 수 없는 기계어로 변환된 코드
- 목적 파일 : 목적 코드로 이루어진 파일
- 이 파일은 main함수의 명령어들을 인코딩하기 위한 17바이트를 포함하는 바이너리 파일이다.
- 링크 단계
- 링커(ld)에 의해 이루어진다.
- 링킹이란 작성한 프로그램이 사용하는 다른 프로그램이나 라이브러리를 가져와 연결하는 과정을 뜻한다.
- 예를 들어, hello 프로그램이 호출하는 표준 C 라이브러리의 printf 함수는 별도의 목적 파일인 printf.o에 들어 있다.
- 이 파일을 hello.o파일과 통합하는 작업을 링커 프로그램이 수행한다.
- 링킹이 완료되면 hello 파일은 실행가능 목적 파일(=실행파일)로 메모리에 적재되어 시스템에 의해 실행된다.
1.3 컴파일 시스템이 어떻게 동작하는지 이해하는 것은 중요하다
- 프로그래머들이 컴파일 시스템이 어떻게 동작하는지 이해해야 하는 이유는 다음과 같다.
- 프로그램 성능 최적화하기
- C 프로그램 작성 시 올바른 판단을 하기 위해서는 기계어 수준 코드에 대한 기본적인 이해를 할 필요가 있으며, 컴파일러가 어떻게 C 문장들을 기계어 코드로 번역하는지 알 필요가 있다.
- 예를 들어, switch문은 if-else문을 연속해서 사용하는 것보다 효율적인지, 함수 호출 시 발생하는 오버헤드는 얼마나 되는지, while 루프는 for 루프보다 더 효율적인지, 포인터 참조가 배열 인덱스보다 더 효율적인지 등을 알기 위해서는 해당 지식이 필요하다.
- 링크 에러 이해하기
- 가장 당혹스러운 프로그래밍 에러는 링커의 동작과 관련되어 있으며, 큰 규모의 소프트웨어 시스템을 빌드하려는 경우에는 더욱 그렇다.
- 예를 들어 링커가 어떤 참조를 풀어낼 수 없다고 할 때는 무엇을 의미하는가? 정적변수와 전역변수의 차이는 무엇인가? 만일 각기 다른 파일에 동일한 이름의 두 개의 전역변수를 정의한다면 무슨 일이 일어나는가? 등의 질문에 대한 대답을 배울 수 있다.
- 보안 약점 피하기
- 오랫동안 버퍼 오버플로우 취약성이 인터넷과 네트워크상의 보안 약점의 주요 원인으로 설명되었다.
- 안전한 프로그래밍을 배우는 첫 단계는 프로그램 스택에 데이터와 제어 정보가 저장되는 방식 때문에 생겨나는 영향을 이해하는 것이다.
- 프로그램 성능 최적화하기
1.4 프로세서는 메모리에 저장된 인스트럭션을 읽고 해석한다
- 위의 과정을 통해 디스크에 저장된 hello 실행파일을 유닉스 시스템에서 실행하기 위해서 쉘이라는 응용프로그램에 그 이름을 입력한다.
linux> ./hello
hello, world
linux>
- 쉘은 커맨드라인 인터프리터로 프롬프트를 출력하고 명령어 라인을 입력 받아 그 명령을 실행한다.
- 프롬프트 : 컴퓨터가 입력을 받아들일 준비가 되어서 기다리고 있다고 알려주는 메시지. 위 예시에서는 linux>
- 만일 명령어 라인이 내장 쉘 명령어가 아니면 실행파일의 이름으로 판단하고 그 파일을 로딩해서 실행해 준다.
- 이 경우, 쉘은 hello 프로그램을 로딩하고 실행한 뒤 종료를 기다리며, 종료 이후에는 프롬프트를 출력해 주고 다음 입력 명령어 라인을 기다린다.
1.4.1 시스템의 하드웨어 조직
hello 프로그램을 실행할 때 무슨 일이 일어나는지를 전형적인 시스템 하드웨어 조직을 통해 설명해 보자.
버스(Buses)
- 시스템 내를 관통하는 전기적 배선군을 버스라고 한다.
- 컴포넌트들 간에 바이트 정보들을 전송한다.
- 일반적으로 워드(word)라고 하는 고정 크기의 바이트 단위로 데이터를 전송하도록 설계된다.
- 한 개의 워드를 구성하는 바이트 수는 시스템마다 보유하는 기본 시스템 변수로, 오늘날 대부분의 컴퓨터들은 4바이트(32비트) 또는 8바이트(64비트) 워드 크기를 갖는다.
입출력 장치(I/O Device)
- 시스템과 외부세계와의 연결을 담당한다.
- 입력용 키보드, 마우스, 출력용 디스플레이, 데이터 및 프로그램의 장기 저장을 위한 디스크 드라이브 등이 입출력 장치이다.
- 각 입출력 장치는 입출력 버스에서 컨트롤러나 어댑터를 통해 연결된다.
- 컨트롤러는 디바이스 자체에 있는 칩셋이거나 시스템의 마더보드에 장착된다.
- 어댑터는 마더보드의 슬롯에 장착되는 카드이다.
- 컨트롤러나 어댑터는 입출력 버스와 입출력 장치들 간에 정보를 주고받도록 한다.
메인 메모리(Main Memory)
- 프로세서가 프로그램을 실행하는 동안 데이터와 프로그램을 모두 저장하는 임시 저장장치
- 물리적으로 메인 메모리는 DRAM(Dynamic Random Access Memory) 칩들로 구성되어 있음
- 논리적으로 메모리는 연속적인 바이트들의 배열로, 각각 0부터 시작해서 고유의 주소(배열의 인덱스)를 가지고 있음
- 일반적으로 한 개의 프로그램을 구성하는 각 기계어 인스트럭션은 다양한 바이트 크기를 갖는다.
프로세서(Processor)(=CPU)
- 주처리장치(CPU)라고도 한다.
- 메인 메모리에 저장된 명령어들을 해독(실행)하는 엔진이다.
- 프로세서의 중심에는 워드 크기의 저장장치(혹은 레지스터)인 프로그램 카운터(PC)가 있다.
- PC는 다음에 실행될 명령어의 주소를 가리킨다.
- 프로세서는 프로그램 카운터가 가리키는 곳의 명령어를 반복적으로 실행하고, 프로그램 카운터 값이 다음 명령어의 위치를 가리키도록 업데이트한다.
- 다음은 명령어 요청에 의해 CPU가 실행하는 단순한 작업의 예이다.
- 적재(Load) : 메인 메모리에서 레지스터에 한 바이트 또는 워드를 이전 값에 덮어쓰는 방식으로 복사한다.
- 저장(Store) : 레지스터에서 메인 메모리로 한 바이트 또는 워드를 이전 값을 덮어쓰는 방식으로 복사한다.
- 작업(Operate) : 두 레지스터의 값을 ALU로 복사하고 두 개의 워드로 수식연산을 수행한 뒤, 결과를 덮어쓰기 방식으로 레지스터에 저장한다.
- 점프(Jump) : 명령어 자신으로부터 한 개의 워드를 추출하고, 이것을 PC에 덮어쓰기 방식으로 복사한다.
1.4.2 hello 프로그램의 실행
hello 프로그램이 실행되는 과정을 하드웨어 조직을 바탕으로 설명해 보자.
- 처음에 쉘 프로그램은 사용자가 명령을 입력하기를 기다린다. 이용자가 쉘에 “.\hello”를 입력하면 쉘 프로그램은 각각의 문자를 레지스터에 읽어들인 후 메모리에 저장한다.
- 키보드에서 엔터를 누르면 쉘은 명령 입력을 끝마쳤음을 이해하고 파일 내의 코드와 데이터(”hello, world\n” 스트링 데이터 포함)를 복사하는 일련의 명령어를 실행하여 실행파일 hello를 디스크에서 메인 메모리로 로딩한다.
- 데이터는 직접 메모리 접근 기법을 이용해 프로세서를 거치지 않고 디스크에서 메인 메모리로 직접 이동한다.
- hello 목적파일의 코드와 데이터가 메모리에 적재된 후, 프로세서는 hello 프로그램의 main 루틴의 기계어 명령어를 실행한다.
- 이 명령어들은 “hello, world\n” 스트링을 메모리로부터 레지스터 파일로 복사하고, 거기로부터 디스플레이 장치로 전송하여 화면에 글자들이 표시된다.
1.5 캐시가 중요하다
- 위와 같은 예제에서 우리는 시스템이 정보를 한 곳에서 다른 곳으로 이동시키는 일에 많은 시간을 보낸다는 사실을 알게 된다.
- hello 프로그램의 기계어 명령어들은 본래 하드디스크에 저장되어 있었으나 프로그램이 로딩되면서 메인 메모리로 복사된다.
- 프로세서가 프로그램을 실행할 때에는 메인 메모리에서 프로세서로 복사된다.
- 마찬가지로 “hello, world\n” 문자열 데이터도 본래는 디스크에 저장되어 있었지만, 메인 메모리로 복사되었다가 디스플레이 장치로 복사된다.
- 이러한 복사 과정들이 프로그램의 실제 작업을 느리게 하는 오버헤드이며, 시스템 설계자들은 이러한 복사과정을 가능한 한 빠르게 동작하도록 하는 것을 목표로 한다.
- 물리학의 법칙 때문에 큰 저장장치들은 작은 저장장치들보다 느리고, 빠른 장치들은 느린 장치들보다 만드는 데 많은 비용이 든다.
- 마찬가지로 레지스터 파일은 메인 메모리에 비해 용량이 작지만, 프로세서는 레지스터 파일의 데이터를 메모리의 경우보다 훨씬 빠르게 읽을 수 있다.
- 이러한 프로세서-메모리 간 격차에 대응하기 위해 시스템 설계자는 보다 작고 빠른 캐시 메모리(간단히 캐시)라고 부르는 저장장치를 고안하여 프로세서가 단기간에 필요로 할 가능성이 높은 정보를 임시로 저장할 목적으로 사용한다.
- 일반적인 시스템에서의 캐시 메모리 구조는 다음과 같다.
- 캐시는 CPU와 가까운 순서대로 계층을 구성하며, CPU와 가까운 순서대로 L1 캐시, L2 캐시, L3 캐시라고 한다.
- 프로세서 칩 내에 들어 있는 L1 캐시는 대략 수천 바이트의 데이터를 저장할 수 있으며, 레지스터 파일만큼 빠른 속도로 액세스할 수 있다.
- 이보다 좀 더 큰 L2 캐시는 수백 킬로바이트에서 수 메가 바이트의 용량을 가지며 프로세서와 전용 버스를 통해 연결된다.
- 프로세서가 L2 캐시를 액세스할 때 L1 캐시보다 5배 정도 느리지만, 그래도 여전히 메인 메모리를 액세스할 때보다는 5배에서 크게는 10배까지 더 빠르다.
- L1, L2 캐시는 SRAM이라는 하드웨어 기술을 이용해 구현한다.
- 일반적으로 L1 캐시와 L2 캐시는 코어 내부에 위치한다.
- 캐시 시스템은 프로그램이 지엽적인 영역의 코드와 데이터를 액세스하는 경향이 있다는 지역성(locality)을 바탕으로 한다.
- 자주 액세스할 가능성이 높은 데이터를 캐시가 보관하도록 설정하면 빠른 캐시를 이용해서 대부분의 메모리 작업을 수행할 수 있게 된다.
- 캐시 메모리를 이해하는 응용 프로그래머는 캐시를 활용하여 자신의 프로그램 성능을 10배 이상 개선할 수 있다.
1.6 저장장치들은 계층구조를 이룬다
- 모든 컴퓨터 시스템의 저장장치들은 다음 그림과 같은 메모리 계층구조로 구성되어 있따.
- 계층의 꼭대기에서부터 맨 밑바닥까지 이동할수록 저장장치들은 더 느리고, 더 크고, 바이트당 가격이 싸진다.
- 레지스터 파일은 계층구조의 최상위인 레벨 0, 즉 L0을 차지한다. L1에서 L3까지는 캐시를 사용하는 구조를 보여주며, 메인 메모리는 레벨 4에 위치한다.
- 메모리 계층구조의 주요 아이디어는 한 레벨의 저장장치가 다음 하위레벨 저장장치의 캐시 역할을 한다는 것이다.
- L1와 L2의 캐시는 각각 L2와 L3의 캐시이다.
- L3 캐시는 메인 메모리의 캐시이고, 이 캐시는 디스크의 캐시 역할을 한다.
- 일부 분산 파일시스템을 가지는 네트워크 시스템에서 로컬 디스크는 다른 시스템의 디스크에 저장된 데이터의 캐시 역할을 수행한다.
1.7 운영체제는 하드웨어를 관리한다
- 쉘 프로그램이 hello 프로그램을 로드하고 실행했을 때와 hello 프로그램이 메시지를 출력할 때, 프로그램이 키보드나 디스플레이, 디스크나 메인 메모리를 직접 액세스하지 않았다.
- 이 때, 운영체제(Operation System)가 제공하는 서비스를 활용한다.
- 운영체제는 하드웨어와 소프트웨어 사이에 위치한 소프트웨어 계층으로 생각할 수 있다.
- 응용프로그램이 하드웨어를 제어하려면 언제나 운영체제를 통해서 해야 한다.
- 운영체제는 두 가지 주요 목적을 지닌다.
- 제멋대로 동작하는 응용프로그램들이 하드웨어를 잘못 사용하는 것을 막기 위해
- 응용프로그램들이 단순하고 균일한 메커니즘을 사용하여 복잡하고 매우 다른 저수준 하드웨어 장치들을 조작할 수 있도록 하기 위해
- 운영체제는 이 두 가지 목표를 다음과 같은 근본적인 추상화를 통해 달성하고 있다.
- 파일은 입출력장치의 추상화이고, 가상메모리는 메인 메모리와 디스크 입출력 장치의 추상화, 그리고 프로세스는 프로세서, 메인 메모리, 입출력장치 모두의 추상화 결과이다. 이들 각각에 대해 차례로 설명한다.
1.7.1 프로세스
- 프로세스는 실행 중인 프로그램에 대한 운영체제의 추상화다.
- 다수의 프로세스들은 동일한 시스템에서 동시에 실행될 수 있으며, 각 프로세스는 하드웨어를 배타적으로 사용하는 것처럼 느끼게 해준다.
- 동시에란 말은 한 프로세스의 명령어들이 다른 프로세스의 명령어들과 섞인다는 것을 의미한다.
- 프로세서는 프로세스들을 바꿔주는 방식으로 한 개의 CPU가 다수의 프로세스를 동시에 실행하는 것처럼 보이게 해준다.
- 운영체제는 문맥 전환(context switching)이라는 방법을 사용해서 이러한 교차 실행을 수행한다.
- 운영체제는 프로세스가 실행하는 데 필요한 모든 상태정보의 변화를 추적하며, 이 상태정보를 컨텍스트라고 한다.
- 컨텍스트는 PC, 레지스터 파일, 메인 메모리의 현재 값을 포함하고 있다.
- 단일 프로세서 시스템은 한 순간에 한 개의 프로세스 코드만을 실행할 수 있다.
- 따라서 운영체제는 현재 프로세스에서 다른 프로세스로 제어를 옮기려고 할 때 현재 프로세스의 컨텍스트를 저장하고, 새 프로세스의 컨텍스트를 복원시키는 문맥 전환을 실행하여 제어권을 새 프로세스로 넘겨준다.
- 새 프로세스는 이전에 중단했던 그 위치부터 다시 실행된다.
- 예제 hello 프로그램을 통해 프로세스 전환을 살펴보자.
- 예제에는 쉘 프로세스, hello 프로세스 이렇게 두 개의 동시성 프로세스가 존재한다.
- 처음에는 쉘 프로세스가 혼자서 동작하고 있다가 명령줄에서 입력을 기다린다.
- hello 프로그램을 실행하라는 명령을 받으면, 쉘은 시스템 콜이라는 특수 함수를 호출하여 운영체제로 제어권을 넘겨준다.
- 운영체제는 쉘의 컨텍스트를 저장하고 새로운 hello 프로세스와 컨텍스트를 생성한 뒤 제어권을 새 hello 프로세스로 넘겨준다.
- hello가 종료되면 운영체제는 쉘 프로세스의 컨텍스트를 복구시키고 제어권을 넘겨주면서 다음 명령 줄 입력을 기다린다.
- 하나의 프로세스에서 다른 프로세스로의 전환은 운영체제 커널에 의해 관리된다.
- 커널은 운영체제 코드의 일부분으로 메모리에 상주한다.
- 응용프로그램이 운영체제에 의한 어떤 작업을 요청하면, 컴퓨터는 파일 읽기나 쓰기와 같은 특정 시스템 콜을 실행해서 커널에 제어를 넘겨준다.
- 그러면 커널은 요청된 작업을 수행하고 응용 프로그램으로 리턴한다.
- 커널은 별도의 프로세스는 아니지만, 모든 프로세스를 관리하기 위해 시스템이 이용하는 코드와 자료구조의 집합이다.
1.7.2 스레드(Thread)
- 프로세스가 마치 한 개의 제어흐름을 갖는 것으로 생각할 수 있지만, 최근의 시스템에서는 프로세스가 실제로 스레드라고 하는 다수의 실행 유닛으로 구성되어 있다.
- 스레드 : 프로세스를 구성하는 실행의 흐름 단위이며, 스레드를 이용하면 하나의 프로세스에서 여러 부분을 동시에 실행할 수 있다.
- 각자의 스레드는 해당 프로세스의 컨텍스트에서 실행되며 동일한 코드와 전역 데이터를 공유한다.
- 다중 스레딩도 다중 프로세서를 활용할 수 있다면 프로그램의 실행 속도를 빠르게 하는 한 가지 방법이다.
1.7.3 가상메모리
- 가상메모리는 각 프로세스들이 메인 메모리 전체를 독점적으로 사용하고 있는 것 같은 환상을 제공하는 추상화이다.
- 각 프로세스는 가상 주소 공간이라고 하는 균일한 메모리의 모습을 갖게 된다.
- 리눅스 프로세스들의 가상 주소 공간은 다음과 같다.
- 리눅스에서 주소 공간의 최상위 영역은 모든 프로세스들이 공통으로 사용하는 운영체제의 코드와 데이터를 위한 공간이다.
- 주소 공간의 하위 영역은 사용자 프로세스의 코드와 데이터를 저장한다.
- 그림에서 위쪽으로 갈수록 주소가 증가한다.
- 가상주소공간을 낮은 주소부터 위로 올라가면서 간단히 살펴보면 다음과 같다.
- 프로그램 코드와 데이터
- 코드는 모든 프로세스들이 같은 고정 주소에서 시작하며, 다음에 C 전역변수에 대응되는 데이터 위치들이 따라온다.
- 코드와 데이터 영역은 실행 파일인 hello로부터 직접 초기화된다.
- 힙
- 런타임 힙이다. 크기가 고정되어 있는 코드, 데이터 영역과 달리 힙은 프로세스가 실행되면서 C 표준함수인 malloc이나 free를 호출하면서 런타임에 동적으로 크기가 줄었다 늘었다 한다.
- 공유 라이브러리
- 주소 공간의 중간 부근에 C 표준 라이브러리나 수학 라이브러리와 같은 공유 라이브러리의 코드와 데이터를 저장하는 영역이 있다.
- 스택
- 사용자 가상메모리 공간의 맨 위에 컴파일러가 함수 호출을 구현하기 위해 사용하는 사용자 스택이 위치한다.
- 사용자 스택은 힙과 마찬가지로 동적으로 늘어났다 줄어들었다 한다.
- 특히 함수를 호출할 때마다 스택이 커지며, 함수에서 리턴될 때는 줄어든다.
- 커널 가상메모리
- 주소 공간의 맨 윗부분은 커널을 위해 예약되어 있다.
- 응용프로그램들은 이 영역의 내용을 읽거나 쓰는 것이 금지되어 있으며, 마찬가지로 커널 코드 내에 정의된 함수를 직접 호출하는 것도 금지되어 있다.
- 위 작업을 수행하기 위해서는 커널을 호출해야 한다.
- 프로그램 코드와 데이터