English · 简体中文
Thin
ddfor disk images: writes the bytes that carry data, not the zeroes between them.
A 2 GiB image holding 316 MiB of payload flashes in the time it takes to write
316 MiB. The name is the thin-provisioning idea applied to dd — space that
holds nothing costs nothing.
Built on the block-map (bmap) format from the Yocto Project's
bmaptool and file-format
compatible with it in both directions, with one substantial addition:
zero-run elision. Gzipped images are decoded transparently, so a .img.gz
flashes in one step.
Upstream bmaptool speeds up flashing by writing only the blocks of an image
that carry data. It finds those blocks by asking the file system which parts of
the image file are backed by real extents (SEEK_HOLE / FIEMAP). That works
beautifully for an image that was built as a sparse file, and not at all for
the image you usually have in your hands:
- one you downloaded and decompressed — decompression writes every byte, zeroes included;
- one you
dd-ed off a device; - one that came out of a build system that did not preserve sparseness;
- anything that travelled through
tarwithout--sparse,scp, or a USB stick.
Those images are dense: every zero byte is really on disk. SEEK_HOLE
reports 100% mapped, and bmaptool degenerates into dd.
thindd detects all-zero blocks by content as well, so those images flash
at the speed of their actual payload.
A 2 GiB dense image containing 316 MiB of real data, written to a file on NVMe
(--mode zero also clears the destination, which is why it does more work):
| wall clock | bytes written | destination on disk | |
|---|---|---|---|
dd bs=8M conv=fsync |
22.7 s | 2.0 GiB | 2.1 GiB |
| upstream-style bmap (holes only) | — | 2.0 GiB (100% mapped) | 2.1 GiB |
thindd copy with a bmap |
1.9 s | 316 MiB | 317 MiB |
thindd copy without a bmap (scan on the fly) |
13.1 s | 316 MiB | 317 MiB |
thindd copy --mode zero |
3.4 s | 316 MiB + hole punch | 317 MiB |
Same image compressed with gzip -1 (324 MiB on disk):
| wall clock | bytes written | destination on disk | |
|---|---|---|---|
gzip -dc image.gz | dd bs=8M conv=fsync |
25.2 s | 2.0 GiB | 2.1 GiB |
thindd copy image.gz out (no map) |
2.0 s | 316 MiB | 317 MiB |
thindd copy image.gz out (with map) |
2.2 s | 316 MiB | 317 MiB |
thindd create image.gz |
0.6 s | — | — |
All outputs are byte-identical to the source image. Against a loop device, the
block layer confirms it: 57344 KiB of sectors written where dd sends 262144.
The two rows worth understanding:
- with a bmap: the map already says which 316 MiB matter, so the image is neither fully read nor fully written. This is the flow you want in CI or on a production line.
- without a bmap: the whole image still has to be read (that is how the zeroes are found), but only 316 MiB are written. On the media people actually flash — SD cards, eMMC, USB sticks at 10–40 MB/s — writing is the bottleneck by an order of magnitude, so this is very close to the with-bmap number in practice, with nothing to prepare in advance.
Prebuilt binaries for every tagged release:
| platform | archive |
|---|---|
| Linux x86_64 | thindd-<tag>-x86_64-unknown-linux-musl.tar.gz |
| Linux arm64 | thindd-<tag>-aarch64-unknown-linux-musl.tar.gz |
| macOS Intel | thindd-<tag>-x86_64-apple-darwin.tar.gz |
| macOS Apple silicon | thindd-<tag>-aarch64-apple-darwin.tar.gz |
# Linux x86_64, latest release
curl -fsSL https://github.com/bkhq/thindd/releases/latest/download/thindd-$(
curl -fsSL https://api.github.com/repos/bkhq/thindd/releases/latest | grep -o '"tag_name": "[^"]*' | cut -d'"' -f4
)-x86_64-unknown-linux-musl.tar.gz | tar xz
sudo install -m755 thindd /usr/local/bin/The Linux archives are statically linked against musl: one file, no glibc
version to match, runs on any distribution. Each archive ships with a
.sha256 alongside it.
From source:
cargo install --git https://github.com/bkhq/thindd thindd
# or, in a clone:
cargo build --release # target/release/thinddNo C dependencies, no openssl, no ioctl, no unsafe. Hole detection is
lseek(SEEK_DATA/SEEK_HOLE), fast zeroing is fallocate, gzip is
zlib-rs — all reached through safe
wrappers.
# Flash an image. A sibling <IMAGE>.bmap is picked up automatically.
thindd copy core-image.wic /dev/sdb
# No bmap file anywhere? Still fast — zero runs are found while reading.
thindd copy --no-bmap core-image.wic /dev/sdb
# Also clear the parts that are not in the image, instead of leaving whatever
# the device held before. Uses fallocate/BLKZEROOUT, so it is nearly free.
thindd copy --mode zero core-image.wic /dev/sdb
# Precompute a map, so later flashes neither read nor write the zeroes.
thindd create core-image.wic # writes core-image.wic.bmap
thindd info core-image.wic.bmap --ranges
# Compressed images are decoded on the fly — no scratch file, no pipeline.
thindd copy core-image.wic.gz /dev/sdb
thindd create core-image.wic.gz # writes core-image.wic.bmap
# Streaming works too, compressed or not.
zstd -dc core-image.wic.zst | thindd copy --no-bmap - /dev/sdb
cat core-image.wic.gz | thindd copy --no-bmap - /dev/sdb| flag | default | meaning |
|---|---|---|
--bmap FILE |
<IMAGE>.bmap if it exists |
map to use |
--no-bmap |
ignore any map; discover everything by scanning | |
--detect holes|zeros|both|none |
both |
what may be skipped |
--mode skip|zero |
skip |
what happens to the skipped regions |
--seek BYTES |
0 |
byte offset on the destination to write the image at |
--zap |
off | clear 4 MiB at each end, where partition tables live — fast |
--wipe |
off | clear the whole destination first, including past the image |
--decompress auto|none|gzip |
auto |
transparent decompression |
--verify |
off | read the destination back afterwards and compare it against the image |
--no-verify |
off | skip the per-range checksums from the map |
--no-sync |
off | do not fsync before exiting |
--force |
off | write to a block device the kernel says is busy |
--bs BYTES |
8M |
size of each read and each write — dd's bs= |
--sync-every BYTES |
16M |
flush the destination this often; 0 disables |
--queue-depth N |
4 |
batches in flight between reader and writer |
--detect holes reproduces upstream bmaptool behaviour exactly, if you want a
like-for-like comparison.
| flag | default | meaning |
|---|---|---|
-o, --output FILE |
<IMAGE>.bmap |
where to write (- for stdout) |
--detect holes|zeros|both|none |
both |
what counts as skippable |
--checksum sha1|sha256|sha512|none |
sha256 |
per-range digests |
--decompress auto|none|gzip |
auto |
map the decompressed image |
--block-size BYTES |
file system's preferred | map granularity |
--bs BYTES |
8M |
size of each read |
--detect holes --checksum none needs no reads at all: the map falls straight
out of SEEK_HOLE.
--seek puts the image somewhere other than byte zero of the destination —
dd's seek=, except in bytes rather than blocks, so --seek 8K means 8192.
# A bootloader where the SoC's ROM expects to find it
thindd copy --seek 8K u-boot-sunxi-with-spl.bin /dev/sdb
# Refresh the system image inside a larger hand-built layout, leaving the data
# partition that follows it alone
thindd copy --seek 32M --mode zero system.img /dev/sdbA non-zero offset makes the copy a partial update, and everything follows from that:
- a regular-file destination is extended if it is too short, and never truncated — whatever sits after the image is left as it was;
- the capacity check accounts for the offset, and says so when it fails: image is 256.0 MiB written at offset 300.0 MiB, needing 556.0 MiB, but destination only holds 512.0 MiB;
--mode zerozeroes the gaps within the image's extent, which now starts at the offset — it still does not reach outside it;- the bmap is unaffected. It describes the image, not where the image is going, so the same map works at any offset.
--wipe still means the whole device, so combining it with --seek clears the
card and then writes the image at the offset — which is exactly what you want
when laying out a fresh card by hand, and a mistake in every other case.
--decompress auto (the default) classifies the input by its magic bytes,
not its name, so a gzip stream is recognised whether it is called .gz, .img,
or arrives on standard input. --decompress none forces the raw reading of a
file that happens to look compressed; --decompress gzip forces the decoder
onto a header-less stream. Multi-member streams — pigz, cat a.gz b.gz,
rsyncable gzip — are handled.
Two consequences worth knowing:
- A compressed stream cannot be rewound, so the whole image is inflated even for the parts that turn out to be skippable — those bytes have to exist before we can tell they are zero. The saving is entirely on the write side, which is where the time goes on real media anyway.
- Because there is no seeking, hole detection does not apply to a compressed image; the zero scan does all the work. Nothing is lost — a compressed file has no holes to find.
Map lookup follows the compression suffix: thindd copy core-image.wic.gz looks
for core-image.wic.gz.bmap and then core-image.wic.bmap, and create on a
compressed image writes the latter, since the map describes the decompressed
image either way.
Decoding uses zlib-rs, the Rust rewrite of
zlib — no C, no *-sys. It is behind the gzip feature of thindd-core, on by
default; --no-default-features drops both the code and the dependency, and the
tool then reports a compressed image as an error instead of writing garbage to a
device.
thindd has no idea what a partition is. It decides one thing per block: is
every byte of it zero? If not, the block is written. Nothing is inferred from a
partition table, a file system, or anything else about the image's structure.
That matters for the content that lives outside any partition, which on an
embedded image is most of the interesting part — a protective MBR at sector 0, a
boot blob at sector 64, a second-stage loader at sector 16384, vendor keys in a
reserved area. All of it is non-zero, so all of it is written, exactly as dd
would. A partition-aware imaging tool is the kind that loses these; this is not
one.
The granularity is the block, 4096 bytes by default, so even a single non-zero byte carries its whole block. A lone vendor marker in an ocean of zeroes survives.
The reverse is worth knowing too. If the image has zeroes where the device
holds something you need to keep — Rockchip vendor storage with a serial number
and MAC address is the usual example — then the default --mode skip preserves
it, and --mode zero or --wipe will destroy it. That is the one case where
the cautious-looking option is the destructive one.
The bmap contract, inherited from upstream, is "the mapped blocks are written". Nothing is promised about the rest. On a blank device, or on a fresh file (which ends up sparse, so it reads back as zero), that is exactly right and costs nothing.
On a device that already holds data, --mode skip leaves the old bytes
in the gaps. That is upstream behaviour and remains the default, but it is
rarely what you want when reflashing:
thindd copy --mode zero core-image.wic /dev/sdbzero asks the kernel to zero the gaps with fallocate: FALLOC_FL_PUNCH_HOLE
on a regular file (no I/O and no disk space at all) or FALLOC_FL_ZERO_RANGE on
a block device, which most SSD/eMMC/SD controllers execute internally rather
than by writing zeroes over the bus. Writing zero pages by hand is only the
fallback for hardware — or platforms — that support neither.
Measured against a 256 MiB loop device pre-filled with random data, flashing a 64 MiB image that holds 16 MiB of payload:
| gap inside the image | past the end of the image | |
|---|---|---|
| blank device, any mode | zero | zero |
dirty device, --mode skip |
old bytes survive | old bytes survive |
dirty device, --mode zero |
zeroed | old bytes survive |
dirty device, --wipe |
zeroed | zeroed |
Upstream bmaptool has only the first behaviour and no option to change it: its
copy() writes the mapped batches and nothing else.
Two things follow.
Leftovers inside the image are usually harmless but not always. They land in what the new file system considers free space, so nothing reads them back as file content and the device boots fine. They matter when the device leaves your control — old keys and logs are recoverable with ordinary forensics — and when you verify a flash by reading the device back and comparing it to the image, which will not match.
No --mode setting reaches beyond the image. A bmap describes the image, and the
image says nothing about the space after it. If the device previously held a
different layout, a stale GPT backup header or an old file-system superblock
survives out there and can confuse blkid, udev or a bootloader into finding a
partition that no longer exists — which is a good way to end up with a card that
will not boot.
Two options address it, and the cheap one is usually the right one:
thindd copy --zap --mode zero core-image.wic /dev/sdb # milliseconds
thindd copy --wipe core-image.wic /dev/sdb # the whole device--zap clears 4 MiB at each end of the device. That is where the damage
lives: an MBR is the first sector, a GPT backup header is the last 33, and
file-system superblocks sit within the first few kilobytes. Everything in
between is left alone, so the cost does not grow with the size of the card —
8 MiB, whether it is a 512 MiB loop device or a 512 GB SSD.
--wipe clears everything. On Linux that is one fallocate(ZERO_RANGE) over
the lot, which a controller implementing write-zeroes or discard executes
internally, so it costs about 20 ms on a 512 MiB device. Where the kernel has no
such call — macOS, notably — it writes every byte, and the cost scales with the
card rather than the image.
A note on "why not just format it": a quick format is fast, and that is
exactly the problem. It writes a fresh partition table and file-system metadata
and leaves every other byte where it was. It would fix the stale-layout half of
this and none of the stale-data half. --zap is the honest version of the same
idea, and --mode zero covers what a format does not.
--mode zero and --wipe are not alternatives to each other. They answer
different questions: make the image's own extent match the image, leave the
rest alone versus make the whole device clean.
| blank or new media | the default — fastest, nothing to clean |
| clean image area, but keep whatever is on the device beyond it | --mode zero |
| whole device clean — new layout, stale GPT backup header, device changing hands | --wipe |
Two things separate them.
--wipe destroys everything on the device, not just the image's extent. If
the card carries a user-data partition after the system image — a very ordinary
arrangement — --wipe takes it with it and --mode zero does not.
Their cost scales with different things. --mode zero is bounded by the
image; --wipe by the device. Measured on an 8 GiB destination holding a
512 MiB image with 64 MiB of payload, counted at the block layer:
| sectors written | |
|---|---|
| default | 64 MiB |
--mode zero |
512 MiB |
--wipe |
8256 MiB |
On a controller that implements write-zeroes or discard all three take about the same wall-clock time, because the zeroing never crosses the bus. On one that does not, that column is real write time: on a 15 MB/s stick the same three runs are roughly 5 s, 35 s and 9 minutes. The bigger the card relative to the image, the wider that gap.
--mode zero is not the default, and deliberately so. It is nearly free
only where the destination implements write-zeroes or discard. Where it does not
— plain USB mass storage is the usual case — fallocate fails and the fallback
writes the zeroes for real: for a 2 GiB image holding 316 MiB that is 1.7 GiB of
zeroes, turning a 20-second flash into a two-minute one on a 15 MB/s stick. A
default whose cost swings between nothing and "as slow as dd" depending on the
hardware is the wrong default for a tool whose point is predictable speed. Ask
for it when you want it.
- Block devices are opened
O_EXCL. The kernel refuses that while the device or any of its partitions is mounted or otherwise claimed, so you cannot overwrite your running root file system by fumbling a device name.--forceopts out. - The destination is size-checked against the image before anything is written.
- Per-range checksums from the bmap are verified while copying, so a corrupt image or a mismatched bmap aborts rather than producing a broken device.
- After the copy, the number of mapped blocks read is checked against what the bmap claims — a bmap that belongs to a different image is caught even if every individual checksum happened to pass.
--verifyreads the destination back and compares it against the image. Those bmap checksums verify the image on the way in; this is the only check that covers what landed on the device. It is the first thing to reach for when a flashed card does not behave, and it will tell you the byte offset where the two first differ.- On a block device the destination is flushed every 16 MiB by default, so
interrupting a flash to a slow USB stick does not leave you waiting minutes
inside
close().
| Linux | macOS | |
|---|---|---|
| copy, create, info, gzip, checksums | yes | yes |
hole detection (SEEK_HOLE/SEEK_DATA) |
yes | the call works, but see below |
O_EXCL guard on block devices |
yes | yes |
--mode zero without writing zeroes |
fallocate |
falls back to explicit writes |
| I/O scheduler / writeback tuning | sysfs |
nothing to tune |
| page-cache hints | posix_fadvise |
left to the kernel's heuristics |
Everything platform-specific sits behind cfg, and CI builds, clippy-checks and
runs the whole test suite on both. Beyond that, on Linux:
tests/blockdev/drives loop devices in a privileged container — theO_EXCLguard, the capacity check, sysfs tuning,fallocatezeroing — and confirms at the block layer that only the mapped sectors are written;tests/vm/boots a QEMU guest with its own virtio disk to cover the one case neither of those can reach: writing to a partition, where the I/O knobs live on the parent disk and have to be walked up to.
On macOS none of that is covered — the macOS binaries are built and unit-tested, not field-tested.
Three macOS specifics to keep in mind:
/dev/rdiskNis the one to use — it is the unbuffered path and much the faster of the two — afterdiskutil unmountDisk /dev/diskN. It is a character device rather than a block device, whichthinddaccounts for: the size comes fromlseek, so the capacity check,--wipeand the final sync all work on it. One caveat remains: a raw device requires every write to be a multiple of its block size, andthindddoes not pad the final partial block, so an image whose size is not a sector multiple can fail on its last write. Disk images essentially always are; if yours is not, use/dev/diskN.--mode zeroand--wipeare honest but slow there: withoutfallocatethey write the zeroes for real, so a whole-device--wipecosts as much as writing the whole device.thinddsays so before it starts.--detect holeson its own can map an image 100%.SEEK_HOLEis implemented, but APFS commonly stores a file written the ordinary way with no holes in it, so there is nothing for it to report — CI sees exactly this. The default--detect bothis unaffected: the zero scan finds those regions by content, which is the whole point of this tool.
Windows is not supported and not planned; the whole design rests on Unix positional writes and sparse-file semantics.
The bmap 2.0 XML format is implemented for reading and writing, and 1.x files are read as well. This is verified against upstream, not assumed:
- a map produced here is parsed by upstream
bmaptool, its self-checksum and every range checksum verify, and the resulting image is byte-identical; - a map produced by upstream
BmapCreateis parsed here and copies correctly.
thindd info will describe either.
reader thread writer thread
───────────── ─────────────
take buffer from pool ──┐
read one batch │ bounded
classify zero / data │ channel ──▶ pwrite the data spans
hash for verification │ fallocate the zero spans
send batch ──┘ return buffer to the pool
Reading and writing overlap, because the source (NVMe or page cache) and the destination (SD card) usually differ by an order of magnitude in speed. Buffers are recycled through a second channel, so copying a 32 GiB image allocates the same fixed 32 MiB as copying a 32 MiB one.
Zero detection is the hot loop — every byte of the image passes through it. It
compares slices against a static zero page rather than iterating bytes, because
slice equality on [u8] lowers to memcmp, which is vectorised in libc and
bails out on the first differing byte. A batch that is uniformly zero, the
common case, settles in a single call. It runs at memory bandwidth and needs no
unsafe.
| crate | contents |
|---|---|
thindd-core |
format, hole/zero detection, decompression, copy engine — usable as a library |
thindd |
the CLI |
Library example:
use thindd_core::{
Destination, ImageSource,
copy::{self, CopyOptions},
create::{self, CreateOptions},
progress::NoProgress,
};
use std::path::Path;
fn flash() -> Result<(), thindd_core::Error> {
let image = Path::new("core-image.wic");
let bmap = create::create(image, &CreateOptions::default(), &NoProgress)?;
bmap.write_to(Path::new("core-image.wic.bmap"))?;
let dest = Destination::open(Path::new("/dev/sdb"), false)?;
let stats = copy::copy(
ImageSource::open(image)?,
&dest,
Some(&bmap),
&CopyOptions::default(),
&NoProgress,
)?;
println!("skipped {:.1}%", stats.elided_percent());
Ok(())
}Deliberately out of scope for now; upstream has them and they are additive:
.xz/.zst/.bz2decompression —.gzis supported; pipe the others through their decompressor and read from-;- reading images directly from a URL;
- GPG signature verification of bmap files;
- the
psplashprogress pipe.
just ci # fmt + clippy + nextest + doctests + cargo-deny + cargo-shearTwo suites need more than cargo test can give them, and both run in a
privileged container against devices they create themselves:
tests/blockdev/— loop devices:O_EXCL, capacity check, sysfs tuning,fallocatezeroing, and a block-layer count proving only the mapped sectors are written;tests/vm/— a QEMU guest with its own virtio disk, for writing to a partition.
Releases are cut by pushing a tag:
git tag -a v0.1.0 -m "v0.1.0" && git push origin v0.1.0The workspace follows the PMA-Rust baseline: edition 2024,
#![forbid(unsafe_code)] in every crate, deny-warnings policy in
[workspace.lints] rather than on the CI command line, no unwrap/expect/
panic! outside tests.
GPL-2.0-only. thindd is an independent implementation, but the bmap file
format it reads and writes — and the semantics it has to match to stay
compatible — come from the Yocto Project's bmaptool
(Copyright (c) 2012-2014 Intel, Inc., GPLv2). The licence is kept the same so
the two can be used together without friction.