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.
#!/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.
#!/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.
#!/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.
#!/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.
#!/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.
#!/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.
#!/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
- Always state the byte order: use
n,N,v,Vor the<and>modifiers, never native templates, for file formats. - Check the magic number first: reject a file with the wrong signature before interpreting any other field.
- Trust lengths carefully: lengths read from a file can be corrupt or malicious; compare them with the file size before reading or allocating.
- Verify checksums: PNG, ZIP and many other formats contain
CRC-32 values;
Compress::Zlib::crc32checks them. - Skip unknown chunks: chunked formats are designed to be extended; a parser should ignore chunk types it does not know.
- Use x to skip fields:
x4orx14in a template is clearer than unpacking values into dummy variables. - Use a CPAN module for production code: modules such as
Image::ExifTool,Archive::ZiporAudio::Wavhandle all variants of a format; hand-written parsers are best for learning, quick checks and simple formats.
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
List all Perl tutorials.