notion cover

C# 코드는 어떻게 실행되는가 - 2편, CIL과 메타데이터 그리고 JIT

컴파일하면 바로 기계어가 될까?

C++의 전통적인 네이티브 빌드 흐름에 익숙하다면, 컴파일 결과는 곧 CPU 명령이라고 생각하기 쉽다. 하지만 일반적인 .NET 빌드에서 C# 컴파일러가 만드는 핵심 결과물은 특정 CPU용 기계어가 아니라 CIL과 메타데이터가 들어 있는 어셈블리다.

 

 

아래 코드는 이번 포스팅에서 이해를 위해 지속적으로 사용할 예제 코드이다. 이 메서드가 빌드 파일 안에서 어떤 형태로 저장되고, 실행 순간 어떤 명령으로 바뀌는지를 보면서 .NET 실행 모델 전체를 그려본다.

C#
public class Caculator { public int Add(int a, int b) { return a + b; } }

 


 

01. 어셈블리는 스스로를 설명하는 실행 단위다

.NET의 어셈블리는 단순히 기계어를 모은 파일이 아니다. 실행할 CIL과 그 코드를 해석하는 데 필요한 구조 정보가 함께 들어간다. 보통 .dll 또는 .exe 형태이며, CLR이 로드하고 버전·참조·타입을 파악하는 기본 단위가 된다.

 

 


01

어셈블리 매니페스트

어셈블리 이름과 버전, 문화권, 참조하는 다른 어셈블리 등 어셈블리 자체에 대한 정보를 담는다.\sixptsize\bf어셈블리~이름과~버전,~문화권 ,~참조하는~다른~어셈블리~등~어셈블리~자체에~대한~정보를~담는다.


02

타입 메타데이터

클래스, 필드, 메서드, 매개변수, 상속 관계와 접근 제한자 등을 설명한다.\sixptsize\bf클래스,~필드,~메서드,~매개변수,~상속~관계와~접근~제한자~등을~설명한다.


03

CIL 코드

메서드의 실제 동작을 나타내는 CPU 독립적인 중간 명령을 담는다.\sixptsize\bf메서드의~실제~동작을~나타내는~CPU~독립적인~중간~명령을~담는다.


04

리소스

문자열, 이미지 등 프로그램에서 사용하는 데이터를 포함할 수 있다.\sixptsize\bf문자열,~이미지~등~프로그램에서~사용하는~데이터를~포함할~수~있다.


 


 

02. CIL은 CPU 대신 평가 스택을 대상으로 한다

CIL은 x64나 ARM 레지스터를 직접 지정하지 않는다. 많은 명령이 값을 평가 스택에 올리고, 꺼내고, 연산하는 방식으로 표현된다. 그래서 같은 CIL을 여러 CPU용 런타임이 각자의 네이티브 명령으로 변환할 수 있다.

C#
public int Add(int a, int b) { return a+b; }

 

compiler\tiny\bf compiler

—>

 

C#
ldarg.1 // a를 스택에 적재 ldarg.2 // b를 스택에 적재 add // 두 값을 더함 ret // 결과를 반환

왜 중간 언어를 둘까?

언어 컴파일러는 CLI 규칙에 맞는 CIL과 메타데이터를 만들고, 각 플랫폼의 런타임은 그것을 현재 CPU에 맞게 변환한다. 언어와 하드웨어 사이에 공통 계약을 둔 셈이다.

그렇다고 완전한 플랫폼 독립은 아니다.

플랫폼 전용 네이티브 API나 라이브러리를 호출하면 해당 운영체제에 종속된다. CIL 자체의 이식성과 어플리케이션 전체의 이식성은 구분해야 한다.

 


 

03. 메터데이터가 없으면 CIL도 의미를 잃는다

CIL이 ‘무엇을 할 것인가’를 나타낸다면 메타데이터는 ‘그 대상이 무엇인가’를 설명한다. CLR은 이 정보를 이용해 타입을 로드하고 메서드 호출을 연결하며 객체의 구조를 이해한다.

metadata token\tiny\bf metadata~token —>

 

 

 


 

04. 서로 다른 언어가 같은 어셈블리를 공유하는 이유

C#, F#, Visual Basic은 문법이 다르지만 .NET을 대상으로 컴파일되면 CLI가 정의한 타입과 메타데이터 형식을 사용한다. 그래서 한 언어로 만든 클래스를 다른 언어에서 호출하거나 상속할 수 있다.

CTS(Common Type System)

타입이 어떻게 선언되고 사용되며 관리 되는지 정의한다. 여러 언어가 서로 다른 문법을 쓰더라도 공통된 타입 모델로 만날 수 있게 한다.

CLS(Common Language Specification)

언어와 라이브러리가 넓게 상호 운용되기 위해 지켜야하는 공통 규칙의 부분집합이다. 모든 언어 기능이 서로 완전히 동일하다는 뜻은 아니다.

 


 

05. CLR은 어셈블리를 읽고, JIT은 필요한 메서드를 번역한다.

파일이 만들어졌다고 실행이 끝난 것은 아니다. CLR은 어셈블리를 로드하고 메타데이터로 타입과 참조를 파악한다. 그리고 호출된 메서드의 CIL을 현재 프로세서가 실행할 수 있는 네이티브 코드로 바꾼다.

 

 


 

06. 한 줄의 덧셈이 CPU에 도착하기까지

 


01

C# 소스 작성

return a + b;라는 의도를 C# 문법으로 표현한다.\scriptsize\bf return~a~+~b;라는~의도를~C\#~문법으로~표현한다.


02

어셈블리 생성

C# 컴파일러가 소스를 분석해 CIL, 타입 메타데이터, 매니페스트를 만든다.\scriptsize\bf C\#~컴파일러가~소스를~분석해~CIL,~타입~메타데이터,~매니페스트를~만든다.


03

CLR이 파일을 로드

매니페스트와 메타데이터를 이용해 타입, 멤버, 외부 참조를 파악한다.\scriptsize\bf 매니페스트와~메타데이터를~이용해~타입,~멤버,~외부~참조를~파악한다.


04

JIT이 메서드를 변환

처음 호출된 Add의 CIL을 현재 CPU용 네이티브 코드로 만든다.\scriptsize\bf 처음~호출된~Add의~CIL을~현재~CPU용~네이티브~코드로~만든다.


05

CPU가 기계어 실행

이제서야 프로세서가 실제 덧셈 명령을 수행하고 결과를 반환한다.\scriptsize\bf 이제서야~프로세서가~실제~덧셈~명령을~수행하고~결과를~반환한다.

 


 

07. 기계어 변환을 꼭 실행 중에 해야 할까?

JIT은 실행하면서 필요한 코드를 만든다. 반대로 실행 전에 네이티브 코드를 준비하는 AOT도 있다. 컴파일 시점이 달라지면 시작 시간, 장시간 실행 성능, 메모리, 파일 크기, 동적 기능과 배포 방식의 균형도 달라진다.

 

 

다음 편에서는 JIT과 AOT : 코드를 언제 기계어로 만들 것인가에 대하여 더 자세하게 알아보도록 하겠다.

 


 

마무리

이번 포스팅은 C#이 어떻게 .NET을 통해 CPU에게까지 전달되는지에 대하여 알아보았다. 아래는 이번 포스팅에서의 핵심 다섯 가지 문장이다.


01

C# 컴파일러는 일반적으로 CIL과 메타데이터가 담긴 .NET 어셈블리를 만든다.


02

CIL은 특정 CPU가 아닌 CIL의 스택 기반 실행 모델을 위한 중간 명령이다.


03

메타데이터는 타입과 멤버, 참조와 Attribute를 설명하여 어셈블리가 스스로를 설명하게 한다.


04

CLR은 메타데이터를 이용해 어셈블리를 로드하고 관리형 코드에 실행 서비스를 제공한다.


04

JIT은 필요한 CIL 메서드를 네이티브 코드로 변환하고, CPU는 그 결과를 실행한다.


 

더 읽을 자료