soobook
INFRA

How Firmware Finds Devices

UEFI firmware가 PCIe 장치를 열거하고, BAR와 Option ROM을 처리하고, BootOrder로 부팅 장치를 고르는 과정

전원이 들어온 직후 firmware는 서버에 어떤 장치가 꽂혀 있는지 모른다. PCIe bus를 bus 0부터 차례로 검사해 장치를 찾고, 장치마다 주소를 배정하고, 장치의 Flash에 들어 있는 driver를 실행한 뒤에야 “어디서 부팅할지”를 고를 수 있다.

Intro

PC나 서버의 전원을 켠 직후 F12 같은 키를 누르면, firmware가 boot menu를 띄우고 부팅할 수 있는 장치를 나열한다.

Lenovo ThinkPad T430의 boot menu 화면. ATAPI CD0(DVD drive), ATA HDD0(하드 디스크), USB HDD(USB 메모리), PCI LAN(NIC의 network boot) 네 항목이 나열되어 있다

Lenovo ThinkPad T430의 boot menu. 사진: Cirosantilli2, Wikimedia Commons, CC BY-SA 4.0

위 사진에는 DVD drive, 하드 디스크, USB 메모리, 그리고 NIC를 통한 network boot 항목(PCI LAN)이 보인다. 이 목록은 미리 정해진 것이 아니다. firmware는 부팅할 때마다 장치를 직접 찾아내고, 그 결과와 NVRAM에 저장된 설정을 합쳐서 목록을 만든다. 그래서 USB 메모리를 뽑고 다시 부팅하면 USB HDD 항목이 목록에서 사라진다.

firmware는 장치 목록을 저장하지 않고, 부팅할 때마다 PCIe를 탐색해서 다시 만든다. NVRAM에 저장되는 것은 어떤 순서로 부팅을 시도할지뿐이다.

이 단계 이전에 CPU가 reset vector에서 SPI Flash의 코드를 실행하고 memory를 초기화하는 과정과, 부팅 장치를 고른 뒤 bootloader와 kernel이 이어받는 과정은 이 글에서 다루지 않는다. 부팅 전체의 순서는 Linux Boot Sequence에 정리해 두었다.

flowchart LR
    A[Memory ready] --> B[PCIe enumeration]
    B --> C[BAR assignment]
    C --> D[Option ROM load]
    D --> E[DXE driver connect]
    E --> F[BDS: BootOrder]
    F --> G[Boot loader]

PCIe Topology

What a Bus Is

Bus라는 단어가 종종 등장한다. 쉽게 생각하면 bus는 여러 장치가 같이 쓰는 선이다.

좀 더 정확히 말하면, 여러 장치가 같은 신호선을 함께 쓰는 연결 방식이다.

1990년대 PC에 쓰인 PCI(Peripheral Component Interconnect)가 대표적인 예다. PCI에서는 메인보드의 slot 여러 개가 수십 가닥의 같은 신호선에 병렬로 연결되어 있었다. 그래서 한 장치가 데이터를 보내면 같은 bus의 모든 장치가 그 신호를 받았고, 한 번에 한 장치만 데이터를 보낼 수 있었다.

신호선을 함께 쓰기 때문에, 데이터를 보내는 쪽은 받을 장치를 주소로 지정해야 했다. PCI는 이 주소를 bus 번호, device 번호, function 번호로 정했다.

또 신호선 하나에 장치를 많이 연결하면 신호 품질이 떨어지므로, slot을 늘릴 때는 PCI-to-PCI bridge로 새 bus를 만들어 기존 bus에 연결했다. 그 결과 PCI 시스템은 bus 0을 최상위로 하고, bridge마다 하위 bus가 생기는 tree 구조가 되었다.

flowchart TD
    H[Host bridge] --- BUS0[bus 0: shared wires]
    BUS0 --- A[Device A]
    BUS0 --- B[Device B]
    BUS0 --- BR[PCI-to-PCI bridge]
    BR --- BUS1[bus 1: shared wires]
    BUS1 --- C[Device C]

PCI에서는 bus 0에 연결된 Device A, Device B, bridge가 같은 신호선을 함께 쓴다. bridge 아래에는 bus 1이라는 별도의 신호선이 생긴다.

From PCI to PCIe

2003년에 나온 PCIe(PCI Express)는 신호선을 공유하지 않는다. 장치마다 전용 serial link가 하나씩 있고, link의 양 끝에는 장치가 하나씩만 있다. 이렇게 연결하면 장치끼리 대역폭을 나눠 쓰지 않으므로 PCI보다 훨씬 빠르게 데이터를 보낼 수 있다.

flowchart TD
    RC[Root Complex] --- P1[Root Port 1]
    RC --- P2[Root Port 2]
    P1 -->|dedicated link| X[Device X]
    P2 -->|dedicated link| Y[Device Y]

하지만 PCIe는 소프트웨어가 보는 모델을 PCI와 똑같이 유지했다. PCIe의 root port와 switch port는 소프트웨어에게 PCI-to-PCI bridge로 보이고, link 하나는 하위 bus 하나로 보인다.

그래서 PCIe 시스템에서도 bus 번호, device 번호, function 번호와 tree 구조가 그대로 쓰이고, firmware와 OS는 PCI용으로 만든 열거 방식을 고치지 않고 쓸 수 있다. 다만 link 하나에는 장치가 하나만 연결되므로, PCIe에서 bridge 아래 bus의 장치 번호는 대부분 0이다.

정리하면, PCIe에서는 장치마다 선을 따로 깔았지만 소프트웨어 입장에서는 여전히 예전 PCI bus와 bridge로 이루어진 tree로 보인다.

Topology

tree의 최상위에는 root complex가 있다. root complex는 CPU와 memory를 PCIe 계층에 연결하는 장치이고, 요즘 서버에서는 CPU package 안에 들어 있다. root complex에는 root port가 여러 개 있고, 각 root port에는 장치나 switch가 연결된다. switch는 내부에 PCI-to-PCI bridge를 여러 개 갖고 있어서 link 하나를 여러 downstream port로 나눈다.

flowchart TD
    CPU[CPU / Memory] --- RC[Root Complex<br/>bus 00]
    RC --- RP1[Root Port 00:01.0]
    RC --- RP2[Root Port 00:02.0]
    RP1 --- NVME[NVMe SSD<br/>01:00.0]
    RP2 --- SW[Switch<br/>bus 02-05]
    SW --- NIC0[NIC port 0<br/>04:00.0]
    SW --- NIC1[NIC port 1<br/>04:00.1]
    SW --- GPU[GPU<br/>05:00.0]

tree에 있는 장치는 모두 BDF(Bus, Device, Function) 세 숫자로 주소를 받는다. 여기서 device는 bus에 연결된 PCI 장치 하나, 보통은 칩 하나를 가리킨다. NIC나 NVMe SSD는 카드 하나에 칩이 하나라서 대개 카드 하나가 device 하나지만, 메인보드에 직접 붙은 칩도 device다.

function은 port가 두 개인 NIC를 예로 들면 이해하기 쉽다. 이 NIC는 카드도 하나, 칩도 하나다. 그런데 lspci를 실행하면 NIC가 두 줄로 나온다.

04:00.0 Ethernet controller: Intel Corporation Ethernet Controller E810-XXV for SFP
04:00.1 Ethernet controller: Intel Corporation Ethernet Controller E810-XXV for SFP

칩이 port마다 독립된 설정 공간(뒤에서 설명할 config space)을 따로 두었기 때문이다. firmware가 04:00.0 자리를 읽어도 응답이 오고, 04:00.1 자리를 읽어도 또 응답이 오며, 각 응답에는 자기만의 ID와 memory 주소 설정이 들어 있다. 그래서 firmware와 OS는 두 응답을 장치 두 개로 다룬다. 주소도 따로 배정하고, driver도 따로 연결하며, network interface도 두 개 만든다. 이렇게 칩 하나가 장치 하나로 응답하는 단위를 function이라고 부르고, 주소 끝의 .0, .1이 function 번호다.

칩을 이렇게 만들면 port 0은 host OS가 쓰고 port 1은 VM에 통째로 넘기는 식으로 port를 따로 다룰 수 있다. 반대로 NVMe SSD처럼 기능이 하나뿐인 장치는 function 0만 갖기 때문에 01:00.0 한 줄만 나온다.

쉽게 말해 device는 칩 하나이고, function은 그 칩이 OS에게 장치 하나로 보이는 단위다.

필드비트 수범위의미
Bus80~255bridge 아래에 생기는 bus 번호
Device50~31bus 위의 장치 번호
Function30~7한 장치 안의 독립된 기능

그러니까 04:00.1은 bus 4, device 0, function 1, 즉 “4번 bus에 있는 0번 장치의 두 번째 function”이라고 읽으면 된다. 여러 PCI segment를 쓰는 큰 서버에서는 앞에 domain 번호가 붙어 0000:04:00.1처럼 보인다.

Enumeration

Config Space

firmware가 장치를 찾을 때 읽는 곳이 config space다. config space는 function마다 하나씩 있는 작은 설정 영역으로, 여기에는 Vendor ID와 Device ID(누가 만든 어떤 장치인지), 장치 종류(NVMe인지 Ethernet인지), 장치에 필요한 memory 크기 같은 정보가 들어 있다. firmware는 BDF 주소만 알면 그 자리의 config space를 읽을 수 있다.

쉽게 말해 firmware는 BDF 주소마다 config space를 읽어 보고, 값이 나오면 그 자리에 장치가 있다고 판단한다.

Scanning the Tree

열거는 bus 0부터 시작한다. firmware는 bus 0의 device 번호를 0부터 31까지 하나씩 읽어 보고, 응답이 없으면 빈자리로 보고 넘어간다. 응답이 오면 그 장치가 일반 장치인지 bridge인지 확인한다.

bridge를 만나면 firmware는 새 bus 번호를 하나 정해서 bridge 아래에 붙이고, 그 bus에서도 같은 방식으로 장치를 찾는다. 그 아래에서 또 bridge가 나오면 다시 새 bus 번호를 정하고 내려간다. bridge에는 자기 아래에 있는 bus 번호의 범위가 기록되는데, 이 범위의 시작을 secondary, 끝을 subordinate라고 부른다. bridge는 이 범위에 속한 bus로 가는 요청만 아래로 전달한다.

flowchart TD
    S[read bus N, dev 0..31] --> R{device here?}
    R -- no --> NX[next dev]
    R -- yes --> H{bridge?}
    H -- no --> REC[record device]
    H -- yes --> B[give it a new bus number]
    B --> SCAN[scan that bus the same way]
    REC --> NX
    SCAN --> NX

앞의 tree 그림에서 switch의 bus 번호가 02-05인 이유도 이 규칙 때문이다. switch의 upstream port가 bus 2를 받고, 내부의 virtual bus가 bus 3을, downstream port 아래의 NIC와 GPU가 각각 bus 4와 bus 5를 받는다.

결국 열거는 bus 0의 모든 자리를 읽어 보고, bridge가 나오면 그 아래 bus도 같은 방식으로 읽는 작업이다.

그냥 트리를 DFS 한다고 생각하면 된다. bus 하나가 노드.

BAR Assignment

장치를 찾았다고 바로 쓸 수 있는 것은 아니다. CPU는 NVMe SSD나 NIC에 명령을 보낼 때 특정 memory 주소에 값을 쓰는데, 장치는 자기가 어느 주소를 쓸지 스스로 정하지 못한다. 주소는 firmware가 정해 주고, 그 결과를 적어 두는 곳이 BAR(Base Address Register)다.

장치는 config space에 “나는 memory 영역이 이만큼 필요하다”는 크기를 알려 준다. firmware는 모든 장치의 요구량을 모은 뒤, 장치마다 겹치지 않는 주소 범위를 정해서 BAR에 적는다. 이때 bridge에도 “내 아래 장치들이 쓰는 주소 범위”를 함께 적어 줘야, CPU가 보낸 요청이 올바른 장치까지 전달된다.

GPU처럼 수십 GB의 memory 영역을 요구하는 장치는 4GB 아래 주소 공간에 들어가지 않는다. 그래서 GPU 서버에서는 BIOS 설정의 “Above 4G Decoding” 옵션을 켜서, firmware가 4GB 위의 주소를 배정할 수 있게 해야 한다. 이 옵션을 끈 채로 큰 GPU를 꽂으면 firmware가 주소를 배정하지 못해 GPU가 인식되지 않는다.

정리하면, BAR 할당은 firmware가 장치마다 “이 장치는 이 주소 범위를 쓴다”고 정하고, 그 주소를 BAR에 적어 주는 과정이다.

Option ROM

열거와 BAR 할당이 끝나면 firmware는 장치에 접근할 수 있다. 하지만 장치를 제어하려면 그 장치의 driver가 있어야 한다. 메인보드 firmware에는 흔한 장치(NVMe, AHCI, USB)의 driver가 들어 있지만, 특정 RAID controller나 특정 NIC의 driver까지 다 넣을 수는 없다.

이 문제를 해결하는 방법이 Option ROM이다. 장치가 자기 Flash에 firmware용 driver를 넣어 두고, 메인보드 firmware는 부팅할 때 그 driver를 읽어서 실행한다. 이렇게 하면 메인보드 firmware를 고치지 않고도 새 장치를 부팅 과정에서 쓸 수 있다. 쉽게 말해 장치가 자기 driver를 직접 가지고 있다가, 부팅할 때 firmware에게 건네주는 구조다.

Option ROM에는 예전 BIOS용 코드(legacy)와 UEFI용 driver가 함께 들어 있는 경우가 많고, UEFI firmware는 UEFI용 driver만 골라서 실행한다. Secure Boot가 켜져 있으면 firmware는 driver의 서명도 검사하므로, 서명되지 않은 Option ROM은 실행되지 않는다.

NIC as an Example

Option ROM의 가장 흔한 예는 NIC다. 서버용 NIC의 Flash에는 보통 UEFI용 network driver와 PXE 코드가 들어 있다. 이 driver 덕분에 firmware는 디스크가 비어 있는 서버에서도 NIC를 통해 network로 부팅할 수 있고, boot menu에 “UEFI PXE IPv4: Intel Ethernet …” 같은 항목이 생긴다.

UEFI Drivers in DXE

UEFI 부팅은 여러 단계(phase)로 나뉜다. memory 초기화가 끝난 뒤 시작하는 DXE(Driver Execution Environment) 단계에서 대부분의 driver가 실행되고, 지금까지 설명한 열거, BAR 할당, Option ROM 로드도 모두 DXE 단계에서 일어난다.

DXE 단계에서 driver는 계층을 이루며 연결된다. 먼저 PCI bus driver가 열거를 하고 장치를 찾는다. 그러면 NVMe driver가 찾은 NVMe 장치를 맡아 “block을 읽고 쓸 수 있는 디스크”로 만들고, partition driver가 그 디스크에서 partition을 찾는다. 마지막으로 FAT filesystem driver가 partition 안의 파일을 읽을 수 있게 만든다. 이 단계까지 와야 firmware가 디스크에 있는 bootloader 파일(\EFI\...\*.efi)을 읽을 수 있다.

flowchart TD
    PB[PCI bus driver: find devices] --> N[NVMe driver: read/write blocks]
    N --> P[Partition driver: find partitions]
    P --> F[FAT driver: read files]
    F --> BOOT[BDS can load \EFI\...\*.efi]

Option ROM에서 나온 driver도 이 계층에 똑같이 연결된다. 예를 들어 NIC의 Option ROM driver가 연결되면, 그 위에서 network boot를 할 수 있게 된다.

정리하면, DXE 단계는 장치를 찾고 그 위에 driver를 한 층씩 연결해서, 최종적으로 “부팅 파일을 읽을 수 있는 상태”를 만드는 단계다.

Boot Device Selection

DXE 단계가 끝나면 BDS(Boot Device Selection) 단계가 시작된다. BDS는 UEFI spec이 “boot manager”라고 부르는 부분으로, 찾은 장치 중 어디서 부팅할지를 정한다. boot manager는 NVRAM 변수에 적힌 순서대로 driver와 application을 로드하는 firmware 구성 요소이며, 일반 부팅에서는 이 순서를 따라야 한다.

일반적인 firmware는 BDS 단계에서 모든 controller에 driver를 연결하지 않는다. 부팅 시간을 줄이려고 console과 부팅 후보 장치만 연결하는 “fast boot” 설정이 많다. 그래서 fast boot가 켜져 있으면 USB 메모리가 boot menu에 나타나지 않는 경우가 있다.

Boot#### and BootOrder

부팅 설정은 NVRAM에 UEFI 변수로 저장된다.

변수내용
Boot####부팅 항목 하나. ####은 16진수 4자리 번호다 (Boot0001)
BootOrderBoot#### 번호를 시도할 순서대로 나열한 목록
BootNext다음 한 번만 먼저 시도할 항목. 사용 후 firmware가 지운다
BootCurrent이번 부팅에 실제로 사용된 항목

Boot#### 변수 하나에는 boot menu에 보일 이름과, 실행할 대상을 가리키는 device path가 들어 있다. device path는 root bridge부터 시작해 PCI 장치, 디스크 partition, 파일 경로까지를 차례로 나열한 값이다.

PciRoot(0x0)/Pci(0x1,0x0)/Pci(0x0,0x0)/NVMe(0x1,...)/HD(1,GPT,<partition GUID>,...)/\EFI\ubuntu\shimx64.efi

이 경로의 Pci(0x1,0x0)가 root port 00:01.0이고, Pci(0x0,0x0)가 그 아래 bus의 device 0, function 0이다. 즉 device path에 적힌 PCI 장치의 순서는 열거로 만든 PCIe tree의 연결 순서와 같다.

BDS는 BootOrder의 첫 항목부터 device path를 따라가 이미지를 로드하고 실행한다. 로드에 실패하면 다음 항목으로 넘어간다. BootOrder의 모든 항목이 실패하면, firmware는 이동식 미디어나 디스크의 기본 경로 \EFI\BOOT\BOOTX64.EFI(x64 기준)를 찾아본다. OS 설치용 USB 메모리가 Boot#### 항목 없이도 부팅되는 것은 이 기본 경로 덕분이다. 결국 BDS가 하는 일은 BootOrder에 적힌 항목을 순서대로 실행해 보고, 처음 성공한 항목으로 부팅하는 것이다.

flowchart TD
    S[BDS start] --> N{BootNext set?}
    N -- yes --> TN[try BootNext<br/>then delete it]
    N -- no --> O[read BootOrder]
    TN -- fail --> O
    O --> T[try Boot#### in order]
    T -- success --> RUN[run EFI image]
    T -- all fail --> FB[removable media fallback<br/>\EFI\BOOT\BOOTX64.EFI]
    FB -- fail --> UI[boot menu / setup]
    TN -- success --> RUN

PXE 항목처럼 파일 경로가 없는 항목도 있다. 이때 device path는 NIC의 MAC 주소에서 끝나고, BDS는 그 장치에서 network boot를 시도한다.

Hands-On

Linux에서는 firmware가 만든 결과를 직접 확인할 수 있다. OS의 kernel도 부팅할 때 PCIe를 다시 열거하지만, 대부분의 경우 firmware가 배정한 bus 번호와 BAR를 그대로 쓴다.

lspci -tv

lspci -tv는 PCIe tree를 그린다.

$ lspci -tv
-[0000:00]-+-00.0  Intel Corporation Device 09a2
           +-01.0-[01]----00.0  Samsung Electronics Co Ltd NVMe SSD Controller PM9A1/PM9A3/980PRO
           +-02.0-[02-05]----00.0-[03-05]--+-00.0-[04]--+-00.0  Intel Corporation Ethernet Controller E810-XXV for SFP
           |                               |            \-00.1  Intel Corporation Ethernet Controller E810-XXV for SFP
           |                               \-01.0-[05]----00.0  NVIDIA Corporation Device 2330
           \-1f.0  Intel Corporation C620 Series Chipset LPC Controller

[02-05]는 bridge의 secondary bus와 subordinate bus다. 00:02.0 root port 아래에 bus 2부터 5까지가 있고, 그 안에 switch의 upstream port(bus 2), 내부 bus(bus 3), NIC(bus 4), GPU(bus 5)가 있다. 열거 과정에서 firmware가 기록한 숫자가 그대로 보이는 것이다.

lspci -vv

lspci -vv -s <BDF>는 장치 하나의 config space를 해석해서 보여 준다. BAR와 Option ROM은 다음처럼 나온다.

$ sudo lspci -vv -s 04:00.0
04:00.0 Ethernet controller: Intel Corporation Ethernet Controller E810-XXV for SFP (rev 02)
	...
	Region 0: Memory at 3bffe000000 (64-bit, prefetchable) [size=32M]
	Region 3: Memory at 3bfff010000 (64-bit, prefetchable) [size=64K]
	Expansion ROM at a5600000 [disabled] [size=1M]
	...

Region 0, Region 3은 firmware가 이 NIC에 배정한 주소와 크기다. 4GB를 넘는 주소에 놓였으므로 Above 4G Decoding이 켜져 있다는 것도 알 수 있다. Expansion ROM at ... [disabled]는 Option ROM BAR다. firmware가 부팅할 때 ROM을 읽은 뒤 다시 꺼 두었기 때문에 OS에서는 disabled로 보인다.

efibootmgr -v

efibootmgr -v는 BDS가 읽는 NVRAM 변수를 보여 준다.

$ efibootmgr -v
BootCurrent: 0001
Timeout: 1 seconds
BootOrder: 0001,0003,0000
Boot0000* UEFI PXE IPv4: Intel(R) Ethernet Controller E810-XXV	PciRoot(0x0)/Pci(0x2,0x0)/Pci(0x0,0x0)/Pci(0x0,0x0)/Pci(0x0,0x0)/MAC(b49691aabbcc,1)/IPv4(0.0.0.00.0.0.0,0,0)
Boot0001* ubuntu	HD(1,GPT,3f2a...,0x800,0x100000)/File(\EFI\ubuntu\shimx64.efi)
Boot0003* UEFI: USB Flash Disk	PciRoot(0x0)/Pci(0x14,0x0)/USB(3,0)/HD(1,MBR,0x1a2b3c4d,0x800,0x3a8f800)

BootOrder가 0001,0003,0000이므로 BDS는 ubuntu, USB 메모리, PXE 순서로 시도하고, 이번에는 BootCurrent: 0001, 즉 ubuntu로 부팅했다. Boot0000의 device path는 lspci -tv의 tree와 같은 경로(00:02.0 → switch → bus 4)를 따라 NIC의 MAC 주소에서 끝난다. Boot0001처럼 HD(...)로 시작하는 짧은 경로는 firmware가 partition GUID로 디스크를 찾는 축약형이다. 이 entry가 GRUB이 아니라 shim을 실행하는 이유는 Linux Boot Sequence의 Secure Boot 항목에서 설명한다.

순서는 다음 명령으로 바꿀 수 있다.

sudo efibootmgr -o 0000,0001,0003   # change BootOrder
sudo efibootmgr -n 0000             # set BootNext: PXE on next boot only

BootNext는 한 번만 적용되고 firmware가 지우기 때문에, 원격 서버를 한 번만 network로 부팅하고 다시 디스크 부팅으로 돌아가게 할 때 유용하다.

Summary

  • PCIe는 root complex를 최상위로 하는 tree이고, 각 function은 Bus, Device, Function 세 숫자로 된 주소(BDF)를 갖는다.
  • firmware는 bus 0부터 모든 자리의 config space를 읽어 장치를 찾고, bridge를 만나면 새 bus 번호를 정해 그 아래도 같은 방식으로 찾는다.
  • firmware는 장치마다 겹치지 않는 memory 주소를 정해 BAR에 적는다. 큰 GPU는 Above 4G Decoding이 켜져 있어야 주소를 받을 수 있다.
  • Option ROM은 장치가 자기 Flash에 담아 두는 driver다. NIC의 PXE 코드가 대표적인 예다.
  • DXE 단계에서 driver가 한 층씩 연결되어, firmware가 디스크의 부팅 파일을 읽을 수 있는 상태가 된다.
  • BDS는 BootNext, BootOrder에 적힌 Boot#### 항목을 순서대로 시도하고, 모두 실패하면 \EFI\BOOT\BOOTX64.EFI를 찾는다.

References