공용 언어 런타임(CLR)의 실행 모델

2025. 2. 2. 18:31STUDY/C#

728x90
반응형

해당 내용은 서적 CLR via C# 을 기반으로 작성하였습니다.

 

들어가기 앞서,

우리는 여러가지 어플리케이션을 개발할때 개발 목적에 따라 언어를 선택하게 된다. 

C++과 같은 언어는 Win32 API를 자유로이 쓸 수 있고 메모리로 직접 접근이 가능하여 우리가 원하는 아주 섬세한 부분까지 제어할 수 있기 때문에, 시스템 서비스와 같은 하위 수준의 어플리케이션을 개발할때 적합하고, Visual Basic과 같은 언어는 쉬운 문법, 빠른 UI 디자인 작업 등이 용이하여 고객의 요구 사항이 빈번하게 바뀌는 업무 프로젝트를 진행하기에 아주 적당하다고 할 수 있다. 

즉, 기존의 언어 사이에는 우열이 존재하며, 개발 목적에 따라 언어의 선택이 이루어질 수 있고, 이러한 결정은 매우 신중하게 이루어져야 한다. 

그러나 닷넷 어플리케이션을 실행시킬 수 있는 일종의 런타임인 공용 언어 런타임(CLR)은 이름이 그러하듯 그 어떤 개발 언어라도 닷넷을 지원하는 언어라면 CLR의 모든 기능을 사용할 수 있다. 

그렇다면 언어간의 우열이 없고 모든 언어의 기능고 성능이 동일하다면 더 구체적으로 언어의 컴파일러의 역할은 무엇인가?

닷넷에서 언어의 컴파일러 역할은 단순히 우리가 작성한 코드의 문법을 해당 언어 기준에서 체크해주는 체커(Checker) 기능과 약간의 최적화 정도의 작업만을 한다고 보면 된다. 역할이 작아보이지만 컴파일러의 기능도 절대 무시할 수는 없다. 예를 들어 회계 /재무와 같이 수학적인 어플리케이션을 개발하고자 할때 APL과 같은 언어로 작성된 코드는 Perl과 같은 언어로 작성된 동일한 의미의 코드보다 훨씬 간결하고 코드 작성 시간도 줄여줄 수 있다. 

 

공용 언어 런타임(CLR)

1. 소스 코드를 관리하는 모듈로 컴파일하기

소스 코드 파일은 닷넷을 지원하는 다양한 언어 중 어느 것을 선택해도 좋다. 소스 코드 작업이 완료되면 선택한 언어의 컴파일러로 컴파일 작업을 수행하게 되고 이 과정에서 해당 언어의문법 체크및 컴파일러가 지원하는 코드 최적화 작업이 진행된다. 

컴파일의 결과물은 선택한 언어와 상관없이 모두 관리되는 모듈(managed module)의 형태로 완성된다. 관리되는 모듈의 파일 포맷은 표준 32비트 PE32 혹은 포쥰 64비트 PE32+의 형태이며, 이는 CLR이 실행시킬 수 있는 실행 파일 포맷이기도 하다.

네이티브 코드 컴파일러의 결과물은 CPU에 의존적이다. 즉 컴파일하기 전에 명시적 혹은 묵시적으로 x86, x64와 같이 여러 CPU 중 어느 CPU를 대상으로 컴파일을 수행할 것인지 미리 설정해 주어야 하며 이 설정에 따라 다른 결과물이 만들어지게 되는 것이다.

하지만 닷넷의 경우는 이와는 다르다. 닷넷을 지원하는 모든 컴파일러들은 그 결과물이 중간 언어(IL : Intermediate Language)라는 코드로서 CPU와는 상관없이 동일한 형태의 결과물을 만들어낸다. CLR이 IL 코드의 실행을 관리함으로써 관리되는 코드(managed code) 혹은 관리 코드라고도 한다. 

  • 메타데이터

CLR을 지원하는 모든 컴파일러는 일정한 형식의 메타데이터를 관리되는 모듈에 포함하게 되어 있다. 이 메타데이터를 확인하면 해당 모듈에서 어느 타입들이 정의되어 있는지 쉽게 확인할 수 있다. 사실 닷넷의 실행 파일 혹은 닷넷 어셈블리라고 불리는 것에는 항상 IL 코드와 메타데이터가 같이 존재하게 된다. 메타데이터의 사용 용도는 상당히 많은데 다음과 같은 기능은 그 가운데 일부이다.

  • 메타데이터의 존재는 컴파일러가 컴파일 수행시 기존의 헤더 혹은 라이브러리 파일 같은 것이 없어도 컴파일을 가능하게 해준다
  • CLR의 코드 검증 기능은 메타데이터의 정보를 통해 해당 실행 파일이 안전하게 실행될 수 있는지 확인할 수 있다.
  • 메타데이터는 가비지 수집기가 객체의 수명 상태를 조사하여 수집 대상을 확인하는데 도움을 준다. 

메타데이터에는 이미 모든 정의된 타입과 참조된 타입의 정보가 실행 파일에 자세히 기록되어 있기 때문에 컴파일러는 이러한 정보를 관리되는 모듈에서 정확히 읽을 수 있기 때문에 기존의 헤더와 라이브러리 없이도 컴파일이 가능하며,

가비지 수집기는 런타임시 객체의 타입, 그리고 해당 객체가 포함하고 있는 멤버들의 리스트를 확인하고 이를 중 어느 멤버가 다른 객체에 의해서 참조되고 있는지를 확인할 수 있어 도움을 받는다.

 

2. 관리되는 모듈을 어셈블리 파일로 결합하기

CLR의 작업 대상은 모듈이 아니고 바로 어셈블리이다. 어셈블리란 하나 이상의 모듈 혹은 리소스 파일로 구성된 논리적인 파일의 그룹을 의미하며 재사용, 보안, 버전 관리의 단위이다. CLR의 세계에서는 어셈블리를 흔히 컴포넌트라고 부르기도 한다.

기본적으로 컴파일러는 생성된 관리 모듈을 어셈블리로 변환하는 작업을한다. 즉 C# 컴파일러가 메니페스트 정보가 포함된 모듈을 생성하는 것이다. 

어셈블리를 이용해서 재사용, 보안, 버전 관리 대상 컴포넌트의 논리적인 구성과 물리적인 구성을 최대한 분리하는 것이 가능하다. 예를 들면, 자주 사용되지 않는 타입과 리소스는 별도의 파일로 분리한 후 이를 어셈블리에 포함할 수 있다. 이 별도의 파일은 상황에 따라서 웹을 통한 다운로드가 가능하다. 만일 사용자에 의해서 파일이 사용되지 않았다면 이 파일은 다운로드되지 않았을 것이며 또한 이로 인해 디스크의 사용 공간과 설치 시간이 줄어들 것이다.

어셈블리는 여러 배포 파일로 나누어지는 것이 가능하며, 이런 파일의 그룹은 역시 하나의 컬렉션으로 취급된다.

 

3. CLR 로딩

CLR이 어떻게 로드되는지 알아보기 전에 32비트 윈도우와 64비트 윈도우에 대해서 먼저 알아보자.

만약 작성된 코드가 순수하게 관리 코드로만 작성되어 있다면, 이 어셈블리는 32비트와 64비트 윈도우 모두에서 잘 작동할 것이다. 그러나 만약 개발자가 특정 버전의 윈도우기능을 사용하길 원한다면, 그래서 특정 CPU에 의존적인 기능을 사용했다면 플랫폼 대상을 선택해줘야 할 것이다.

이러한 기능을제공하기 위해 C# 컴파일러에는 플랫폼 스위치 옵션을 제공한다. 이 스위치 옵션을 통해 32비트 혹은 64비트에서만 동작하는 결과 어셈블리를 생성할 수도 있다. 

플랫폼 스위치에 따라 C# 컴파일러는 PE32 혹은 PE32+ 헤더를 포함한 어셈블리를 생성할 것이며, 헤더에 대상 CPU의 아키텍처도 생성할 것이다.PE32 헤더를 포함한 파일은 32비트 혹은 64비트 주소 공간에서 실행 가능하며, PE32+ 헤더를 포함한 파일은 64비트 주소 공간을 필요로 한다. Window 시스템은 또 헤더에 포함된 CPU의 아키텍처 정보도 검사하여 시스템의 CPU와 일치하는지 확인한다.

마지막으로 64비트 윈도우에 32비트 윈도우 어플리케이션을 정상적으로 동작하게 해주는 WoW64(Window on Window64)라는 기술을 포함한다. 

/platform 옵션 관리되는 모듈의 형식 x86 Windows x64 Windows
anycpu(기본값) PE32/범용 32비트 어플리케이션으로 실행됨 64비트 어플리케이션으로 실행됨
x86 PE32/x86 전용 32비트 어플리케이션으로 실행됨 WoW64 어플리케이션으로 실행됨
x64 PE32+/x64 전용 실행되지 않음 64비트 어플리케이션으로 실행됨

윈도우는 어셈블리의 관리 코드를 실행하기 위해 exe 파일 헤더의 내용을 확인한 후 32비트 프로세스, 64비트 프로세스 혹은 WoW64비트 프로세스 중 어느 프로세스를 생성할지 결정하게 된다. 그런 후 윈도우 시스템은 각 버전의 MSCorEE.dll을프로세스 주소 공간에 로드한다.

MSCorEE.dll 의 각 위치는 다음과 같다.

  • x86 :  C:\Windows\System32
  • x64 :  C:\Windows\System32 (버전 호환성을 위해  x86 버전과 같은 위치에 있다.)
  • x64 Window에서 x86 :  :  C:\Windows\SysWOW64

그 다음 프로세스의 메인 스레드는 MSCorEE.dll에 정의되어 있는 메서드를 호출한다. 호출된 MSCorEE.dll 의 메서드는 CLR을 초기화하고 exe 어셈블리를로드한후어플리케이션 진입점인 main() 메서드를 호출한다. 그리고이시점 이후부터 관리되는 어플리케이션이 실제 실행된다.

 

4. 어셈블리 코드의 실행

관리 코드로 작성된 어셈블리는 IL이라는 중간 언어와 메타데이터를 포함한다. 실제 메서드를 실행하기 위해서 IL 코드는 우선 네이티브 CPU 지시어로 변환되어야 한다. 이 작업은 CLR의 JIT(just in time) 컴파일러가 수행해 준다.

static void Main() {
    Console.WriteLine("Hello");
    Console.WriteLine("GoodBye");
}

main 메서드가 실행되기 바로 전에 CLR은 main 메서드 코드에서 참조되는 모든 타입을 감지한다. 이는 CLR에게 참조된 타입을 관리할 내부 자료구조를 할당한다. 위 코드에서 보듯 main 함수는 console 타입 하나를 참조하고 있으며 이를 CLR에게 내부 자료구조를 할당하게 한다. 

main 메서드에서 writeLine 메서드를 처음 호출하면 JITCompiler 기능이 실행한다. 이는 구현된 특정 메서드의 IL 코드를 네이티브 CPU 지시어로 컴파일 하는 것이다. 그리고 메서드별로 IL 코드가 필요할 때만 즉시 컴파일되므로 (Just in time) CLR의 이러한 컴포넌트를 JIT 컴파일러 라고 말한다.

JIT Compiler의 동작

  1. 호출되는 메서드를 포함한 어셈블리의 메타데이터를 검색한다.
  2. 메타데이터로부터 IL 코드를 가져온다.
  3. 메모리 블록을 할당한다.
  4. 네이티브 CPU 지시어로 컴파일 한다.  네이티브 코드는 3번에서 할당한 메모리에 저장된다.
  5. 3번에서 할당된 메모리 블록을 가르키도록 타입 테이블 내의 메서드 엔트리를 수정한다.
  6. 메모리 블랙 내부에 포함된 네이티브 코드로 이동한다.

4번 동작을 통해 차후에 다시 이 메서드가 호출될 때 컴파일을 다시 수행하지 않고 이전에 컴파일된 네이티브 CPU 지시어를 바로 사용할 수있게 한다.

메서드 컴파일 작업과 컴파일된 내용을 저장하는 작업이 끝나면 JIT 컴파일러는 호출에 필요한 인자들을 전달하며 해당메서드를 실행한다. 그리고 메서드의 수행이 끝나서 값이 반환되면 다시 main 메서드로 돌아와서 다음 작업을 실행한다.

 Console.WriteLine("GoodBye");

main 메서드의 두번째 라인인 위 코드는 이미 검증 및 컴파일되었기 때문에 컴파일된 결과가 저장된 메모리 블록으로 바로 이동하고 JIT 컴파일러의 작업을 모두 생략한다.

 

추가적으로 작성할 내용 : 메타데이터, 어셈블리

728x90
반응형

'STUDY > C#' 카테고리의 다른 글

[C#] async, await  (0) 2024.12.04
[C#] 동기 비동기 개념 이해하기  (0) 2024.11.28
[C#] Array vs List vs ArrayList  (0) 2024.11.12
[C#] Class vs Struct  (0) 2024.11.06
interface는 인스턴스는 못 만들지만 참조는 만들 수 있다?  (1) 2024.10.03