Does this machine's Arm MTE implementation tell the truth?
A single-file diagnostic that answers one question before you trust anything Arm's Memory Tagging Extension reports:
can this machine still remember an allocation tag after the cache line holding it has been evicted?
On some Armv9 platforms the answer is no — and the failure looks exactly like a flood of genuine memory-safety bugs in whatever software you happened to be testing.
$ make && ./mte-selftest
[ OK ] kernel enables MTE sync tag checking
[ OK ] mmap(PROT_MTE) succeeds
[ OK ] allocation tag stores and reads back wrote 5, read 5
[ OK ] matching-tag access does not fault
[ OK ] mismatched-tag access faults si_code=9 (9=SEGV_MTESERR)
[ OK ] tags survive cache eviction 0/4096 granules lost their tag
[ OK ] no spurious faults after eviction
MTE hardware behaves correctly (0 failures)
thrash buffer used: 256 MiB
Arm MTE keeps a 4-bit allocation tag for every 16-byte granule of memory.
Pointers carry a matching tag in their top byte, and the CPU faults with
SEGV_MTESERR when the two disagree. That is how MTE catches buffer overflows
and use-after-free.
Those tags have to be stored somewhere. While a line is in the caches its tag travels with it. When the line is evicted, the tag must be written back to a dedicated region of physical memory, managed on the platform side — on Arm's CI-700 interconnect, by the MTSX block, which is programmed by firmware with a base address for the tag carveout.
If firmware points that carveout at physical addresses where no DRAM is installed, the write-back goes nowhere. Nothing reports an error. The CPU is correct, the kernel is correct, the instructions work. But the moment a cache line is evicted its tag is gone, and every later access to that memory looks like a tag mismatch.
MTE then produces a steady stream of SIGSEGVs in programs that have no bug
at all.
The seductive part is that MTE appears to work perfectly at first:
- Small allocations stay cache-resident for their whole lifetime, so they never lose a tag and never fault spuriously.
- Deliberate overflows and use-after-free are caught correctly, with the right
si_code. Your positive control passes. - Tags read back correctly the instant after you write them.
Only data that actually leaves the cache is affected — which in practice means
larger, longer-lived, more interesting allocations. This was originally found
because OpenSSL's 32 KiB namemap hash table faulted inside a plain memset,
on a granule whose tag had already reverted to zero.
So the observed symptom is "this widely used library has a serious heap bug that only manifests on my board". It does not. The board cannot store tags.
On an Orange Pi 6 Plus Armv9 board (with default settings), every tag was lost after eviction:
cache-hot : 256/256 granules hold their assigned tag
after evict: 0/256 hold their tag
read-back tag histogram: 0x0=256
The histogram is the diagnostic. Lost tags read back as exactly 0x0,
never as garbage and never as one of the other tag values in use:
- corrupted or aliased tag storage would scatter the read-back values across the histogram;
- uniform
0x0— the architectural "no tag" value — is what unbacked storage returns.
That distinction is why this program prints the histogram when it finds a mismatch, rather than just a count.
Seven checks, in order. Each one only means something if the ones before it passed.
| # | Check | Why |
|---|---|---|
| 1 | kernel enables MTE synchronous tag checking | prctl(PR_SET_TAGGED_ADDR_CTRL) must succeed |
| 2 | mmap(PROT_MTE) succeeds |
the kernel will hand out taggable memory |
| 3 | a tag stores and reads back | STG / LDG work at all |
| 4 | a matching-tag access does not fault | no false positives while cache-hot |
| 5 | a mismatched-tag access does fault | si_code == 9 (SEGV_MTESERR) — no false negatives |
| 6 | tags survive cache eviction | the real question |
| 7 | no spurious faults after eviction | innocent code is not being killed |
Check 6 tags all 4096 granules of a 64 KiB region with 15 different values (1–15, never 0), pushes several hundred MiB of unrelated traffic through the caches, and then reads every tag back.
makeor directly:
cc -O0 -g -std=c11 -Wall -Wextra -march=armv8.5-a+memtag \
-o mte-selftest mte-selftest.c-march=armv8.5-a+memtag is mandatory. The source issues STG and LDG
as inline assembly, and without an MTE-capable target the assembler rejects
them outright:
Error: selected processor does not support `stg x0,[x0]'
No dependencies beyond libc.
- aarch64 with Armv8.5-A MTE —
mtein/proc/cpuinfoFeatures - a Linux kernel with MTE support (5.10+)
- the process must be allowed to enable tag checking
If the CPU does not advertise MTE the program says so and exits 2 rather than producing a misleading failure.
./mte-selftest # or: make check| code | meaning |
|---|---|
| 0 | MTE is trustworthy on this machine |
| 1 | MTE is not trustworthy — do not believe tag faults from this machine |
| 2 | the test could not run (no MTE, kernel refused, out of memory) |
[ OK ] kernel enables MTE sync tag checking
[ OK ] mmap(PROT_MTE) succeeds
[ OK ] allocation tag stores and reads back wrote 5, read 5
[ OK ] matching-tag access does not fault
[ OK ] mismatched-tag access faults si_code=9 (9=SEGV_MTESERR)
[FAIL] tags survive cache eviction 4096/4096 granules lost their tag -- MTE UNRELIABLE
read-back tag histogram: 0x0=4096
every tag read back as 0x0: tag storage is not backed by RAM
[FAIL] no spurious faults after eviction FALSE POSITIVE
MTE IS NOT TRUSTWORTHY ON THIS MACHINE (2 failures)
thrash buffer used: 256 MiB
(exit status 1)
Note that checks 1–5 still pass. A machine can pass every conventional MTE smoke test and still be unusable.
If you see this, any SEGV_MTESERR from this machine is presumed false until
proven otherwise, and MTE-based memory-safety testing on it is not meaningful.
The fix is firmware, not software: the tag carveout must be programmed to
point at populated physical memory.
Check 6 is only valid if the thrash buffer is comfortably larger than the last-level cache. If it is too small the tagged region may never be evicted and a broken machine will look healthy — a false pass.
The size is chosen automatically as 4× the largest cache reported in
/sys/devices/system/cpu/cpu0/cache/, with a 256 MiB floor, and the value
used is printed at the end of every run. Some platforms do not expose cache
sizes in sysfs, in which case the floor applies.
Override it on machines with very large caches:
MTE_SELFTEST_THRASH_MB=4096 ./mte-selftestIf check 6 passes, confirm the printed thrash size really does exceed your LLC before trusting it.
- Checks 6 and 7 are inherently probabilistic in the sense that they rely on cache-eviction behaviour. A pass with a thrash buffer far larger than the LLC is strong evidence; a pass with a small one is not.
- This tests the platform, not any particular program. It says whether MTE reports can be believed here — nothing about whether your software has bugs.