ZetCode

Perl parsing binary formats

last modified November 7, 2026

Binary file formats describe how values are laid out as bytes: a signature at the start, then headers with fields of fixed size, often followed by a sequence of chunks that each carry their own length. Perl's unpack function turns such byte sequences into numbers and strings with a single template, and pack builds them. In this article we parse several common formats using only core modules.

The Perl binary files article covers reading files with :raw, read and seek; the Perl hex dump article shows how to look at the bytes. The sample PNG and BMP images were created with ImageMagick, and the ZIP archive with the zip tool. The examples were run with Perl 5.44 on Linux.

Byte order

A number larger than one byte can be stored with its most significant byte first (big-endian, also called network order) or its least significant byte first (little-endian). Each format defines its byte order: PNG uses big-endian, while BMP, WAV and ZIP, which come from the PC world, use little-endian.

endian.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;

my $bytes = pack 'C4', 0x12, 0x34, 0x56, 0x78;

printf "n  big-endian 16:     0x%04x\n", unpack 'n', $bytes;
printf "v  little-endian 16:  0x%04x\n", unpack 'v', $bytes;
printf "N  big-endian 32:     0x%08x\n", unpack 'N', $bytes;
printf "V  little-endian 32:  0x%08x\n", unpack 'V', $bytes;
printf "L  native 32:         0x%08x\n", unpack 'L', $bytes;

my $neg = pack 'C2', 0xff, 0xfe;
say "v  unsigned:  ", unpack 'v', $neg;
say "s< signed LE: ", unpack 's<', $neg;
say "s> signed BE: ", unpack 's>', $neg;

say "q< 64-bit LE: ", unpack 'q<', pack 'C8', (0xff) x 8;
say "f< float LE:  ", unpack 'f<', pack 'V', 0x40490fdb;

say unpack 'H*', pack 'n', 2026;
say unpack 'H*', pack 'v', 2026;
say unpack 'H*', pack 'l>', -2;

The templates n and N read unsigned 16-bit and 32-bit big-endian numbers, and v and V read little-endian ones. Native templates such as L use the byte order of the machine, which is little-endian on x86, so they should not be used for file formats. For signed values and other sizes, the < and > modifiers force little- or big-endian order: s< is a signed 16-bit little-endian number, q< a signed 64-bit one and f< a little-endian float. The same bytes ff fe are 65279, -257 or -2 depending on the template. The last three lines show the packing direction: 2026 is 07ea in big-endian order.

$ perl endian.pl
n  big-endian 16:     0x1234
v  little-endian 16:  0x3412
N  big-endian 32:     0x12345678
V  little-endian 32:  0x78563412
L  native 32:         0x78563412
v  unsigned:  65279
s< signed LE: -257
s> signed BE: -2
q< 64-bit LE: -1
f< float LE:  3.14159274101257
07ea
ea07
fffffffe

Writing a WAV file

Before reading formats, we create one. A WAV file is a RIFF container: a 12-byte header followed by chunks, each with a four-character id and a 32-bit little-endian length. A minimal file has a fmt chunk describing the audio and a data chunk with the samples.

wav.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;

use constant PI => 4 * atan2(1, 1);

my ($rate, $secs, $freq) = (8000, 0.5, 440);
my $count = $rate * $secs;

my $samples = pack 's<*',
    map { int(12000 * sin(2 * PI * $freq * $_ / $rate)) } 0 .. $count - 1;

my ($channels, $bits) = (1, 16);
my $align = $channels * $bits / 8;

my $fmt = pack 'a4 V v v V V v v', 'fmt ', 16,
    1, $channels, $rate, $rate * $align, $align, $bits;
my $data = pack 'a4 V', 'data', length $samples;

my $body = 'WAVE' . $fmt . $data . $samples;

open my $fh, '>:raw', 'tone.wav' or die "Cannot open tone.wav: $!";
print $fh pack('a4 V', 'RIFF', length $body), $body;
close $fh;

say "wrote tone.wav, ", -s 'tone.wav', " bytes";

The program generates half a second of a 440 Hz sine tone at 8,000 samples per second. Each sample is a signed 16-bit little-endian integer, so the samples are packed with s<*. The fmt chunk holds the format code 1 (uncompressed PCM), the number of channels, the sample rate, the bytes per second, the bytes per sample frame and the bits per sample. The a4 template writes the four-character ids. The RIFF size field counts everything after itself, so the body is built first and its length is packed into the header. The file has 44 bytes of headers and 8,000 bytes of samples.

$ perl wav.pl
wrote tone.wav, 8044 bytes

Detecting file types by magic numbers

Most binary formats start with a fixed signature, called a magic number. Tools such as file identify files by these bytes rather than by their extension.

magic.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;

my @signatures = (
    ["\x89PNG\r\n\x1a\n", 'PNG image'],
    ["\xff\xd8\xff", 'JPEG image'],
    ['GIF87a', 'GIF image'],
    ['GIF89a', 'GIF image'],
    ['BM', 'BMP image'],
    ["PK\x03\x04", 'ZIP archive'],
    ["\x1f\x8b", 'gzip data'],
    ['%PDF-', 'PDF document'],
    ["\x7fELF", 'ELF executable'],
);

for my $file (@ARGV) {
    open my $fh, '<:raw', $file or do { warn "Cannot open $file: $!\n"; next };
    my $head = '';
    read $fh, $head, 16;
    close $fh;

    my $type = 'unknown';
    for my $sig (@signatures) {
        my ($magic, $name) = @$sig;
        if (substr($head, 0, length $magic) eq $magic) {
            $type = $name;
            last;
        }
    }

    if ($type eq 'unknown' && substr($head, 0, 4) eq 'RIFF') {
        $type = 'RIFF ' . substr($head, 8, 4) . ' container';
    }

    printf "%-12s %-20s %s\n", $file, $type, unpack 'H16', $head;
}

The program reads the first 16 bytes of each file and compares them with a list of known signatures. The PNG signature contains a carriage return, a newline and a Ctrl-Z byte, so that a file transferred in text mode is detected as corrupted. RIFF files share one signature, so their form type at offset 8 tells whether the container holds WAVE audio, AVI video or WebP images. The last column shows the first eight bytes in hexadecimal.

$ perl magic.pl sample.png sample.bmp sample.zip tone.wav notes.txt /bin/ls
sample.png   PNG image            89504e470d0a1a0a
sample.bmp   BMP image            424d061200000000
sample.zip   ZIP archive          504b030414000000
tone.wav     RIFF WAVE container  52494646641f0000
notes.txt    unknown              5065726c20697320
/bin/ls      ELF executable       7f454c4602010100

Walking PNG chunks

After its 8-byte signature, a PNG file is a list of chunks. Each chunk has a 4-byte big-endian length, a 4-byte type, the data, and a CRC-32 checksum of the type and data. The first chunk, IHDR, holds the image header, and IEND marks the end.

png.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;

use Compress::Zlib qw(crc32);

my $file = shift // 'sample.png';
open my $fh, '<:raw', $file or die "Cannot open $file: $!";

my $sig;
read $fh, $sig, 8;
die "$file is not a PNG file\n" unless $sig eq "\x89PNG\r\n\x1a\n";

my %colour = (0 => 'grey', 2 => 'RGB', 3 => 'palette', 4 => 'grey+alpha', 6 => 'RGBA');

while (1) {
    my $head;
    read $fh, $head, 8 or last;
    my ($len, $type) = unpack 'N a4', $head;

    my ($data, $crc) = ('', '');
    read $fh, $data, $len;
    read $fh, $crc, 4;

    my $ok = crc32($type . $data) == unpack 'N', $crc;
    printf "%s %6d bytes  crc %s\n", $type, $len, $ok ? 'ok' : 'BAD';

    if ($type eq 'IHDR') {
        my ($w, $h, $depth, $ctype, undef, undef, $inter) = unpack 'N N C C C C C', $data;
        say "     ${w}x$h, $depth-bit $colour{$ctype}, ",
            $inter ? 'interlaced' : 'not interlaced';
    }

    last if $type eq 'IEND';
}

close $fh;

The template N a4 reads the length and type of a chunk. The program reads the data and checksum and verifies the checksum with the crc32 function from the core Compress::Zlib module. The IHDR data is unpacked with N N C C C C C: the width and height, then single bytes for the bit depth, colour type, compression method, filter method and interlace method. Since chunks carry their own length, the parser can skip chunks it does not understand, which lets the format be extended without breaking old readers. ImageMagick stored our two-colour gradient as an indexed image, so it has a PLTE palette chunk; the compressed pixels are in IDAT.

$ perl png.pl
IHDR     13 bytes  crc ok
     120x80, 8-bit palette, not interlaced
PLTE    240 bytes  crc ok
IDAT    200 bytes  crc ok
IEND      0 bytes  crc ok

Reading a BMP header

A BMP file starts with a 14-byte file header followed by a 40-byte information header. All numbers are little-endian.

bmp.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;

use Fcntl qw(:seek);

my $file = shift // 'sample.bmp';
open my $fh, '<:raw', $file or die "Cannot open $file: $!";

my $hdr;
read $fh, $hdr, 54;

my ($magic, $size, $offset) = unpack 'a2 V x4 V', $hdr;
die "$file is not a BMP file\n" unless $magic eq 'BM';

my ($dib, $width, $height, $planes, $bpp, $compr, $imgsize) =
    unpack 'x14 V l< l< v v V V', $hdr;

say "file size:   $size (on disk ", -s $fh, ")";
say "pixel data:  offset $offset, $imgsize bytes";
say "header:      $dib bytes";
say "dimensions:  ${width}x", abs $height, ($height > 0 ? ' bottom-up' : ' top-down');
say "bits/pixel:  $bpp, compression $compr";

my $row = int(($width * $bpp + 31) / 32) * 4;
say "row size:    $row bytes (", $row - $width * $bpp / 8, " padding)";

seek $fh, $offset, SEEK_SET or die "Cannot seek: $!";
my $px;
read $fh, $px, 3;
my ($blue, $green, $red) = unpack 'C3', $px;
printf "first pixel: #%02x%02x%02x\n", $red, $green, $blue;

close $fh;

The file header is read with a2 V x4 V: the BM signature, the file size, four reserved bytes skipped with x4, and the offset of the pixel data. The second unpack skips the file header with x14 and reads the information header. The width and height are signed (l<), because a negative height means that the rows are stored from top to bottom. Each pixel row is padded to a multiple of four bytes: 50 pixels of 3 bytes take 150 bytes, so every row has 2 bytes of padding. Pixels are stored in blue, green, red order, so the bytes are reversed when the colour is printed.

$ perl bmp.pl
file size:   4614 (on disk 4614)
pixel data:  offset 54, 4560 bytes
header:      40 bytes
dimensions:  50x30 bottom-up
bits/pixel:  24, compression 0
row size:    152 bytes (2 padding)
first pixel: #ffa500

Parsing RIFF chunks

A generic RIFF parser walks the chunks one after another and decodes those it knows. We use it to read back the WAV file created earlier.

riff.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;

my $file = shift // 'tone.wav';
open my $fh, '<:raw', $file or die "Cannot open $file: $!";

my $head;
read $fh, $head, 12;
my ($riff, $size, $form) = unpack 'a4 V a4', $head;
die "$file is not a RIFF file\n" unless $riff eq 'RIFF';
say "$riff $form, $size bytes";

my %fmt;

while (read $fh, my $ch, 8) {
    my ($id, $len) = unpack 'a4 V', $ch;
    my $chunk = '';
    read $fh, $chunk, $len;
    read $fh, my $pad, 1 if $len % 2;

    say "chunk '$id' $len bytes";

    if ($id eq 'fmt ') {
        @fmt{qw(format channels rate byterate align bits)} = unpack 'v v V V v v', $chunk;
        say "  format $fmt{format}, $fmt{channels} channel(s), ",
            "$fmt{rate} Hz, $fmt{bits} bits";
    } elsif ($id eq 'data') {
        my @s = unpack 's<*', $chunk;
        my ($min, $max) = (sort { $a <=> $b } @s)[0, -1];
        printf "  %d samples, %.3f s, peak %d / %d\n",
            scalar @s, $len / $fmt{byterate}, $min, $max;
    }
}

close $fh;

Each iteration reads the 8-byte chunk header with a4 V and then the chunk data. RIFF chunks are aligned to two bytes, so a chunk of odd length is followed by one padding byte, which the parser skips. The fields of the fmt chunk are unpacked directly into a hash slice. The data chunk is unpacked with s<* into a list of samples; the duration is the data length divided by the byte rate. The peak values match the amplitude used when the tone was generated.

$ perl riff.pl
RIFF WAVE, 8036 bytes
chunk 'fmt ' 16 bytes
  format 1, 1 channel(s), 8000 Hz, 16 bits
chunk 'data' 8000 bytes
  4000 samples, 0.500 s, peak -12000 / 12000

Listing and extracting a ZIP archive

A ZIP archive is read from the end. The end of central directory record, which starts with PK\x05\x06, gives the location of the central directory; the central directory lists every file with its sizes, checksum and the offset of its local header, which is followed by the compressed data.

zip.pl
#!/usr/bin/perl

use strict;
use warnings;
use v5.34.0;

use IO::Uncompress::RawInflate qw(rawinflate $RawInflateError);
use Compress::Zlib qw(crc32);

my $file = shift // 'sample.zip';
open my $fh, '<:raw', $file or die "Cannot open $file: $!";
my $zip = do { local $/; <$fh> };
close $fh;

my $eocd = rindex $zip, "PK\x05\x06";
die "$file: end of central directory not found\n" if $eocd < 0;

my ($entries, $cd_size, $cd_off) = unpack 'x10 v V V', substr $zip, $eocd, 22;
say "$entries entries, central directory at $cd_off ($cd_size bytes)";

my $pos = $cd_off;

for (1 .. $entries) {
    my ($sig, $method, $time, $date, $crc, $csize, $usize, $nlen, $xlen, $clen, $local)
        = unpack 'a4 x6 v v v V V V v v v x8 V', substr $zip, $pos, 46;
    die "Bad central directory entry\n" unless $sig eq "PK\x01\x02";

    my $name = substr $zip, $pos + 46, $nlen;
    $pos += 46 + $nlen + $xlen + $clen;

    my $stamp = sprintf '%04d-%02d-%02d %02d:%02d',
        ($date >> 9) + 1980, ($date >> 5) & 0x0f, $date & 0x1f,
        $time >> 11, ($time >> 5) & 0x3f;

    my ($lnlen, $lxlen) = unpack 'x26 v v', substr $zip, $local, 30;
    my $raw = substr $zip, $local + 30 + $lnlen + $lxlen, $csize;

    my $content;
    if ($method == 8) {
        rawinflate(\$raw => \$content) or die "inflate failed: $RawInflateError\n";
    } elsif ($method == 0) {
        $content = $raw;
    } else {
        die "Unsupported method $method\n";
    }

    my $ok = crc32($content) == $crc ? 'crc ok' : 'crc BAD';
    printf "%-10s %s  %5d -> %4d  %s\n", $name, $stamp, $usize, $csize, $ok;
}

The program loads the small archive into memory and finds the end record with rindex, searching from the end because the record may be followed by a comment. From the end record it reads the number of entries and the size and offset of the central directory. Each central directory entry is 46 bytes followed by the file name, an extra field and a comment, whose lengths are in the entry. The x items in the template skip fields we do not need.

The modification time is stored in the MS-DOS format, where bit fields hold the parts of the date and time: the year since 1980 in the top seven bits of the date, the month in the next four and the day in the lowest five. Shifts and masks extract them. The local header has variable-length fields as well, so we read their lengths at offset 26 to find the file data. Method 8 is deflate, which rawinflate from IO::Uncompress::RawInflate decompresses; method 0 means the file is stored without compression. Finally the CRC-32 of the extracted content is compared with the checksum from the archive. The unzip -v command reports the same sizes and dates.

$ perl zip.pl
2 entries, central directory at 175 (110 bytes)
notes.txt  2026-11-07 10:30   1220 ->   72  crc ok
users.csv  2026-11-07 10:30     25 ->   25  crc ok

Best practices

In this article we have parsed binary formats in Perl. We have used byte order templates, written a WAV file with pack, detected file types by magic numbers, walked PNG chunks and checked their CRCs, read a BMP header, parsed RIFF chunks, and listed and extracted a ZIP archive.

Source

perlpacktut · perlfunc: pack · PNG specification · ZIP APPNOTE

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Perl tutorials.