Skip to content

pyunwind: read the thread pointer on arm64, and measure what the walk needs - #208

Merged
dpsoft merged 9 commits into
mainfrom
feat/arm64-python-tsd
Oct 14, 2026
Merged

dpsoft merged 9 commits into
mainfrom
feat/arm64-python-tsd

Conversation

@dpsoft

@dpsoft dpsoft commented Oct 13, 2026

Copy link
Copy Markdown
Owner

First half of #206. Draft on purpose: the arm64 test arm is expected to
fail, and its failure is the measurement.

Why this is narrower than "add arm64 support"

Most of it is already there:

  • pyunwind/pywalk_arm64_bpfel.o is committed beside the x86 object — the
    BPF walker already builds for arm64.
  • tsdOffsets already carries PthreadSize uint32 // arm64 only; 0 on amd64,
    and both consumers already subtract it: hostTSSGet (attach.go) and
    bpf/interp/python/python_walk.h.

What was missing is Go-side, and it is two things.

1. The thread pointer

The ptrace machinery — seize, interrupt, the bounded spin, and the detach
that must not be skipped — is architecture-independent and moves to
tlsbase_ptrace.go, leaving each architecture exactly one function:

arch how
amd64 PTRACE_GETREGS; user_regs_struct carries the FS base
arm64 PTRACE_GETREGSET with NT_ARM_TLS, payload is TPIDR_EL0

Both return the raw register. The PthreadSize correction is
deliberately not applied there — the two consumers above already do it, so
doing it here as well would subtract twice on arm64 and not at all on amd64.

2. A probe that can measure a second architecture

The amd64 row was measured with a probe that was described in a comment and
never committed
. So nothing guarded it, and nothing could reproduce it for
another architecture.

pyunwind/internal/tsdprobe is that probe, committed: create three real
pthread keys, store markers through the real API, find them relative to
struct pthread. Three keys rather than one because the stride between them
is what separates sizeof(pthread_key_data) from a coincidence.

The part that makes arm64 measurable at all: on glibc, pthread_self() is
the struct pthread *, so the distance to __builtin_thread_pointer() is
exactly the TLS_PRE_TCB_SIZE the walk has to undo. No second method needed.
Zero on x86-64 (TLS variant II), non-zero on aarch64 (variant I).

It is a package rather than a _test.go because Go does not permit cgo in
test files, and this has to be real C calling the real pthread API.

Validated where the answer is already known

On amd64 the probe independently derives Specific1stBlock=0x310,
KeyDataSize=16, KeyDataOff=8, PthreadSize=0 — matching the committed
row it has no access to. Mutation-checked: changing the table to 0x318
fails the test with "measured 0x310 … table says 0x318".

What this PR does not have yet

glibcTSDOffsets has no arm64 row. Filling it needs the probe run on
aarch64, and tests run only on pull requests, on native arm64 runners — so
this PR is the measurement vehicle. The arm64 job will fail with the four
numbers in the message; I will add the row and mark this ready.

Precedent: #152 did the same thing for the shim's USDT register binding,
proving it on a real arm64 runner rather than asserting it.

… needs

First half of #206. Python frames are the only amd64-only part of the agent,
and the gap is narrower than it looks: pywalk_arm64_bpfel.o is already
committed, tsdOffsets already carries PthreadSize ("arm64 only; 0 on amd64"),
and both hostTSSGet and bpf/interp/python/python_walk.h already subtract it.
What was missing is Go-side.

Thread pointer. The ptrace machinery -- seize, interrupt, the bounded spin,
and the detach that must not be skipped -- is architecture-independent and
moves to tlsbase_ptrace.go, leaving each architecture one function:

  amd64  PTRACE_GETREGS, user_regs_struct carries the FS base directly
  arm64  PTRACE_GETREGSET with NT_ARM_TLS, whose payload is TPIDR_EL0

Both return the RAW register. The PthreadSize correction is deliberately not
applied here: hostTSSGet and the BPF walker each do it already, so doing it
here too would subtract twice on arm64 and not at all on amd64.

Measurement. The amd64 row in glibcTSDOffsets was measured with a probe that
was described in a comment and never committed, so nothing guarded it and
nothing could reproduce it for a second architecture. That probe now exists
as pyunwind/internal/tsdprobe: create three real pthread keys, store markers
through the real API, and find them relative to `struct pthread`. Three keys
rather than one because the stride between them is what distinguishes
sizeof(pthread_key_data) from a coincidence.

PthreadSize falls out of the same probe without a second method, which is
what makes measuring arm64 possible at all: on glibc, pthread_self() IS the
struct pthread *, so the distance to __builtin_thread_pointer() is exactly
the TLS_PRE_TCB_SIZE the walk has to undo. Zero on x86-64 (TLS variant II,
thread pointer at the struct), non-zero on aarch64 (variant I, struct before
the TCB).

It is a package rather than a _test.go file because Go does not permit cgo
in test files, and this has to be real C calling the real pthread API --
the point is to observe where glibc puts things, not to restate a header.

Verified on this machine: the probe independently derives
Specific1stBlock=0x310, KeyDataSize=16, KeyDataOff=8, PthreadSize=0 for
amd64, matching the committed row it had no access to, and the test fails if
the row is altered.

glibcTSDOffsets still has NO arm64 row. Filling it needs the probe run on
aarch64, which this branch exists to do: tests run only on pull requests, on
native arm64 runners, so the arm64 arm of this PR is expected to fail and
its failure message carries the four measured numbers.
CI measured them on a native aarch64 runner (Ubuntu 24.04 arm64, image
20261002.596):

  Specific1stBlock=0x110  KeyDataSize=16  KeyDataOff=8  PthreadSize=1856

KeyDataSize and KeyDataOff come out identical to amd64, which is a useful
check on the measurement rather than an assumption: pthread_key_data is the
same struct on both LP64 targets. PthreadSize is 1856 there and 0 on amd64
because the two use different TLS variants -- x86-64 is variant II, where
the thread pointer IS the struct pthread address, and aarch64 is variant I,
where the struct sits before it.

A bounds bug in the probe, found by running the whole suite rather than one
test. The scan window was 4096 bytes from the start of struct pthread. On
x86-64 the struct is about 2.3 KB and the static TLS block is at NEGATIVE
offsets from the thread pointer, so scanning forward past the struct walked
into unmapped memory and took the test binary down with SIGSEGV -- a crash
with one of the probe's own markers still in rcx. It survived a single-test
run and only died under the full suite, which is the worst way for a bounds
bug to behave. The window is now 1536, inside the struct on both targets,
and the scan stops once all three markers are found.

Two existing tests asserted arm64 is REFUSED and now fail, correctly: the
behaviour changed on purpose. They move to architectures that are still
unmeasured (riscv64, 386, ppc64le, s390x), and gain a counterpart --
TestGlibcTSDOffsetsServesMeasuredArches -- asserting the other half, that
every architecture the agent ships on HAS a row. Without it, dropping a row
would make Python frames quietly unavailable on that architecture with
nothing failing to say so.

This does NOT finish #206. prepareInfoForArch gets past glibcTSDOffsets on
arm64 now and reaches ParseAutoTSSKeyRef, which decodes the interpreter's
prologue as x86-64 machine code and recognises three shapes, all x86-64. Its
own comment anticipated this: "an arm64 shape becomes worth measuring on the
day glibc's TSD layout is measured for arm64, and not before." That day is
now. Until a shape is added, an arm64 target is refused by name with
ErrTSSPatternUnrecognised rather than walked wrongly, which is the designed
failure.
Second half of #206, and the reason it needed the first: the file's own
comment said an arm64 shape "becomes worth measuring on the day glibc's TSD
layout is measured for arm64, and not before". That day was the previous
commit.

This is not a data addition. Every existing shape reads its reference as one
signed 32-bit displacement sitting whole in four little-endian bytes,
measured from the end of its instruction. aarch64 has no such thing: a
page-relative reference is ADRP plus ADD, and the part this parser wants is
a 12-bit immediate at bits 21:10 of the ADD. Read as an int32 those four
bytes are the encoded instruction, not an offset. So tssShape gains a
fieldEnc, and fold() learns the second encoding -- including bit 22, the
shift flag, which scales the immediate by 4096 when set.

The shape is measured from Ubuntu 24.04's libpython3.12.so.1.0
(3.12.3-1ubuntu0.17, arm64), disassembled rather than guessed: 76 bytes,
nineteen instructions, with the reference appearing twice as
`add x0, x19, #0x608`.

Four instructions are deliberately not pinned, because their immediates are
link-time values that differ per build: the ADRP page, the GOT slot in the
LDR, and the two 26-bit branch targets. Pinning them would make the shape
match exactly one build of one distro. The CBZ is pinned, because its
target is inside this body and so is a property of the shape.

Two bytes of each ADD are pinned and two are not. The immediate must stay
free -- it is what differs between CPython versions (0x608 on 3.12, 0x870
on 3.13) -- but Rd, the low bits of Rn, and the opcode byte do not vary, and
pinning them is what stops some other instruction being read as an ADD and
its bits 21:10 reported as an offset. That gap was visible only from
writing the corruption case, which is why the case is in this commit rather
than left for later.

It decodes to 0x608, the same value the x86-64 3.12 fixture gives. That
agreement is the strongest check here: autoTSSkey's position inside
_PyRuntime is a struct layout fact, so the same CPython version must yield
the same offset on both LP64 targets. A new decoder returning a plausible
but different number would fail on that, not on byte matching alone.

TestParseAutoTSSKeyRefRefusesEverySingleByteCorruption now covers the arm64
fixture too. Its unchecked list is exactly the four link-varying
instructions: the ADD immediates are NOT excluded, because a single-byte
flip can only corrupt one of the two and the cross-check then sees them
disagree.
Lint caught encRIPDisp32 as unused: the x86-64 shapes reach it by leaving
enc at its zero value, so the name was never written down. Silencing that
would have kept a two-valued switch written as one if and an implicit else.

fold() now switches on both, with a default that panics rather than falling
through to the x86 reading. A third encoding should fail loudly at the one
place that has to understand it, not quietly decode an ADD as a signed
displacement.
Three live tests gated on `runtime.GOARCH != "amd64"`, giving the reason
"arm64 is a named refusal". That reason stopped being true when #206
measured arm64's glibc TSD offsets, but the gates did not, so the arm64
runner reported those tests as skipped while the support they were skipping
for was present. Measured-but-never-exercised is the worst of both: the
table says the walk works there and nothing has ever made it walk.

They now ask glibcTSDOffsets whether this architecture has a row, which is
the same question the production path asks. An architecture with no row
still skips, with the refusal quoted. One that has a row runs, so the act
of measuring a future architecture is also the act of putting its
end-to-end path under test.

Verified on amd64 that all three still run rather than skipping -- a gate
rewritten into something that never fires would look identical in CI to one
that was correctly relaxed.
…umber

CI refused the interpreter it actually reaches. The arm64 runner's
/usr/bin/python3.12 has a 72-byte body and the shape added last commit is
76 -- measured from libpython3.12.so.1.0, the shared library, which that
executable does not use. This is the loop the file comment already
describes for x86-64, where the 37-byte shape was found the same way.

The two differ by one instruction, and that instruction changes the
meaning:

  lib: adrp x19, page ; ldr x19, [x19, #slot] ; add x0, x19, #0x608
  exe: adrp x19, page ;                         add x0, x19, #0xab8

The library loads &_PyRuntime through the GOT, so its immediate is an
offset inside that struct. The executable has no GOT hop: x19 is a page and
the immediate is the low 12 bits of the field's own address. Decoding the
second as a runtime offset yields 0xab8 -- a plausible number pointing at
nothing.

So this is a fourth AutoTSSKeyRefKind rather than another shape.
KeyRefA64PageRelative cannot collapse into KeyRefBodyRelative ("BodyVaddr
plus a constant") because ADRP is relative to the 4 KB page its own
instruction sits in: the answer depends on BodyVaddr's low bits as well as
its value, and no constant folded at parse time can carry that. Resolve
does the page arithmetic, and refuses without a body address rather than
resolving against zero and landing in the first page.

Verified against the binary rather than against itself: decoded and
resolved, autoTSSkey lands 0x608 into _PyRuntime -- the same offset the
x86-64 and aarch64 shared-library fixtures give directly. Three builds,
two architectures, two linkage models, one layout fact. That agreement is
what catches a page-alignment or sign-extension error, which byte matching
cannot see.

A gap the fixtures could not close: every real body measured so far
addresses a page ABOVE its own, so the sign-extension branch never ran, and
deleting it left the end-to-end test passing. TestA64AdrpPageDeltaSignExtends
covers it with built words, and its own encoder is validated against the
fixture's real instruction so the synthetic cases cannot be vacuous. Without
that, a target page below the instruction would resolve 4 GB away.
#206 measured the glibc TSD offsets for aarch64 and added the two shapes
its interpreters use, so the "amd64 + glibc only" limitation is no longer
true. CI now runs the live-interpreter tests on the arm64 runner and they
pass against /usr/bin/python3.12: the TSD lookup returns the same
PyThreadState the interpreter itself reports, on two threads.

The entry says what is covered there and what is not. CI validates the
ATTACH path on arm64 -- thread pointer, offsets, shape decode, TSD lookup
-- not the BPF walker emitting Python frames into a profile. The walker
object is built for aarch64 and shares the same offsets, but no test on an
arm64 runner observes frames coming out of it. Calling that "supported"
without the distinction would be claiming a measurement nobody took.
@dpsoft
dpsoft marked this pull request as ready for review October 14, 2026 16:09
Two places. The supported-range line is now wrong -- #206 added arm64 --
and the Py_tss_t layout paragraph said "on amd64" about a struct that is
8 bytes on both supported architectures, since int and pthread_key_t are
4 bytes on each. The second was never arch-specific, only written while
one architecture was all there was.
It said tlsBase refuses on every architecture but linux/amd64 and that the
arm exists "to keep the package building for arm64" -- both written when
amd64 was the only measured layout. arm64 now has a measured layout and a
real implementation in its own file, so this file is what is left over, not
the arm64 path.
@dpsoft
dpsoft merged commit c994377 into main Oct 14, 2026
16 checks passed
@dpsoft
dpsoft deleted the feat/arm64-python-tsd branch October 17, 2026 14:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant