C# 코드는 어떻게 실행되는가 - 2편, CIL과 메타데이터 그리고 JIT
컴파일하면 바로 기계어가 될까?
C++의 전통적인 네이티브 빌드 흐름에 익숙하다면, 컴파일 결과는 곧 CPU 명령이라고 생각하기 쉽다. 하지만 일반적인 .NET 빌드에서 C# 컴파일러가 만드는 핵심 결과물은 특정 CPU용 기계어가 아니라 CIL과 메타데이터가 들어 있는 어셈블리다.
아래 코드는 이번 포스팅에서 이해를 위해 지속적으로 사용할 예제 코드이다. 이 메서드가 빌드 파일 안에서 어떤 형태로 저장되고, 실행 순간 어떤 명령으로 바뀌는지를 보면서 .NET 실행 모델 전체를 그려본다.
public class Caculator
{
public int Add(int a, int b)
{
return a + b;
}
}
01. 어셈블리는 스스로를 설명하는 실행 단위다
.NET의 어셈블리는 단순히 기계어를 모은 파일이 아니다. 실행할 CIL과 그 코드를 해석하는 데 필요한 구조 정보가 함께 들어간다. 보통 .dll 또는 .exe 형태이며, CLR이 로드하고 버전·참조·타입을 파악하는 기본 단위가 된다.
01
어셈블리 매니페스트
02
타입 메타데이터
03
CIL 코드
04
리소스
02. CIL은 CPU 대신 평가 스택을 대상으로 한다
CIL은 x64나 ARM 레지스터를 직접 지정하지 않는다. 많은 명령이 값을 평가 스택에 올리고, 꺼내고, 연산하는 방식으로 표현된다. 그래서 같은 CIL을 여러 CPU용 런타임이 각자의 네이티브 명령으로 변환할 수 있다.
public int Add(int a, int b)
{
return a+b;
}
—>
ldarg.1 // a를 스택에 적재
ldarg.2 // b를 스택에 적재
add // 두 값을 더함
ret // 결과를 반환왜 중간 언어를 둘까?
언어 컴파일러는 CLI 규칙에 맞는 CIL과 메타데이터를 만들고, 각 플랫폼의 런타임은 그것을 현재 CPU에 맞게 변환한다. 언어와 하드웨어 사이에 공통 계약을 둔 셈이다.
그렇다고 완전한 플랫폼 독립은 아니다.
플랫폼 전용 네이티브 API나 라이브러리를 호출하면 해당 운영체제에 종속된다. CIL 자체의 이식성과 어플리케이션 전체의 이식성은 구분해야 한다.
03. 메터데이터가 없으면 CIL도 의미를 잃는다
CIL이 ‘무엇을 할 것인가’를 나타낸다면 메타데이터는 ‘그 대상이 무엇인가’를 설명한다. CLR은 이 정보를 이용해 타입을 로드하고 메서드 호출을 연결하며 객체의 구조를 이해한다.
—>
04. 서로 다른 언어가 같은 어셈블리를 공유하는 이유
C#, F#, Visual Basic은 문법이 다르지만 .NET을 대상으로 컴파일되면 CLI가 정의한 타입과 메타데이터 형식을 사용한다. 그래서 한 언어로 만든 클래스를 다른 언어에서 호출하거나 상속할 수 있다.
CTS(Common Type System)
타입이 어떻게 선언되고 사용되며 관리 되는지 정의한다. 여러 언어가 서로 다른 문법을 쓰더라도 공통된 타입 모델로 만날 수 있게 한다.
CLS(Common Language Specification)
언어와 라이브러리가 넓게 상호 운용되기 위해 지켜야하는 공통 규칙의 부분집합이다. 모든 언어 기능이 서로 완전히 동일하다는 뜻은 아니다.
05. CLR은 어셈블리를 읽고, JIT은 필요한 메서드를 번역한다.
파일이 만들어졌다고 실행이 끝난 것은 아니다. CLR은 어셈블리를 로드하고 메타데이터로 타입과 참조를 파악한다. 그리고 호출된 메서드의 CIL을 현재 프로세서가 실행할 수 있는 네이티브 코드로 바꾼다.
06. 한 줄의 덧셈이 CPU에 도착하기까지
01
C# 소스 작성
02
어셈블리 생성
03
CLR이 파일을 로드
04
JIT이 메서드를 변환
05
CPU가 기계어 실행
07. 기계어 변환을 꼭 실행 중에 해야 할까?
JIT은 실행하면서 필요한 코드를 만든다. 반대로 실행 전에 네이티브 코드를 준비하는 AOT도 있다. 컴파일 시점이 달라지면 시작 시간, 장시간 실행 성능, 메모리, 파일 크기, 동적 기능과 배포 방식의 균형도 달라진다.
다음 편에서는 JIT과 AOT : 코드를 언제 기계어로 만들 것인가에 대하여 더 자세하게 알아보도록 하겠다.
마무리
이번 포스팅은 C#이 어떻게 .NET을 통해 CPU에게까지 전달되는지에 대하여 알아보았다. 아래는 이번 포스팅에서의 핵심 다섯 가지 문장이다.
01
C# 컴파일러는 일반적으로 CIL과 메타데이터가 담긴 .NET 어셈블리를 만든다.
02
CIL은 특정 CPU가 아닌 CIL의 스택 기반 실행 모델을 위한 중간 명령이다.
03
메타데이터는 타입과 멤버, 참조와 Attribute를 설명하여 어셈블리가 스스로를 설명하게 한다.
04
CLR은 메타데이터를 이용해 어셈블리를 로드하고 관리형 코드에 실행 서비스를 제공한다.
04
JIT은 필요한 CIL 메서드를 네이티브 코드로 변환하고, CPU는 그 결과를 실행한다.
더 읽을 자료