UEFI - Parte 2

1 - Sistema de Arquivos, Partições e Tudo Mais

No último artigo, expliquei como funciona os handles, protocolos, GUIDs e outros recursos do UEFI. Nessa segunda parte, não só irei me aprofundar nisso, mas também falarei de outras características, principalmente, com relação a partições, sistema de arquivos e outros.

Como já foi explicado, o UEFI usa FAT como sistema de arquivos para carregar um binário PE. Devido a isso, a especificação disponibiliza protocolos para abrir, ler e escrever em um partição FAT. Existem também protocolos para ler e escrever em uma determinada offset do disco, que pode ser útil, por exemplo, caso você queira alterar a memória diretamente.

Além disso, o UEFI suporta o esquema de partições GPT. O próximo tópico será uma explicação desse esquema, pois isso pode influenciar a maneira como lidamos com os protocolos citados acima.

2 - Partições GPT

O GPT (GUID Partition Table) é um esquema usado pelo UEFI para organizar o disco em áreas de memória chamadas “partições”. Isso possibilita, por exemplo, que possamos ter vários sistemas de arquivos no mesmo disco. Existem também outros esquemas para descrever partições. Um exemplo disso é uma tabela com entradas de 16 bytes que fica no offset 446 da MBR. Essa tabela tem 64 bytes de tamanho, então podemos ter apenas 4 entradas. Cada entrada nessa tabela tem a seguinte estrutura:

struct partition_table_entry
{
  UINT8   BootIndicator;
  UINT8   StartingCHS[3];
  UINT8   OSType;  
  UINT8   EndingCHS[3];
  UINT32  StartingLBA;
  UINT32  SizeInLBA;
} __attribute__((packed));

Em primeiro lugar, note que o StartingCHS e o EndingCHS contém a localização do início e final da partição, respectivamente. Esses dois campos são ignorados, pois o UEFI usa apenas LBA e não CHS quando quer apontar para um bloco no disco, ou seja, apenas o StartingLBA e o SizeInLBA são utilizados. Se o disco usar LBA28, então podemos ter partições com até 128 GiB (\(2^{28} \cdot 512\)) se cada bloco lógico valer 512 bytes. Apesar de que podemos endereçar até 127 PiB (\(2^{48} \cdot 512\)) de memória com o LBA48, será possível somente 2 TiB (\(2^{32} \cdot 512\)), porque o SizeInLBA tem apenas 32 bits para armazenar o tamanho, em blocos, da partição. Mesmo sendo muito coisa para os padrões atuais, ainda é uma limitação que pode ser contornada com o GPT.

Embora a tabela somente tenha 4 entradas, ainda podemos criar o que é chamado de “partições extendidas”, porém não falaremos disso nesse artigo.

No caso do GPT, a estrutura é mais ou menos assim:

Perceba que existem três tipos de áreas que compõem todo esse esquema. Elas são a Protective MBR, o GPT Header e o GPT Partition Entry Array. Note também que há um backup desses dois últimos tipos. O GPT Header e o GPT Partition Entry Array, no início do disco, são chamadas de Primary GPT. Quanto às áreas no final, elas são chamadas de Backup GPT e são usadas, claro, para recuperar a Primary GPT caso seja corrompida.

2.1 - Protective MBR

A Protective MBR é usada para manter a compatibilidade com ferramentas legadas que não suportam o GPT. Ela vai ficar no LBA 0 e deve ter uma entrada que descreve uma partição que tenha o começo no LBA 1 e vá até o final do disco.

2.2 - GPT Header

O GPT Header é um estrutura que contém informações importantes para o uso das partições. Essa estrutura fica localizada no LBA 1. Além disso, ela tem 92 bytes de tamanho na especificação que estou usando como base para esse artigo, ou seja, ela pode mudar nas futuras versões. A estrutura é a seguinte:

typedef struct
{
  UINT64    Signature;
  UINT32    Revision;
  UINT32    HeaderSize;
  UINT32    HeaderCRC32;
  UINT32    Reserved;
  EFI_LBA   MyLBA;
  EFI_LBA   AlternateLBA;
  EFI_LBA   FirstUsableLBA;
  EFI_LBA   LastUsableLBA;
  EFI_GUID  DiskGUID;
  EFI_LBA   PartitionEntryLBA;
  UINT32    NumberOfPartitionEntries;
  UINT32    SizeOfPartitionEntry;
  UINT32    PartitionEntryArrayCRC32;
} EFI_GPT_HEADER __attribute__ ((packed));

Em primeiro lugar, o Signature é usado para identificar se estamos mesmo lidando com um GPT Header. Para fazer isso, temos que verificar se o Signature é igual a 0x5452415020494645 ("EFI PART").

O Revision é a versão do GPT Header. Isso está aí, porque futuras especificações podem mudar essa estrutura, então uma nova versão dela será indicada por esse valor.

O HeaderSize, como o próprio nome já diz, é o tamanho do GPT Header, em bytes. Não tem muito o que falar sobre ele.

No caso do HeaderCRC32, ele é o resultado de um cálculo CRC32 do GPT Header. Quanto ao MyLBA, esse campo vai conter o LBA com a localização do GPT Header. Existe também o AlternativeLBA que vai dar o LBA do GPT Header Backup. Você pode usar o Signature, HeaderCRC32, MyLBA e AlternativeLBA para verificar a integridade da estrutura.

Outros elementos que contém um LBA é o FirstUsableLBA e o LastUsableLBA. O FirstUsableLBA vai apontar para o início de uma área que irá armazenar o conteúdo das partições, e o LastUsableLBA vai apontar para o final dessa área. A ilustração a seguir mostra como isso funciona:

Como você pode ver, o início da área apontado pelo FirstUsableLBA deve ficar logo após a Primary GPT, e o final dela, apontado pelo LastUsableLBA, fica antes da Backup GPT.

O DiskGUID é usado para identificar o GPT Header e o disco. Além disso, podemos usar o GPT Header para localizar o GPT Partition Entry Array. Fazemos isso por meio do PartitionEntryLBA que, geralmente, aponta para o LBA 2. Calculamos o tamanho dessa área, em bytes, multiplicando NumberOfPartitionEntries por SizeOfPartitionEntry.

Por último, há também o PartitionEntryArrayCRC32. Ele o resultado de um calculo CRC32 de toda a GPT Partition Entry Array.

2.3 - GPT Partition Entry Array

O GPT Partition Entry Array é uma tabela que contém entradas que representam partições. Cada entrada vai descrever o tipo de partição, a localização, nome, etc. A estrutura de uma entrada é bem simples:

typedef struct
{
  EFI_GUID  PartitionTypeGUID;
  EFI_GUID  UniquePartitionGUID;
  EFI_LBA   StartingLBA;
  EFI_LBA   EndingLBA;
  UINT64    Attributes;
  CHAR16    PartitionName[36];
} EFI_PARTITION_ENTRY __attribute__((packed));

Para obter o tamanho de cada entrada na tabela, você precisa consultar o SizeOfPartitionEntry do GPT Header.

O PartitionTypeGUID é um GUID que diz com que tipo de partição estamos lidando. Por exemplo, se você tiver uma partição FAT, um GUID próprio desse sistema de arquivos deve ser colocado no PartitionTypeGUID da entrada que representa essa partição. Não pode ter sistema de arquivos com o mesmo GUID. Quanto ao UniquePartitionGUID, ele é um GUID feito, unicamente, para partição, isto é, cada partição tem sua própria GUID.

Com relação ao StartingLBA e EndingLBA, eles vão conter o LBA do início e do final da partição, respectivamente. A imagem a seguir mostra como essa tabela funciona:

A tabela na Primary GPT e na de Backup devem ser a cópia uma da outra, portanto a primeira entrada de cada tabela, como mostrado acima, vai apontar para a mesma área.

Cada entrada também precisa ter atributos que definem propriedades para a partição. Para isso, o Attributes tem bits que representam essas características. Por exemplo, o bit 0, se estiver setado, serve para avisar que a remoção da partição (como a de boot) deve ser evitada, pois pode comprometer o sistema. Em relação ao bit 1, se ele estiver setado, uma interface do protocolo EFI_BLOCK_IO_PROTOCOL não deve ser gerado. Calma, vou falar desse protocolo mais tarde.

Por último, temos o PartitionName, que é uma string com o nome da partição. Esse nome pode ter, no máximo, 36 caracteres (incluindo o caractere nulo para indicar o final da string).

2.4 - Criando as partições

Precisamos criar uma imagem que usaremos para testar os códigos deste artigo. Como vamos fazer testes com partições, então precisamos modificar um pouco o Makefile que usamos no último artigo:

CFLAGS=-mno-red-zone -fno-stack-protector -fpic -fshort-wchar -I/usr/include/efi/
LDFLAGS=-T /usr/lib/elf_x86_64_efi.lds -shared -Bsymbolic -L /usr/lib -l:libgnuefi.a -l:libefi.a

test.img: BOOTx64.EFI
	dd if=/dev/zero of=$@ bs=25M count=1
	parted -s $@ mklabel gpt \
		mkpart p1 fat16 0% 50% \
		mkpart p2 fat16 50% 100%
	sudo losetup -P /dev/loop0 $@
	sudo mkfs.fat -F 16 /dev/loop0p1
	sudo mkfs.fat -F 16 /dev/loop0p2
	mkdir test/
	sudo mount /dev/loop0p1 test/
	sudo mkdir test/efi/boot -p
	sudo cp $^ test/efi/boot
	sudo umount test/
	rm -rf test
	sudo losetup -d /dev/loop0

BOOTx64.EFI: bootx64.so
	objcopy -j .text -j .sdata -j .data -j .dynamic -j .dynsym -j .rel -j .rela -j .reloc --target=efi-app-x86_64 $< $@

bootx64.so: efi_main.o
	$(LD) $^ /usr/lib/crt0-efi-x86_64.o $(LDFLAGS) -o $@

efi_main.o: efi_main.c
	$(CC) $(CFLAGS) -c -o $@ $<

.PHONY: clean run

run:
	qemu-system-x86_64 -L /usr/share/edk2-ovmf/x64/ -bios OVMF.fd -drive file=test.img,format=raw
clean:
	rm efi_main.o bootx64.so BOOTx64.EFI test.img

Para criar toda a estrutura do GPT, usamos o parted. A flag -s permite que possamos colocar comandos internos do parted em uma só linha do terminal. O primeiro comando, mkpart, vai criar uma partição FAT16 chamada “p1”, que irá cobrir de 0% até 50% da área disponibilizada para as partições. Quanto ao segundo, ele irá criar uma partição FAT16 com o nome de “p2”, que irá cobrir de 50% até 100%.

Veja também que coloquei o losetup. Com ele podemos fazer que um loop device possa representar nossa imagem. Isso quer dizer que eu posso usar o /dev/loop0 para manipular indiretamente o test.img. “Tá, mas eu não posso fazer isso diretamente?” Pode sim, mas o losetup tem a flag -P, que faz o comando criar um loop device para cada partição, ou seja, podemos manipular cada partição individualmente. Veja acima que estou usando o mkfs.fat no loop0p1 e no loop0p2. Eles foram criados para a p1 e a p2.

Além disso, quando você inicia o UEFI Shell, você verá que agora existe o fs0 e o fs1. Eles são as partições p1 e p2, respectivamente. Como você colocou o BOOTx64.EFI na p1, basta executar fs0: para acessar a p2:

A partição p2 não vai ter nada dentro. Vamos criar ela apenas para testar alguns protocolos do qual iremos falar nos próximos tópicos.

3 - EFI_SIMPLE_FILE_SYSTEM_PROTOCOL

O EFI_SIMPLE_FILE_SYSTEM_PROTOCOL é um protocolo simples que possibilita o acesso a um sistema de arquivos FAT. Veja como é a estrutura:

typedef struct _EFI_SIMPLE_FILE_SYSTEM_PROTOCOL
{
  UINT64 Revision;
  EFI_SIMPLE_FILE_SYSTEM_PROTOCOL_OPEN_VOLUME OpenVolume;
} EFI_SIMPLE_FILE_SYSTEM_PROTOCOL;

Essa estrutura não é tão grande, já que tem apenas o Revision e a função OpenVolume(). Com essa função podemos ter acesso ao protocolo EFI_FILE_PROTOCOL, que irá fornecer rotinas para manipular arquivos dentro de uma partição. O protótipo do OpenVolume() é o seguinte:

typedef
EFI_STATUS
(EFIAPI *EFI_SIMPLE_FILE_SYSTEM_PROTOCOL_OPEN_VOLUME) (
  IN EFI_SIMPLE_FILE_SYSTEM PROTOCOL *This,
  OUT EFI_FILE_PROTOCOL **Root
);

O ponteiro de uma interface do EFI_FILE_PROTOCOL será retornado pelo Root.

Se buscarmos por todos os handles que tem instalado o protocolo EFI_SIMPLE_FILE_SYSTEM_PROTOCOL, podemos ver que foi gerado um handle para cada partição. Para demonstrar isso, precisamos da LocateHandleBuffer():

#include <efi.h>
#include <efilib.h>

EFI_STATUS efi_main(EFI_HANDLE image, EFI_SYSTEM_TABLE *system_table)
{
  EFI_STATUS status;
  InitializeLib(image, system_table);
 
  EFI_GUID file_system_guid = EFI_SIMPLE_FILE_SYSTEM_PROTOCOL_GUID;
  UINTN nhandles = 0;
  EFI_HANDLE *buffer; 
  status = uefi_call_wrapper(BS->LocateHandleBuffer, 5, 
                             ByProtocol,
                             &file_system_guid,
                             NULL,
                             &nhandles,
                             &buffer);
  if(EFI_ERROR(status))
  {
    Print(L"Falha ao localizar os handles.\r\n");
    return status;
  }

  Print(L"Total de handles: %d\r\n", nhandles);
  
  uefi_call_wrapper(BS->FreePool, 1, buffer);

  return EFI_SUCCESS;
}

Execute e você verá que o total de handles é 2, porque cada handle vai representar uma partição:

“Tá, mas se houver dois discos ou mais no mesmo PC?” A LocateHandleBuffer() irá retornar todas os handles que ela conseguir achar, mesmo se alguns representam partições em discos diferentes. Um modo de você diferenciar as partições é verificando se a tem o EFI_PARTITION_INFO_PROTOCOL_GUID, que tem a seguinte estrutura:

typedef struct __attribute__((packed)){
  UINT8       BootIndicator;
  UINT8       StartHead;
  UINT8       StartSector;
  UINT8       StartTrack;
  UINT8       OSIndicator;
  UINT8       EndHead;
  UINT8       EndSector;
  UINT8       EndTrack;
  UINT8       StartingLBA[4];
  UINT8       SizeInLBA[4];
} MBR_PARTITION_RECORD;

typedef struct __attribute__((packed)){
  EFI_GUID PartitionTypeGUID;
  EFI_GUID UniquePartitionGUID;
  EFI_LBA StartingLBA;
  EFI_LBA EndingLBA;
  UINT64 Attributes;
  CHAR16 PartitionName[36];
} EFI_PARTITION_ENTRY;

typedef struct __attribute__((packed)){
  UINT32 Revision;
  UINT32 Type;
  UINT8 System;
  UINT8 Reserved[7];
  union {
    ///
    /// MBR data
    ///
    MBR_PARTITION_RECORD Mbr;
    ///
    /// GPT data
    ///
    EFI_PARTITION_ENTRY Gpt;
  } Info;
} EFI_PARTITION_INFO_PROTOCOL;

Com isso, podemos verificar várias informações como o nome da partição, tipo, etc.

Verifiquei que a estrutura deste protocolo não está no gnu-efi, então precisamos colocar ela manualmente:

#ifndef __GPT_H__
#define __GPT_H__

#include <efi.h>
#include <efigpt.h> // Para incluir o EFI_PARTITION_ENTRY.

#define EFI_PARTITION_INFO_PROTOCOL_GUID \
  { 0x8cf2f62c, 0xbc9b, 0x4821, {0x80, 0x8d, 0xec, 0x9e, 0xc4, 0x21, 0xa1, 0xa0} }

#define EFI_PARTITION_INFO_PROTOCOL_REVISION 0x0001000
#define PARTITION_TYPE_OTHER 0x00
#define PARTITION_TYPE_MBR 0x01
#define PARTITION_TYPE_GPT 0x02

typedef struct __attribute__((packed)){
  UINT32 Revision;
  UINT32 Type;
  UINT8 System;
  UINT8 Reserved[7];
  union {
    ///
    /// MBR data
    ///
    MBR_PARTITION_RECORD Mbr;
    ///
    /// GPT data
    ///
    EFI_PARTITION_ENTRY Gpt;
  } Info;
} EFI_PARTITION_INFO_PROTOCOL;

#endif

Agora vamos buscar as informações dos handles acima. Para isso, iremos usar HandleProtocol():

...

#include "gpt.h"

...

  UINTN i;
  EFI_GUID gpt_guid = EFI_PARTITION_INFO_PROTOCOL_GUID;
  EFI_PARTITION_INFO_PROTOCOL *gpt;
  for(i = 0; i < nhandles; i++)
  {
    status = uefi_call_wrapper(BS->HandleProtocol, 3, buffer[i], &gpt_guid, &gpt);
    if(EFI_ERROR(status))
    {
      Print(L"Falha ao obter a interface\r\n");
      uefi_call_wrapper(BS->FreePool, 1, buffer);
      return status;
    }

    if(gpt == NULL)
      continue;

    Print(L"\r\nNome: %s\r\n", gpt->Info.Gpt.PartitionName);
    Print(L"Tamanho: %d bytes\r\n", (gpt->Info.Gpt.EndingLBA - gpt->Info.Gpt.StartingLBA) * 512);
  }

...

Resultado do código:

Pelo nome e tamanho dá para ver que, realmente, são as partições p1 e p2.

3.1 - EFI_FILE_PROTOCOL

Como eu falei acima, a interface do EFI_FILE_PROTOCOL é retornada pela OpenVolume(). Essa interface vai disponibilizar o seguinte conjunto de funções para a manipulação de arquivos na partição no qual ela representa:

typedef struct _EFI_FILE_PROTOCOL {
  UINT64 Revision;
  EFI_FILE_OPEN Open;
  EFI_FILE_CLOSE Close;
  EFI_FILE_DELETE Delete;
  EFI_FILE_READ Read;
  EFI_FILE_WRITE Write;
  EFI_FILE_GET_POSITION GetPosition;
  EFI_FILE_SET_POSITION SetPosition;
  EFI_FILE_GET_INFO GetInfo;
  EFI_FILE_SET_INFO SetInfo;
  EFI_FILE_FLUSH Flush;
  EFI_FILE_OPEN_EX OpenEx; // Added for revision 2
  EFI_FILE_READ_EX ReadEx; // Added for revision 2
  EFI_FILE_WRITE_EX WriteEx; // Added for revision 2
  EFI_FILE_FLUSH_EX FlushEx; // Added for revision 2
} EFI_FILE_PROTOCOL;

Esse protocolo não tem um GUID, portanto a única forma de obter uma interface é através do OpenVolume().

Agora que sabemos como obter os handles, podemos acessar a interface e, por exemplo, abrir um arquivo e escrever nele, veja:

...
  
  // Vamos burcar a buscar a interface para a p1 (handle[0]).
  EFI_SIMPLE_FILE_SYSTEM_PROTOCOL *file_system;
  status = uefi_call_wrapper(BS->HandleProtocol, 3,
                             buffer[0],
                             &file_system_guid,
                             &file_system); 

  if(EFI_ERROR(status))
  {
    Print(L"Falha ao obter a interface.\r\n");
    uefi_call_wrapper(BS->FreePool, 1, buffer);
    return status;
  }

  // Aqui vai ser acessado o sistema de arquivos.
  EFI_FILE_PROTOCOL *root;
  status = uefi_call_wrapper(file_system->OpenVolume, 2, 
                             file_system, 
                             &root); 
   
  if(EFI_ERROR(status))
  {
    Print(L"Falha ao obter a interface.\r\n");
    uefi_call_wrapper(BS->FreePool, 1, buffer);
    return status;
  }
  
  EFI_FILE_PROTOCOL *test_file;
  
  // Abre o arquivo. Se não existir, então cria ele.
  status = uefi_call_wrapper(root->Open, 5, 
                             root, &test_file, 
                             L"\\efi\\boot\\test.txt",
                             EFI_FILE_MODE_WRITE | 
                             EFI_FILE_MODE_READ  | 
                             EFI_FILE_MODE_CREATE,
                             EFI_FILE_ARCHIVE);
  if(EFI_ERROR(status))
  {
    Print(L"Falha ao abrir o arquivo.\r\n");
    uefi_call_wrapper(BS->FreePool, 1, buffer);
    return status;
  }

  // Escreve o conteudo "Particao p1" no arquivo .txt.
  char content[] = "Particao p1";
  UINTN content_size = sizeof content;
  uefi_call_wrapper(test_file->Write, 3,
                    test_file,
                    &content_size,
                    content);
   
  uefi_call_wrapper(root->Close, 1, root);
  uefi_call_wrapper(test_file->Close, 1, test_file);
  uefi_call_wrapper(BS->FreePool, 1, buffer);

...

Primeiramente, veja que usamos o OpenVolume() para “abrir” uma partição. Com isso, uma interface do EFI_FILE_PROTOCOL será atribuída ao root. Coloquei o nome root, porque essa interface irá representar o diretório raiz (root) da partição. A partir disso, podemos abrir arquivos com o Open(). O protótipo dessa função é esse:

typedef
EFI_STATUS
(EFIAPI *EFI_FILE_OPEN) (
  IN EFI_FILE_PROTOCOL *This,
  OUT EFI_FILE_PROTOCOL **NewHandle,
  IN CHAR16 *FileName,
  IN UINT64 OpenMode,
  IN UINT64 Attributes
);

Note que essa função também retorna uma instância de uma interface do EFI_FILE_PROTOCOL, que é passada por meio do NewHandle. Essa interface irá representar o arquivo (test.txt) que estamos tentando abrir.

O FileName, como você pode ver no exemplo, é o path do arquivo (L"\\efi\\boot\\test.txt"). Temos que especificar o path completo, ou seja, se eu colocar L"text.txt", o arquivo será procurado no diretório raiz e não no diretório do BOOTx64.EFI.

No OpenMode temos que indicar o modo ou permissão de como queremos abrir o arquivo. No exemplo acima, usamos as flags EFI_FILE_MODE_WRITE para o arquivo ter permissão de escrita e EFI_FILE_MODE_READ, para permissão de leitura. A EFI_FILE_MODE_CREATE serve para dizer que o arquivo deve ser criado caso ele não exista.

Quanto ao Attributes, teremos que dizer o tipo de arquivo. Como queremos abrir um arquivo comum, será usando a flag EFI_FILE_ARCHIVE.

Para escrever no arquivo, usamos a Write():

typedef
EFI_STATUS
(EFIAPI *EFI_FILE_WRITE) (
  IN EFI_FILE_PROTOCOL *This,
  IN OUT UINTN *BufferSize,
  IN VOID *Buffer
);

É uma função bem simples. O BufferSize irá conter o tamanho do buffer que armazena o conteúdo que será escrito no arquivo. O buffer, claro, irá apontar para o conteúdo.

O resultado do nosso exemplo acima será o seguinte:

O test.txt não existia, porém foi criado, já que indiquei, com a flag EFI_FILE_MODE_CREATE, que o arquivo deverá ser criado caso não exista.

Como usamos o primeiro handle do buffer na hora de buscar a interface do EFI_SIMPLE_FILE_SYSTEM_PROTOCOL, então tudo isso irá acontecer somente na partição representada por esse handle, ou seja, a p1. Experimente trocar o índice de 0 para 1, na HandleProtocol(), e ajuste o path:

...

  EFI_SIMPLE_FILE_SYSTEM_PROTOCOL *file_system;
  status = uefi_call_wrapper(BS->HandleProtocol, 3,
                             buffer[1],
                             &file_system_guid,
                             &file_system);

...

  status = uefi_call_wrapper(root->Open, 5,
                             root, &test_file,
                             L"test.txt",
                             EFI_FILE_MODE_WRITE |
                             EFI_FILE_MODE_READ  |
                             EFI_FILE_MODE_CREATE,
                             EFI_FILE_ARCHIVE);
...

Você verá que o arquivo agora será criado na partição p2, mesmo que o código executado fique na p1:

Não se esqueça de fechar o root e test_file após usá-los.

Existe também as funções Read(), Delete(), SetPosition(), GetPosition(), etc, entretanto não falarei delas em detalhes aqui.

4 - EFI_DISK_IO_PROTOCOL e EFI_BLOCK_IO_PROTOCOL

Para lidar, diretamente, com o disco, existem os protocolos EFI_DISK_IO_PROTOCOL e EFI_BLOCK_IO_PROTOCOL. Ambos podem ser usados, por exemplo, para ler e escrever em um determinado offset. O EFI_DISK_IO_PROTOCOL é bem simples:

typedef struct _EFI_DISK_IO_PROTOCOL {
  UINT64 Revision;
  EFI_DISK_READ ReadDisk;
  EFI_DISK_WRITE WriteDisk;
} EFI_DISK_IO_PROTOCOL;

As únicas duas funções são a ReadDisk() para ler o disco, e o WriteDisk() para escrever.

O EFI_BLOCK_IO_PROTOCOL, por outro lado, tem um estrutura maior:

typedef struct _EFI_BLOCK_IO_PROTOCOL {
  UINT64 Revision;
  EFI_BLOCK_IO_MEDIA *Media;
  EFI_BLOCK_RESET Reset;
  EFI_BLOCK_READ ReadBlocks;
  EFI_BLOCK_WRITE WriteBlocks;
  EFI_BLOCK_FLUSH FlushBlocks;
} EFI_BLOCK_IO_PROTOCOL;

Perceba que ela tem as funções ReadBlocks() e WriteBlocks(), que são usadas para leitura e escrita do disco, respectivamente.

Esses protocolos têm uma coisa em comum com o EFI_FILE_PROTOCOL: O firmware também gera um handle para cada partição. Por exemplo, execute esse código:

...

  EFI_GUID disk_io_guid = EFI_DISK_IO_PROTOCOL_GUID;
  UINTN nhandles = 0;
  EFI_HANDLE *buffer;
  status = uefi_call_wrapper(BS->LocateHandleBuffer, 5,
                             ByProtocol,
                             &disk_io_guid,
                             NULL,
                             &nhandles,
                             &buffer);
  if(EFI_ERROR(status))
  {
    Print(L"Falha ao localizar os handles.\r\n");
    return status;
  }

  Print(L"Total de handles: %d\r\n", nhandles);

...

Se você compilar e executar, vai ver que o total de handles não é 2:

Dois deles são para as partições existentes. Isso quer dizer, por exemplo, que você só pode acessar os dados que estão entre StartingLBA e EndingLBA. Além disso, eles tem o EFI_PARTITION_INFO_PROTOCOL instalado. É mais ou menos a mesma coisa que acontece com o EFI_FILE_PROTOCOL. Existe também um handle, entre esses 4, que é para o disco inteiro, ou seja, você pode fazer o que quiser desde o ínicio até o final da memória, portanto você não é limitado a uma partição.

Há também entensões desses protocolos. Eles são EFI_DISK_IO2_PROTOCOL e o EFI_BLOCK_IO2_PROTOCOL. Não vou explorar eles em detalhes. Meu objetivo aqui foi mostrar a relação dos handles com as partições, sistema de arquivos e protocolos. Recomendo a leitura da especificação para você entender melhor o funcionamento desses últimos protocolos.

Referências

Unified Extensible Firmware Interface (UEFI) Specification

GNU-EFI Source Code