前言
写OS必不可少的就是获取硬盘的分区信息。本文以UEFI的磁盘I/O协议为例解析磁盘分区表。
和以前我玩UEFI的方法一样,编译照样用LLVM,编译EDK的库还是用我自己的非标准方法。
本文的程序不仅能解析MBR(主要)分区表,也能解析GPT分区表。
第一步:枚举所有磁盘
磁盘I/O协议对应的GUID是:
#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
,这个函数能枚举出所有支持该协议的所有句柄。其函数原型为:
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的代码如下:
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[i].DevicePath=DevicePathFromHandle(HandleBuffer[i]);
gBS->HandleProtocol(HandleBuffer[i],&gEfiBlockIoProtocolGuid,&DiskDevices[i].BlockIo);
if(HandleBuffer[i]==CurrentImage->DeviceHandle)
{
CHAR16* DevPath=ConvertDevicePathToText(DiskDevices[i].DevicePath,FALSE,FALSE);
if(DevPath)
{
Print(L"Image was loaded from Disk Device: %s\r\n",DevPath);
FreePool(DevPath);
}
CurrentDiskDevice=&DiskDevices[i];
}
}
}
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
结构体的定义:
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
里有这个磁盘的详细信息:
typedef struct {
///
/// The curent media Id. If the media changes, this value is changed.
///
UINT32 MediaId;
///
/// 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.
///
UINT32 BlockSize;
///
/// Supplies the alignment requirement for any buffer to read or write block(s).
///
UINT32 IoAlign;
///
/// 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
可以确认这个磁盘是否仍然在位。不在位的磁盘暂时是读不出东西的,因此不在位的磁盘也要跳过。
代码如下:
void EnumAllDiskPartitions()
{
for(UINTN i=0;i<NumberOfDiskDevices;i++)
{
// Skip absent media and partition media.
if(DiskDevices[i].BlockIo->Media->MediaPresent && !DiskDevices[i].BlockIo->Media->LogicalPartition)
{
CHAR16 *DiskDevicePath=ConvertDevicePathToText(DiskDevices[i].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[i].BlockIo->Media->BlockSize,DiskDevices[i].BlockIo->Media->IoAlign,DiskDevices[i].BlockIo->Media->LastBlock);
EnumDiskPartitions(DiskDevices[i].BlockIo);
}
}
}
StdOut->OutputString(StdOut,L"=============================================================================\r\n");
}
第三步:根据磁盘设备解析分区表
我认为UEFI的文档里有关分区表的描述是最为权威的。但不可否认的是,维基百科的内容确实比UEFI文档里的内容多。
在论坛上,坛主曾经也发过GPT分区表资料和MBR分区表资料。
这里还是简单说一下步骤:
第一步:读取LBA0,即第零扇区,这里存着MBR,即主启动记录。不用去鸟MBR头上存着的系统启动代码,直接去解析那里面存着的分区表。里面就四个玩意,如果里面的OSIndicator
不为零则该分区有效。如果它的值是0xEE
或者是0xEF
,那就是GPT分区,通常是前者。
第二步:按照UEFI的文档,OSIndicator
的值定义如下:
#define PMBR_GPT_PARTITION 0xEE
#define EFI_PARTITION 0xEF
前者的意思是GPT Protective
,按照UEFI的规定,GPT Protective
的分区应当覆盖整个磁盘。而后者的意思是UEFI System Partition
,但没有具体解释。我认为所谓“混合MBR+GPT”的磁盘里,给GPT分区的就只能是后者,而不可以是前者。
如果该分区描述的是GPT分区表,则StartingLBA
域存的是GPT表头所在的扇区。直接把它传给ReadBlocks
函数去读就完事了。其函数原型如下:
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
。
说到这,代码如下:
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[i];
if(Partition->OSIndicator)
{
UINT32 StartLBA=*(UINT32*)Partition->StartingLBA;
UINT32 SizeInLBA=*(UINT32*)Partition->SizeInLBA;
CHAR16 ScaledStart[32],ScaledSize[32];
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%02X Start Position: %s Partition 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: %u Number 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
函数,用于把字节数转换为一个描述大小的含有单位的字符串,但方便起见我直接向右移位,因此单位越大精确度越差。代码如下:
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:钓鱼派
钓鱼派上搭载了Intel Atom E3845处理器,和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:巫毒派
巫毒派上搭载了AMD Ryzen Embedded V1605B处理器,我在上面插了两条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
或许你们会发现,代理包括了一段获取启动来源设备路径的代码。这段代码的控制台输出告诉你,启动来源设备是它所在逻辑分区,而不是它所在的磁盘。至于为什么我就不解释了。