260129) min-os 맥북으로 포팅하기
맥북을 줍다
회사에서 교육을 위해 도파민 탈옥용 iOS 기기가 필요해서 당근을 뒤져보던 중 2012년형 맥북이 9만원에 올라온 것을 확인했다.
퇴근하고 달려가서 주워왔는데, 맥북프로 2012년 mid 판이였고 램은 기재된 8GB가 아니라 16GB.. 상태도 정말 좋고 백라이트도 너무 예뻐서 바로 프사에 걸어뒀다
min-os의 포팅 시작
구매하고나서 gpt와 이야기하며 이유를 갖다 붙이던 중 인텔 맥북이니 min-os를 올려볼 수 있을까 싶은 생각에 물어봤다.
가능하다고 한다.
실행해보기
과거 맥북에는 UEFI 로 부트로더를 실행하고 그게 맥 OS를 로딩하는 구조라서 EFI 볼륨을 마운트 후 마음대로 앱을 올릴 수 있는 구조였다.
EFI를 직접 마운트해서 /Volumes/EFI/EFI/BOOT/BOOTX64.EFI 에 올려도 실행할 수 있지만, 프리즈가 발생하면 어떤 버그인지 알아볼 수 없어서 UEFI Shell을 먼저 실행하기로 했다.
또 구매한 맥북 프로에서 EDK2 빌드환경 세팅하는게 오래걸려서 USB를 EFI 파티션처럼 사용해서 부트로더를 옮기기로 결정했다.
min-os의 부트로더를 실행해도 아무런 변화없이 프리즈걸리고, 다른 쉘들을 실행해도 프리즈걸리길래 찾아보니 구형 맥에서 최신버전 OS를 실행시킬 수 있는 OpenCore Legacy Patcher 라는 프로젝트가 있었고 이 프로젝트 바이너리에 OpenShell.efi 라는 쉘이 있었다.
다행히 내 맥북이 지원범위에 있다.
다운로드 받고 압축을 풀면 아래의 폴더 구조가 나오는데, 이 중 \OpenCore-RELEASE\X64\EFI\OC\Tools\OpenShell.efi 를 BOOTX64.EFI 로 복사하면 된다.
무조건 FAT32 GPT 구조로 파티션을 만들어야해서 USB를 일단 포맷 후 파티셔닝한다.
# USB를 사용해서 UEFI Shell 실행
diskutil list
diskutil partitionDisk /dev/diskN GPT \ # USB 포맷
FAT32 "EFIUSB" 200MB \ # FAT32로 200MB 할당하고 EFIUSB 이름붙임
FREE "" R # 나머지는 빈공간으로 사용
mkdir -p /Volumes/EFIUSB/EFI/BOOT
cp OpenShell.efi /Volumes/EFIUSB/EFI/BOOT/BOOTX64.EFI # OpenShell.efi 로 바로부팅
cp Loader.efi /Volumes/EFIUSB/APP/MYAPP.EFI
diskutil unmount /Volumes/EFIUSB
디스크를 200MB로 제한하지 않으면 현재 volume_image를 최대 128MB 까지만 읽어오기 때문에 실제 디스크 저장공간을 읽기전에 128MB 제한이 끝나버린다. FAT정도만 읽을수있음.
재부팅 하면서 Option 키를 꾹 누르면 EFI 선택창(Startup Manager)이 표시되고 APPLE이 아닌 EFI Boot를 선택하면 BOOTX64.EFI(OpenShell.efi) 로 부트할 수 있게된다.
Startup Manager는 전체 디스크의 전체 파티션에서 /EFI/BOOT/BOOTX64.efi 가 있는지 확인해서 보여주기 때문에 USB는 ESP 파티션이 아님에도 선택할 수 있게된다.
쉘이 뜨면 아래 명령어를 통해 min-os의 부트로더를 실행할 수 있다.
map -r
fs1:
cd APP
ls
./MYAPP.EFI
EFI 실행은 잘 되지만, 중간에 멈추는 것(프리즈 상태)을 볼 수 있다.
이제 이걸 잘 포팅하는게 이 글의 목표이다.
OpenShell을 안쓰고 부트로더를 바로 사용해도 된다. 하지만 부트로더 메시지가 출력되지 않고 커널이 뜨기 때문에 부트로더 문제라면 어디에서 멈췄는지 알 수 없게된다.
폴더구조
EFI 폴더를 ESP 파티션에 넣으면 더 깔끔하지만, PC에서 읽고 쓰려면 따로 마운트를 해야하기 때문에 마운트 없이 일반 파티션에 EFI 폴더를 넣었다. (어차피 Startup Manager가 찾아준다)
PS F:\> tree /f
EFIUSB 볼륨에 대한 폴더 경로의 목록입니다.
볼륨 일련 번호는 4017-0A17입니다.
F:.
│ memmap
│ kernel.elf
│
├─EFI
│ └─BOOT
│ BOOTX64.efi
│
├─MINLOADER
│ Loader.efi
│
├─APPS
│ stars
│ winhello
│
└─RESOURCE
kor.txt
korddm.ttf
minyong_1.jpg
포팅 하기
작업 목표
멀티코어를 켜는 등 성능향상은 나중 목표로 두고, 1차 목표는 min-os를 맥북에서 정상 실행하는 것이다. QEMU OVMF 펌웨어 타겟으로 만들어져 동작하지 않는 것으로 파악된다.
당연하게도 아래 두개만 해결하면 된다.
- 부트로더에서 문제되는 상황들을 해결하기
- 커널에서 문제되는 상황들을 해결하기
1. 부트로더 문제
1-1. 에러로그 출력문제
부트로더 실행 이미지를 보면 Frame Buffer 메시지 출력 후 프리즈에 걸린것을 볼 수 있다. 처음에는 루트에 kernel.elf 파일이 없어서 발생한 문제라고 생각했는데, 파일 열기에 실패하면 “open file …” 에러가 발생해야 맞다.
_IfErrorHalt 함수의 사용법이 문제가 있었는데, status를 함수 결과로 바꾸지 않았다는 점이다.. 이제 어디에서 에러가 발생했는지 정확히 알 수 있을것이다.
Print(L"Frame Buffer: 0x%0lx - 0x%0lx, Size: %lu bytes\n",
gop->Mode->FrameBufferBase,
gop->Mode->FrameBufferBase + gop->Mode->FrameBufferSize,
gop->Mode->FrameBufferSize);
// #@@range_end(draw)
// #@@range_begin(read_kernel)
EFI_FILE_PROTOCOL* kernel_file;
root_dir->Open(
root_dir, &kernel_file, L"\\kernel.elf",
EFI_FILE_MODE_READ, 0);
_IfErrorHalt(L"open file '\\kernel.elf' Failed", status);
1-2. GetMemoryMap.. Buffer Too Small
지금은 메모리맵 버퍼를 0x4000 byte 고정 크기로 받고있는데, 정석은 두번 호출해서 필요한 크기를 동적으로 할당하는 방식이다.
EFI_STATUS GetMemoryMap(struct MemoryMap* map) {
EFI_STATUS status;
// main 의 스택에서 만든 map 변수는 하나로 두고
// 여러번 호출되는 경우 이전 버퍼는 반납한다.
if (map->buffer != NULL) {
gBS->FreePool(map->buffer);
map->buffer = NULL;
}
// 처음에는 사이즈를 0으로 세팅하고 호출해서 필요한 크기를 알아온다.
map->map_size = 0;
gBS->GetMemoryMap(
&map->map_size,
NULL,
&map->map_key,
&map->descriptor_size,
&map->descriptor_version);
// 해당 크기만큼(+여유분) 동적할당
map->map_size += map->descriptor_size * 2;
map->buffer_size = map->map_size;
status = gBS->AllocatePool(EfiLoaderData, map->map_size, &map->buffer);
if (EFI_ERROR(status)) return status;
// 한번 더 요청
return gBS->GetMemoryMap(
&map->map_size,
(EFI_MEMORY_DESCRIPTOR*)map->buffer,
&map->map_key,
&map->descriptor_size,
&map->descriptor_version);
}
1-3. 볼륨 이미지 읽기 실패
QEMU에서는 볼륨 이미지를 fat_disk 라는 파일로 루트에 넣어두는 방식을 사용한다.
부트로더 코드에서 커널을 실행할때 전달하는 volume_image는 \fat_disk 를 읽은 값 그대로 넘기게 되는데, USB에는 fat_disk가 없기 때문에 문제가 발생한다.
파일이 없어도 BlockIO를 통해 접근하기 때문에 정상적으로 동작해야한다. volume_image를 출력해보면 이상한 값이 출력되는데, USB의 다른 파티션에 접근해서 발생한 문제도 아닌 것을 확인할 수 있다.
# QEMU BlockIO로 읽어온 volume_image
LogicalPartition=0, BlockSize=512, IoAlign=8, MediaId=0
EB 58 90 6D 6B 66 73 2E ...
# 맥에서 출력한 volume_image
LogicalPartition=1, BlockSize=512, IoAlign=0, MediaId=11
FF FF FF FF FB FF FF FF ...
# USB의 파티션을 읽어보기
gimdonghyeon@gimdongcBookPro ~ % sudo xxd -l 32 /dev/disk2
00000000: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000010: 0000 0000 0000 0000 0000 0000 0000 0000 ................
gimdonghyeon@gimdongcBookPro ~ % sudo xxd -l 32 /dev/disk2s1
00000000: eb58 9042 5344 2020 342e 3400 0201 2000 .X.BSD 4.4... .
00000010: 0200 0000 00f0 0000 2000 1000 0000 0000 ........ .......
gimdonghyeon@gimdongcBookPro ~ % sudo xxd -l 32 /dev/disk2s2
00000000: eb58 9042 5344 2020 342e 3400 0220 2000 .X.BSD 4.4.. .
00000010: 0200 0000 00f8 0000 2000 ff00 0048 0600 ........ ....H..
gimdonghyeon@gimdongcBookPro ~ % sudo xxd -l 32 /dev/disk0s1
00000000: eb58 9042 5344 2020 342e 3400 0201 2000 .X.BSD 4.4... .
00000010: 0200 0000 00f0 0000 3f00 ff00 2800 0000 ........?...(...
해결법 1.
qemu용 빌드 후에 만들어지는 disk.img 파일은 모든 내용이 포함된 qemu의 디스크이미지라서 이 파일을 fat_disk 이름으로 변경해서 usb에 넣어두면 부팅이 된다.
이 경우 kernel.elf 는 부팅을 위해 usb에 넣어야해서 disk.img 내부 데이터와 중복되며 커널 실행중 만들어진 데이터는 USB에 저장이 안된다는 단점이 있다.
해결법 2.
별짓을 다해봤는데 원인을 찾았다.
USB Mass Storage(USB 장치를 디스크처럼 보이게 만드는 규격)는 SCSI 명령 형식(프로토콜)에 따라 최대로 한번 명령에 읽을 수 있는 크기가 정해져 있는데, READ(10) 의 경우 최대 32MB(16bit*블록크기) 까지만 읽을 수 있다.
EDK2 라이브러리는 USB Mass Storage 장치를 block_io->ReadBlocks로 읽을 때 READ(10) 프로토콜을 쓰는 대신 EDK USB Driver에서 적당한 크기로 나눠 읽는다.
현재 가지고있는 맥북의 Apple EDK 드라이버는 자체 구현인데, 나눠서 읽는것이 구현되어 있지 않아서 UB 동작으로 어디에선가 가져온 쓰레기값들을 읽어오게 된것이였다.
메모리를 미리 할당받고, 64KB씩 나눠서 읽어오면 해결된다.
EFI_STATUS ReadBlocksChunked(
EFI_BLOCK_IO_PROTOCOL* block_io, UINT32 media_id,
UINTN total_bytes, VOID* buffer) {
UINT32 block_size = block_io->Media->BlockSize;
UINTN chunk_size = 64 * 1024; // 64KB씩
UINT8* dst = (UINT8*)buffer;
EFI_LBA lba = 0;
for (UINTN offset = 0; offset < total_bytes; offset += chunk_size) {
UINTN read_size = chunk_size;
if (offset + read_size > total_bytes) {
read_size = total_bytes - offset;
}
EFI_STATUS status = block_io->ReadBlocks(
block_io, media_id, lba, read_size, dst + offset);
if (EFI_ERROR(status)) return status;
lba += read_size / block_size;
}
return EFI_SUCCESS;
}
// 실제 사용
EFI_BLOCK_IO_PROTOCOL* block_io;
// 부트로더가 포함된 디바이스의 block_io 획득
status = OpenBlockIoProtocolForLoadedImage(image_handle, &block_io);
_IfErrorHalt(L"failed to open Block I/O Protocol", status);
EFI_BLOCK_IO_MEDIA* media = block_io->Media;
UINTN volume_bytes = (UINTN)media->BlockSize * (media->LastBlock + 1);
if (volume_bytes > 128 * 1024 * 1024)
volume_bytes = 128 * 1024 * 1024;
num_pages = (volume_bytes + 0xFFF) / 0x1000;
EFI_PHYSICAL_ADDRESS addr;
status = gBS->AllocatePages(AllocateAnyPages, EfiLoaderData, num_pages, &addr);
_IfErrorHalt(L"AllocatePages failed", status);
volume_image = (VOID*)addr;
status = ReadBlocksChunked(block_io, media->MediaId, volume_bytes, volume_image);
이것까지 해결하면 잘 부팅돼서 고장난 커널을 확인할 수 있다.
2. 커널문제
2-1. xHCI 드라이버 행 문제
xhci.cpp 코드에서 Controller를 생성하는 코드에서 행이 걸렸다.
생성자를 보면 cap_->CAPLENGTH.Read() 이 코드가 MMIO(Memory Mapped IO)로 CPU가 메모리에 매핑된 장치 레지스터(BAR)에 접근하는 코드인데, 실제 장치에서는 CPU가 장치레지스터에 접근하는게 장치에 따라 막혀있을 수 있어서 켜준 후 접근할 수 있게 된다.
장치는 DMA(Direct Memory Access)로 CPU가 메모리에 쓴 커맨드들을 직접 읽거나 CPU에게 알리기 위한 결과를 메모리에 직접 쓰게 되는데, 이것도 장치마다 다르다.
일반적으로 MMIO 레지스터 접근(Controller 생성/초기화) 전에 MSE/BME를 켜는 것이 정석이다.
uint32_t cmd = pci::ReadConfReg(*xhc_dev, 0x04);
cmd |= (1 << 1) | (1 << 2); // Bus Master (b0010) | Memory Space (b0001)
pci::WriteConfReg(*xhc_dev, 0x04, cmd);
usb::xhci::controller = new Controller{xhc_mmio_base}; // 원래 행 위치
2-2. InvaliudPhase 에러
멈추는 위치가 대략 아래와 같다. (콘솔로그)
PortStatusChangeEvent: port_id = 1
EnableSlot: port.IsEnabled() = false, port.IsPortResetChanged() = true
PortStatusChangeEvent: port_id = 5
Error while ProcessEvent: kInvalidPhase at usb/xhci/xhci.cpp:207
PortStatusChangeEvent: port_id = 1
EnableSlot: port.IsEnabled() = false, port.IsPortResetChanged() = true
ConfigPhase 검사에서 default로 빠졌을때 InvalidPhase 에러를 출력하고, port_id=1 이 먼저 리셋을 진행중에 처리가 안됐기 때문에 port_id=5 가 WaitingAddressed 로 주소할당을 대기하게 된다.
OnEvent에서 다른 포트가 주소를 할당중일때 발생하는 이벤트는 무시하도록 수정한다.
switch (port_config_phase[port_id]) {
case ConfigPhase::kNotConnected:
return ResetPort(xhc, port);
case ConfigPhase::kResettingPort:
return EnableSlot(xhc, port);
case ConfigPhase::kWaitingAddressed:
return MAKE_ERROR(Error::kSuccess);
default:
return MAKE_ERROR(Error::kInvalidPhase);
}
2-3. xHCI 드라이버에서 하드웨어 초기화 대기
위 2-2. 항목의 버그를 발견한 결정적인 원인이다. port_id=1 이 주소할당중 멈췄기 때문에 port_id=5 도 정상적으로 진행되지 않는 문제가 있다.
xHCI 드라이버 초기화가 완료되면 컨트롤러를 실행하고, 반복문을 돌면서 포트를 스캔한 후 연결되어 있는 포트는 ConfigurePort에서 port.Reset() 함수를 실행 한다.
xhc.Initialize();
xhc.Run();
for (int i = 1; i <= xhc.MaxPorts(); ++i) {
auto port = xhc.PortAt(i);
Log(kDebug, "Port %d: IsConnected=%d\n", i, port.IsConnected());
if (port.IsConnected()) {
if (auto err = ConfigurePort(xhc, port)) {
// ...
}
}
}
- Reset 함수에서는 해당 xHC(USB 컨트롤러)에 리셋 요청 신호를 보내고 리셋요청이 완료(PR=0)될때까지 busy-wait 한다.
- 리셋 중 PORTSC(상태 플래그들)이 변경되면 xHC가 인터럽트를 발생시키고 main_task의 메시지루프에서 ProcessEvents 를 통해 드라이버의 OnEvent 가 호출된다.
- OnEvent의 EnableSlot 함수 는 is_enabled 값과 reset_completed 값이 둘다 true인 경우에만 xHC(USB 컨트롤러) 에게 디바이스 슬롯 할당을 요청한다.
- xHC가 슬롯을 할당하면 CommandCompletionEvent가 발생하고, 이후 여러 작업을 통해 드라이버 활성화를 거쳐 키보드/마우스 입력 수신이 가능해진다.
1번 과정은 xHC가 PR=0을 신호를 보내 리셋완료를 알린 시점에서 busy-wait이 종료되는데, 사실 PR=0 은 하드웨어에 리셋 신호를 더이상 안보낸다는거지 실제 리셋이 완료됐다는 의미는 PED=1(디바이스와 통신가능함), PRC=1(하드웨어가 리셋 절차를 완료함) 이 비트들이 의미한다.
USB 드라이버에 대한 개념 없이 아무리 고치려고 해도 고장나길래 그냥 드라이버 공부를 시작했다.
드라이버도 내 코드로 완전히 교체하고 일석이조이다.
드라이버 링크