Rez Moss

Rez Moss

Digital Reflections: Exploring Tech, Innovation & Ideas

Building a Disk Partition Analyzer in Go

Jun 2025

Ever wondered what happens when you run fdisk -l or open Disk Management on Windows? These tools read raw disk data to show you partition information. Today we’ll build our own partition analyzer in Go that can parse both MBR and GPT partition tables.

Before writing code, we need to understand what we’re parsing. Every disk starts with a partition table that tells the operating system how the disk is divided up.

The Master Boot Record (MBR)

The MBR is the legacy partitioning scheme, limited to 4 primary partitions and 2TB disks. It lives in the first 512 bytes of the disk:

  • Bytes 0-445: Boot code
  • Bytes 446-509: Four 16-byte partition entries
  • Bytes 510-511: Magic signature (0x55AA)

Each partition entry contains: - Boot flag (1 byte) - Starting CHS address (3 bytes) - Partition type (1 byte) - Ending CHS address (3 bytes) - Starting LBA (4 bytes) - Size in sectors (4 bytes)

The GUID Partition Table (GPT)

GPT is the modern replacement for MBR, supporting 128 partitions and disks larger than 2TB:

  • LBA 0: Protective MBR (partition type 0xEE)
  • LBA 1: GPT header (512 bytes)
  • LBA 2+: Partition entries (128 bytes each)

The GPT header starts with “EFI PART” signature and contains metadata about the partition table, including the number of partitions and their location.

Setting Up Our Go Project

Let’s start by defining the data structures we’ll need:

package main

import (
	"encoding/binary"
	"fmt"
	"os"
)

const (
	MBR_SIGNATURE = 0xAA55
	GPT_SIGNATURE = "EFI PART"
	SECTOR_SIZE   = 512
)

// MBR Partition Entry (16 bytes)
type MBRPartition struct {
	Status     uint8
	StartCHS   [3]uint8
	Type       uint8
	EndCHS     [3]uint8
	StartLBA   uint32
	SizeBlocks uint32
}

// GPT Header (first 92 bytes we care about)
type GPTHeader struct {
	Signature          [8]byte
	Revision           uint32
	HeaderSize         uint32
	HeaderCRC32        uint32
	Reserved           uint32
	CurrentLBA         uint64
	BackupLBA          uint64
	FirstUsableLBA     uint64
	LastUsableLBA      uint64
	DiskGUID           [16]byte
	PartitionTableLBA  uint64
	NumPartitions      uint32
	PartitionEntrySize uint32
	PartitionTableCRC  uint32
}

// GPT Partition Entry (128 bytes)
type GPTPartition struct {
	TypeGUID      [16]byte
	PartitionGUID [16]byte
	StartLBA      uint64
	EndLBA        uint64
	Attributes    uint64
	Name          [72]byte // UTF-16LE
}

These structs map directly to the on-disk format. Notice how we use fixed-size arrays for binary data and specific integer types that match the disk layout.

The encoding/binary package will help us convert the raw bytes into these structured types, handling endianness correctly.

Reading and Validating the MBR

Now let’s implement the core logic to read a disk image and determine what type of partition table it uses:

func main() {
	if len(os.Args) != 2 {
		fmt.Printf("Usage: %s <disk_image_file>\n", os.Args[0])
		os.Exit(1)
	}

	filename := os.Args[1]
	file, err := os.Open(filename)
	if err != nil {
		fmt.Printf("Error opening file: %v\n", err)
		os.Exit(1)
	}
	defer file.Close()

	// Read first sector (MBR)
	mbr := make([]byte, SECTOR_SIZE)
	n, err := file.Read(mbr)
	if err != nil || n != SECTOR_SIZE {
		fmt.Printf("Error reading MBR: %v\n", err)
		os.Exit(1)
	}

	// Check MBR signature
	signature := binary.LittleEndian.Uint16(mbr[510:512])
	if signature != MBR_SIGNATURE {
		fmt.Println("Invalid MBR signature")
		os.Exit(1)
	}

	fmt.Printf("Disk Image: %s\n", filename)
	fmt.Println("=" + string(make([]byte, len(filename)+12)))

	// Check if it's GPT by looking at partition type of first entry
	firstPartType := mbr[446+4] // First partition type byte
	if firstPartType == 0xEE {
		// This is GPT
		fmt.Println("Partition Table Type: GPT")
		readGPTPartitions(file)
	} else {
		// This is MBR
		fmt.Println("Partition Table Type: MBR")
		readMBRPartitions(mbr)
	}
}

This main function handles the basic workflow by first parsing the command line to expect a single argument representing the disk image file. It then performs essential file operations by opening the specified file and reading the first 512 bytes, which corresponds to sector 0 of the disk. The function validates the Master Boot Record by checking for the magic signature 0xAA55 located at bytes 510-511, ensuring the sector contains valid boot record data.

The partition table detection process involves examining the first partition’s type byte to determine the disk format. If this byte contains the value 0xEE, it indicates the presence of a GPT protective MBR. The key insight is that GPT disks maintain backward compatibility by including an MBR in sector 0, but this MBR contains only a single partition entry of type 0xEE that spans the entire disk. This protective mechanism prevents older disk management tools from accidentally corrupting the more advanced GPT partition data structure.

Parsing MBR Partitions

Let’s implement the MBR parser first since it’s simpler:

func readMBRPartitions(mbr []byte) {
	fmt.Println("\nPartitions:")
	fmt.Printf("%-4s %-8s %-12s %-12s %-12s %s\n",
		"#", "Status", "Type", "Start LBA", "Size", "Description")
	fmt.Println(string(make([]byte, 70)))

	partCount := 0
	for i := 0; i < 4; i++ {
		offset := 446 + (i * 16)

		status := mbr[offset]
		partType := mbr[offset+4]
		startLBA := binary.LittleEndian.Uint32(mbr[offset+8 : offset+12])
		sizeBlocks := binary.LittleEndian.Uint32(mbr[offset+12 : offset+16])

		if partType != 0 {
			partCount++
			statusStr := "Inactive"
			if status == 0x80 {
				statusStr = "Active"
			}

			sizeGB := float64(sizeBlocks*SECTOR_SIZE) / (1024 * 1024 * 1024)
			typeDesc := getMBRTypeDescription(partType)

			fmt.Printf("%-4d %-8s 0x%-10X %-12d %-12.2f %s\n",
				i+1, statusStr, partType, startLBA, sizeGB, typeDesc)
		}
	}

	if partCount == 0 {
		fmt.Println("No partitions found")
	}
}

The MBR parser iterates through all 4 possible partition entries, starting at byte 446. For each entry, we extract the status byte (0x80 means bootable), partition type, starting LBA, and size in sectors. We convert the size to GB for better readability.

Parsing GPT Partitions

GPT parsing is more complex because we need to read the header first to understand the partition table layout:

func readGPTPartitions(file *os.File) {
	// Read GPT header from LBA 1
	file.Seek(SECTOR_SIZE, 0)
	headerBytes := make([]byte, 512)
	n, err := file.Read(headerBytes)
	if err != nil || n != 512 {
		fmt.Printf("Error reading GPT header: %v\n", err)
		return
	}

	// Parse GPT header
	var header GPTHeader
	header.Signature = *(*[8]byte)(headerBytes[0:8])
	header.Revision = binary.LittleEndian.Uint32(headerBytes[8:12])
	header.HeaderSize = binary.LittleEndian.Uint32(headerBytes[12:16])
	header.NumPartitions = binary.LittleEndian.Uint32(headerBytes[80:84])
	header.PartitionEntrySize = binary.LittleEndian.Uint32(headerBytes[84:88])
	header.PartitionTableLBA = binary.LittleEndian.Uint64(headerBytes[72:80])

	// Verify GPT signature
	if string(header.Signature[:]) != GPT_SIGNATURE {
		fmt.Println("Invalid GPT signature")
		return
	}

	fmt.Printf("GPT Revision: %d.%d\n", header.Revision>>16, header.Revision&0xFFFF)
	fmt.Printf("Number of Partitions: %d\n", header.NumPartitions)

	// Read partition entries
	file.Seek(int64(header.PartitionTableLBA*SECTOR_SIZE), 0)

	fmt.Println("\nPartitions:")
	fmt.Printf("%-4s %-12s %-12s %-12s %s\n",
		"#", "Start LBA", "End LBA", "Size", "Name")
	fmt.Println(string(make([]byte, 60)))

	partCount := 0
	for i := uint32(0); i < header.NumPartitions; i++ {
		partBytes := make([]byte, header.PartitionEntrySize)
		n, err := file.Read(partBytes)
		if err != nil || uint32(n) != header.PartitionEntrySize {
			break
		}

		// Check if partition entry is used (non-zero type GUID)
		allZero := true
		for j := 0; j < 16; j++ {
			if partBytes[j] != 0 {
				allZero = false
				break
			}
		}

		if !allZero {
			partCount++
			startLBA := binary.LittleEndian.Uint64(partBytes[32:40])
			endLBA := binary.LittleEndian.Uint64(partBytes[40:48])

			// Convert UTF-16LE name to string (simplified)
			name := ""
			for j := 56; j < 56+72; j += 2 {
				if partBytes[j] == 0 && partBytes[j+1] == 0 {
					break
				}
				if partBytes[j+1] == 0 {
					name += string(partBytes[j])
				}
			}
			if name == "" {
				name = "Unnamed"
			}

			sizeGB := float64((endLBA-startLBA+1)*SECTOR_SIZE) / (1024 * 1024 * 1024)

			fmt.Printf("%-4d %-12d %-12d %-12.2f %s\n",
				partCount, startLBA, endLBA, sizeGB, name)
		}
	}

	if partCount == 0 {
		fmt.Println("No partitions found")
	}
}

The GPT parser operates through a systematic approach that begins with header parsing by reading LBA 1 and extracting essential fields such as the number of partitions and their storage location. Then performs signature verification to ensure the header starts with the “EFI PART” identifier, confirming the presence of a valid GPT structure. Unlike the MBR format which is limited to four fixed partition entries, GPT supports dynamic partition reading with the capability to handle up to 128 partitions, providing significantly more flexibility for disk organization.

The parser must also handle UTF-16 name conversion since GPT partition names are stored in UTF-16LE format, requiring basic conversion logic to make them readable. The most challenging aspect involves detecting which partitions are actually in use, as GPT reserves space for all possible partition entries regardless of whether they contain data. The system determines partition validity by examining the first 16 bytes of each entry, which contains the type GUID. Unused partition entries will have all-zero type GUIDs, while valid partitions contain meaningful GUID values that identify their purpose and format.

Adding Partition Type Descriptions

To make our output more readable, we need a lookup table for MBR partition types:

func getMBRTypeDescription(partType uint8) string {
	descriptions := map[uint8]string{
		0x00: "Empty",
		0x01: "FAT12",
		0x04: "FAT16 <32M",
		0x05: "Extended",
		0x06: "FAT16",
		0x07: "HPFS/NTFS/exFAT",
		0x0B: "W95 FAT32",
		0x0C: "W95 FAT32 (LBA)",
		0x0E: "W95 FAT16 (LBA)",
		0x0F: "W95 Ext'd (LBA)",
		0x11: "Hidden FAT12",
		0x14: "Hidden FAT16 <32M",
		0x16: "Hidden FAT16",
		0x17: "Hidden HPFS/NTFS",
		0x1B: "Hidden W95 FAT32",
		0x1C: "Hidden W95 FAT32 (LBA)",
		0x1E: "Hidden W95 FAT16 (LBA)",
		0x82: "Linux swap",
		0x83: "Linux",
		0x85: "Linux extended",
		0x8E: "Linux LVM",
		0xA0: "Hibernation",
		0xA5: "FreeBSD",
		0xA6: "OpenBSD",
		0xA8: "Darwin UFS",
		0xA9: "NetBSD",
		0xAB: "Darwin boot",
		0xAF: "HFS / HFS+",
		0xBE: "Solaris boot",
		0xBF: "Solaris",
		0xEB: "BeOS fs",
		0xEE: "GPT",
		0xEF: "EFI (FAT-12/16/32)",
		0xFB: "VMware VMFS",
		0xFC: "VMware VMKCORE",
		0xFD: "Linux raid autodetect",
	}

	if desc, exists := descriptions[partType]; exists {
		return desc
	}
	return "Unknown"
}

This lookup table covers the most common partition types you’ll encounter. The 0xEE type is particularly important as it indicates a GPT protective MBR.

Usage

Now that we have a complete partition analyzer, let’s see it in action. Compile the program:

go build -o partition-analyzer main.go

Test it on various disk images:

# Analyze a disk image
./partition-analyzer /dev/sda
./partition-analyzer disk.img

# Example output for MBR disk:
Disk Image: mbr-disk.img
=======================
Partition Table Type: MBR

Partitions:
#    Status   Type         Start LBA    Size         Description
----------------------------------------------------------------------
1    Active   0x7          2048         50.00        HPFS/NTFS/exFAT
2    Inactive 0x83         104857600    25.50        Linux
3    Inactive 0x82         158007296    2.00         Linux swap

# Example output for GPT disk:
Disk Image: gpt-disk.img
=======================
Partition Table Type: GPT
GPT Revision: 1.0
Number of Partitions: 128

Partitions:
#    Start LBA    End LBA      Size         Name
------------------------------------------------------------
1    2048         1050623      512.00       EFI System Partition
2    1050624      210765823    100.00       Microsoft Basic Data
3    210765824    214960127    2.00         Linux swap

Beyond the Basics

This partition analyzer provides a solid foundation for more advanced disk utilities. You could extend it to:

  • Validate checksums: GPT headers and partition tables have CRC32 checksums
  • Handle extended partitions: MBR supports logical partitions within extended partitions
  • Parse filesystem headers: Read filesystem metadata from each partition
  • Repair corrupted tables: Use backup GPT header to recover from corruption
  • Support other architectures: Handle big-endian systems and different sector sizes

The core concepts remain the same: read binary data, validate signatures, and parse structured information. Understanding these fundamentals gives you the tools to work with any disk format or binary file structure.

Building tools like this gives you deep insight into how operating systems manage storage. Every time you format a drive, create a partition, or boot your computer, similar code is reading and writing these exact data structures.

You can find code for this project on https://github.com/rezmoss/partition-analyzer.

Web assembly Version

Want to try this partition analyzer without installing anything? I’ve compiled this exact Go code to WebAssembly and created a browser-based disk analyzer tool that runs entirely in your browser. Upload any disk image and analyze its partition table instantly - no server uploads required, everything runs locally using the same logic we built here. It’s a perfect demonstration of how systems programming tools can be made accessible through modern web technologies while maintaining the performance of compiled code.