唐凌 发表于 2021-3-11 10:17:33

【UEFI】用UEFI的磁盘I/O协议接口解析硬盘分区表


# 前言
写OS必不可少的就是获取硬盘的分区信息。本文以UEFI的磁盘I/O协议为例解析磁盘分区表。
和以前我玩UEFI的方法一样,编译照样用LLVM,编译EDK的库还是用我自己的非标准方法。
本文的程序不仅能解析MBR(主要)分区表,也能解析GPT分区表。

## 第一步:枚举所有磁盘
磁盘I/O协议对应的GUID是:
```C
#define EFI_BLOCK_IO_PROTOCOL_GUID \
{ \
    0x964e5b21, 0x6459, 0x11d2, {0x8e, 0x39, 0x0, 0xa0, 0xc9, 0x69, 0x72, 0x3b } \
}
```
这里用`Block I/O Protocol`的原因其实是因为它的内容比较丰富,尽管操作起来有些蛋疼,访问起来得按扇区大小对齐,但是由于分区表正好都在扇区的头上,分区表里的玩意不少都是LBA,因此麻烦就省了。
虽然还有一种访问磁盘的协议叫`Disk I/O Protocol`,不过两种协议都要`MediaId`参数,而这个值可以从`Block I/O Protocol`里拿,但`Disk I/O Protocol`里拿不到,还得回来在`Block I/O Protocol`里拿。
在`SystemTable->BootServices`里有个函数叫`LocateHandleBuffer`,这个函数能枚举出所有支持该协议的所有句柄。其函数原型为:
```C
typedef
EFI_STATUS
(EFIAPI *EFI_LOCATE_HANDLE_BUFFER)(
IN   EFI_LOCATE_SEARCH_TYPE       SearchType,
IN   EFI_GUID                     *Protocol,      OPTIONAL
IN   VOID                         *SearchKey,   OPTIONAL
OUT    UINTN                        *NoHandles,
OUT    EFI_HANDLE                   **Buffer
);
```
我们要的是所有支持磁盘I/O协议的句柄,因此`SearchType`填`ByProtocol`,`SearchKey`填`NULL`,然后`Protocol`填磁盘I/O的GUID。
`NoHandles`会返回支持的句柄数量,而`Buffer`会返回句柄数组。别忘了释放掉。
有一个相似的函数叫`LocateHandle`,功能差不多,但是要自己分配数组内存,略蛋疼,除非你有特别规划好的内存池,否则不建议用`LocateHandle`函数。
拿到句柄之后,要拿到磁盘服务,这里拿两个玩意:`Block I/O Protocol`和`Device Path Protocol`。
其中`Device Path Protocol`里面的内容是设备路径,不过不是字符串,是一种挺特别的结构。
初始化磁盘I/O的代码如下:
```C
EFI_STATUS InitializeDiskIoProtocol()
{
        UINTN BuffCount=0;
        EFI_HANDLE *HandleBuffer=NULL;
        // Locate all devices that support Disk I/O Protocol.
        EFI_STATUS st=gBS->LocateHandleBuffer(ByProtocol,&gEfiBlockIoProtocolGuid,NULL,&BuffCount,&HandleBuffer);
        if(st==EFI_SUCCESS)
        {
                DiskDevices=AllocateZeroPool(sizeof(DISK_DEVICE_OBJECT)*BuffCount);
                if(DiskDevices)
                {
                        NumberOfDiskDevices=BuffCount;
                        for(UINTN i=0;i<BuffCount;i++)
                        {
                                DiskDevices.DevicePath=DevicePathFromHandle(HandleBuffer);
                                gBS->HandleProtocol(HandleBuffer,&gEfiBlockIoProtocolGuid,&DiskDevices.BlockIo);
                                if(HandleBuffer==CurrentImage->DeviceHandle)
                                {
                                        CHAR16* DevPath=ConvertDevicePathToText(DiskDevices.DevicePath,FALSE,FALSE);
                                        if(DevPath)
                                        {
                                                Print(L"Image was loaded from Disk Device: %s\r\n",DevPath);
                                                FreePool(DevPath);
                                        }
                                        CurrentDiskDevice=&DiskDevices;
                                }
                        }
                }
                else
                {
                        st=EFI_OUT_OF_RESOURCES;
                        StdOut->OutputString(StdOut,L"Failed to build list of Disk Devices!\r\n");
                }
                FreePool(HandleBuffer);
        }
        else
                Print(L"Failed to locate Disk I/O handles! Status=0x%p\n",st);
        return st;
}
```

## 第二步:区分磁盘性质
刚才给出的代码枚举出了所有的磁盘设备,但磁盘设备里不光是整个的硬盘,就连硬盘里的分区也会被视为磁盘设备,因为里面的分区也支持`Block I/O Protocol`。
这里看一下`EFI_BLOCK_IO_PROTOCOL`结构体的定义:
```C
struct _EFI_BLOCK_IO_PROTOCOL {
///
/// The revision to which the block IO interface adheres. All future
/// revisions must be backwards compatible. If a future version is not
/// back wards compatible, it is not the same GUID.
///
UINT64            Revision;
///
/// Pointer to the EFI_BLOCK_IO_MEDIA data for this device.
///
EFI_BLOCK_IO_MEDIA*Media;

EFI_BLOCK_RESET   Reset;
EFI_BLOCK_READ      ReadBlocks;
EFI_BLOCK_WRITE   WriteBlocks;
EFI_BLOCK_FLUSH   FlushBlocks;

};
```
我们会用`ReadBlocks`函数读磁盘。结构体的`Media`里有这个磁盘的详细信息:
```C
typedef struct {
///
/// The curent media Id. If the media changes, this value is changed.
///
UINT32MediaId;

///
/// TRUE if the media is removable; otherwise, FALSE.
///
BOOLEAN RemovableMedia;

///
/// TRUE if there is a media currently present in the device;
/// othersise, FALSE. THis field shows the media present status
/// as of the most recent ReadBlocks() or WriteBlocks() call.
///
BOOLEAN MediaPresent;

///
/// TRUE if LBA 0 is the first block of a partition; otherwise
/// FALSE. For media with only one partition this would be TRUE.
///
BOOLEAN LogicalPartition;

///
/// TRUE if the media is marked read-only otherwise, FALSE.
/// This field shows the read-only status as of the most recent WriteBlocks () call.
///
BOOLEAN ReadOnly;

///
/// TRUE if the WriteBlock () function caches write data.
///
BOOLEAN WriteCaching;

///
/// The intrinsic block size of the device. If the media changes, then
/// this field is updated.
///
UINT32BlockSize;

///
/// Supplies the alignment requirement for any buffer to read or write block(s).
///
UINT32IoAlign;

///
/// The last logical block address on the device.
/// If the media changes, then this field is updated.
///
EFI_LBA LastBlock;

///
/// Only present if EFI_BLOCK_IO_PROTOCOL.Revision is greater than or equal to
/// EFI_BLOCK_IO_PROTOCOL_REVISION2. Returns the first LBA is aligned to
/// a physical block boundary.
///
EFI_LBA LowestAlignedLba;

///
/// Only present if EFI_BLOCK_IO_PROTOCOL.Revision is greater than or equal to
/// EFI_BLOCK_IO_PROTOCOL_REVISION2. Returns the number of logical blocks
/// per physical block.
///
UINT32 LogicalBlocksPerPhysicalBlock;

///
/// Only present if EFI_BLOCK_IO_PROTOCOL.Revision is greater than or equal to
/// EFI_BLOCK_IO_PROTOCOL_REVISION3. Returns the optimal transfer length
/// granularity as a number of logical blocks.
///
UINT32 OptimalTransferLengthGranularity;
} EFI_BLOCK_IO_MEDIA;
```
其中`LogicalPartition`可以区分这个`Block I/O Protocol`是否为逻辑分区。而`MediaPresent`可以确认这个磁盘是否仍然在位。不在位的磁盘暂时是读不出东西的,因此不在位的磁盘也要跳过。
代码如下:
```C
void EnumAllDiskPartitions()
{
        for(UINTN i=0;i<NumberOfDiskDevices;i++)
        {
                // Skip absent media and partition media.
                if(DiskDevices.BlockIo->Media->MediaPresent && !DiskDevices.BlockIo->Media->LogicalPartition)
                {
                        CHAR16 *DiskDevicePath=ConvertDevicePathToText(DiskDevices.DevicePath,FALSE,FALSE);
                        if(DiskDevicePath)
                        {
                                StdOut->OutputString(StdOut,L"=============================================================================\r\n");
                                Print(L"Partition Info of Device Path: %s\n",DiskDevicePath);
                                FreePool(DiskDevicePath);
                                Print(L"Block Size: %d bytes. I/O Alignment: 0x%X. Last LBA: 0x%llX.\n",DiskDevices.BlockIo->Media->BlockSize,DiskDevices.BlockIo->Media->IoAlign,DiskDevices.BlockIo->Media->LastBlock);
                                EnumDiskPartitions(DiskDevices.BlockIo);
                        }
                }
        }
        StdOut->OutputString(StdOut,L"=============================================================================\r\n");
}
```

## 第三步:根据磁盘设备解析分区表
我认为UEFI的文档里有关分区表的描述是最为权威的。但不可否认的是,维基百科的内容确实比UEFI文档里的内容多。
在论坛上,坛主曾经也发过(https://www.0xaa55.com/thread-1462-1-1.html)和(https://www.0xaa55.com/thread-7-1-1.html)。
这里还是简单说一下步骤:
第一步:读取LBA0,即第零扇区,这里存着MBR,即主启动记录。不用去鸟MBR头上存着的系统启动代码,直接去解析那里面存着的分区表。里面就四个玩意,如果里面的`OSIndicator`不为零则该分区有效。如果它的值是`0xEE`或者是`0xEF`,那就是GPT分区,通常是前者。
第二步:按照UEFI的文档,`OSIndicator`的值定义如下:
```C
#define PMBR_GPT_PARTITION          0xEE
#define EFI_PARTITION               0xEF
```
前者的意思是`GPT Protective`,按照UEFI的规定,`GPT Protective`的分区应当覆盖整个磁盘。而后者的意思是`UEFI System Partition`,但没有具体解释。我认为所谓“混合MBR+GPT”的磁盘里,给GPT分区的就只能是后者,而不可以是前者。
如果该分区描述的是GPT分区表,则`StartingLBA`域存的是GPT表头所在的扇区。直接把它传给`ReadBlocks`函数去读就完事了。其函数原型如下:
```C
typedef
EFI_STATUS
(EFIAPI *EFI_BLOCK_READ)(
IN EFI_BLOCK_IO_PROTOCOL          *This,
IN UINT32                         MediaId,
IN EFI_LBA                        Lba,
IN UINTN                        BufferSize,
OUT VOID                        *Buffer
);
```
别的没啥好说的,也就是那个意思,其中那个`MediaId`参数可以去`EFI_BLOCK_IO_MEDIA`结构体里拿。
读到GPT表头后,可以依据`PartitionEntryLBA`域取得存放分区表项数组的扇区起始位置。而`NumberOfPartitionEntries`域存放表项数量,这个地方一般都是128,原则上必须是2的整数次幂且大于等于128。
读到表项数组之后,需要判断每个表项的`PartitionTypeGUID`域,如果为全零则表示该表项不使用。那么就得跳过。这个域和MBR中的`OSIndicator`很是相像,不过GPT的分区类型比MBR更为自由,写OS的话可以直接用GUID生成器给自己分配一个专属的分区类型GUID。
而`UniquePartitionGUID`用于标识一个分区。原则上GUID是不会冲突的,但要是冲突了,那可就很尴尬了,固件或者操作系统处理分区的行为就无法确定了。
`StartingLBA`和`EndingLBA`两个域标识分区的起始扇区和终止扇区。依据小学数学的种树原理,这个分区的总扇区数是`EndingLBA-StartingLBA+1`。
说到这,代码如下:
```C
EFI_STATUS EnumDiskPartitions(IN EFI_BLOCK_IO_PROTOCOL *BlockIoProtocol)
{
        EFI_STATUS st=EFI_DEVICE_ERROR;
        if(!BlockIoProtocol->Media->LogicalPartition)
        {
                MASTER_BOOT_RECORD MBRContent;
                st=BlockIoProtocol->ReadBlocks(BlockIoProtocol,BlockIoProtocol->Media->MediaId,0,sizeof(MASTER_BOOT_RECORD),&MBRContent);
                if(st==EFI_SUCCESS)
                {
                        if(MBRContent.Signature!=MBR_SIGNATURE)
                                StdOut->OutputString(StdOut,L"Invalid MBR Signature! MBR might be corrupted!\r\n");
                        for(UINT8 i=0;i<MAX_MBR_PARTITIONS;i++)
                        {
                                MBR_PARTITION_RECORD *Partition=&MBRContent.Partition;
                                if(Partition->OSIndicator)
                                {
                                        UINT32 StartLBA=*(UINT32*)Partition->StartingLBA;
                                        UINT32 SizeInLBA=*(UINT32*)Partition->SizeInLBA;
                                        CHAR16 ScaledStart,ScaledSize;
                                        DisplaySize(__emulu(StartLBA,BlockIoProtocol->Media->BlockSize),ScaledStart,sizeof(ScaledStart));
                                        DisplaySize(__emulu(SizeInLBA,BlockIoProtocol->Media->BlockSize),ScaledSize,sizeof(ScaledSize));
                                        Print(L"MBR-Defined Partition %d: OS Type: 0x%02XStart Position: %sPartition Size: %s\n",i,Partition->OSIndicator,ScaledStart,SizeInLBA==0xFFFFFFFF?L"Over 2TiB":ScaledSize);
                                        if(Partition->OSIndicator==PMBR_GPT_PARTITION || Partition->OSIndicator==EFI_PARTITION)
                                        {
                                                EFI_PARTITION_TABLE_HEADER *GptHeader=AllocatePool(BlockIoProtocol->Media->BlockSize);
                                                if(GptHeader)
                                                {
                                                        st=BlockIoProtocol->ReadBlocks(BlockIoProtocol,BlockIoProtocol->Media->MediaId,StartLBA,BlockIoProtocol->Media->BlockSize,GptHeader);
                                                        if(st==EFI_SUCCESS)
                                                        {
                                                                if(GptHeader->Header.Signature!=EFI_PTAB_HEADER_ID)
                                                                        StdOut->OutputString(StdOut,L"Improper GPT Header Signature!");
                                                                else
                                                                {
                                                                        UINT32 PartitionEntrySize=GptHeader->SizeOfPartitionEntry*GptHeader->NumberOfPartitionEntries;
                                                                        VOID* PartitionEntries=AllocatePool(PartitionEntrySize);
                                                                        Print(L"Disk GUID: {%g}Partition Array LBA: %uNumber of Partitions: %u\n",&GptHeader->DiskGUID,GptHeader->PartitionEntryLBA,GptHeader->NumberOfPartitionEntries);
                                                                        if(PartitionEntries)
                                                                        {
                                                                                st=BlockIoProtocol->ReadBlocks(BlockIoProtocol,BlockIoProtocol->Media->MediaId,GptHeader->PartitionEntryLBA,PartitionEntrySize,PartitionEntries);
                                                                                if(st==EFI_SUCCESS)
                                                                                {
                                                                                        for(UINT32 j=0;j<GptHeader->NumberOfPartitionEntries;j++)
                                                                                        {
                                                                                                EFI_PARTITION_ENTRY *PartitionEntry=(EFI_PARTITION_ENTRY*)((UINTN)PartitionEntries+j*GptHeader->SizeOfPartitionEntry);
                                                                                                if(EfiCompareGuid(&PartitionEntry->PartitionTypeGUID,&gEfiPartTypeUnusedGuid))
                                                                                                {
                                                                                                        DisplaySize(MultU64x32(PartitionEntry->StartingLBA,BlockIoProtocol->Media->BlockSize),ScaledStart,sizeof(ScaledStart));
                                                                                                        DisplaySize(MultU64x32(PartitionEntry->EndingLBA-PartitionEntry->StartingLBA+1,BlockIoProtocol->Media->BlockSize),ScaledSize,sizeof(ScaledSize));
                                                                                                        Print(L"GPT-Defined Partition %u: Start Position: %s Partition Size: %s\n",j,ScaledStart,ScaledSize);
                                                                                                        Print(L"Partition Type GUID:    {%g}\n",&PartitionEntry->PartitionTypeGUID);
                                                                                                        Print(L"Unique Partition GUID:{%g}\n",&PartitionEntry->UniquePartitionGUID);
                                                                                                }
                                                                                        }
                                                                                }
                                                                                FreePool(PartitionEntries);
                                                                        }
                                                                }
                                                        }
                                                        else
                                                                Print(L"Failed to read GPT Header! Status=0x%p\n",st);
                                                        FreePool(GptHeader);
                                                }
                                        }
                                }
                        }
                }
        }
        return st;
}
```
由于从扇区数换算到字节数的这个过程里,数字都挺大,因此要注意32位扩展到64位的情况,不能简单的用乘法符号。32位数乘32位数可以用`__emulu`这个编译器内置宏,而64位数乘32位数可以用EDK II库里的`MultU64x32`函数。
这里我还写了个`DisplaySize`函数,用于把字节数转换为一个描述大小的含有单位的字符串,但方便起见我直接向右移位,因此单位越大精确度越差。代码如下:
```C
void DisplaySize(IN UINT64 Size,OUT CHAR16 *Buffer,IN UINTN Limit)
{
        if(Size<LimitKiB)
                UnicodeSPrint(Buffer,Limit,L"%u Bytes",Size);
        else if(Size>=LimitKiB && Size<LimitMiB)
                UnicodeSPrint(Buffer,Limit,L"%u KiB",Size>>10);
        else if(Size>=LimitMiB && Size<LimitGiB)
                UnicodeSPrint(Buffer,Limit,L"%u MiB",Size>>20);
        else
                UnicodeSPrint(Buffer,Limit,L"%u GiB",Size>>30);
}
```

## 第四步:编译测试
本文的代码已在GitHub上开源了,所以编译没啥好说的,直接用我给的脚本就完事了。

### 测试1:VMware虚拟机
这个虚拟机里安装了两个硬盘,一个用MBR分区的,另一个用GPT分区的,两个都装了Ubuntu,运行效果如下:

可以发现里面有三块盘,其中两块是虚拟机的虚拟硬盘,另一块是放了EFI程序的U盘。
着重看一下第二块盘的分区表内容,可以看出里面有两个分区,分区类型分别是`{C12A7328-F81F-11D2-BA4B00A0C93EC93B}`和`{0FC63DAF-8483-4772-8E793D69D8477DE4}`。也就是说,一个是EFI系统分区,另一个是Linux文件系统数据分区。

### 测试2:VMware虚拟机
这个虚拟机里安装了一个硬盘,以GPT分区,装的是Windows 7 x64,运行效果如下:

有意思的地方就来了,Windows似乎不鸟MBR上的分区大小域,直接就设置了个0xFFFFFFFF值,于是被我的程序识别为`Over 2TiB`。而Linux却很注意这个点。
里面有三个分区,类型分别是:
EFI系统分区,即`{C12A7328-F81F-11D2-BA4B00A0C93EC93B}`
微软保留分区,即`{E3C9E316-0B5C-4DB8-817DF92DF00215AE}`
Windows基本数据分区,即`{EBD0A0A2-B9E5-4433-87C068B6B72699C7}`

### 测试3:钓鱼派
钓鱼派上搭载了(https://ark.intel.com/content/www/us/en/ark/products/78475/intel-atom-processor-e3845-2m-cache-1-91-ghz.html)处理器,和2GiB的DDR3L内存,我在上面插了一张Micro SD卡,以GPT分区,里面装了Windows 10 x64,运行效果如下:

由于钓鱼派上有UART接口,可以把控制台输出到串口上,因此可以用PuTTY获取控制台输出。
这张SD卡上有四个分区,类型分别是:
Windows恢复环境分区,即`{DE94BBA4-06D1-4D40-A16ABFD50179D6AC}`
EFI系统分区,即`{C12A7328-F81F-11D2-BA4B00A0C93EC93B}`
微软保留分区,即`{E3C9E316-0B5C-4DB8-817DF92DF00215AE}`
Windows基本数据分区,即`{EBD0A0A2-B9E5-4433-87C068B6B72699C7}`

### 测试4:巫毒派
巫毒派上搭载了(https://www.amd.com/en/product/7281)处理器,我在上面插了两条4GiB的DDR4内存,和一个128G的M.2 SATA固态硬盘,以GPT分区,里面装了Windows 10 x64,运行效果如下:

这块SSD上有五个分区,类型都一样,相比之下就是多了一个Windows基本数据分区。

# 结语
代码已在GitHub的组织号上开源了:https://github.com/MickeyMeowMeowHouse/UefiDiskAccess
编译好的文件也放到GitHub上了:https://github.com/MickeyMeowMeowHouse/UefiDiskAccess/releases
或许你们会发现,代理包括了一段获取启动来源设备路径的代码。这段代码的控制台输出告诉你,启动来源设备是它所在逻辑分区,而不是它所在的磁盘。至于为什么我就不解释了。

0xAA55 发表于 2021-3-12 00:49:10

梦回2011年,我当时还在玩 NASM 用 INT 0x13 的功能读取硬盘的 MBR、DBR、VBR,判断分区并加载分区引导扇区。然后分区引导扇区再加载自己分区里的引导加载器来加载自己的引导列表(兼容 XP 的 NTLDR),和自己的裸机系统。

现在用 UEFI 加载分区的时候,每个分区已经有 GUID、超过 2TB 的位置和大小的表达能力,以及更为细致的描述信息了。放在当时我对 GPT 完全是摸不着头脑的。

时代的变化真快。虽说 UEFI 从很早很早就有了。
页: [1]
查看完整版本: 【UEFI】用UEFI的磁盘I/O协议接口解析硬盘分区表