Emulation and Virtualization
emulation은 없는 하드웨어를 소프트웨어로 흉내 내는 것이고, virtualization은 있는 하드웨어를 여러 게스트가 직접 나눠 쓰게 하는 것이다.
Intro
Apple Silicon 맥에서 인텔용 앱이 그대로 실행된다.
어떤 클라우드에서는 리눅스 VM 수십 개가 서버 한 대 위에서 돈다.
둘 다 “가상”이라는 말이 붙지만 안에서 일어나는 일은 다르다.
앞의 경우는 없는 CPU를 흉내 내는 emulation이다. x86 명령을 ARM CPU가 이해할 수 있게 번역한다.
뒤의 경우는 있는 CPU를 나눠 쓰는 virtualization이다. 게스트 코드는 대부분 실제 CPU에서 그대로 돈다.
게스트의 명령이 실제 CPU에서 직접 실행되는가, 아니면 소프트웨어가 한 번 번역한 뒤 실행되는가.
Comparison
{
"diagram": "html-diagram",
"variant": "explainer",
"title": "At a glance: emulation vs virtualization",
"width": 960,
"height": 500,
"mobileWidth": 900,
"regions": [
{
"id": "emul-side",
"label": "Emulation\\ntranslate every instruction",
"x": 36,
"y": 60,
"width": 426,
"height": 400
},
{
"id": "virt-side",
"label": "Virtualization\\nrun guest natively",
"x": 498,
"y": 60,
"width": 426,
"height": 400
}
],
"nodes": [
{
"id": "e-guest",
"kind": "note",
"label": "Foreign binary",
"caption": "다른 ISA (x86, ARM ...)",
"x": 249,
"y": 172,
"width": 190,
"height": 64
},
{
"id": "e-trans",
"kind": "switch",
"label": "Interpreter / JIT",
"caption": "명령마다 번역",
"x": 249,
"y": 290,
"width": 190,
"height": 64
},
{
"id": "e-cpu",
"kind": "gpu",
"label": "Host CPU",
"caption": "번역된 코드 실행",
"x": 249,
"y": 408,
"width": 190,
"height": 64
},
{
"id": "v-guest",
"kind": "host",
"label": "Guest OS",
"caption": "수정 없는 binary",
"x": 711,
"y": 172,
"width": 190,
"height": 64
},
{
"id": "v-cpu",
"kind": "gpu",
"label": "Host CPU",
"caption": "게스트 직접 실행",
"x": 711,
"y": 408,
"width": 190,
"height": 64
},
{
"id": "v-vmm",
"kind": "switch",
"label": "VMM",
"caption": "특권 명령만 처리",
"x": 858,
"y": 290,
"width": 118,
"height": 62
}
],
"links": [
{
"id": "e1",
"points": [[249, 206], [249, 258]],
"tone": "primary",
"flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
},
{
"id": "e2",
"points": [[249, 324], [249, 374]],
"tone": "primary",
"flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
},
{
"id": "v1",
"points": [[711, 206], [711, 374]],
"tone": "primary",
"flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
},
{
"id": "v-trap",
"points": [[711, 290], [799, 290]],
"tone": "muted",
"dashed": true,
"flow": { "speed": "slow", "emphasis": "muted" }
}
],
"labels": [
{
"text": "대부분 명령: 직접 실행",
"x": 620,
"y": 332
},
{
"text": "특권 명령: trap",
"x": 760,
"y": 262
}
]
}
왼쪽이 emulation이다.
게스트 binary는 host와 다른 ISA(Instruction Set Architecture, 명령어 집합)를 쓴다.
host CPU는 그 명령을 직접 이해하지 못한다. 그래서 interpreter나 JIT가 명령을 하나하나 host 명령으로 번역한 뒤 실행한다.
모든 명령이 이 번역을 거치므로 원본보다 느리다. 대신 host에 없는 CPU도 흉내 낼 수 있다.
오른쪽이 virtualization이다.
게스트 OS는 host와 같은 ISA를 쓴다. 그래서 게스트 명령은 대부분 실제 CPU에서 그대로 실행된다.
단, 하드웨어를 직접 건드리는 특권 명령(privileged instruction)은 그대로 두면 위험하다. 이때만 VMM(Virtual Machine Monitor)이 개입한다.
즉 emulation은 “전부 번역”, virtualization은 “대부분 직접 실행 + 일부만 가로채기”다.
Emulation
emulation은 하드웨어의 동작을 소프트웨어로 재현한다.
가장 단순한 형태는 interpreter다. 게스트 명령을 하나 읽고, 그 명령이 하는 일을 host 코드로 흉내 내고, 다음 명령으로 넘어간다.
이 방식은 정확하지만 느리다. 게스트 명령 하나에 host 명령 수십 개가 든다.
그래서 대부분 binary translation을 쓴다. 게스트 명령 블록을 한 번 번역해 두고, 같은 블록이 다시 실행되면 번역 결과를 재사용한다.
QEMU가 대표적이다. QEMU의 TCG(Tiny Code Generator)는 게스트 명령을 중간 표현으로 바꾼 뒤 host 명령으로 컴파일한다.
Apple의 Rosetta 2도 같은 계열이다. x86 binary를 ARM 명령으로 번역해 Apple Silicon에서 돌린다.
emulation의 가치는 이식성이다. host에 없는 ISA, 심지어 오래전에 단종된 CPU까지 흉내 낼 수 있다.
대가는 속도다. 명령을 그대로 실행하는 native보다 항상 느리다.
Trap-and-Emulate
같은 ISA끼리라면 굳이 번역할 이유가 없다. 게스트 명령을 실제 CPU에서 그대로 실행하면 된다.
문제는 게스트가 하드웨어를 마음대로 건드리려 할 때다.
CPU에는 특권 수준(privilege level)이 있다. x86에서는 이를 ring이라 부른다. ring 0이 커널, ring 3이 사용자 코드다.
OS 커널은 원래 ring 0에서 돌면서 인터럽트 제어, 페이지 테이블 교체 같은 특권 명령을 쓴다.
VM 안에서는 게스트 커널이 진짜 ring 0을 가지면 안 된다. 게스트가 물리 하드웨어를 직접 바꾸면 다른 VM과 host와의 경계가 무너진다.
고전적인 해법이 trap-and-emulate다.
게스트 커널을 낮은 특권으로 내려서 실행한다. 게스트가 특권 명령을 만나면 CPU가 예외를 던진다.
이 예외가 VMM으로 넘어가고(trap), VMM이 그 명령이 원래 하려던 일을 대신 처리한다(emulate).
Hardware Support
trap-and-emulate를 소프트웨어로만 처리하면 개입 비용이 크다. 그래서 요즘 CPU는 이 일을 직접 맡는다.
Intel VT-x와 AMD-V가 그 하드웨어 지원이다. CPU에 가상화 전용 모드를 두어, 게스트를 실제 CPU에서 돌리되 특권 동작만 자동으로 가로챈다.
{
"diagram": "html-diagram",
"variant": "explainer",
"title": "VT-x: root and non-root mode",
"width": 960,
"height": 520,
"mobileWidth": 900,
"regions": [
{
"id": "nonroot",
"label": "VMX non-root mode\\nguest runs here",
"x": 36,
"y": 62,
"width": 512,
"height": 400
},
{
"id": "root",
"label": "VMX root mode\\nVMM runs here",
"x": 588,
"y": 62,
"width": 336,
"height": 400
}
],
"nodes": [
{
"id": "g-app",
"kind": "host",
"label": "Guest apps",
"caption": "ring 3",
"x": 292,
"y": 150,
"width": 210,
"height": 60
},
{
"id": "g-kernel",
"kind": "host",
"label": "Guest kernel",
"caption": "ring 0 (non-root)",
"x": 292,
"y": 262,
"width": 210,
"height": 60
},
{
"id": "vmm",
"kind": "switch",
"label": "VMM / KVM",
"caption": "ring 0 (root)",
"x": 756,
"y": 190,
"width": 190,
"height": 62
},
{
"id": "vmcs",
"kind": "memory",
"label": "VMCS",
"caption": "guest + host 상태",
"x": 756,
"y": 336,
"width": 190,
"height": 62
}
],
"links": [
{
"id": "syscall",
"points": [[292, 180], [292, 232]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "vmexit",
"path": "M 397 246 C 540 200 640 190 661 190",
"tone": "primary",
"flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
},
{
"id": "vmentry",
"path": "M 661 214 C 540 300 430 282 400 270",
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "vmm-vmcs",
"points": [[756, 221], [756, 305]],
"tone": "muted",
"dashed": true,
"flow": { "speed": "slow", "emphasis": "muted" }
}
],
"labels": [
{
"text": "syscall은 guest 안에서 끝난다",
"x": 292,
"y": 336
},
{
"text": "VM exit (특권 명령)",
"x": 542,
"y": 165
},
{
"text": "VM entry",
"x": 505,
"y": 300
}
]
}
핵심은 CPU가 두 모드를 갖게 된 것이다.
게스트는 non-root 모드에서 돈다. VMM은 root 모드에서 돈다.
이제 게스트 커널은 non-root 모드 안에서 진짜 ring 0을 쓴다. 평범한 명령과 syscall은 게스트 안에서 그대로 처리된다. VMM을 부를 필요가 없다.
게스트가 하드웨어를 실제로 건드리는 명령을 실행하면, CPU가 non-root에서 root로 전환한다. 이 전환을 VM exit이라 한다.
VMM이 그 상황을 처리하고 나면 VM entry로 게스트에 돌아간다.
두 모드 사이를 오갈 때 게스트와 host의 레지스터 상태를 저장하고 복원해야 한다. 이 상태를 담는 자료구조가 VMCS(VM Control Structure)다. AMD에서는 VMCB라 부른다.
게스트 OS는 자신이 가상화됐다는 것을 몰라도 된다. 수정 없는 OS가 그대로 돈다. 이것을 full virtualization이라 한다.
CPU 명령은 거의 네이티브 속도로 실행된다. 오버헤드는 대부분 뒤에서 볼 I/O에서 나온다.
리눅스 KVM이 이 위에서 동작한다. KVM은 커널을 하이퍼바이저로 바꾸고, VT-x/AMD-V를 써서 게스트를 non-root 모드로 실행한다.
여기서 emulation과 virtualization이 다시 만난다. KVM이 CPU를 가상화하고, 그 옆에서 QEMU가 디스크나 네트워크 카드 같은 장치를 emulation한다.
Paravirtualization
full virtualization은 게스트를 속인다. 게스트는 자신이 진짜 하드웨어 위에 있다고 믿는다.
paravirtualization은 반대로 게스트에게 사실을 알려 준다. 게스트 OS는 자신이 VM 안에 있다는 것을 알고, 하이퍼바이저와 협력한다.
게스트는 위험한 명령을 직접 실행하는 대신 하이퍼바이저에게 요청을 보낸다. 이 요청을 hypercall이라 한다.
특권 동작마다 trap과 VM exit을 일으키는 대신, 필요한 일을 명시적으로 부탁하는 것이다. 그만큼 오가는 비용이 줄어든다.
효과가 가장 큰 곳은 I/O다. 게스트가 가상 장치를 실제 장치처럼 다루느라 생기는 왕복을, 약속된 통로 하나로 줄인다. 리눅스의 virtio가 대표적인 paravirtualized 장치 인터페이스다.
대가는 게스트 수정이다. 게스트 OS나 드라이버가 hypercall과 virtio를 알아야 한다.
그래서 오늘날 흔한 구성은 둘의 절충이다. CPU는 VT-x/AMD-V로 full virtualization하고, 장치는 virtio로 paravirtualization한다.
Memory
CPU만 나눠서는 부족하다. 메모리도 나눠야 한다.
게스트는 자기가 물리 메모리를 통째로 쓴다고 믿는다. 하지만 게스트의 “물리 주소”는 사실 host의 진짜 물리 주소가 아니다.
{
"diagram": "html-diagram",
"variant": "explainer",
"title": "Two-dimensional paging",
"width": 900,
"height": 300,
"mobileWidth": 880,
"regions": [
{
"id": "translate",
"label": "주소 변환 2단계",
"x": 40,
"y": 60,
"width": 820,
"height": 190
}
],
"nodes": [
{
"id": "gva",
"kind": "note",
"label": "Guest virtual",
"caption": "게스트 프로세스",
"x": 170,
"y": 168,
"width": 170,
"height": 72
},
{
"id": "gpa",
"kind": "note",
"label": "Guest physical",
"caption": "게스트가 믿는 물리주소",
"x": 450,
"y": 168,
"width": 180,
"height": 72
},
{
"id": "hpa",
"kind": "gpu",
"label": "Host physical",
"caption": "실제 DRAM",
"x": 730,
"y": 168,
"width": 170,
"height": 72
}
],
"links": [
{
"id": "gpt",
"points": [[255, 168], [360, 168]],
"tone": "primary",
"flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
},
{
"id": "ept",
"points": [[540, 168], [645, 168]],
"tone": "primary",
"flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
}
],
"labels": [
{
"text": "Guest page table (게스트 OS)",
"x": 307,
"y": 120
},
{
"text": "EPT / NPT (CPU)",
"x": 592,
"y": 120
}
]
}
주소 변환이 두 단계가 된다.
먼저 게스트 OS가 자기 페이지 테이블로 게스트 가상 주소를 게스트 물리 주소로 바꾼다.
그다음 게스트 물리 주소를 host 물리 주소로 한 번 더 바꿔야 한다.
초기에는 이 두 번째 변환을 VMM이 소프트웨어로 흉내 냈다. shadow page table이라 불렀고, 관리 비용이 컸다.
지금은 CPU가 이 단계를 직접 한다. Intel은 EPT(Extended Page Table), AMD는 NPT(Nested Page Table)라 부른다.
CPU가 두 단계 변환을 하드웨어로 처리하므로 VMM이 매번 끼어들 필요가 없다. 이 방식을 two-dimensional paging이라 한다.
I/O
장치도 게스트에게 어떻게 보여줄지 정해야 한다. 여기에도 emulation과 직접 접근의 대비가 그대로 나타난다.
가장 호환성이 좋은 방식은 device emulation이다. QEMU가 실제로 존재하는 네트워크 카드나 디스크 컨트롤러를 소프트웨어로 흉내 낸다. 게스트는 평범한 드라이버로 그 장치를 쓴다. 대신 모든 접근이 VMM을 거쳐 느리다.
다음은 앞서 본 paravirtualized 장치다. virtio가 대표적이다. 게스트가 가상 장치임을 알고 약속된 통로로 주고받으므로, emulation보다 왕복이 적어 빠르다.
가장 빠른 방식은 passthrough다. 물리 장치를 게스트에 직접 붙인다.
이때 필요한 것이 IOMMU다. Intel VT-d, AMD-Vi가 이 역할을 한다. IOMMU는 장치가 DMA로 접근하는 주소를 변환하고 격리한다. 게스트에 붙은 장치가 다른 메모리를 건드리지 못하게 막는 것이다.
SR-IOV는 한 물리 장치가 스스로를 여러 개의 가상 장치로 쪼개어 여러 게스트에 직접 나눠 주는 확장이다.
References
- Popek, Goldberg — Formal Requirements for Virtualizable Third Generation Architectures
https://dl.acm.org/doi/10.1145/361011.361073 - OASIS — Virtual I/O Device (VIRTIO) Specification v1.2
https://docs.oasis-open.org/virtio/virtio/v1.2/virtio-v1.2.html - Barham et al. — Xen and the Art of Virtualization
https://dl.acm.org/doi/10.1145/945445.945462 - Intel — 64 and IA-32 Architectures Software Developer’s Manual (Vol. 3C, VMX)
https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html - Linux Kernel Documentation — KVM
https://docs.kernel.org/virt/kvm/index.html - Linux Kernel Documentation — VFIO
https://docs.kernel.org/driver-api/vfio.html